feat(#154): add word-by-word typing effect toggle and speed slider in settings (#165)

* feat(#154): add word-by-word typing effect toggle and speed slider in settings

* fix(settings): import Slider to fix build failure

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-20 17:09:42 +03:00
1 parent 8c9c11df23
commit 3c5fcf504c
4 files changed
+167 -6

No files matched your search

@@ -78,6 +78,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -101,11 +102,13 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -482,12 +485,23 @@ fun ChatScreen(
// Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
val typingEnabled = AuthManager.isTypingEffectEnabled()
val typingDelayMs = AuthManager.getTypingEffectDelayMs()
if (typingEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = typingDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
}
@@ -1039,3 +1053,46 @@ private fun SearchBarRow(
}
}
}
/**
* Wraps a streaming [ChatBubble] with a word-by-word typing reveal effect.
* Shows words one at a time at [typingDelayMs] intervals while the message is
* still streaming. When streaming completes the full text is shown immediately.
* The underlying [ChatMessage.content] in state is never modified — this is a
* display-only transformation.
*/
@Composable
private fun StreamingBubbleWithTypingEffect(
streaming: ChatMessage,
typingDelayMs: Int,
isDark: Boolean,
) {
var visibleWordCount by remember { mutableIntStateOf(0) }
// Timer that ticks at the configured delay, incrementing the visible word
// count each tick. Stops ticking when streaming ends, then shows all words.
LaunchedEffect(Unit) {
while (streaming.isStreaming) {
delay(typingDelayMs.toLong())
visibleWordCount++
}
visibleWordCount = Int.MAX_VALUE
}
// Derive display text from the latest full content at each recomposition
val words = streaming.content.split(" ")
val visibleCount =
if (visibleWordCount >= Int.MAX_VALUE / 2) {
words.size
} else {
visibleWordCount.coerceIn(0, words.size)
}
val displayText = words.take(visibleCount.coerceAtLeast(1)).joinToString(" ")
ChatBubble(
message = streaming.copy(content = displayText),
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}