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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -1324,18 +1323,17 @@ fun RelaySettingsScreen(
accountManager.loadNwcConnection()
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
com.vitorpamplona.amethyst.desktop.ui
.readingHorizontalPadding()
Column(
modifier =
Modifier
.widthIn(max = 720.dp)
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp),
.padding(horizontal = sidePadding),
) {
Row(
modifier =
@@ -249,13 +249,14 @@ fun BookmarksScreen(
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header with tabs
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -306,7 +307,7 @@ fun BookmarksScreen(
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = sidePadding),
) {
items(currentEvents, key = { it.id }) { event ->
Column(
@@ -67,12 +67,13 @@ fun DraftsScreen(
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
ReadingColumn {
val sidePadding = readingHorizontalPadding()
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -103,7 +104,7 @@ fun DraftsScreen(
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(drafts, key = { it.slug }) { entry ->
@@ -55,7 +55,10 @@ fun FeedHeader(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = readingHorizontalPadding(), vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -546,8 +546,9 @@ fun FeedScreen(
is FeedState.Loaded -> {
val loadedState by state.feed.collectAsState()
val sidePadding = LocalReadingSidePadding.current
LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = sidePadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(loadedState.list, key = { it.idHex }) { note ->
@@ -649,8 +650,12 @@ private fun FeedHeader(
onNavigateToRelays: () -> Unit = {},
onOpenRelayPicker: () -> Unit = {},
) {
val sidePadding = LocalReadingSidePadding.current
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,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -256,7 +256,7 @@ fun NotificationsScreen(
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
@@ -20,15 +20,13 @@
*/
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.ColumnScope
import androidx.compose.foundation.layout.fillMaxHeight
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.ui.Alignment
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
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
/**
* A top-level content scaffold that caps width and centers its column on wide
* displays. Each feed / list / profile screen wraps its contents in this so
* cards maintain a consistent proportion across the whole app.
* Side padding the current screen should apply to its scrollable list /
* headers to keep content centered at [DefaultReadingWidth] within the
* 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:
* - Messages (two-pane layout with its own sizing)
* - 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
fun ReadingColumn(
modifier: Modifier = Modifier,
maxWidth: Dp = DefaultReadingWidth,
content: @Composable ColumnScope.() -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
// 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,
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val sidePadding = ((this.maxWidth - maxWidth) / 2).coerceAtLeast(0.dp)
CompositionLocalProvider(LocalReadingSidePadding provides sidePadding) {
Column(modifier = Modifier.fillMaxSize(), content = content)
}
}
}
@@ -286,13 +286,14 @@ fun ReadsScreen(
}
ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header — Messages-style: tabs left, refresh right. The selected tab
// (Following / Global) acts as the screen title, so no separate label.
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -357,7 +358,7 @@ fun ReadsScreen(
else -> {
LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -292,16 +290,10 @@ fun SearchScreen(
focusRequester.requestFocus()
}
androidx.compose.foundation.layout.Box(
modifier =
androidx.compose.ui.Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.TopCenter,
) {
ReadingColumn {
Column(
modifier =
modifier
.widthIn(max = DefaultReadingWidth)
.fillMaxWidth()
.fillMaxHeight()
.onPreviewKeyEvent { event ->
@@ -342,12 +334,13 @@ fun SearchScreen(
)
// Title row
val sidePadding = readingHorizontalPadding()
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -384,7 +377,7 @@ fun SearchScreen(
textStyle = MaterialTheme.typography.bodyMedium,
placeholder = {
Text(
"Search notes, people, tags... or use operators",
"Search people, tags, notes…",
style = MaterialTheme.typography.bodyMedium,
)
},
@@ -548,7 +541,7 @@ private fun SearchEmptyState(
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Saved searches
@@ -199,9 +199,10 @@ fun ThreadScreen(
Box(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header — Messages-style: compact row with back + titleMedium
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
@@ -240,7 +241,7 @@ fun ThreadScreen(
else -> {
LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note
@@ -30,14 +30,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -439,20 +437,14 @@ fun UserProfileScreen(
previousFirstVisibleItemScrollOffset = currentOffset
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Box(
modifier =
Modifier
.widthIn(max = DefaultReadingWidth)
.fillMaxWidth()
.fillMaxHeight(),
) {
ReadingColumn {
Box(modifier = Modifier.fillMaxSize()) {
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else {
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp),
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") {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -902,8 +895,11 @@ fun UserProfileScreen(
}
}
// Floating header — appears on scroll up when profile header is out of view
AnimatedVisibility(
// Floating header — appears on scroll up when profile header is out of view.
// 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,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
@@ -73,12 +73,15 @@ fun MyHighlightsScreen(
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
com.vitorpamplona.amethyst.desktop.ui
.readingHorizontalPadding()
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -95,7 +98,7 @@ fun MyHighlightsScreen(
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp),
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
allHighlights.forEach { (addressTag, highlights) ->