fix(desktop): full-width scroll with centered content via LocalReadingSidePadding

The widthIn cap confined every scrollable element to a 720dp column in
the middle of the window. Anywhere the mouse hovered outside that
column, the scroll wheel did nothing — scroll events only reached the
LazyColumn when the cursor was inside.

Refactored ReadingColumn to use BoxWithConstraints + a CompositionLocal
(LocalReadingSidePadding) instead of a width-capping Box wrapper. The
outer Column now fills the whole window so scroll gestures land on the
scrollable wherever the mouse is. Each screen reads the side padding
from the CompositionLocal and applies it to:
- its header Row as `horizontal = readingHorizontalPadding()`
- its LazyColumn as `contentPadding = PaddingValues(horizontal = readingHorizontalPadding())`

Items still appear centered at DefaultReadingWidth (720dp), but the
scrollable surface now spans the full window width.

Also:
- Search placeholder shortened ("Search people, tags, notes…") so it
  stops getting cut off in the compact 40dp field.
- UserProfile's floating header AnimatedVisibility call needed to be
  fully-qualified (androidx.compose.animation.AnimatedVisibility) to
  avoid the compiler picking the ColumnScope overload inherited from
  ReadingColumn's outer ColumnScope — the inner BoxScope's
  Modifier.align(Alignment.TopCenter) required the non-scoped variant.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
Claude
2026-04-24 15:49:24 +00:00
parent 6c33279449
commit 3b9ef980a6
12 changed files with 88 additions and 73 deletions
@@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -1324,18 +1323,17 @@ fun RelaySettingsScreen(
accountManager.loadNwcConnection() accountManager.loadNwcConnection()
} }
Box( com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
modifier = Modifier.fillMaxSize(), val sidePadding =
contentAlignment = Alignment.TopCenter, com.vitorpamplona.amethyst.desktop.ui
) { .readingHorizontalPadding()
Column( Column(
modifier = modifier =
Modifier Modifier
.widthIn(max = 720.dp)
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp), .padding(horizontal = sidePadding),
) { ) {
Row( Row(
modifier = modifier =
@@ -249,13 +249,14 @@ fun BookmarksScreen(
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
ReadingColumn { ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header with tabs // Header with tabs
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -306,7 +307,7 @@ fun BookmarksScreen(
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = sidePadding),
) { ) {
items(currentEvents, key = { it.id }) { event -> items(currentEvents, key = { it.id }) { event ->
Column( Column(
@@ -67,12 +67,13 @@ fun DraftsScreen(
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) } var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
ReadingColumn { ReadingColumn {
val sidePadding = readingHorizontalPadding()
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -103,7 +104,7 @@ fun DraftsScreen(
) )
} else { } else {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(drafts, key = { it.slug }) { entry -> items(drafts, key = { it.slug }) { entry ->
@@ -55,7 +55,10 @@ fun FeedHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier =
modifier
.fillMaxWidth()
.padding(horizontal = readingHorizontalPadding(), vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -546,8 +546,9 @@ fun FeedScreen(
is FeedState.Loaded -> { is FeedState.Loaded -> {
val loadedState by state.feed.collectAsState() val loadedState by state.feed.collectAsState()
val sidePadding = LocalReadingSidePadding.current
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = sidePadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(loadedState.list, key = { it.idHex }) { note -> items(loadedState.list, key = { it.idHex }) { note ->
@@ -649,8 +650,12 @@ private fun FeedHeader(
onNavigateToRelays: () -> Unit = {}, onNavigateToRelays: () -> Unit = {},
onOpenRelayPicker: () -> Unit = {}, onOpenRelayPicker: () -> Unit = {},
) { ) {
val sidePadding = LocalReadingSidePadding.current
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -256,7 +256,7 @@ fun NotificationsScreen(
) )
} else { } else {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification -> items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
@@ -20,15 +20,13 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -42,35 +40,50 @@ import androidx.compose.ui.unit.dp
val DefaultReadingWidth: Dp = 720.dp val DefaultReadingWidth: Dp = 720.dp
/** /**
* A top-level content scaffold that caps width and centers its column on wide * Side padding the current screen should apply to its scrollable list /
* displays. Each feed / list / profile screen wraps its contents in this so * headers to keep content centered at [DefaultReadingWidth] within the
* cards maintain a consistent proportion across the whole app. * window. `0.dp` outside of a [ReadingColumn].
*
* Screens use this to widen the gutter on wide displays (`horizontal =
* readingSidePadding + 12.dp`) while keeping the scrollable area itself at
* full window width so the mouse wheel scrolls the feed wherever it
* hovers, not only inside the 720 dp column.
*/
val LocalReadingSidePadding = compositionLocalOf { 0.dp }
/**
* Convenience: reads the current reading-column side padding and adds the
* standard 12.dp screen-edge gutter. Use this inside composable bodies when
* applying horizontal padding to header rows or `contentPadding` on
* LazyColumns so items stay centered at [DefaultReadingWidth] while the
* scrollable surface still spans the full window width.
*/
@Composable
fun readingHorizontalPadding(): Dp = LocalReadingSidePadding.current + 12.dp
/**
* Top-level scaffold for single-pane content screens. Measures the window
* width and computes the side padding that centers a [maxWidth]-wide column
* within it. The actual content (header + LazyColumn) fills the window the
* centering is done via [LocalReadingSidePadding] applied to inner modifiers
* (`horizontal` padding on a header Row, `contentPadding` on a LazyColumn).
* This keeps scroll events live across the full window, not just the center
* column.
* *
* Not used by: * Not used by:
* - Messages (two-pane layout with its own sizing) * - Messages (two-pane layout with its own sizing)
* - Article Reader (has its own narrower reading-width logic) * - Article Reader (has its own narrower reading-width logic)
* - Editor / Chess / Relay Dashboard (rely on full width for tools / boards) * - Editor / Chess / Relay Dashboard (tools that want full width)
*/ */
@Composable @Composable
fun ReadingColumn( fun ReadingColumn(
modifier: Modifier = Modifier,
maxWidth: Dp = DefaultReadingWidth, maxWidth: Dp = DefaultReadingWidth,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Box( BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), val sidePadding = ((this.maxWidth - maxWidth) / 2).coerceAtLeast(0.dp)
contentAlignment = Alignment.TopCenter, CompositionLocalProvider(LocalReadingSidePadding provides sidePadding) {
) { Column(modifier = Modifier.fillMaxSize(), content = content)
// Order matters: widthIn must come BEFORE fillMaxWidth, otherwise }
// fillMaxWidth locks the Column to parent.width and the cap is ignored.
// fillMaxHeight is safe (it only constrains the other axis).
Column(
modifier =
modifier
.widthIn(max = maxWidth)
.fillMaxWidth()
.fillMaxHeight(),
content = content,
)
} }
} }
@@ -286,13 +286,14 @@ fun ReadsScreen(
} }
ReadingColumn { ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header — Messages-style: tabs left, refresh right. The selected tab // Header — Messages-style: tabs left, refresh right. The selected tab
// (Following / Global) acts as the screen title, so no separate label. // (Following / Global) acts as the screen title, so no separate label.
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -357,7 +358,7 @@ fun ReadsScreen(
else -> { else -> {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(events, key = { it.id }) { event -> items(events, key = { it.id }) { event ->
@@ -32,14 +32,12 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -292,16 +290,10 @@ fun SearchScreen(
focusRequester.requestFocus() focusRequester.requestFocus()
} }
androidx.compose.foundation.layout.Box( ReadingColumn {
modifier =
androidx.compose.ui.Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.TopCenter,
) {
Column( Column(
modifier = modifier =
modifier modifier
.widthIn(max = DefaultReadingWidth)
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight() .fillMaxHeight()
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
@@ -342,12 +334,13 @@ fun SearchScreen(
) )
// Title row // Title row
val sidePadding = readingHorizontalPadding()
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -384,7 +377,7 @@ fun SearchScreen(
textStyle = MaterialTheme.typography.bodyMedium, textStyle = MaterialTheme.typography.bodyMedium,
placeholder = { placeholder = {
Text( Text(
"Search notes, people, tags... or use operators", "Search people, tags, notes…",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
}, },
@@ -548,7 +541,7 @@ private fun SearchEmptyState(
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
// Saved searches // Saved searches
@@ -199,9 +199,10 @@ fun ThreadScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
ReadingColumn { ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header — Messages-style: compact row with back + titleMedium // Header — Messages-style: compact row with back + titleMedium
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
@@ -240,7 +241,7 @@ fun ThreadScreen(
else -> { else -> {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
// Root note // Root note
@@ -30,14 +30,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -439,20 +437,14 @@ fun UserProfileScreen(
previousFirstVisibleItemScrollOffset = currentOffset previousFirstVisibleItemScrollOffset = currentOffset
} }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { ReadingColumn {
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier =
Modifier
.widthIn(max = DefaultReadingWidth)
.fillMaxWidth()
.fillMaxHeight(),
) {
if (connectedRelays.isEmpty()) { if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")
} else { } else {
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
@@ -470,10 +462,11 @@ fun UserProfileScreen(
) )
} }
// Header — Messages-style: compact row, titleMedium title // Header — Messages-style: compact row, titleMedium title.
// Horizontal gutter already supplied by LazyColumn.contentPadding.
item(key = "header") { item(key = "header") {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -902,8 +895,11 @@ fun UserProfileScreen(
} }
} }
// Floating header — appears on scroll up when profile header is out of view // Floating header — appears on scroll up when profile header is out of view.
AnimatedVisibility( // Fully-qualified call to force the non-scoped overload; ReadingColumn
// provides a ColumnScope in the outer lambda which would otherwise win
// overload resolution and break the BoxScope Modifier.align call below.
androidx.compose.animation.AnimatedVisibility(
visible = showFloatingHeader, visible = showFloatingHeader,
enter = slideInVertically { -it }, enter = slideInVertically { -it },
exit = slideOutVertically { -it }, exit = slideOutVertically { -it },
@@ -73,12 +73,15 @@ fun MyHighlightsScreen(
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) } var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
com.vitorpamplona.amethyst.desktop.ui
.readingHorizontalPadding()
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -95,7 +98,7 @@ fun MyHighlightsScreen(
) )
} else { } else {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
allHighlights.forEach { (addressTag, highlights) -> allHighlights.forEach { (addressTag, highlights) ->