diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 290eae80..7cddfd91 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -86,6 +86,7 @@ kotlin { implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) + implementation(libs.compose.material3.adaptive) implementation (libs.compose.material.icons.core) implementation (libs.compose.material.icons.extended) implementation(libs.compose.ui) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt new file mode 100644 index 00000000..5d210bf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt @@ -0,0 +1,102 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowDpSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * M3's five window width breakpoints. + * + * Renamed from "window size class" in the May 2026 revision, which also grew the set from + * three to five: [Large] and [ExtraLarge] were split off the old expanded class because a + * 1600dp window and an 850dp one want different numbers of panes. Values are from + * m3.material.io/foundations/layout/applying-layout/window-size-classes, read September + * 2026. + * + * | breakpoint | width | panes | navigation | + * |--------------|-------------|------------------------|-------------------------| + * | [Compact] | under 600dp | 1 | navigation bar | + * | [Medium] | 600–839dp | 1 recommended | collapsed rail | + * | [Expanded] | 840–1199dp | 2 recommended | collapsed/expanded rail | + * | [Large] | 1200–1599dp | 2 recommended | expanded rail | + * | [ExtraLarge] | 1600dp+ | up to 3 | expanded rail | + * + * The classification is on the **window**, not on the composable being measured. That is + * the distinction between this and `BoxWithConstraints`: a pane 300dp wide inside a + * 1400dp window is still in a large layout, and should not start behaving like a phone. + * Anything wanting the local constraints should still measure them. + * + * @property minWidth the narrowest window that falls in this breakpoint. + */ +enum class Breakpoint(val minWidth: Dp) { + Compact(0.dp), + Medium(600.dp), + Expanded(840.dp), + Large(1200.dp), + ExtraLarge(1600.dp); + + /** + * `true` when this breakpoint is [other] or anything wider. + * + * The comparison call sites want almost always. Enum ordering already answers it, but + * `breakpoint >= Breakpoint.Expanded` reads as a size comparison on a width and is + * one edit away from being wrong if a breakpoint is ever inserted; this says what it + * means. + */ + fun isAtLeast(other: Breakpoint): Boolean = ordinal >= other.ordinal + + companion object { + /** + * The breakpoint a window of [width] falls in. + * + * Ranges are half-open on the upper bound -- exactly 600dp is [Medium], not + * [Compact] -- which is how M3 states them and how `WindowSizeClass` computes + * them. + * + * Total, including for a width below [Compact.minWidth]. This is called from + * [TorchTheme] on every composition, and a desktop window reports a zero size for + * the frame before its first layout pass; throwing there would take the app down + * on a resize rather than on anything a user did. + */ + fun ofWidth(width: Dp): Breakpoint = + entries.lastOrNull { width >= it.minWidth } ?: Compact + } +} + +/** + * The breakpoint the current window is in. + * + * Provided by [TorchTheme] as [LocalBreakpoint], so screens read + * `MaterialTheme.breakpoint` rather than calling this. It is public because the two + * entry points that compose above the theme -- the desktop passphrase gate, and any + * future splash -- have nowhere else to get it. + * + * **Why the window size and not `currentWindowAdaptiveInfo()`.** The latter also computes + * a [androidx.compose.material3.adaptive.Posture] from the platform's fold state, which + * on android means reaching for `WindowInfoTracker` and an `Activity`. This is called + * from [TorchTheme], which wraps every `@Preview` in the tree, and a preview context is + * not an activity. `currentWindowDpSize()` is `LocalWindowInfo` and `LocalDensity` and + * nothing else, so it answers everywhere. The pane scaffolds ask for posture themselves, + * where a fold genuinely changes the answer. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun currentBreakpoint(): Breakpoint = Breakpoint.ofWidth(currentWindowDpSize().width) + +/** + * Defaults to [Breakpoint.Compact] rather than throwing, because that is the layout every + * screen in this app was written against: a composable that never reaches a [TorchTheme] + * should render as it did before breakpoints existed, not fail. + */ +val LocalBreakpoint = staticCompositionLocalOf { Breakpoint.Compact } + +/** `MaterialTheme.breakpoint`, to match `MaterialTheme.spacing` and `MaterialTheme.colorScheme`. */ +val MaterialTheme.breakpoint: Breakpoint + @Composable + @ReadOnlyComposable + get() = LocalBreakpoint.current diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt index ad1aa8c8..99c6b6c8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt @@ -19,11 +19,10 @@ import androidx.compose.ui.unit.dp * m3.material.io/m3/pages/spacing/tokens, read September 2026; see * docs/material-design-conformance.md for the full table. * - * **Why a `data class` and not constants.** Nothing scales today, but two things are - * coming that need to: spacing adapts across breakpoints, and M3 has a density setting - * for data-heavy views. Both are a matter of providing a different [Spacing] instance - * rather than of touching a call site, and that is only true if the values arrive through - * the local. A file of top-level `val`s would read the same and adapt to nothing. + * **Why a `data class` and not constants.** So that a different instance can be provided + * without touching a call site, which is what [spacingFor] now does per [Breakpoint] and + * what M3's density setting for data-heavy views would do next. A file of top-level + * `val`s would read the same at the call site and adapt to nothing. */ @Immutable data class Spacing( @@ -45,7 +44,7 @@ data class Spacing( val space700: Dp = 56.dp, val space800: Dp = 64.dp, val space900: Dp = 72.dp, -) { + // ----------------------------------------------------------------------- // Semantic names // ----------------------------------------------------------------------- @@ -65,30 +64,64 @@ data class Spacing( // aren't uniform, and require more tokens" -- so there is exactly one margin here, // for the screen edge, and everything else is padding or a gap. + // They are constructor parameters rather than `get()`s over the scale so that a + // breakpoint can reassign one without moving the stop underneath it. That direction + // matters: M3's spacing tokens are absolute values that do not change with window + // width -- `space200` is 16dp on a phone and 16dp on a desktop -- and what adapts is + // which token a given job reaches for. A wider window takes a wider screen margin, it + // does not take a wider 16. + // + // Kotlin resolves a default expression against the parameters before it, so each of + // these still reads its stop by name and follows it when the scale itself is + // overridden. `Spacing(space200 = 24.dp)` still moves `screenMargin`. + /** Screen edge to content. The one margin; everything inside a screen is padding or a gap. */ - val screenMargin: Dp get() = space200 + val screenMargin: Dp = space200, /** Inside a card, dialog, sheet or list row: container edge to its content. */ - val containerPadding: Dp get() = space200 + val containerPadding: Dp = space200, /** Inside a compact container -- a chip, a badge, a dense row. */ - val compactPadding: Dp get() = space100 + val compactPadding: Dp = space100, /** Between two elements that belong to the same thought: a label and its value. */ - val relatedGap: Dp get() = space50 + val relatedGap: Dp = space50, /** The default gap between items in a list or column. */ - val itemGap: Dp get() = space100 + val itemGap: Dp = space100, /** Between one group of content and the next within a screen. */ - val sectionGap: Dp get() = space300 + val sectionGap: Dp = space300, /** Around a lone element that needs to stand apart -- an empty state, a hero action. */ - val emphasisGap: Dp get() = space500 + val emphasisGap: Dp = space500, /** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */ - val targetGap: Dp get() = space100 -} + val targetGap: Dp = space100, +) + +/** + * The spacing a window at [breakpoint] should use. + * + * Exactly one value moves, and that is not an oversight. M3 publishes a margin per + * breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else that + * varies with window width: padding inside a card and the gap between two list rows are + * component decisions, and a card does not become a different component because the + * window grew. Widening them all would be the "everything breathes on a big screen" + * instinct, which reads as a zoomed phone rather than as a layout. + * + * What actually fills a wide window is a second pane and a bounded measure, not fatter + * gaps. Those are layout, and they live in `AdaptiveContent` rather than here. + */ +fun spacingFor(breakpoint: Breakpoint): Spacing = + if (breakpoint == Breakpoint.Compact) CompactSpacing else MediumAndWiderSpacing + +// Held as singletons rather than built per call. `LocalSpacing` is a +// `staticCompositionLocalOf`, so a provider that hands it a fresh but equal instance on +// every recomposition would restart every composition reading it; `Spacing` is a data +// class, but static locals compare by identity when deciding whether to invalidate. +private val CompactSpacing = Spacing() +private val MediumAndWiderSpacing = Spacing(screenMargin = 24.dp) val LocalSpacing = staticCompositionLocalOf { Spacing() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index cb695984..3e160e8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -505,12 +505,17 @@ fun TorchTheme( val colorScheme = dynamicColorScheme(darkTheme, dynamicColor) ?: appColorScheme(darkTheme, contrast) + // Classified once, here, so that every screen below reads the same answer. Doing it + // per screen would let two of them disagree about the window they are both in, which + // is the failure mode of `BoxWithConstraints`-per-screen adaptivity. + val breakpoint = currentBreakpoint() + CompositionLocalProvider( LocalExtendedColors provides extendedColorsFor(darkTheme), - // Nothing scales it yet. It rides the theme now so that the breakpoint phase can - // provide a wider instance without touching a call site -- which is the whole - // reason for tokenising spacing rather than leaving it in literals. - LocalSpacing provides Spacing(), + LocalBreakpoint provides breakpoint, + // The payoff for tokenising spacing in phase 2: the screen margin widens from + // 16dp to 24dp at medium and above without a single call site changing. + LocalSpacing provides spacingFor(breakpoint), ) { MaterialExpressiveTheme( colorScheme = colorScheme, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/BreakpointTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/BreakpointTest.kt new file mode 100644 index 00000000..58fce9da --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/BreakpointTest.kt @@ -0,0 +1,123 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The breakpoint thresholds against M3's published values, and the spacing they select. + * + * The thresholds are the kind of number that is wrong silently: a layout that switches to + * two panes at 640dp instead of 600dp still looks like a working layout on every device + * anybody happens to test, and only misbehaves in the 40dp nobody opens. Asserting the + * boundary from both sides is the only way that shows up. + */ +class BreakpointTest { + + /** m3.material.io/foundations/layout/applying-layout/window-size-classes, September 2026. */ + private val published = listOf( + Breakpoint.Compact to 0, + Breakpoint.Medium to 600, + Breakpoint.Expanded to 840, + Breakpoint.Large to 1200, + Breakpoint.ExtraLarge to 1600, + ) + + @Test + fun `each breakpoint starts at its published width`() { + assertEquals(published.size, Breakpoint.entries.size, "a breakpoint was added or removed") + published.forEach { (breakpoint, lowerBound) -> + assertEquals( + lowerBound.dp, + breakpoint.minWidth, + "$breakpoint starts at ${breakpoint.minWidth}, M3 says $lowerBound.dp", + ) + } + } + + @Test + fun `the lower bound belongs to the breakpoint it opens`() { + // Half-open on the upper bound: exactly 600dp is Medium, and 599dp is Compact. + // Off by one here and a phone in landscape gets the tablet layout, or does not. + published.forEach { (breakpoint, lowerBound) -> + assertEquals( + breakpoint, + Breakpoint.ofWidth(lowerBound.dp), + "${lowerBound}dp should be exactly at the bottom of $breakpoint", + ) + } + } + + @Test + fun `one dp below a lower bound is the breakpoint beneath it`() { + published.drop(1).forEachIndexed { indexBefore, (breakpoint, lowerBound) -> + val beneath = published[indexBefore].first + assertEquals( + beneath, + Breakpoint.ofWidth((lowerBound - 1).dp), + "${lowerBound - 1}dp fell in $breakpoint rather than $beneath", + ) + } + } + + @Test + fun `the widths the phase is meant to be checked at land where the plan says`() { + // The five window widths phase 6's acceptance criterion names. If one of these + // ever moves, the manual check and the code have stopped talking about the same + // thing. + assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(400.dp)) + assertEquals(Breakpoint.Medium, Breakpoint.ofWidth(700.dp)) + assertEquals(Breakpoint.Expanded, Breakpoint.ofWidth(1000.dp)) + assertEquals(Breakpoint.Large, Breakpoint.ofWidth(1400.dp)) + assertEquals(Breakpoint.ExtraLarge, Breakpoint.ofWidth(1800.dp)) + } + + @Test + fun `a zero or negative width is compact rather than an error`() { + // A window has zero width for one frame on desktop, before the first layout pass, + // and `ofWidth` is called from the theme every composition. Throwing there would + // take the app down on a resize. + assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(0.dp)) + assertEquals(Breakpoint.Compact, Breakpoint.ofWidth((-1).dp)) + } + + @Test + fun `isAtLeast reads up the scale and not down it`() { + assertTrue(Breakpoint.Large.isAtLeast(Breakpoint.Expanded)) + assertTrue(Breakpoint.Expanded.isAtLeast(Breakpoint.Expanded)) + assertTrue(!Breakpoint.Medium.isAtLeast(Breakpoint.Expanded)) + } + + @Test + fun `the screen margin widens at medium and holds there`() { + // M3 publishes 16dp compact, 24dp for every wider breakpoint -- it does not keep + // growing. A margin that scaled with the window would push a bounded column of + // text further from the edge for no reason at 1800dp. + assertEquals(16.dp, spacingFor(Breakpoint.Compact).screenMargin) + listOf(Breakpoint.Medium, Breakpoint.Expanded, Breakpoint.Large, Breakpoint.ExtraLarge) + .forEach { assertEquals(24.dp, spacingFor(it).screenMargin, "$it") } + } + + @Test + fun `nothing but the screen margin moves with the breakpoint`() { + // The scale itself is absolute -- space200 is 16dp in every window -- and the + // other semantic names are component decisions. This is the assertion that stops + // a later "make it breathe on desktop" edit from quietly turning the whole scale + // into a zoom factor. + val compact = spacingFor(Breakpoint.Compact) + val wide = spacingFor(Breakpoint.ExtraLarge) + + assertEquals(compact, wide.copy(screenMargin = compact.screenMargin)) + } + + @Test + fun `every breakpoint gets one of two shared instances`() { + // LocalSpacing is a static composition local: it invalidates on identity, not on + // equality, so handing it a freshly built but equal Spacing every recomposition + // would restart every composition that reads spacing. + assertSame(spacingFor(Breakpoint.Medium), spacingFor(Breakpoint.ExtraLarge)) + assertSame(spacingFor(Breakpoint.Compact), spacingFor(Breakpoint.Compact)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d36a928..90e4013b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,12 @@ ksp = "2.3.6" ktor = "3.5.2" lightningKmpCore = "1.11.5" material3 = "1.10.0-alpha05" +# The version material3 1.10.0-alpha05 itself resolves: its +# material3-adaptive-navigation-suite pom asks for adaptive 1.2.0. Picking the newer +# 1.3.0-beta02 would drag window-core 1.5.0 in beside the 1.4.0 the pinned material3 +# compiled against, for a large/extra-large breakpoint pair that 1.2.0 already computes +# through `supportLargeAndXLargeWidth`. +material3Adaptive = "1.2.0" materialIconsCore = "1.7.3" materialIconsExtended = "1.7.3" navigationCompose = "2.9.2" @@ -54,6 +60,7 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" } compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "materialIconsCore" } compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }