diff --git a/AGENTS.md b/AGENTS.md index 52b35c1..c5f3e3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,8 @@ The project **is Kotlin Multiplatform**. The migration ran one module at a time, `model` → `network` → `repository` → `presenter` → `ui` → `shared` → `shared-compose`. **Every library module is migrated.** The only Android-specific module left is `app`, which stays -an Android application module — it is the Android entry point, and the CMP iOS, desktop and web -apps get their own equivalents. +an Android application module — it is the Android entry point. `web` and `desktop` are its +equivalents for the browser and the JVM; a CMP iOS app would be the fourth. Supported targets, declared once in the `kmp-library` convention plugin: @@ -229,11 +229,12 @@ tier, the Metro provider itself — stays in `commonMain`. Add new per-platform ## Module structure -Nine modules, with dependencies flowing strictly downward: +Ten modules, with dependencies flowing strictly downward: ``` app → Android entry point: Activity, theme, manifest. Nothing else. web → Browser entry point (js + wasmJs): main(), index.html, URL routing. +desktop → Desktop entry point (jvm): main(), Window, keyboard back, flag font. shared-compose → ComposeGraph — the Metro graph every Compose app shares shared → CoreGraph for non-Compose consumers, plus the root Logger ui → Compose UI (Circuit Ui implementations), CircuitProviders @@ -246,8 +247,8 @@ model → Kotlin domain types There are **two graphs** because of how the platform apps differ: - `shared-compose` declares `ComposeGraph`, which exposes `Circuit`. Every Compose consumer - shares it — the Android and browser apps today, and the Compose Multiplatform iOS and desktop - apps alongside them. None of them declares a graph of its own. + shares it — the Android, browser and desktop apps today, and a Compose Multiplatform iOS app + alongside them. None of them declares a graph of its own. - `shared` declares `CoreGraph`, which exposes repositories and no Compose types at all. That is what a SwiftUI/UIKit iOS app uses: it drives Circuit `Presenter`s directly (see Circuit's counter sample) and needs neither a `Circuit` instance nor any `Ui.Factory`, so it must not @@ -256,21 +257,25 @@ There are **two graphs** because of how the platform apps differ: All packages live under `io.github.solcott.countries`, with each module using its own name as the suffix — `…countries.model`, `…countries.network`, `…countries.repository`, `…countries.presenter`, `…countries.ui`, -`…countries.shared`, `…countries.shared.compose`, `…countries.web`. The `app` +`…countries.shared`, `…countries.shared.compose`, `…countries.web`, +`…countries.desktop`. The `app` module uses the root `io.github.solcott.countries`, which is also the `applicationId`. Each module's Gradle `namespace` matches its package. Rules: -- **An app module holds no dependency wiring.** `app` and `web` depend on `shared-compose` - and nothing else from this project for the graph. Adding a `@Provides` to an app module is - almost always wrong — it would not be available to the other platform apps. +- **An app module holds no dependency wiring.** `app`, `web` and `desktop` depend on + `shared-compose` and nothing else from this project for the graph. Adding a `@Provides` to an app + module is almost always wrong — it would not be available to the other platform apps. - **The app itself is `CountriesApp` in `:ui`, not the entry point.** The theme, the backstack, - `CircuitCompositionLocals` and `NavigableCircuitContent` live there; `MainActivity` and the - browser `main()` each do two things only — read `circuit` off the graph, and call it. New - screen-agnostic wiring belongs in `CountriesApp`, not in an entry point. + `CircuitCompositionLocals` and `NavigableCircuitContent` live there; `MainActivity`, the browser + `main()` and the desktop `main()` each do two things only — read `circuit` off the graph, and + call it. New screen-agnostic wiring belongs in `CountriesApp`, not in an entry point. `rememberCircuitNavigator`'s `onRootPop` is the exception: it is genuinely per-platform - (Android finishes the Activity, the browser no-ops) and is passed in. + (Android finishes the Activity; the browser and desktop no-op) and is passed in. +- **`:ui` has exactly one platform seam: `LocalFlagFontFamily`.** It is null everywhere but + desktop — see [Fonts on desktop](#fonts-on-desktop). Resist adding a second; the reason this one + earns its place is that the alternative was a wrong-looking list on two of the six platforms. - A module contributes its own providers with `@ContributesTo(AppScope::class)`, next to the code they construct: `NetworkProviders` in `network`, `CircuitProviders` in `ui`, `LoggingProviders` in `shared`. @@ -352,6 +357,82 @@ module — see `presenter/build.gradle.kts`. CMP 1.12 added bundle reaches skiko without an executable binary to bundle it into. It fires off the target's test task existing, not off there being test sources, and there is no opt-out property. +### The `desktop` module + +The Windows/Linux/macOS app. It is the smallest of the three entry points, because everything that +made `:web` interesting — history, a service worker, npm — the JVM either has already or does not +need. Four things are worth knowing: + +- **It is a plain `kotlin("jvm")` module, not multiplatform.** Desktop *is* the jvm target, so + `kotlin { }` would hold exactly one target and `src/jvmMain` would be a directory with nothing to + distinguish it from `src/main`. `:web` is multiplatform because it genuinely serves two targets + from one module. Like `:web` it does not apply `kmp-library`, and like `:web` it declares + `kotlin("test")` and the JVM toolchain itself, since no convention is doing it. +- **`compose.desktop.currentOs` is the one dependency declared through a plugin accessor rather + than a catalog coordinate.** It has to be: skiko's runtime jar is classified by OS *and* + architecture, and only the accessor picks the right one. **The consequence is that everything + built here runs on the build host's OS only** — including `packageUberJarForCurrentOS`. Real + cross-platform installers need the packaging task run on each OS, because jpackage cannot + cross-build either; that is a CI matrix, and this repo has no CI yet. +- **`nativeDistributions { modules(...) }` is load-bearing and fails invisibly.** jpackage jlinks a + trimmed JDK, and the default module set has neither `java.sql`/`jdk.unsupported` (sqlite-jdbc, + under the Apollo cache) nor `java.naming`/`jdk.crypto.ec` (OkHttp's TLS). `run` uses the full + JDK, so a missing module never shows up in development — only in an installed build, as a crash + on the first query. Test packaging changes with `packageDistributionForCurrentOS`, not `run`. +- **Keyboard back is `isBackShortcut()` in `BackShortcut.kt`**, pure and tested, for the same + reason `historyAction()` is: a rule welded to a `KeyEvent` cannot be tested without a window. The + backstack is hoisted out of `CountriesApp` so `Window`'s `onKeyEvent` can reach it. `onRootPop` + is deliberately left at its default no-op — the close button is how you leave a desktop app, and + Esc on the root screen should not quit it. + +Icons live in `desktop/icons/` and are the source of truth for both consumers: jpackage reads all +three from disk, and `icon.png` is also on the runtime classpath for the window and dock icon. +`build.gradle.kts` adds that directory as a resource root and excludes `*.icns`/`*.ico` from the +jar, since only the PNG is useful at runtime. + +### Fonts on desktop + +Same root cause as [Fonts on web](#fonts-on-web) — Skia has no system font manager — but a +different outcome per platform, and the 1.12 web font downloader does not apply here. + +| Platform | Flags | Non-Latin native names | +| --- | --- | --- | +| macOS | Apple Color Emoji, fine | fine | +| Windows | **Segoe UI Emoji has no flag glyphs** — renders as the letter pair, e.g. "FR" | fine | +| Linux | tofu without Noto Color Emoji | tofu without Noto CJK etc. | + +Windows omitting flag glyphs is Microsoft's deliberate policy, not a gap that will close. So +`:desktop` bundles `NotoColorEmoji-flagsonly.ttf` and provides it through `:ui`'s +`LocalFlagFontFamily`. That covers the flags. **It does not cover the non-Latin names on Linux** — +that would mean committing several MB of Noto CJK, and a Linux desktop that renders no CJK at all +is a system that will fail on far more than this app. + +The font is upstream, verbatim, so updating it is a download: + +``` +curl -LO https://github.com/googlefonts/noto-emoji/raw/main/fonts/NotoColorEmoji-flagsonly.ttf +``` + +It is SIL Open Font License 1.1; the notice is committed beside it as +`desktop/src/main/resources/font/OFL.txt`. + +Two rules that `FlagFontTest` pins, both discovered the hard way: + +- **It must be the CBDT build, not `Noto-COLRv1.ttf`.** COLRv1 needs FreeType 2.11+ on Linux or a + Windows 11-era DirectWrite to rasterise, and where it is unsupported it draws *nothing* rather + than falling back. Blank is a worse failure than letters. CBDT stores each glyph as a PNG, which + is the most widely supported colour format there is. +- **It must not be handed to macOS.** Skia goes through CoreText there, and CoreText refuses to + load a bitmap-only font outright — `makeFromData` returns null, and the flags would disappear on + the one platform that never needed the font. `needsBundledFlagFont()` is the guard, and + `flagFontFamily` is null on macOS. (The COLRv1 build is not the escape hatch: CoreText loads it + and then Skia's CoreText scaler renders its layers as nothing.) + +The practical consequence for anyone changing this: **macOS cannot verify the font renders.** The +test states the invariant as "wherever Skia can load it, it must ligate and rasterise in colour; +where it cannot, the app must not be using it", which is the strongest thing a Mac can assert. +Flags on Windows and Linux need a real machine. + ### Offline, and the service worker `web/src/commonMain/resources/sw.js`, registered from `ServiceWorker.kt`. **This is the thing that @@ -485,8 +566,16 @@ needs, so it is easy to fix one and forget the other. ./gradlew :web:jsBrowserDevelopmentRun ./gradlew :web:wasmJsBrowserDistribution # → web/build/dist/wasmJs/productionExecutable ./gradlew :web:jsBrowserDistribution # → web/build/dist/js/productionExecutable + +# Desktop app +./gradlew :desktop:run +./gradlew :desktop:packageUberJarForCurrentOS # → desktop/build/compose/jars +./gradlew :desktop:packageDistributionForCurrentOS # → desktop/build/compose/binaries ``` +Both desktop packaging tasks produce a build for the **host** OS only — see +[The `desktop` module](#the-desktop-module). + `ktfmtCheck` at the root does not cover `build-logic` — that is a separate included build. Run it from inside `build-logic/` to check the convention plugins. diff --git a/DECISION_LOG.md b/DECISION_LOG.md index 71fe742..d635f3d 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -460,6 +460,70 @@ never render. Android was re-verified on an emulator rather than assumed, since the Compose jump from stable to rc is the part of this change least exercised by the test suite and easiest to skip. +## How is the desktop app put together? + +`:desktop` is the third entry point, and by far the least interesting one to build — which is the +point. `CountriesApp` was already extracted for `:web`, every library module already published a +`jvm` target, and `:network` already had a JVM `platformConfiguration` pointing the Apollo SQLite +cache at `~/.apollo`. The module is a `main()`, a `Window`, and two platform affordances. + +**A plain `kotlin("jvm")` module, not multiplatform.** Desktop *is* the jvm target. A `kotlin { }` +block would hold exactly one target and `src/jvmMain` would be a directory distinguishable from +`src/main` only by name. `:web` earns multiplatform because it serves js and wasmJs from one +module; this does not. + +**Two things are genuinely per-platform, and both are small.** A keyboard back binding, because +desktop has no back gesture and the top-bar arrow was the only way out of the detail screen; and a +window with a starting size and a floor under it. `onRootPop` stays the default no-op — Android +passes `finish()` because leaving the app is what back-past-root means there, but on desktop the +close button is how you leave, and Esc should not quit the app out from under you. The back rule +went into a pure `isBackShortcut()` for the same reason `historyAction()` is pure: a decision +welded to a `KeyEvent` cannot be tested without a window. + +**Flags forced the one platform seam in `:ui`.** This is the same root cause as the web tofu — +Skia has no system font manager, so it draws with the font it is handed — but it does not resolve +the same way. macOS is fine. Windows renders every one of the 250 rows as a letter pair, because +Segoe UI Emoji has no flag glyphs *by Microsoft's policy*, and no amount of waiting fixes that. +Linux without Noto Color Emoji renders tofu. So `:ui` gained `LocalFlagFontFamily`, null +everywhere but desktop, applied to the two composables that render nothing but a flag. + +**Choosing the font is where the real work was, and my first two answers were both wrong.** I +started from the plan's assumption — subset the flag block out of `Noto-COLRv1.ttf` with +`pyftsubset` — then found upstream ships `NotoColorEmoji-flagsonly.ttf` ready-made, at a third of +the size, which made the whole pipeline a `curl`. Both of those were decided on size and +provenance, neither on whether Skia could actually draw them. It cannot, in both cases, on the +machine I was building on: + +- The CBDT build does not **load** on macOS at all. Skia goes through CoreText there, and CoreText + refuses a bitmap-only font; `makeFromData` returns null. +- The COLRv1 build loads, shapes the ligature correctly, reports a sensible advance — and + rasterises to a blank canvas, because Skia's CoreText scaler has no COLRv1 path. + +The second one is the instructive failure. Every signal short of looking at the pixels said it +worked. What settled it was a control: rendering the same string with Apple Color Emoji through +the identical code produced 697 distinct colours, and the bundled font produced one. That is the +lesson the web fonts section already paid for once — an explanation that predicts the observation +is not one that has been checked — arriving in a form where the check was cheap and I nearly +skipped it anyway. + +The resolution is that macOS never gets the font: it does not need one, and handing it either +build would *delete* the flags rather than leave them alone. CBDT is right for the two platforms +that do need it, because PNG glyph images are the most widely supported colour format there is, +where COLRv1 wants FreeType 2.11+ or a Windows 11-era DirectWrite. Between "letters" and "blank", +letters is the better failure. + +**Packaging stops short of installers on purpose.** `run`, an uber jar, and +`packageDistributionForCurrentOS` are all wired, and the `.dmg` builds. But `compose.desktop +.currentOs` resolves skiko by OS *and* architecture, and jpackage cannot cross-build, so genuine +Windows and Linux artifacts need the task run on each OS. That is a CI matrix, and this repo has +none yet; adding one alongside signing and notarisation is a larger change than the module itself. + +**What is not verified: Windows and Linux.** I have neither, and the flag font is precisely the +thing that only shows itself on those two. The test states the invariant as far as a Mac can — +"wherever Skia can load this font it must ligate and rasterise in colour, and where it cannot, the +app must not be using it" — plus a structural check that the file is still the CBDT build. That is +a real guard against a regression, and it is not the same as having seen a flag on Windows. + ## What tradeoffs did I make due to time constraints? - Minimal error handling/presentation (generic messages, swallowed cache misses). diff --git a/README.md b/README.md index 38b6ade..88eeaca 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ A small Kotlin Multiplatform app that lists world countries from the public [Countries GraphQL API](https://countries.trevorblades.com/), lets you filter them by name and continent, and drills into a detail screen for each one. -It started as an Android app and every library module is now multiplatform. There are two -entry points today — the Android app (`:app`) and a Compose Multiplatform browser app -(`:web`, targeting both Kotlin/JS and Kotlin/Wasm) — sharing all of their UI, presentation -and data code. +It started as an Android app and every library module is now multiplatform. There are three +entry points today — the Android app (`:app`), a Compose Multiplatform browser app (`:web`, +targeting both Kotlin/JS and Kotlin/Wasm), and a Compose Multiplatform desktop app +(`:desktop`, for Windows, Linux and macOS) — sharing all of their UI, presentation and data +code. ## API choice @@ -70,6 +71,25 @@ Both produce a static bundle you can serve from anywhere: Routes are hashes, so the bundle needs no server-side rewriting and links are shareable: `#/` is the list, `#/country/FR` opens France. Browser back and forward work. +### Desktop + +No Android SDK needed. + +```bash +./gradlew :desktop:run +``` + +`Esc`, `Cmd+[` and `Alt+←` navigate back from the detail screen. To build something +installable: + +```bash +./gradlew :desktop:packageUberJarForCurrentOS # → desktop/build/compose/jars +./gradlew :desktop:packageDistributionForCurrentOS # .dmg / .msi / .deb, host OS only +``` + +Both are **host-OS builds** — the Skia runtime is selected by OS and architecture, and +jpackage cannot cross-build, so a Windows installer has to be produced on Windows. + ### Cleaner `git blame` Bulk formatting commits are listed in [`.git-blame-ignore-revs`](.git-blame-ignore-revs) so @@ -82,14 +102,15 @@ git config blame.ignoreRevsFile .git-blame-ignore-revs ## Architecture at a glance -Nine Gradle modules, dependencies flowing strictly downward: +Ten Gradle modules, dependencies flowing strictly downward: ``` app → Android entry point: Activity, theme, manifest web → Browser entry point (js + wasmJs): main(), index.html, URL routing +desktop → Desktop entry point (jvm): main(), Window, keyboard back, flag font shared-compose → ComposeGraph — the Metro graph every Compose app shares shared → CoreGraph for non-Compose consumers, plus the root Logger -ui → Compose UI (Circuit Ui), and CountriesApp — the app both entry points mount +ui → Compose UI (Circuit Ui), and CountriesApp — the app every entry point mounts presenter → Circuit Screens, presenters, state, events (the state holders) repository → domain-facing data access, generated → model mapping network → Apollo client, .graphql operations, generated code @@ -97,8 +118,9 @@ model → plain Kotlin domain types + Response ``` Everything from `shared-compose` down is Kotlin Multiplatform and builds for Android, JVM, -iOS, macOS, js and wasmJs. `app` and `web` are the only platform-specific modules, and each -does the same two things: build the Metro graph, and hand its `Circuit` to `CountriesApp`. +iOS, macOS, js and wasmJs. `app`, `web` and `desktop` are the only platform-specific modules, +and each does the same two things: build the Metro graph, and hand its `Circuit` to +`CountriesApp`. - **UI:** Jetpack Compose throughout. - **Architecture:** MVI via [Circuit](https://slackhq.github.io/circuit/) — presenters own diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts new file mode 100644 index 0000000..60cf313 --- /dev/null +++ b/desktop/build.gradle.kts @@ -0,0 +1,73 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +// The desktop entry point, and the jvm counterpart to `:app` and `:web`. Like both of those it +// holds no dependency wiring of its own — it reads `ComposeGraph` from `:shared-compose` and mounts +// `CountriesApp` from `:ui`. +// +// A plain Kotlin/JVM module rather than a multiplatform one: desktop *is* the jvm target, so there +// is no second target for `kotlin { }` to hold, and `compose.desktop.application` is built around +// this shape. `:web` is multiplatform only because it has to serve js and wasmJs from one module. +plugins { + id("formatting") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.compose") + // Brings the `compose.desktop` extension — the packaging tasks and the OS-classified runtime. + alias(libs.plugins.compose.multiplatform) + // So createGraph() resolves, exactly as in :app and :web. The graph itself, and + // every contribution to it, is aggregated on :shared-compose's compile classpath — not here. + alias(libs.plugins.metro) +} + +// Versions.JVM_TOOLCHAIN, restated: build-logic's `Versions` is visible to convention plugins only, +// and one module does not justify a new convention. +kotlin { jvmToolchain(17) } + +sourceSets.main { + // `icons/` is the source of truth for both consumers: jpackage reads the three files from disk + // (below), and the running app loads icon.png off the classpath for its window and dock icon. + // Only the PNG is useful at runtime, so the two installer-only formats stay out of the jar. + resources.srcDir(layout.projectDirectory.dir("icons")) + resources.exclude("*.icns", "*.ico") +} + +dependencies { + implementation(project(":shared-compose")) + implementation(project(":ui")) + implementation(project(":presenter")) + + implementation(libs.circuit.foundation) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + + // The one place this project reaches for a `compose.*` accessor instead of a catalog coordinate. + // It has to: skiko's runtime artifact is classified by OS *and* architecture + // (skiko-awt-runtime-macos-arm64, …) and only this accessor picks the right one. The consequence + // is that anything built here — including the uber jar — runs on the build host's OS only. + implementation(compose.desktop.currentOs) + + testImplementation(kotlin("test")) +} + +compose.desktop.application { + mainClass = "io.github.solcott.countries.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "Countries" + // jpackage requires x.y.z, and rejects a major version of 0. + packageVersion = "1.0.0" + + // jpackage jlinks a trimmed JDK, and these are not in the default module set: java.sql and + // jdk.unsupported for sqlite-jdbc behind the Apollo cache, java.naming and jdk.crypto.ec for + // OkHttp's TLS. Nothing warns — `run` uses the full JDK, so a missing module surfaces only in + // an installed build, as a crash on the first query. + modules("java.sql", "java.naming", "jdk.crypto.ec", "jdk.unsupported") + + macOS { + bundleID = "io.github.solcott.countries" + iconFile.set(project.file("icons/icon.icns")) + } + windows { iconFile.set(project.file("icons/icon.ico")) } + linux { iconFile.set(project.file("icons/icon.png")) } + } +} diff --git a/desktop/icons/icon.icns b/desktop/icons/icon.icns new file mode 100644 index 0000000..502a4f3 Binary files /dev/null and b/desktop/icons/icon.icns differ diff --git a/desktop/icons/icon.ico b/desktop/icons/icon.ico new file mode 100644 index 0000000..ff2b99a Binary files /dev/null and b/desktop/icons/icon.ico differ diff --git a/desktop/icons/icon.png b/desktop/icons/icon.png new file mode 100644 index 0000000..c157c57 Binary files /dev/null and b/desktop/icons/icon.png differ diff --git a/desktop/src/main/kotlin/io/github/solcott/countries/desktop/AppIcon.kt b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/AppIcon.kt new file mode 100644 index 0000000..f651b1e --- /dev/null +++ b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/AppIcon.kt @@ -0,0 +1,27 @@ +package io.github.solcott.countries.desktop + +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image + +private const val ICON_PATH = "/icon.png" + +/** + * The window and dock icon, loaded from `desktop/icons/icon.png` on the classpath — the same file + * jpackage bundles for Linux, and the source the `.icns` and `.ico` were derived from. + * + * Decoded once, eagerly: it is a 40 KB PNG read before the first frame, and `Window` wants a + * [Painter] rather than a composable. + */ +internal val appIcon: Painter by lazy { + val bytes = + checkNotNull(AppIconMarker::class.java.getResourceAsStream(ICON_PATH)) { + "Missing $ICON_PATH on the classpath" + } + .use { it.readBytes() } + BitmapPainter(Image.makeFromEncoded(bytes).toComposeImageBitmap()) +} + +/** Anchors the classloader lookup above. */ +private object AppIconMarker diff --git a/desktop/src/main/kotlin/io/github/solcott/countries/desktop/BackShortcut.kt b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/BackShortcut.kt new file mode 100644 index 0000000..3566338 --- /dev/null +++ b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/BackShortcut.kt @@ -0,0 +1,36 @@ +package io.github.solcott.countries.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType + +/** + * Whether a key press should navigate back. + * + * Desktop has no system back gesture, and the back arrow in the top app bar is the only affordance + * the shared UI offers. This adds the three bindings a desktop user reaches for: `Esc`, macOS's + * `Cmd+[` and `Cmd+←`, and `Alt+←` everywhere else. + * + * The rule is a pure function for the same reason `historyAction()` is one in `:web` — a decision + * welded to a `KeyEvent` cannot be tested without a window. The caller supplies [canPop], so the + * guard against popping the root screen is part of the rule rather than something the effect + * remembers to do. + * + * A bare `←` is deliberately not a shortcut: the list screen's filter field needs it. + */ +internal fun isBackShortcut( + key: Key, + type: KeyEventType, + isMetaPressed: Boolean, + isAltPressed: Boolean, + canPop: Boolean, +): Boolean { + if (!canPop || type != KeyEventType.KeyDown) return false + return when (key) { + // Unmodified, so it cannot collide with a system shortcut. On the list screen canPop is false, + // which is also what keeps Esc away from the filter field — the only screen that has one. + Key.Escape -> !isMetaPressed && !isAltPressed + Key.DirectionLeft -> isMetaPressed || isAltPressed + Key.LeftBracket -> isMetaPressed + else -> false + } +} diff --git a/desktop/src/main/kotlin/io/github/solcott/countries/desktop/FlagFont.kt b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/FlagFont.kt new file mode 100644 index 0000000..bed8858 --- /dev/null +++ b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/FlagFont.kt @@ -0,0 +1,49 @@ +package io.github.solcott.countries.desktop + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font + +internal const val FLAG_FONT_RESOURCE = "/font/NotoColorEmoji-flagsonly.ttf" + +/** + * The font `:ui` should draw the flag emoji with, or `null` to leave them to the platform. + * + * Desktop is the one platform where the flags cannot simply be left to the OS, because Skia has no + * system font manager of its own and draws with whatever font it is handed. On Windows that is + * Segoe UI Emoji, which has no flag glyphs at all — Microsoft omits them deliberately — so every + * row comes out as the two letters of the country code. A Linux install without Noto Color Emoji + * renders tofu. + * + * macOS needs nothing: Apple Color Emoji has the flags and Skia's CoreText backend finds it. See + * [needsBundledFlagFont] for why that is not merely an optimisation. + */ +internal val flagFontFamily: FontFamily? by lazy { + if (!needsBundledFlagFont(System.getProperty("os.name").orEmpty())) return@lazy null + val bytes = + checkNotNull(FlagFontMarker::class.java.getResourceAsStream(FLAG_FONT_RESOURCE)) { + "Missing $FLAG_FONT_RESOURCE on the classpath" + } + .use { it.readBytes() } + FontFamily(Font(identity = "NotoColorEmojiFlags", data = bytes)) +} + +/** + * Whether [osName] is a platform that needs the bundled font. Everything except macOS. + * + * Handing the bundled font to macOS would not be a harmless no-op, it would delete the flags. The + * file is `NotoColorEmoji-flagsonly.ttf` as googlefonts/noto-emoji publishes it — a CBDT font, + * meaning the glyphs are embedded PNGs and the outlines are empty — and Skia's macOS backend goes + * through CoreText, which refuses to load a bitmap-only font at all: `makeFromData` returns null. + * + * The COLRv1 build is not the way out of that. CoreText loads it, and then Skia's CoreText scaler + * renders the layers as nothing, which is worse — a blank column instead of a wrong one. CBDT is + * also the safer of the two for the platforms that *do* need it: PNG glyph images are the most + * widely supported colour format there is, where COLRv1 needs FreeType 2.11+ on Linux and a Windows + * 11-era DirectWrite. Between "letters" and "blank", letters is the better failure. + * + * Pure and separate from the loading so it can be tested on a machine of either kind. + */ +internal fun needsBundledFlagFont(osName: String): Boolean = !osName.startsWith("mac", true) + +/** Anchors the classloader lookup above. */ +private object FlagFontMarker diff --git a/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt new file mode 100644 index 0000000..44573f5 --- /dev/null +++ b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt @@ -0,0 +1,74 @@ +package io.github.solcott.countries.desktop + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.slack.circuit.backstack.rememberSaveableBackStack +import dev.zacsweers.metro.createGraph +import io.github.solcott.countries.presenter.CountryListScreen +import io.github.solcott.countries.shared.compose.ComposeGraph +import io.github.solcott.countries.ui.CountriesApp +import io.github.solcott.countries.ui.LocalFlagFontFamily +import java.awt.Dimension + +/** + * `:ui` generates its `Res` class privately, so the window title cannot come from `strings.xml`. + */ +private const val WINDOW_TITLE = "Countries" + +private val INITIAL_SIZE = DpSize(1100.dp, 800.dp) +private val MINIMUM_SIZE = Dimension(480, 600) + +/** + * The desktop entry point, and the jvm counterpart to `:app`'s `MainActivity` and `:web`'s + * `main()`. All three do the same two things: read the `Circuit` out of [ComposeGraph], and hand it + * to `CountriesApp`. + * + * The backstack is hoisted for the same reason `:web` hoists it — something outside `CountriesApp` + * needs to drive it. There it is `window.history`; here it is the keyboard. + */ +fun main() = application { + val circuit = remember { createGraph().circuit } + val backStack = rememberSaveableBackStack(root = CountryListScreen) + val windowState = + rememberWindowState(size = INITIAL_SIZE, position = WindowPosition(Alignment.Center)) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = WINDOW_TITLE, + icon = appIcon, + onKeyEvent = { event -> + isBackShortcut( + key = event.key, + type = event.type, + isMetaPressed = event.isMetaPressed, + isAltPressed = event.isAltPressed, + canPop = backStack.size > 1, + ) + // Returning true consumes the event; false lets it reach the focused composable. + .also { if (it) backStack.pop() } + }, + ) { + // AWT, and not expressible through WindowState. Without it the window can be dragged narrower + // than the list rows tolerate. + LaunchedEffect(Unit) { window.minimumSize = MINIMUM_SIZE } + + // onRootPop is left at its default no-op: on desktop the window's close button is how you + // leave, and popping past the list should not quit the app out from under the user. + CompositionLocalProvider(LocalFlagFontFamily provides flagFontFamily) { + CountriesApp(circuit = circuit, backStack = backStack) + } + } +} diff --git a/desktop/src/main/resources/font/NotoColorEmoji-flagsonly.ttf b/desktop/src/main/resources/font/NotoColorEmoji-flagsonly.ttf new file mode 100644 index 0000000..e06b761 Binary files /dev/null and b/desktop/src/main/resources/font/NotoColorEmoji-flagsonly.ttf differ diff --git a/desktop/src/main/resources/font/OFL.txt b/desktop/src/main/resources/font/OFL.txt new file mode 100644 index 0000000..d952d62 --- /dev/null +++ b/desktop/src/main/resources/font/OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/src/test/kotlin/io/github/solcott/countries/desktop/BackShortcutTest.kt b/desktop/src/test/kotlin/io/github/solcott/countries/desktop/BackShortcutTest.kt new file mode 100644 index 0000000..8a18c65 --- /dev/null +++ b/desktop/src/test/kotlin/io/github/solcott/countries/desktop/BackShortcutTest.kt @@ -0,0 +1,78 @@ +package io.github.solcott.countries.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BackShortcutTest { + + private fun shortcut( + key: Key, + type: KeyEventType = KeyEventType.KeyDown, + meta: Boolean = false, + alt: Boolean = false, + canPop: Boolean = true, + ) = isBackShortcut(key, type, meta, alt, canPop) + + @Test + fun escapeGoesBack() { + assertTrue(shortcut(Key.Escape)) + } + + @Test + fun metaLeftBracketGoesBack() { + assertTrue(shortcut(Key.LeftBracket, meta = true)) + } + + @Test + fun metaArrowGoesBack() { + assertTrue(shortcut(Key.DirectionLeft, meta = true)) + } + + @Test + fun altArrowGoesBack() { + assertTrue(shortcut(Key.DirectionLeft, alt = true)) + } + + /** The list screen's filter field needs a bare arrow key for cursor movement. */ + @Test + fun bareArrowDoesNotGoBack() { + assertFalse(shortcut(Key.DirectionLeft)) + } + + /** Likewise a bare bracket, which is an ordinary character. */ + @Test + fun bareLeftBracketDoesNotGoBack() { + assertFalse(shortcut(Key.LeftBracket)) + } + + /** Firing on both down and up would pop twice per press. */ + @Test + fun keyUpDoesNotGoBack() { + assertFalse(shortcut(Key.Escape, type = KeyEventType.KeyUp)) + } + + /** + * On the root screen there is nothing to pop, which is what keeps Esc out of the filter field. + */ + @Test + fun nothingGoesBackWhenTheStackIsAtItsRoot() { + assertFalse(shortcut(Key.Escape, canPop = false)) + assertFalse(shortcut(Key.DirectionLeft, meta = true, canPop = false)) + } + + /** Cmd+Esc is macOS's Force Quit chord; it must not be swallowed as a back gesture. */ + @Test + fun modifiedEscapeDoesNotGoBack() { + assertFalse(shortcut(Key.Escape, meta = true)) + assertFalse(shortcut(Key.Escape, alt = true)) + } + + @Test + fun unrelatedKeysDoNotGoBack() { + assertFalse(shortcut(Key.A)) + assertFalse(shortcut(Key.DirectionRight, meta = true)) + } +} diff --git a/desktop/src/test/kotlin/io/github/solcott/countries/desktop/FlagFontTest.kt b/desktop/src/test/kotlin/io/github/solcott/countries/desktop/FlagFontTest.kt new file mode 100644 index 0000000..739fab9 --- /dev/null +++ b/desktop/src/test/kotlin/io/github/solcott/countries/desktop/FlagFontTest.kt @@ -0,0 +1,118 @@ +package io.github.solcott.countries.desktop + +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.jetbrains.skia.Data +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Font +import org.jetbrains.skia.FontMgr +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Surface +import org.jetbrains.skia.TextLine + +/** + * Guards the bundled flag font, which nothing else would notice breaking: swap it for a monochrome + * subset, or for the COLRv1 build, and all 250 rows quietly lose their flags on the two platforms + * that cannot be checked from here. + */ +class FlagFontTest { + + private val fontBytes = + checkNotNull(javaClass.getResourceAsStream(FLAG_FONT_RESOURCE)) { + "$FLAG_FONT_RESOURCE is not on the test classpath" + } + .use { it.readBytes() } + + @Test + fun macOsUsesItsOwnEmojiFont() { + assertFalse(needsBundledFlagFont("Mac OS X")) + assertFalse(needsBundledFlagFont("macOS")) + } + + @Test + fun windowsAndLinuxNeedTheBundledFont() { + assertTrue(needsBundledFlagFont("Windows 11")) + assertTrue(needsBundledFlagFont("Windows 10")) + assertTrue(needsBundledFlagFont("Linux")) + } + + /** + * The colour glyphs must be CBDT bitmaps. COLRv1 needs FreeType 2.11+ or a Windows 11-era + * DirectWrite to rasterise, and where it is unsupported it draws nothing at all rather than + * falling back — see the note on [needsBundledFlagFont]. + */ + @Test + fun theBundledFontIsTheBitmapColourBuild() { + val tables = sfntTableTags(fontBytes) + assertContains(tables, "CBDT") + assertContains(tables, "CBLC") + // ccmp lives here: a flag is a ligature of two regional indicators, and without GSUB the font + // has every glyph and forms none of them. + assertContains(tables, "GSUB") + assertFalse("COLR" in tables, "expected the CBDT build, got the COLRv1 one") + } + + /** + * States the invariant across both kinds of machine: wherever Skia can load the font we require + * it to ligate and rasterise in colour, and where it cannot load it we require that the app was + * never going to use it. On macOS it is the second branch that runs — CoreText rejects a + * bitmap-only font — and [flagFontFamily] is null there for exactly that reason. + */ + @Test + fun theFontRendersFlagsWhereverThisPlatformCanLoadIt() { + val typeface = FontMgr.default.makeFromData(Data.makeFromBytes(fontBytes)) + if (typeface == null) { + assertFalse( + needsBundledFlagFont(System.getProperty("os.name").orEmpty()), + "Skia cannot load the bundled font on a platform that depends on it", + ) + return + } + + assertEquals("Noto Color Emoji Flags", typeface.familyName) + + val line = TextLine.make(FRANCE, Font(typeface, FONT_SIZE)) + assertEquals(1, line.glyphs.size, "the regional indicator pair did not ligate") + + val surface = Surface.makeRasterN32Premul(CANVAS, CANVAS) + surface.canvas.clear(WHITE) + surface.canvas.drawTextLine(line, MARGIN, BASELINE, Paint()) + val png = checkNotNull(surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)).bytes + val image = ImageIO.read(ByteArrayInputStream(png)) + val colours = buildSet { + for (x in 0 until image.width) for (y in 0 until image.height) add(image.getRGB(x, y)) + } + // The French flag is three, plus the background; the margin allows for antialiasing. + assertTrue(colours.size > 4, "expected a colour flag, saw ${colours.size} distinct colours") + } + + /** The sfnt table directory: a 12-byte header, then one 16-byte record per table, tag first. */ + private fun sfntTableTags(bytes: ByteArray): Set = + DataInputStream(ByteArrayInputStream(bytes)).use { input -> + input.skipBytes(4) + val tableCount = input.readUnsignedShort() + input.skipBytes(6) + buildSet { + repeat(tableCount) { + val tag = ByteArray(4).also(input::readFully).decodeToString() + input.skipBytes(12) + add(tag) + } + } + } + + private companion object { + const val FRANCE = "🇫🇷" + const val CANVAS = 96 + const val FONT_SIZE = 64f + const val MARGIN = 8f + const val BASELINE = 76f + val WHITE = 0xFFFFFFFF.toInt() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9471a6e..bfb16c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,3 +64,4 @@ include(":shared") include(":shared-compose") include(":app") include(":web") +include(":desktop") diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt index f2c8b6a..dfa9d33 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt @@ -86,7 +86,7 @@ fun CountryDetailUi( @Composable private fun CountryDetailContent(country: CountryDetail, modifier: Modifier = Modifier) { Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(24.dp)) { - Text(country.emoji, fontSize = 56.sp) + Text(country.emoji, fontSize = 56.sp, fontFamily = LocalFlagFontFamily.current) Text( country.name, style = MaterialTheme.typography.headlineMedium, diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt index 3aebc35..659db4d 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt @@ -211,7 +211,7 @@ private fun CountryRow(country: Country, onClick: () -> Unit, modifier: Modifier horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(country.emoji) + Text(country.emoji, fontFamily = LocalFlagFontFamily.current) Column(modifier = Modifier.weight(1f)) { Text(country.name) Text(listOfNotNull(country.capital, country.continentName).joinToString(" · ")) diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/FlagFont.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/FlagFont.kt new file mode 100644 index 0000000..045a53e --- /dev/null +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/FlagFont.kt @@ -0,0 +1,23 @@ +package io.github.solcott.countries.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.font.FontFamily + +/** + * The font used for the flag emoji, or `null` to let the platform resolve them. + * + * `null` is right almost everywhere: Android, iOS and macOS all ship a font with the regional + * indicator ligatures, and on web Compose Multiplatform downloads the Noto Color Emoji subset + * itself (see the fonts note in AGENTS.md). + * + * Desktop is the exception, and only on two of its three platforms. Skia has no system font manager + * of its own, so it renders whatever the OS hands it: Windows' Segoe UI Emoji has no flag glyphs at + * all — by Microsoft's choice, not by omission — so every row comes out as a letter pair, and a + * Linux install without Noto Color Emoji renders tofu. `:desktop` bundles a flag-only subset and + * provides it here. + * + * This is the only platform seam in `:ui`. It is a font rather than a whole `Typography` because + * the two composables that consume it render *nothing but* the flag, so the family needs no + * fallback chain behind it. + */ +val LocalFlagFontFamily = staticCompositionLocalOf { null }