From 3030e431f218a4db31139c4768e42b5f2dc947b0 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:45:13 +0300 Subject: [PATCH] feat: auto-scroll long chat title once on load + on tap (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: auto-scroll long chat title once on load + on tap * fix: resolve state delegate + suspension compile errors in AutoScrollingTitleText * fix: use Int scroll offset for animateScrollTo * fix: measure real container width for title marquee (was always Infinity) * fix: ease the title marquee scroll (FastOutSlowInEasing + glided return) * fix: address agy review — cancel overlapping scrolls + one-shot on-load guard + clickable on box * fix(ui): smooth out top app bar collapse transition Use dynamic layout padding based on scroll offset to prevent blank gap at the top of content when app bar collapses. --------- Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 6 +- .../ui/common/AutoScrollingTitleText.kt | 160 ++++++++++++++++++ .../hermescontrol/ui/common/HermesScaffold.kt | 37 +++- 3 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/AutoScrollingTitleText.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 4c38aaee..57e0f5fd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -133,6 +133,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -346,14 +347,13 @@ fun ChatScreen( pinTopBar = true, title = { Row(verticalAlignment = Alignment.CenterVertically) { - Text( + AutoScrollingTitleText( text = state.chatTitle, + modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, ) // Connection status dot — red when offline, hidden when connected if (!state.isConnected) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/AutoScrollingTitleText.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/AutoScrollingTitleText.kt new file mode 100644 index 00000000..f60135e0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/AutoScrollingTitleText.kt @@ -0,0 +1,160 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A single-line title [Text] that, when its content is wider than the available + * space, performs a one-shot horizontal auto-scroll (marquee) so the user can + * read the whole string. + * + * The scroll fires automatically once when the title first lays out and + * overflows, and again every time the title is tapped. If the text fits within + * the available width there is no animation — it simply renders statically. + * + * @param text The title string. + * @param onClick Optional callback fired on tap in addition to re-triggering the + * scroll (e.g. focus the input, open details). + */ +@Composable +fun AutoScrollingTitleText( + text: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + style: TextStyle = LocalTextStyle.current, +) { + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + + // Real available width (px) of the title slot — captured from the outer + // container, NOT from inside horizontalScroll (which would report Infinity). + var availableWidthPx by remember { mutableStateOf(0) } + var textWidthPx by remember { mutableStateOf(0) } + var hasLaidOut by remember { mutableStateOf(false) } + // Guards the on-first-load auto-scroll so it fires exactly once. + var hasAutoScrolled by remember { mutableStateOf(false) } + // Tracks the active scroll animation so rapid taps can't overlap. + var scrollJob by remember { mutableStateOf(null) } + + // True only when the text actually overflows its container. + val overflows = availableWidthPx > 0 && textWidthPx > availableWidthPx + + fun triggerScroll() { + if (!overflows) return + val maxScroll = textWidthPx - availableWidthPx + // Eased reveal + eased return so the motion glides instead of snapping. + val revealSpec = tween(durationMillis = 2600, easing = FastOutSlowInEasing) + val returnSpec = tween(durationMillis = 700, easing = FastOutSlowInEasing) + // Cancel any in-flight animation so taps can't stack on the same state. + scrollJob?.cancel() + scrollJob = + coroutineScope.launch { + // Restart from the beginning so each trigger reads left-to-right. + scrollState.scrollTo(0) + delay(350) + scrollState.animateScrollTo(maxScroll, animationSpec = revealSpec) + delay(900) + // Glide back to the start so the title rests at its beginning. + scrollState.animateScrollTo(0, animationSpec = returnSpec) + } + } + + // Reset position whenever the title text itself changes (e.g. session rename), + // so a fresh title starts clean and can re-measure. + LaunchedEffect(text) { + scrollState.scrollTo(0) + hasAutoScrolled = false + } + + // Auto-scroll once when the title first lays out and overflows. + LaunchedEffect(hasLaidOut, overflows) { + if (hasLaidOut && overflows && !hasAutoScrolled) { + hasAutoScrolled = true + delay(600) + triggerScroll() + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .clipToBounds() + .onSizeChanged { availableWidthPx = it.width } + .clickable { + triggerScroll() + onClick?.invoke() + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = text, + modifier = + Modifier + .horizontalScroll(scrollState, enabled = true) + .fillMaxWidth(), + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = TextOverflow.Visible, + softWrap = false, + maxLines = 1, + style = style, + onTextLayout = { result: TextLayoutResult -> + textWidthPx = result.size.width + hasLaidOut = true + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index 89bc6a7f..6520b6a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.common import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -16,13 +18,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import com.m57.hermescontrol.R +import kotlin.math.roundToInt /** Sealed interface for navigation icon types — eliminates boolean-flag anti-pattern. */ sealed interface NavIcon { @@ -125,12 +132,17 @@ fun HermesScaffold( }, containerColor = MaterialTheme.colorScheme.background, ) { paddingValues -> + val layoutDirection = LocalLayoutDirection.current val refreshContent: @Composable () -> Unit = { Box( modifier = Modifier .fillMaxSize() - .padding(paddingValues), + .padding( + start = paddingValues.calculateStartPadding(layoutDirection), + end = paddingValues.calculateEndPadding(layoutDirection), + bottom = paddingValues.calculateBottomPadding(), + ).dynamicTopBarPadding(scrollBehavior, paddingValues.calculateTopPadding()), ) { content(paddingValues) } @@ -149,3 +161,26 @@ fun HermesScaffold( } } } + +@OptIn(ExperimentalMaterial3Api::class) +private fun Modifier.dynamicTopBarPadding( + scrollBehavior: TopAppBarScrollBehavior, + baseTopPadding: Dp, +): Modifier = + this.layout { measurable, constraints -> + val baseTopPaddingPx = baseTopPadding.roundToPx() + val heightOffset = scrollBehavior.state.heightOffset.roundToInt() + val activeTopPadding = (baseTopPaddingPx + heightOffset).coerceAtLeast(0) + + val placeable = + measurable.measure( + constraints.copy( + maxHeight = (constraints.maxHeight - activeTopPadding).coerceAtLeast(0), + minHeight = (constraints.minHeight - activeTopPadding).coerceAtLeast(0), + ), + ) + + layout(placeable.width, placeable.height + activeTopPadding) { + placeable.placeRelative(0, activeTopPadding) + } + }