From a6677d5bf99821be61ce8311c1a63e091d2d7a72 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 17 Sep 2026 03:40:58 -0500 Subject: [PATCH] fix(rust): what the emulator walk found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, none of which a unit test could have seen. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone, so it was read at connect and never again — which is not what "live" means on a row somebody opens the drawer to look at. It now refreshes on resume, beside the inbox's unread badge and for the same reason: coming back to the app is exactly when a stale number would be noticed. Still never on a timer, still nothing at all on a site without the module. **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries no padding of its own — each caller pads its own content, and these four did not. On a phone the first glyph of each line read as clipped. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left "Brannock" and "50" reading as one field. The name now takes a wider share and ellipsizes, and the ACTIVE SORT is marked on the header rather than by tinting a column of numbers — the header is the control, and tinting the values says "these are special" instead of "this is what the table is ordered by". Walked against the phase-4 rig: a core with the module installed, one live server and one that has never reported. Both halves of the phase criterion hold on a phone — the Rust site renders every panel with its server unreachable, and the same app against the UO core shows its five shard rows and no Rust row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- .../java/com/runicgateway/app/ui/RunicApp.kt | 27 +++++----- .../app/ui/rust/RustServerScreen.kt | 49 +++++++++++++++---- .../app/ui/rust/RustServersScreen.kt | 6 +++ 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 053ade0..c4f221e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -162,26 +162,29 @@ fun RunicApp( val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle() // Re-validate the cached role each time the app returns to the foreground (§4.3), - // and re-read the unread count with it: a tickle that arrived while the app was - // away is exactly what brings someone back to it. - LifecycleResumeEffect(Unit) { + // and re-read the two drawer counts with it: a tickle that arrived while the app + // was away is exactly what brings someone back to it, and a live player count is + // only live if it is re-read when somebody looks. + LifecycleResumeEffect(capabilities) { sessionViewModel.revalidate() inboxBadgeViewModel.refresh() + // The Rust count is a LIVE number, so it is re-read on the same clock the + // unread badge is: coming back to the app is exactly when a stale one + // would be noticed. Keyed on the capability answer as well as on resume, + // because the very first resume happens before this host has said whether + // the module is there — and asking then would either make a request on a + // site that has no Rust, or never make one at all. + capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) } onPauseOrDispose { } } val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle() // How many people are on the Rust fleet, for the drawer row's badge — the - // phone's answer to D15's footer count (M14). Refreshed with the capability - // answer rather than on a timer: a badge is a glance, not a feed, and this is - // the only place that knows whether the module is installed at all. A host - // that has not answered yet asks nothing, so a cold start makes no request - // until it knows there is something to ask about. + // phone's answer to D15's footer count (M14). Refreshed on resume, never on a + // timer: a badge is a glance, not a feed. Gated here rather than inside the + // view model because this is the only place that knows whether the module is + // installed at all, and a host that has not answered yet asks nothing. val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle() - LaunchedEffect(capabilities) { - val caps = capabilities - if (caps != null) rustBadgeViewModel.refresh(Capability.RUST in caps) - } // The badge follows the session, so signing out clears it rather than leaving // the previous account's count on the drawer. LaunchedEffect(session) { inboxBadgeViewModel.refresh() } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index db7a543..7b6c2f5 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -341,6 +343,12 @@ private fun FeedRow(row: RustEventDto) { */ private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String) +/** The name's share of the row against one numeric column's. */ +private const val NAME_WEIGHT = 1.7f + +/** How far a header that is not the current sort is faded. */ +private const val SORTED_AWAY = 0.55f + private val RUST_COLUMNS = listOf( RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() }, RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() }, @@ -372,8 +380,15 @@ private fun LeaderboardPanel( ) { item { Row(Modifier.fillMaxWidth()) { - SectionLabel(stringResource(R.string.rust_col_player), Modifier.weight(1f)) + SectionLabel( + text = stringResource(R.string.rust_col_player), + modifier = Modifier.weight(NAME_WEIGHT), + ) RUST_COLUMNS.forEach { column -> + // The ACTIVE sort is marked on the header, not on the + // values: the header is the control, and tinting a + // column of numbers instead says "these are special" + // rather than "this is what the table is ordered by". SectionLabel( text = stringResource(column.labelRes), modifier = Modifier @@ -384,6 +399,13 @@ private fun LeaderboardPanel( } else { Modifier }, + ) + .then( + if (column.sort == sort) { + Modifier.alpha(1f) + } else { + Modifier.alpha(SORTED_AWAY) + }, ), ) } @@ -391,20 +413,23 @@ private fun LeaderboardPanel( } items(state.data, key = { it.steamId }) { row -> Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + // A wider share for the name, and one line with an ellipsis. + // Five numeric columns beside an equal-weight name column + // left "Brannock" touching its own kill count, which the + // walk read as one field. Text( text = playerLabel(row.name, row.steamId), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp), ) RUST_COLUMNS.forEach { column -> Text( text = column.value(row), style = MaterialTheme.typography.bodySmall, - color = if (column.sort == sort) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, modifier = Modifier.weight(1f), ) } @@ -456,7 +481,10 @@ private fun OnlinePanel( } items(s.data, key = { it.steamId }) { player -> ShardCard(Modifier.fillMaxWidth()) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = playerLabel(player.name, player.steamId), style = MaterialTheme.typography.bodyMedium, @@ -500,7 +528,10 @@ private fun WipesPanel( ShardCard( Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) }, ) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt index fdb45bf..353b55e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt @@ -100,6 +100,11 @@ private fun ServerList( @Composable private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) { ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) { + // `ShardCard` is the themed Card and nothing more — it carries no padding + // of its own, so every caller pads its own content. Without this the text + // sits flush against the card's edge and the first glyph of each line + // reads as clipped, which is what the walk saw. + Column(Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -143,6 +148,7 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), ) + } } }