From 12b2d37ce2e673db6f4143b87e21e47efba1f1f7 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Sun, 4 Oct 2026 21:21:08 +0200 Subject: [PATCH] Make the colours adjustable per operator profile Six colour roles per design, per profile: surface, window surface, field interior, text, accent and separator line. A change applies at once in every open window and survives a restart once the settings are saved, like any other preference. Three sources, later wins role by role: the shipped stylesheet from the classpath, then the profile's own stylesheet file if there is one, then the overrides stored in the profile's preferences. resolvePalette is a pure function and reports where every role's colour came from, which the tab shows per field -- with two sources able to decide one colour, that is the only thing keeping "why is this colour what it is" answerable, and the spec calls it mandatory rather than decorative. Four things this had to get right, each learned the hard way and each pinned: A role is a selector and a property, not a property alone. Three of the six are stated outside .root -- text on .label, the separator on .separator *.line -- and -fx-background-color alone appears a dozen times in the evening sheet. Measured: the shipped daylight sheet states no colour at all, the evening sheet states five and implies the sixth. Kst4ContestTheme reads the palette from the store's state and not from remember. That read is what makes a change repaint every open window; remember(darkMode) held the palette until somebody toggled day/evening, so a change reached nothing. A derived colour follows its source's value, never its provenance. The root profile's own stylesheet is the one copyResourceIfRequired writes at every startup -- the shipped sheet itself -- so layer 2 declares the accent on every existing installation with nobody having chosen anything. Keying textAccent on "does this come from a file" repainted the evening accent from green to blue on upgrade and made "reset to shipped" disagree with "discard my changes". The contrast guard measures where colours are actually drawn, read off Theme.kt's Material mapping rather than off the role names: background and surface are both the window surface, and primary is textAccent, not accent. It reports a pair only when it is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have three pairs under 4.5 themselves, so an absolute floor would warn on an untouched client and teach the operator to ignore warnings. The way back out of an unreadable palette is deliberately overbuilt, because it is the one failure that locks itself in. "Reset to the shipped colours" takes the shipped palette as a required parameter and reads no composition local, so there is no path by which it could be drawn in the colours it exists to undo; it looks out of place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D and Ctrl+R reach the three ways back even with the caret in a colour field. A mistyped colour is refused and the field snaps back. The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has been promising since it was first written and never read. It is read now. A button writes the shipped template as a starting point and never replaces a file that is already there. An unreadable, truncated or non-CSS file does not prevent startup -- the role falls back to the shipped design. A sheet stating -fx-base and no -fx-background states the derived window surface too, the way JavaFX does, or the most natural hand edit would leave every window the shipped light grey. 101 new tests. All of it runs headless. --- .../view/Kst4ContestApplication.java | 43 ++- .../view/compose/ComposeWindowHost.kt | 18 +- .../kst4contest/view/compose/ContrastRule.kt | 89 ++++++ .../view/compose/JavaFxStylesheet.kt | 94 +++++- .../kst4contest/view/compose/MainMenuBar.kt | 8 +- .../view/compose/MainWindowHost.kt | 6 + .../kst4contest/view/compose/MonitorWindow.kt | 6 + .../view/compose/PaletteResolution.kt | 88 ++++++ .../kst4contest/view/compose/PaletteStore.kt | 211 +++++++++++++ .../view/compose/PaletteStoreFactory.kt | 95 ++++++ .../kst4contest/view/compose/SettingsTabs.kt | 34 +++ .../view/compose/SettingsWindow.kt | 6 + .../kotlin/kst4contest/view/compose/Theme.kt | 30 +- .../compose/TopPriorityCandidatesWindow.kt | 6 + .../kst4contest/view/compose/UpdateWindow.kt | 6 + .../view/compose/map/StationMapWindow.kt | 10 +- .../view/compose/tabs/ColourShortcuts.kt | 83 ++++++ .../view/compose/tabs/ColoursTab.kt | 252 ++++++++++++++++ .../view/compose/tabs/ColoursTabState.kt | 166 +++++++++++ .../view/compose/tabs/StylesheetExport.kt | 59 ++++ .../view/StartupStylesheetCopyTest.kt | 56 ++++ .../view/compose/ContrastRuleTest.kt | 169 +++++++++++ .../view/compose/PaletteResolutionTest.kt | 169 +++++++++++ .../view/compose/PaletteStoreFactoryTest.kt | 109 +++++++ .../view/compose/PaletteStoreTest.kt | 278 ++++++++++++++++++ .../compose/StylesheetDeclaredRolesTest.kt | 163 ++++++++++ .../compose/ThemeReadsPaletteStoreTest.kt | 83 ++++++ .../view/compose/tabs/ColourShortcutsTest.kt | 80 +++++ .../view/compose/tabs/ColoursTabStateTest.kt | 207 +++++++++++++ .../view/compose/tabs/LifelineColoursTest.kt | 76 +++++ .../view/compose/tabs/StylesheetExportTest.kt | 124 ++++++++ .../controller/OperatorProfilePaths.java | 29 ++ .../kst4contest/model/ChatPreferences.java | 69 ++++- .../kst4contest/model/PaletteOverrides.java | 120 ++++++++ .../java/kst4contest/model/PaletteRole.java | 97 ++++++ .../controller/OperatorProfilePathsTest.java | 28 ++ .../model/ChatPreferencesPaletteTest.java | 46 +++ .../model/PaletteOverridesTest.java | 101 +++++++ .../kst4contest/model/PaletteRoleTest.java | 87 ++++++ 39 files changed, 3373 insertions(+), 28 deletions(-) create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColourShortcuts.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StylesheetExport.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/StartupStylesheetCopyTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColourShortcutsTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LifelineColoursTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt create mode 100644 core/src/main/java/kst4contest/model/PaletteOverrides.java create mode 100644 core/src/main/java/kst4contest/model/PaletteRole.java create mode 100644 core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java create mode 100644 core/src/test/java/kst4contest/model/PaletteOverridesTest.java create mode 100644 core/src/test/java/kst4contest/model/PaletteRoleTest.java diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index d340b9fc..c3efb23f 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -159,11 +159,18 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos - public static final String STYLE_DEFAULTCSSDAY_FILE = "KST4ContestDefaultDay.css"; - public static final String STYLE_DEFAULTCSSDAY_RESOURCE = "/KST4ContestDefaultDay.css"; + /* + * Derived and never spelled out: this is the file the root profile's own stylesheet layer + * reads, under the name OperatorProfilePaths.paletteRelativeFileName builds from the same + * constant. Two independent literals of this value would have to stay equal for that to + * work, and a rename on one side would restore the "written but never read" defect + * silently, with a green build. + */ + public static final String STYLE_DEFAULTCSSDAY_FILE = ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; + public static final String STYLE_DEFAULTCSSDAY_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; - public static final String STYLE_DEFAULTCSSEVENING_FILE = "KST4ContestDefaultEvening.css"; - public static final String STYLE_DEFAULTCSSEVENING_RESOURCE = "/KST4ContestDefaultEvening.css"; + public static final String STYLE_DEFAULTCSSEVENING_FILE = ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING; + public static final String STYLE_DEFAULTCSSEVENING_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING; /** Hands worker-thread results over to the AWT event thread, where Compose draws. */ private final UiDispatcher uiDispatcher = new AwtUiDispatcher(); @@ -279,14 +286,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private void toggleStationMapWindow() { if (composeMainWindowState != null) { - kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState); + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore); } } private void showSelectedCallsignOnMap() { if (composeMainWindowState != null) { if (!kst4contest.view.compose.map.StationMapWindow.INSTANCE.isShowing()) { - kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState); + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore); } } } @@ -1365,7 +1372,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos () -> chatcontroller.getScoreService().topCandidates().snapshot(), candidate -> uiDispatcher.runOnUi(() -> selectTopCandidate(candidate)), chatcontroller.getChatPreferences().isGUI_darkModeActive(), - SETTINGS_WINDOW_FONT_SIZE_SP + SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore ); } @@ -2157,6 +2165,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos // Every profile starts with its own configured design; the menu quick switch only lasts for the session. chatcontroller.getChatPreferences().setGUI_darkModeActive( chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()); + /* + * The palette, before any window is built: every Compose window reads it, and a window + * built without it would draw the shipped colours until it was rebuilt. + */ + paletteStore = kst4contest.view.compose.PaletteStoreFactory.create( + chatcontroller.getChatPreferences(), activeOperatorProfile.getProfile()); messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual @@ -2962,6 +2976,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos /** Held so the feeds can be released; a discarded runtime that still feeds a window lives on. */ private MainWindowState composeMainWindowState; + + /** + * The palette of the active profile, handed to every Compose window. + * + * Built once per runtime start, which is also once per profile: a profile switch tears + * the runtime down and starts it again on a fresh application object, so there is no + * separate rebuild to do here. + */ + private kst4contest.view.compose.PaletteStore paletteStore; private java.util.function.Consumer> composeMemberListener; private java.util.function.Consumer> composeChatListener; private java.util.function.Consumer> composeClusterListener; @@ -3249,6 +3272,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos "KST4Contest (Compose)", buildOperatorProfileTitleSuffixForCompose()), chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, (float) size.getWidthDp(), (float) size.getHeightDp(), (width, height) -> { @@ -3643,6 +3667,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos qsoTable, chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1], (width, height) -> { @@ -3676,6 +3701,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos updateState, chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1], address -> ExternalDocuments.open(address), @@ -3702,10 +3728,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private void openSettingsWindow() { SettingsWindow.show( - SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices), + SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore), settingsNotices, chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1], (width, height) -> { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt index 7e8fa55b..1a31bf08 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt @@ -1,6 +1,7 @@ package kst4contest.view.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow @@ -164,6 +165,11 @@ class ComposeWindowHost(private val threadName: String) { * it exists not to do. */ alwaysOnTop: Boolean = false, + /** + * The palette of the active profile. Null draws the shipped palette, which is right + * for a window that opens before a profile exists. + */ + paletteStore: PaletteStore? = null, /** * What a close request from outside should do — the title bar's X, and a tiling * window manager's close, which is the same WM_DELETE_WINDOW either way. @@ -246,11 +252,13 @@ class ComposeWindowHost(private val threadName: String) { onDispose { frame = null } } - Kst4ContestTheme( - darkMode = darkModeState.value, - baseFontSizeSp = baseFontSizeSp, - ) { - content(::exitApplication) + CompositionLocalProvider(LocalPaletteStore provides paletteStore) { + Kst4ContestTheme( + darkMode = darkModeState.value, + baseFontSizeSp = baseFontSizeSp, + ) { + content(::exitApplication) + } } } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt new file mode 100644 index 00000000..6da60459 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt @@ -0,0 +1,89 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kotlin.math.pow + +/** WCAG AA for body text. Below this a pair is reported, never refused. */ +const val CONTRAST_FLOOR = 4.5 + +/** + * One pair that reads badly, named the way the settings tab shows it. + * + * @param ratio what the pair measures now + * @param shippedRatio what the same pair measures in the shipped sheet, so the tab can say + * how far the change moved it + */ +data class ContrastWarning(val what: String, val ratio: Double, val shippedRatio: Double) + +/** + * The WCAG contrast ratio of two colours, between 1.0 and 21.0. + * + * Relative luminance and not brightness: the two are easy to confuse, and the JavaFX colour + * derivation in this same package is a standing reminder that guessing a colour formula + * produces values that look plausible and are wrong. + */ +fun contrastRatio(a: Color, b: Color): Double { + val lighter = maxOf(relativeLuminance(a), relativeLuminance(b)) + val darker = minOf(relativeLuminance(a), relativeLuminance(b)) + return (lighter + 0.05) / (darker + 0.05) +} + +private fun relativeLuminance(colour: Color): Double { + fun channel(value: Float): Double { + val v = value.toDouble() + return if (v <= 0.03928) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4) + } + + return 0.2126 * channel(colour.red) + + 0.7152 * channel(colour.green) + + 0.0722 * channel(colour.blue) +} + +/** + * The pairs that actually sit on top of each other and read worse than they shipped. + * + * Two conditions, both required: under [CONTRAST_FLOOR], and worse than the same pair in the + * shipped sheet. The second is there because the shipped sheets do not meet the floor + * themselves — the evening accent measures 2.26 on its own surface — so an absolute rule + * would warn about a client nobody has touched, which is how an operator learns to ignore + * warnings. With this rule the shipped state never warns and an improvement never warns. + * + * Four pairs, and each one is read off `Theme.kt`'s Material mapping rather than off the role + * names. The names mislead: `background` and `surface` are both `windowBackground`, with + * `onBackground` and `onSurface` both `labelTextFill`, so almost every label in every window + * sits on the window surface and not on `base` — `base` reaches the screen only through the + * menu strip and the selection tint. And `primary` is `textAccent`, not `accent`, drawn as + * text and as tab underlines. Measuring `labelTextFill` against `base` alone let an operator + * black out every window and hear nothing. + * + * Four and not fifteen, deliberately: most pairs of six roles never meet on screen, and a + * warning about a pair that cannot occur costs the same attention as a real one. + */ +fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List { + + fun pairsOf(palette: JavaFxPalette) = listOf( + /* onBackground/onSurface on background/surface: nearly every label in every window. */ + "Text on the window surface" to (palette.labelTextFill to palette.windowBackground), + /* onSurfaceVariant on surfaceVariant: inside a field, a list, and on every button. */ + "Text inside a field" to (palette.labelTextFill to palette.controlInnerBackground), + /* primary as text and as a tab underline, on the window surface. */ + "Accent on the window surface" to (palette.textAccent to palette.windowBackground), + /* The menu strip paints base behind its titles; narrow, but it is real. */ + "Text on the menu strip" to (palette.labelTextFill to palette.base), + ) + + val shippedRatios = pairsOf(shipped).associate { (what, colours) -> + what to contrastRatio(colours.first, colours.second) + } + + return pairsOf(resolved).mapNotNull { (what, colours) -> + val ratio = contrastRatio(colours.first, colours.second) + val shippedRatio = shippedRatios.getValue(what) + + if (ratio < CONTRAST_FLOOR && ratio < shippedRatio) { + ContrastWarning(what, ratio, shippedRatio) + } else { + null + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt index 35f9f4b5..e0fcf8b6 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt @@ -1,6 +1,8 @@ package kst4contest.view.compose import androidx.compose.ui.graphics.Color +import kst4contest.ApplicationConstants +import kst4contest.model.PaletteRole /** * The colours the Compose windows take from the JavaFX stylesheets. @@ -52,6 +54,26 @@ data class JavaFxPalette( */ object JavaFxStylesheet { + /* + * The classpath paths of the two shipped sheets, derived from the one place that owns + * their file names. They used to be spelled out in four places -- here, the theme, the + * store factory and the startup copy -- and two of those had to stay equal for the root + * profile's own stylesheet to be read at all: the copy is written under one name and read + * under the other. A rename on one side would silently restore the very defect this stage + * was commissioned to fix, with a green build. + */ + val DAYLIGHT_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT + val EVENING_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING + + /** + * The shipped palette of one design. + * + * @param darkMode true for the evening design, false for the daylight one + * @return the complete palette, Modena fallbacks included + */ + fun shipped(darkMode: Boolean): JavaFxPalette = + read(if (darkMode) EVENING_RESOURCE else DAYLIGHT_RESOURCE) + /** Modena's own `-fx-background: derive(-fx-base, 26.4%)`. */ private const val MODENA_BACKGROUND_DERIVE = 26.4 @@ -113,6 +135,69 @@ object JavaFxStylesheet { ) } + /** + * The roles a stylesheet source actually names. + * + * Deliberately not [read]: that one fills a missing role from the Modena defaults, which + * is right for the shipped sheets and wrong for a layer meant to override one role at a + * time. A sheet saying only `-fx-base` must declare exactly one role. + * + * Each role is looked up at its own selector, because three of the six are stated + * outside `.root` and one of those properties -- `-fx-background-color` -- appears a + * dozen times elsewhere in the evening sheet. `.root` stays the scope in which a `-fx-` + * reference or a `derive(...)` is resolved, exactly as [read] resolves them. + * + * A value this reader does not understand -- a gradient, say -- leaves the role + * undeclared, so it falls through to the layer below rather than becoming a guess. + */ + fun declaredRoles(source: String): Map { + val blocks = parseBlocks(source) + val root = blocks[".root"].orEmpty() + + val stated = PaletteRole.values().mapNotNull { role -> + val value = blocks[role.selector()]?.get(role.cssProperty()) + ?: return@mapNotNull null + val colour = colorOf(value, root) ?: return@mapNotNull null + role to colour + }.toMap() + + /* + * A sheet that states `-fx-base` and no `-fx-background` has stated the window surface + * too, because that is what JavaFX does with it: Modena's `-fx-background` is + * `derive(-fx-base, 26.4%)`, and [read] applies the same fallback. Without this the + * most natural hand edit is nearly inert -- `-fx-base: #303030` would leave every + * window the shipped light grey, since the base itself reaches the screen only through + * the menu strip and the selection tint. + * + * Only here, in the file layer. The settings tab keeps the two roles independent, + * which is why the spec separates them at all: six fields are visible there, so an + * operator who wants both changed can say so. + * + * A consequence worth stating rather than discovering: the tab then reports the window + * surface as coming *from file* for a colour the file never spells out. That is the + * right answer. The provenance exists to answer "why is this colour what it is", not + * "which line names it", and the file's `-fx-base` is why. + */ + val base = stated[PaletteRole.SURFACE] + + if (base == null || stated.containsKey(PaletteRole.WINDOW_SURFACE)) { + return stated + } + + return stated + (PaletteRole.WINDOW_SURFACE to derive(base, MODENA_BACKGROUND_DERIVE)) + } + + /** + * The roles a stylesheet file names, or none when it cannot be read. + * + * Never throws. A client that will not start because of a broken colour file is worse + * than a client in the wrong colours, and this file is one the operator edited by hand. + */ + fun declaredRolesOfFile(file: java.io.File): Map = + runCatching { + if (!file.isFile) emptyMap() else declaredRoles(file.readText()) + }.getOrDefault(emptyMap()) + /** * JavaFX `derive(colour, percent)`, reimplemented. * @@ -130,9 +215,12 @@ object JavaFxStylesheet { * reaches here: three of the four call sites pass the window background or * `-fx-base`, and the fourth is [parseColour]'s own `derive(...)` branch below, * which passes whatever a sheet names — and every `derive(...)` in both shipped - * sheets derives from `-fx-base` alone. A hand-edited sheet could break that, so - * Etappe 8 of the migration, which makes the sheets operator-editable, has to - * revisit it. + * sheets derives from `-fx-base` alone. A hand-edited sheet can break that: an + * operator who sets `-fx-base` to a saturated colour sends every `derive(...)` of it + * through the inaccurate part. Revisited when the colours became operator-editable and + * deliberately left alone — a drift of three steps per channel is not perceptible in a + * colour somebody chose themselves, and what the settings tab sets does not pass + * through here at all: the resolver substitutes finished colours. */ fun derive(color: Color, percent: Double): Color { val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt index 1ff91711..6087e6a5 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt @@ -162,7 +162,13 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) { Text( text = menu.title, style = MaterialTheme.typography.bodyMedium, - color = if (open) palette.textAccent else palette.labelTextFill, + /* + * contrastingText and not textAccent: the accent is the background right behind + * this title, and once an operator can set the accent the two can be the same + * colour -- an invisible menu title. Black or white by brightness is readable by + * construction. + */ + color = if (open) contrastingText(palette.accent) else palette.labelTextFill, modifier = Modifier .background( when { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt index 1f333521..81842f2c 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -53,6 +53,11 @@ object MainWindowHost { title: String, darkMode: Boolean, baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, @@ -93,6 +98,7 @@ object MainWindowHost { title = title, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt index e1b394b9..082ff7d2 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt @@ -46,6 +46,11 @@ object MonitorWindow { qsoTable: DataTableState, darkMode: Boolean, baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, @@ -54,6 +59,7 @@ object MonitorWindow { title = "Cluster & QSO of the other", darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt new file mode 100644 index 00000000..a18fc99c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt @@ -0,0 +1,88 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteRole + +/** Which of the three layers a colour in force came from. */ +enum class PaletteSource { SHIPPED, FILE, CHANGED } + +/** One role's colour, and where it came from. */ +data class ResolvedRole(val colour: Color, val source: PaletteSource) + +/** + * The palette in force, and the provenance of every role in it. + * + * @param roles always all six, so the settings tab can show a source for each + * @param palette what the windows draw + */ +data class ResolvedPalette( + val roles: Map, + val palette: JavaFxPalette, +) + +/** + * Resolves the three layers, role by role: the later layer wins. + * + * The shipped sheet is the floor and always complete. A file and a change each carry only + * the roles they name — which is why [JavaFxStylesheet.declaredRoles] exists rather than + * `read`: a file saying only `-fx-base` must override exactly one role. + * + * Fields that are not roles — the hover gradient, the pressed border, the font size — stay + * with the shipped sheet. They are derived values rather than decisions an operator wants to + * make one by one. + * + * The exception is `textAccent`, which follows the accent role whenever that role's colour + * actually differs from the shipped one: Material's primary is mapped onto it rather than onto + * `accent`, because `-fx-accent` paints only JavaFX's selection bar while the green of + * `.text-field .text` is what an operator reads as the accent. Left on the sheet's own value, + * a chosen accent would change almost nothing visible; keyed on provenance instead of on the + * value, an accent nobody chose would repaint it. + */ +fun resolvePalette( + shipped: JavaFxPalette, + fromFile: Map, + changed: Map, +): ResolvedPalette { + + val shippedColour = mapOf( + PaletteRole.SURFACE to shipped.base, + PaletteRole.WINDOW_SURFACE to shipped.windowBackground, + PaletteRole.FIELD_INTERIOR to shipped.controlInnerBackground, + PaletteRole.TEXT to shipped.labelTextFill, + PaletteRole.ACCENT to shipped.accent, + PaletteRole.SEPARATOR to shipped.separatorLine, + ) + + val roles = PaletteRole.values().associateWith { role -> + when { + changed.containsKey(role) -> ResolvedRole(changed.getValue(role), PaletteSource.CHANGED) + fromFile.containsKey(role) -> ResolvedRole(fromFile.getValue(role), PaletteSource.FILE) + else -> ResolvedRole(shippedColour.getValue(role), PaletteSource.SHIPPED) + } + } + + fun colourOf(role: PaletteRole) = roles.getValue(role).colour + + /* + * Keyed on the value and deliberately not on the provenance. The root profile's own + * stylesheet is the one copyResourceIfRequired writes at startup -- the shipped sheet + * itself -- so on every existing installation layer 2 declares the accent without anybody + * having chosen anything. Asking "does it come from a file" repainted the evening primary + * from green to blue on upgrade, and made resetToShipped disagree with discardChanges + * about what shipped looks like. + */ + val chosenAccent = roles.getValue(PaletteRole.ACCENT).colour.takeIf { it != shipped.accent } + + return ResolvedPalette( + roles = roles, + palette = shipped.copy( + base = colourOf(PaletteRole.SURFACE), + windowBackground = colourOf(PaletteRole.WINDOW_SURFACE), + controlInnerBackground = colourOf(PaletteRole.FIELD_INTERIOR), + labelTextFill = colourOf(PaletteRole.TEXT), + accent = colourOf(PaletteRole.ACCENT), + textAccent = chosenAccent ?: shipped.textAccent, + separatorLine = colourOf(PaletteRole.SEPARATOR), + ), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt new file mode 100644 index 00000000..9ca64b27 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt @@ -0,0 +1,211 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteOverrides +import kst4contest.model.PaletteRole + +/** + * The palette of both designs for the active profile, held as Compose state. + * + * **This is what makes a colour change reach every open window.** `Kst4ContestTheme` used to + * take its palette from `remember(darkMode)`, so a change reached nothing until somebody + * toggled day/evening. Reading [resolved] inside the theme turns every palette change into a + * recomposition of every window that draws it. + * + * Built over four lambdas rather than over `ChatPreferences` so it can be tested without a + * profile on disk: that constructor copies resources into the operator's home directory. + * + * **Where "immediately" and "survives a restart" part company:** every change writes through + * to the preferences in memory at once, which is what recolours the windows, but the XML is + * written by the existing `savePreferencesFromSettings`. That is deliberately the same + * contract every other settings tab has — the JavaFX controls wrote as they changed and + * "Save settings" only persisted — but it means a change made and never saved is gone after a + * restart. + * + * @param shippedOf layer 1: the shipped sheet for a design, always complete + * @param fileRolesOf layer 2: the roles the profile's own stylesheet names, if any + * @param storedOverridesOf layer 3 as stored, encoded by [PaletteOverrides] + * @param storeOverrides writes layer 3 back + */ +class PaletteStore( + shippedOf: (darkMode: Boolean) -> JavaFxPalette, + fileRolesOf: (darkMode: Boolean) -> Map, + storedOverridesOf: (darkMode: Boolean) -> String, + private val storeOverrides: (darkMode: Boolean, encoded: String) -> Unit, +) { + + /* + * Layers 1 and 2 are read once per design, on first use, and then held. They do not change + * while the application runs -- the shipped sheet is a classpath resource, and both manuals + * promise that edits to the profile's stylesheet take effect after a restart. Re-reading + * them inside resolved() meant two CSS parses and a disk read per recomposition of every + * window, on the UI thread, which in this codebase is never a detail. + * + * Only the overrides below stay live. The state read is the mechanism that repaints the + * windows; re-parsing immutable input was never part of it. + */ + private val shippedDaylight by lazy { shippedOf(false) } + private val shippedEvening by lazy { shippedOf(true) } + private val fileRolesDaylight by lazy { fileRolesOf(false) } + private val fileRolesEvening by lazy { fileRolesOf(true) } + + /* + * The encoded overrides per design, as state. Everything else derives from these two, so + * there is exactly one place a change has to land for the windows to follow. + */ + private val daylightOverrides = mutableStateOf(storedOverridesOf(false)) + private val eveningOverrides = mutableStateOf(storedOverridesOf(true)) + + /** What [restoreSnapshot] goes back to, or null while nothing has been remembered. */ + private var snapshot: Pair? = null + + /** + * The palette in force for a design, with every role's provenance. + * + * Reads the override state, so a composable calling this recomposes when a colour + * changes. That state read is the mechanism and must not be cached; the two immutable + * layers under it are held, which is a different thing. + */ + fun resolved(darkMode: Boolean): ResolvedPalette = resolvePalette( + shipped = shipped(darkMode), + fromFile = if (darkMode) fileRolesEvening else fileRolesDaylight, + changed = changedRoles(darkMode), + ) + + /** + * The shipped palette for a design, untouched by file or change. + * + * The lifeline reads this. A control drawn from [resolved] would disappear in exactly the + * palette it exists to undo. + * + * @param darkMode the design + * @return layer 1 + */ + fun shipped(darkMode: Boolean): JavaFxPalette = + if (darkMode) shippedEvening else shippedDaylight + + /** + * Sets one role of one design. + * + * An unusable colour is ignored rather than stored: an operator mistyping a hex value + * must not end up with a black client. The tab reports the refusal. + * + * @param darkMode the design to change + * @param role the role to set + * @param colour a six-digit hex colour; anything else is ignored + */ + fun set(darkMode: Boolean, role: PaletteRole, colour: String) { + + if (!PaletteOverrides.isValidColour(colour)) { + return + } + + val overrides = PaletteOverrides.parse(encoded(darkMode)).toMutableMap() + overrides[role] = colour + write(darkMode, PaletteOverrides.format(overrides)) + } + + /** + * Sets every role of a design to its shipped colour, as an explicit change. + * + * Not the same as [discardChanges]: it has to beat a stylesheet file too, and only an + * explicit override on all six roles does that. This is the action the stage's acceptance + * criterion names — "exactly the shipped colours", file or no file. + * + * @param darkMode the design to reset + */ + fun resetToShipped(darkMode: Boolean) { + val shipped = shipped(darkMode) + + val overrides = PaletteRole.values().associateWith { role -> + hexOf(shippedColourOf(shipped, role)) + } + + write(darkMode, PaletteOverrides.format(overrides)) + } + + /** + * Empties layer 3, so the file or the shipped sheet applies again. + * + * @param darkMode the design to clear + */ + fun discardChanges(darkMode: Boolean) { + write(darkMode, "") + } + + /** Remembers the overrides of both designs, for [restoreSnapshot]. */ + fun takeSnapshot() { + snapshot = daylightOverrides.value to eveningOverrides.value + } + + /** + * Restores the overrides remembered by [takeSnapshot], including a change that was + * already there when it was taken. Does nothing when nothing was remembered. + * + * Both designs, not only the one in force. [takeSnapshot] captures both, and an operator + * who changed a colour, switched day/evening from the main window's menu, changed another + * and then asked to go back to how it was means both -- restoring half of it would leave + * a state they never chose and cannot name. + */ + fun restoreSnapshot() { + val remembered = snapshot ?: return + write(darkMode = false, encoded = remembered.first) + write(darkMode = true, encoded = remembered.second) + } + + private fun changedRoles(darkMode: Boolean): Map = + PaletteOverrides.parse(encoded(darkMode)).mapValues { (_, hex) -> colourOf(hex) } + + private fun encoded(darkMode: Boolean): String = + if (darkMode) eveningOverrides.value else daylightOverrides.value + + private fun write(darkMode: Boolean, encoded: String) { + if (darkMode) { + eveningOverrides.value = encoded + } else { + daylightOverrides.value = encoded + } + storeOverrides(darkMode, encoded) + } + + private fun shippedColourOf(shipped: JavaFxPalette, role: PaletteRole): Color = when (role) { + PaletteRole.SURFACE -> shipped.base + PaletteRole.WINDOW_SURFACE -> shipped.windowBackground + PaletteRole.FIELD_INTERIOR -> shipped.controlInnerBackground + PaletteRole.TEXT -> shipped.labelTextFill + PaletteRole.ACCENT -> shipped.accent + PaletteRole.SEPARATOR -> shipped.separatorLine + } +} + +/** + * A six-digit hex colour as a drawable colour. + * + * Only ever called with a value [PaletteOverrides.isValidColour] has already accepted, which + * is why it does not have to answer what an unparsable value means. + */ +internal fun colourOf(hex: String): Color = + Color(("ff" + hex.trim().removePrefix("#")).toLong(16)) + +/** + * A colour as the six-digit hex form the preferences store. + * + * Alpha is dropped on purpose: the roles are opaque surfaces and text, and a stored colour + * the operator cannot type back in would be a colour they cannot edit. + */ +internal fun hexOf(colour: Color): String = "#%02X%02X%02X".format( + (colour.red * 255).toInt(), + (colour.green * 255).toInt(), + (colour.blue * 255).toInt(), +) + +/** + * The store for the windows below, or null where there is none. + * + * Null is not caution but a real case: the operator profile picker draws before a profile — + * and therefore before a store — exists, and so do the tests of individual controls. Those + * get the shipped palette. + */ +val LocalPaletteStore = staticCompositionLocalOf { null } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt new file mode 100644 index 00000000..e3ab1951 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt @@ -0,0 +1,95 @@ +package kst4contest.view.compose + +import kst4contest.ApplicationConstants +import kst4contest.controller.OperatorProfilePaths +import kst4contest.model.ChatPreferences +import kst4contest.model.OperatorProfile +import kst4contest.utils.ApplicationFileUtils +import java.io.File + +/** + * Where one profile's own stylesheets live — layer 2 of the palette, one file per design. + * + * A value rather than a path derived inside the factory, so the resolution can be tested + * without writing into the operator's home directory. + */ +data class OperatorProfilePaletteFiles(val dayFile: File, val eveningFile: File) { + + /** The file for a design. */ + fun fileFor(darkMode: Boolean): File = if (darkMode) eveningFile else dayFile + + companion object { + + /** + * The files of a profile, by the project's own path convention. + * + * @param profile the profile whose stylesheets are wanted + * @return both files, which need not exist + */ + @JvmStatic + fun of(profile: OperatorProfile): OperatorProfilePaletteFiles = + OperatorProfilePaletteFiles( + dayFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, false)), + eveningFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, true)), + ) + + private fun absoluteFile(relativeName: String): File = + File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, relativeName)) + } +} + +/** + * Builds the [PaletteStore] of the active profile. + * + * The store itself takes four lambdas so it can be tested without a profile on disk; this is + * where those lambdas are tied to the real preferences and the real files, and it is the only + * place that knows which preference belongs to which design. + */ +object PaletteStoreFactory { + + /** + * Creates the store for one profile. + * + * Changes write through to [prefs] immediately, which is what recolours the open windows. + * The XML is written by the existing "save settings" path, exactly like every other + * setting in this application. + * + * @param prefs the active profile's preferences + * @param files the active profile's own stylesheets, which need not exist + * @return a store for both designs + */ + @JvmStatic + fun create(prefs: ChatPreferences, files: OperatorProfilePaletteFiles): PaletteStore = + PaletteStore( + shippedOf = { darkMode -> JavaFxStylesheet.shipped(darkMode) }, + fileRolesOf = { darkMode -> + JavaFxStylesheet.declaredRolesOfFile(files.fileFor(darkMode)) + }, + storedOverridesOf = { darkMode -> + if (darkMode) { + prefs.guiOptions_paletteOverridesEvening + } else { + prefs.guiOptions_paletteOverridesDay + } + }, + storeOverrides = { darkMode, encoded -> + if (darkMode) { + prefs.guiOptions_paletteOverridesEvening = encoded + } else { + prefs.guiOptions_paletteOverridesDay = encoded + } + }, + ) + + /** + * Creates the store for the active profile, or for the root profile when no profile has + * been resolved yet. + * + * @param prefs the active profile's preferences + * @param profile the active profile + * @return a store for both designs + */ + @JvmStatic + fun create(prefs: ChatPreferences, profile: OperatorProfile): PaletteStore = + create(prefs, OperatorProfilePaletteFiles.of(profile)) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt index 84d8037e..bb3538ce 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -8,12 +8,16 @@ import androidx.compose.runtime.setValue import kst4contest.controller.ChatController import kst4contest.controller.ActiveOperatorProfile import kst4contest.controller.OperatorProfileManagementService +import kst4contest.controller.OperatorProfilePaths import kst4contest.model.ChatMember import kst4contest.model.OperatorProfile +import kst4contest.view.compose.OperatorProfilePaletteFiles import kst4contest.view.compose.tabs.AirscoutTab import kst4contest.view.compose.tabs.AirscoutTabState import kst4contest.view.compose.tabs.BeaconTab import kst4contest.view.compose.tabs.BeaconTabState +import kst4contest.view.compose.tabs.ColoursTab +import kst4contest.view.compose.tabs.ColoursTabState import kst4contest.view.compose.tabs.GuiOptionsTab import kst4contest.view.compose.tabs.GuiOptionsTabState import kst4contest.view.compose.tabs.LogSynchTab @@ -105,6 +109,7 @@ fun buildSettingsTabs( controller: ChatController, host: SettingsHost, notices: SettingsNotices, + paletteStore: PaletteStore, ): List { val prefs = controller.chatPreferences @@ -174,6 +179,14 @@ fun buildSettingsTabs( val reportRefusal: (String) -> Unit = notices::problem + /* + * The profile whose stylesheet the colours tab offers to write. Resolved here rather than + * captured, because the settings window outlives nothing but is built once per profile. + */ + fun activeProfileOrRoot(): OperatorProfile = + ActiveOperatorProfile.get()?.profile + ?: OperatorProfilePaths.buildRootProfile(prefs.stn_loginCallSign ?: "") + /* * Order and titles are those of the JavaFX TabPane, character for character. The * comment there is explicit that operators navigate these tabs by muscle memory, @@ -202,6 +215,27 @@ fun buildSettingsTabs( * the JavaFX tab gave for the same placement. */ SettingsTab("Profiles", onSelected = profilesState::refresh) { ProfilesTab(profilesState) }, + /* + * Appended after Profiles for the same reason Profiles was appended after GUI. The + * tab edits whichever design is in force, which is why it reads the flag through a + * lambda rather than taking its value here: the operator can switch day/evening from + * the main window's menu while this tab is open. + */ + SettingsTab("Colours") { + /* + * Remembered rather than rebuilt: the tab recomposes on every colour change, and + * the design flag is read through a lambda precisely so the object itself does not + * have to be rebuilt to follow a day/evening switch. + */ + val coloursState = remember(paletteStore) { + ColoursTabState( + paletteStore, + { prefs.isGUI_darkModeActive }, + OperatorProfilePaletteFiles.of(activeProfileOrRoot()), + ) + } + ColoursTab(coloursState, reportRefusal, notices::info) + }, ) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt index 6d6b7d4c..fd5811fe 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt @@ -135,6 +135,11 @@ object SettingsWindow { notices: SettingsNotices, darkMode: Boolean, baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, @@ -144,6 +149,7 @@ object SettingsWindow { title = "Change Client Settings", darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt index f93d8072..525a7183 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt @@ -29,10 +29,21 @@ fun Kst4ContestTheme( baseFontSizeSp: Float = 0f, content: @Composable () -> Unit, ) { - val palette = remember(darkMode) { - JavaFxStylesheet.read( - if (darkMode) EVENING_STYLESHEET else DAYLIGHT_STYLESHEET - ) + val store = LocalPaletteStore.current + + val palette = if (store != null) { + /* + * Read from the store's state, not remembered: that read is what makes a palette + * change recompose every open window. remember(darkMode) kept the palette until + * somebody toggled day/evening, which is why a colour change used to reach nothing. + */ + store.resolved(darkMode).palette + } else { + /* + * No store yet. Not caution: the operator profile picker composes before a profile, + * and therefore before a store, exists. + */ + remember(darkMode) { JavaFxStylesheet.shipped(darkMode) } } val fontSize = if (baseFontSizeSp > 0f) baseFontSizeSp else palette.fontSizePx @@ -46,16 +57,13 @@ fun Kst4ContestTheme( } } -/** The two sheets JavaFX loads, by the names `applyThemeStylesheet` passes on. */ -private const val DAYLIGHT_STYLESHEET = "/KST4ContestDefaultDay.css" -private const val EVENING_STYLESHEET = "/KST4ContestDefaultEvening.css" /** * The palette in force, for the few places that need a colour Material has no slot * for — the button hover gradient and its pressed border. */ val LocalJavaFxPalette = staticCompositionLocalOf { - JavaFxStylesheet.read(DAYLIGHT_STYLESHEET) + JavaFxStylesheet.shipped(darkMode = false) } /** @@ -126,8 +134,12 @@ private fun blend(foreground: Color, background: Color, amount: Float): Color = * Black or white, whichever reads on the given colour. Uses the same brightness * measure JavaFX does, so the decision matches what the rest of the application * would make. + * + * Internal rather than private because the menu row needs it too: once the operator can + * choose the accent, a menu title drawn in any fixed colour on an accent background can + * collapse into it. */ -private fun contrastingText(background: Color): Color = +internal fun contrastingText(background: Color): Color = if (brightness(background) > 0.5f) Color.Black else Color.White /** diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt index 7cf895a7..96fd9954 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt @@ -73,11 +73,17 @@ object TopPriorityCandidatesWindow { onPicked: Consumer, darkMode: Boolean, baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, ) { host.show( title = "Top priority candidates", darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, widthDp = 360f, heightDp = 500f, ) { close -> diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt index a76ab89a..e65f04f1 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt @@ -56,6 +56,11 @@ object UpdateWindow { state: UpdateWindowState, darkMode: Boolean, baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, widthDp: Float, heightDp: Float, openReleasePage: Consumer, @@ -65,6 +70,7 @@ object UpdateWindow { title = "Update information", darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt index d6859d47..24efbad8 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kst4contest.model.Band import kst4contest.view.ApplicationRuntimeLauncher +import kst4contest.view.compose.PaletteStore import kst4contest.view.compose.ComposeWindowHost import kst4contest.view.compose.MainWindowState import kst4contest.view.map.MapCallsignRawSnapshotBuilder @@ -32,6 +33,11 @@ object StationMapWindow { mainWindowState: MainWindowState, darkMode: Boolean, baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, @@ -41,6 +47,7 @@ object StationMapWindow { title = "Station Map", darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, @@ -347,7 +354,7 @@ object StationMapWindow { fun isShowing(): Boolean = host.isOpen - fun toggle(mainWindowState: MainWindowState) { + fun toggle(mainWindowState: MainWindowState, paletteStore: PaletteStore? = null) { if (isShowing()) { hide() } else { @@ -355,6 +362,7 @@ object StationMapWindow { mainWindowState = mainWindowState, darkMode = mainWindowState.prefs.isGUI_darkModeActive, baseFontSizeSp = 12f, + paletteStore = paletteStore, widthDp = 1000f, heightDp = 700f, onResized = { w, h -> } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColourShortcuts.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColourShortcuts.kt new file mode 100644 index 00000000..65d60cb5 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColourShortcuts.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type + +/** One of the three ways back, in the order the tab shows them. */ +enum class ColourAction { BACK_TO_PREVIOUS, DISCARD_CHANGES, RESET_TO_SHIPPED } + +/** + * The keyboard path to the three ways back. + * + * The spec asks for one for all three, and says why: there is no "not yet applied" state, so + * the ways back are the only way back, and they have to be reachable without colour + * perception. Default focus traversal technically reaches the buttons, but tabbing blindly + * past six hex fields to find them is not a rescue. + * + * Control is required on all three. Six text fields live in this tab and `#DDEEFF` contains a + * D: a bare letter would swallow the typing it exists to protect. + * + * Split the same way [kst4contest.view.compose.WindowShortcuts] is — a pure decision plus a + * thin event adapter — because a desktop `KeyEvent` wraps a native AWT event, and a test that + * built one would be testing Compose. + */ +object ColourShortcuts { + + /** + * The action a key combination means, or null when it means nothing here. + * + * @param ctrlPressed whether control was held + * @param key the key pressed + * @return the action, or null to let the key travel on to the fields + */ + fun actionFor(ctrlPressed: Boolean, key: Key): ColourAction? { + + if (!ctrlPressed) { + return null + } + + return when (key) { + Key.Z -> ColourAction.BACK_TO_PREVIOUS + Key.D -> ColourAction.DISCARD_CHANGES + Key.R -> ColourAction.RESET_TO_SHIPPED + else -> null + } + } + + /** + * How the shortcut is written where the operator can read it. + * + * Shown in the tab beside each button: a shortcut nobody is told about is not a second + * safety net, and this one exists for an operator who cannot read the buttons. + * + * @param action the way back + * @return the key combination as text + */ + fun labelFor(action: ColourAction): String = when (action) { + ColourAction.BACK_TO_PREVIOUS -> "Ctrl+Z" + ColourAction.DISCARD_CHANGES -> "Ctrl+D" + ColourAction.RESET_TO_SHIPPED -> "Ctrl+R" + } + + /** + * Handles one key event for the colours tab. + * + * @param event the key event + * @param perform runs the action the keys named + * @return true when the event was consumed and must travel no further + */ + fun handle(event: KeyEvent, perform: (ColourAction) -> Unit): Boolean { + + if (event.type != KeyEventType.KeyDown) { + return false + } + + val action = actionFor(event.isCtrlPressed, event.key) ?: return false + perform(action) + return true + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt new file mode 100644 index 00000000..a6603cf8 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt @@ -0,0 +1,252 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.foundation.BorderStroke +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.runtime.remember +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kst4contest.view.compose.Density +import kst4contest.view.compose.Form +import kst4contest.view.compose.JavaFxPalette +import kst4contest.view.compose.JavaFxStylesheet +import kst4contest.view.compose.PaletteSource + +/** + * The colours of the lifeline, taken from one palette and nothing else. + * + * A value type rather than three expressions inside the composable, so the one property that + * matters — that these come from the shipped sheet — is testable without rendering. + */ +data class LifelineColours(val container: Color, val content: Color, val border: Color) + +/** + * The lifeline's colours from a palette. + * + * @param palette must be the shipped palette; passing the resolved one is the fault this + * whole arrangement exists to make impossible + * @return container, content and border + */ +fun lifelineColours(palette: JavaFxPalette): LifelineColours = LifelineColours( + container = palette.base, + content = palette.labelTextFill, + /* A step away from the container, so it reads as a control and not as a patch of colour. */ + border = JavaFxStylesheet.derive(palette.base, -35.0), +) + +/** + * The colours tab: six roles, where each of them comes from, and three ways back. + * + * @param state the tab's own state over the palette store + * @param reportRefusal shows a refused colour, the same channel the notification, shortcut + * and beacon tabs use for theirs + * @param reportOutcome shows something that went right; a success told through the refusal + * channel arrives looking like a failure + */ +@Composable +fun ColoursTab( + state: ColoursTabState, + reportRefusal: (String) -> Unit, + reportOutcome: (String) -> Unit, +) { + + /* + * The palette in force when the tab is opened, which is what "back to how it was" goes + * to. Remembered on first composition because there is no "not yet applied" state to + * go back to -- a change is live the moment it is typed. + */ + val focus = remember { FocusRequester() } + + LaunchedEffect(Unit) { + state.rememberCurrentState() + /* + * Focus the tab itself, so the shortcuts work the moment it opens rather than only + * after the operator has clicked something. The one case this is for is the operator + * who cannot see what to click. + */ + runCatching { focus.requestFocus() } + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + /* + * onPreviewKeyEvent, so the three ways back are reachable while the focus sits in one + * of the six hex fields -- which is exactly where it will be when an operator has just + * made the client unreadable. + */ + modifier = Modifier + .focusRequester(focus) + .focusable() + .onPreviewKeyEvent { event -> + ColourShortcuts.handle(event) { action -> + when (action) { + ColourAction.BACK_TO_PREVIOUS -> state.restoreRemembered() + ColourAction.DISCARD_CHANGES -> state.discardChanges() + ColourAction.RESET_TO_SHIPPED -> state.resetToShipped() + } + } + }, + ) { + + Form.section("Colours of this design") { + state.roles().forEach { row -> + /* + * The provenance sits in the label, beside the field it explains, rather than + * in a column of its own: it answers "why is this colour this?", which only + * means anything next to the colour. + */ + Form.committed( + label = "${row.label} (${provenanceOf(row.source)})", + stored = row.hex, + commit = { typed -> state.set(row.role, typed) }, + onRefused = reportRefusal, + ) + } + } + + val warnings = state.warnings() + if (warnings.isNotEmpty()) { + Form.section("Readability") { + warnings.forEach { warning -> + Text( + /* + * Both numbers, because the message is comparative: the guard reports + * that a pair got worse than it shipped, not that it fails a standard. + */ + text = "%s now reads at %.1f to 1, where this design ships %.1f to 1." + .format(warning.what, warning.ratio, warning.shippedRatio), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "The colours are kept either way — a hard combination can be deliberate.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Form.section("Keeping these colours") { + /* + * The tab with the most visible immediate effect is also the one where "it looked + * right, so it must be stored" is the easiest wrong conclusion to draw. Every + * other settings tab has the same contract; none of them changes four windows + * under the operator's hands while they decide. + */ + Text( + "A colour applies at once, everywhere. It survives a restart only once " + + "Save settings has been pressed.", + style = MaterialTheme.typography.bodySmall, + ) + } + + Form.section("Ways back") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Form.button( + "Back to how it was (${ColourShortcuts.labelFor(ColourAction.BACK_TO_PREVIOUS)})" + ) { state.restoreRemembered() } + Form.button( + "Discard my changes (${ColourShortcuts.labelFor(ColourAction.DISCARD_CHANGES)})" + ) { state.discardChanges() } + } + + /* + * Always drawn in the shipped colours, and deliberately out of place under a + * customised palette: an operator who sets text and surface to the same black + * cannot see any other button, including the ones that would undo it. Not fitting + * in is what makes it findable. + */ + ResetToShippedButton(state.shipped()) { state.resetToShipped() } + } + + Form.section("Editing the stylesheet by hand") { + Text( + if (state.stylesheetExists()) { + "This design reads ${state.stylesheetPath()}. A colour set above " + .plus("overrides what the file says for that role.") + } else { + "This design has no stylesheet of its own. One can be written to " + .plus("${state.stylesheetPath()} as a starting point.") + }, + style = MaterialTheme.typography.bodySmall, + ) + Form.button("Write this design's stylesheet into my profile") { + /* + * An existing file is never replaced -- it is the operator's own work, and a + * misclick here would be the one loss this tab could cause that cannot be + * undone from inside the application. + */ + reportExport(state.exportStylesheet(), reportOutcome, reportRefusal) + } + } + } +} + +/** Says what the export did, through the channel that matches the outcome. */ +private fun reportExport( + outcome: StylesheetExportOutcome, + reportOutcome: (String) -> Unit, + reportRefusal: (String) -> Unit, +) = when (outcome) { + is StylesheetExportOutcome.Written -> reportOutcome( + "Written to ${outcome.path}. Edit it and restart to see the result." + ) + + /* Not a failure: the file is the operator's own, and leaving it alone is the point. */ + is StylesheetExportOutcome.AlreadyThere -> reportOutcome( + "${outcome.path} is already there and was left untouched." + ) + + is StylesheetExportOutcome.Failed -> reportRefusal( + "Could not write the stylesheet: ${outcome.reason}" + ) +} + +/** + * The rescue control. + * + * Takes the shipped palette as a required parameter and reads no composition local, so there + * is no path by which it could be drawn in the operator's own colours. + */ +@Composable +private fun ResetToShippedButton(shipped: JavaFxPalette, onClick: () -> Unit) { + val colours = lifelineColours(shipped) + + Button( + onClick = onClick, + shape = Density.BUTTON_SHAPE, + contentPadding = Density.BUTTON_CONTENT_PADDING, + border = BorderStroke(Density.SELECTION_BORDER, colours.border), + colors = ButtonDefaults.buttonColors( + containerColor = colours.container, + contentColor = colours.content, + ), + modifier = Modifier.padding(top = 6.dp), + ) { + Text( + "Reset to the shipped colours " + + "(${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})", + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +/** Why a role is the colour it is — the spec's mandatory answer to "why is this colour this?" */ +private fun provenanceOf(source: PaletteSource): String = when (source) { + PaletteSource.SHIPPED -> "shipped" + PaletteSource.FILE -> "from file" + PaletteSource.CHANGED -> "changed" +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt new file mode 100644 index 00000000..26416ba9 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt @@ -0,0 +1,166 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.PaletteRole +import kst4contest.view.compose.ContrastWarning +import kst4contest.view.compose.OperatorProfilePaletteFiles +import kst4contest.view.compose.JavaFxPalette +import kst4contest.view.compose.PaletteSource +import kst4contest.view.compose.PaletteStore +import kst4contest.view.compose.contrastWarnings +import kst4contest.view.compose.hexOf + +/** + * One row of the colours tab: a role, what it is set to, and where that came from. + * + * @param label the role's name as the operator reads it + * @param hex the colour in force, in the one form the field accepts + * @param source why it is that colour — the mitigation the spec calls mandatory + */ +data class RoleRow( + val role: PaletteRole, + val label: String, + val hex: String, + val source: PaletteSource, +) + +/** + * The colours tab as a typed facade over the palette store. + * + * Writes through immediately and has no apply(), like every settings tab: the JavaFX controls + * wrote as they changed and "Save settings" only persisted. + * + * @param store the active profile's palette + * @param darkModeNow which design the operator is looking at; the tab always edits that one + * @param paletteFiles where this profile's own stylesheets live, for the hand-editing route + */ +class ColoursTabState( + private val store: PaletteStore, + private val darkModeNow: () -> Boolean, + private val paletteFiles: OperatorProfilePaletteFiles, +) { + + /** + * The six rows, in a fixed order, for the design in force. + * + * @return one row per role, always all six + */ + fun roles(): List { + val resolved = store.resolved(darkModeNow()) + + return PaletteRole.values().map { role -> + val it = resolved.roles.getValue(role) + RoleRow(role = role, label = LABELS.getValue(role), hex = hexOf(it.colour), source = it.source) + } + } + + /** + * Sets one role of the design in force. + * + * An unusable value is refused rather than stored: a mistyped hex value must not leave + * the operator with a black client. The refusal is returned rather than pushed, which is + * what `Form.committed` expects — it puts the field back and shows the reason. + * + * @param role the role to set + * @param text what the operator typed + * @return null when it was accepted, otherwise why it was not + */ + fun set(role: PaletteRole, text: String): String? { + val before = store.resolved(darkModeNow()).roles.getValue(role).colour + + store.set(darkModeNow(), role, text) + + if (store.resolved(darkModeNow()).roles.getValue(role).colour != before) { + return null + } + + /* + * Setting a role to the colour it already had is indistinguishable from a refusal + * here, and saying so would be wrong -- so the value itself decides. + */ + if (kst4contest.model.PaletteOverrides.isValidColour(text)) { + return null + } + + return "'$text' is not a colour. Six hexadecimal digits with a leading hash, " + .plus("for example #3C7A4B. ${LABELS.getValue(role)} is unchanged.") + } + + /** + * The pairs that now read worse than the shipped sheet makes them. + * + * @return the warnings to show, empty in the shipped state + */ + fun warnings(): List = contrastWarnings( + resolved = store.resolved(darkModeNow()).palette, + shipped = store.shipped(darkModeNow()), + ) + + /** + * The shipped palette of the design in force. + * + * The lifeline is drawn from this and never from [roles]: a rescue control that took the + * operator's palette would disappear in exactly the palette it exists to undo. + * + * @return layer 1 + */ + fun shipped(): JavaFxPalette = store.shipped(darkModeNow()) + + /** Remembers the palette in force, for [restoreRemembered]. Called when the tab opens. */ + fun rememberCurrentState() = store.takeSnapshot() + + /** The first way back: the palette as it was when the tab was opened, both designs. */ + fun restoreRemembered() = store.restoreSnapshot() + + /** The second way back: empty this design's changes, so file or shipping applies again. */ + fun discardChanges() = store.discardChanges(darkModeNow()) + + /** The third way back, and the one that beats a stylesheet file as well. */ + fun resetToShipped() = store.resetToShipped(darkModeNow()) + + /** + * The path of this design's own stylesheet, shown so the operator knows where to look. + * + * @return absolute path, whether or not the file exists + */ + fun stylesheetPath(): String = paletteFiles.fileFor(darkModeNow()).path + + /** + * Whether this design's own stylesheet is there. + * + * @return true when a file exists at [stylesheetPath] + */ + fun stylesheetExists(): Boolean = paletteFiles.fileFor(darkModeNow()).isFile + + /** + * Writes the shipped stylesheet into the profile so it can be edited by hand. + * + * Never replaces a file that is already there: that file is the operator's own work. + * + * @return what happened, for the window's notice bar + */ + fun exportStylesheet(): StylesheetExportOutcome = + exportShippedStylesheet(paletteFiles.fileFor(darkModeNow()), darkModeNow()) + + private companion object { + + /* + * One place for the six names. Stage 9 moves the interface text into a resource + * bundle; keeping them together is what makes that a single move. + */ + /* + * Each label says where the colour is actually painted, because the role names alone + * mislead: "Surface" is -fx-base, which reaches the screen only through the menu + * strip and the selected-row tint, while "Window surface" is what the windows are + * filled with. An operator reaching for "Surface" to darken the client and seeing + * almost nothing happen would reasonably conclude the control is broken. + */ + val LABELS = mapOf( + PaletteRole.SURFACE to "Surface (menu strip, selected rows)", + PaletteRole.WINDOW_SURFACE to "Window surface (the windows themselves)", + PaletteRole.FIELD_INTERIOR to "Field interior (fields, lists, buttons)", + PaletteRole.TEXT to "Text", + PaletteRole.ACCENT to "Accent", + PaletteRole.SEPARATOR to "Separator line", + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StylesheetExport.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StylesheetExport.kt new file mode 100644 index 00000000..42a9ff23 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StylesheetExport.kt @@ -0,0 +1,59 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.compose.JavaFxStylesheet +import java.io.File + +/** What writing the shipped stylesheet into the profile did. */ +sealed interface StylesheetExportOutcome { + + /** Written; the operator can now edit it by hand. */ + data class Written(val path: String) : StylesheetExportOutcome + + /** + * There was already a file, and it was left exactly as it was. + * + * The important case. That file is the operator's own work, and replacing it on a + * misclick is the one thing this button could do that cannot be undone from inside the + * application. + */ + data class AlreadyThere(val path: String) : StylesheetExportOutcome + + /** Nothing was written, and this is why. */ + data class Failed(val reason: String) : StylesheetExportOutcome +} + +/** + * Writes the shipped stylesheet of one design to a file, unless something is already there. + * + * The file is the middle of the three palette layers — the hand-editing route the copy in the + * application directory has been promising since it was first written and never read. Giving + * the operator the shipped sheet as a starting point is what makes that route usable: the + * roles live on three different selectors, which is not guessable. + * + * @param target where to write; its directory is created when missing + * @param darkMode true for the evening design, false for the daylight one + * @return what happened, never an exception + */ +fun exportShippedStylesheet(target: File, darkMode: Boolean): StylesheetExportOutcome { + + if (target.exists()) { + return StylesheetExportOutcome.AlreadyThere(target.path) + } + + val resource = + if (darkMode) JavaFxStylesheet.EVENING_RESOURCE else JavaFxStylesheet.DAYLIGHT_RESOURCE + + return runCatching { + target.parentFile?.mkdirs() + + val source = JavaFxStylesheet::class.java.getResource(resource) + ?: return StylesheetExportOutcome.Failed("$resource is not on the classpath") + + target.writeText(source.readText()) + StylesheetExportOutcome.Written(target.path) as StylesheetExportOutcome + }.getOrElse { failure -> + StylesheetExportOutcome.Failed( + failure.message ?: failure.javaClass.simpleName + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/StartupStylesheetCopyTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/StartupStylesheetCopyTest.kt new file mode 100644 index 00000000..08591cb0 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/StartupStylesheetCopyTest.kt @@ -0,0 +1,56 @@ +package kst4contest.view + +import kst4contest.controller.OperatorProfilePaths +import kst4contest.view.compose.JavaFxStylesheet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The file the startup copy writes is the file the root profile's palette layer reads. + * + * These were two independent literals of the same string in two classes, and they had to stay + * equal for the root profile's own stylesheet to be read at all -- the copy is written under + * one name and resolved under the other. A rename on either side would silently restore the + * defect this stage was commissioned to fix: a file written at every startup that nothing + * ever reads. The build would stay green and no other test would notice. + * + * Both now derive from `ApplicationConstants`, so there is nothing left to drift. This pins + * that it stays that way. + */ +class StartupStylesheetCopyTest { + + private val rootProfile = OperatorProfilePaths.buildRootProfile("Default") + + @Test + fun theCopyTargetIsWhatTheRootProfilesPaletteLayerReads() { + assertEquals( + OperatorProfilePaths.paletteRelativeFileName(rootProfile, false), + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_FILE, + ) + assertEquals( + OperatorProfilePaths.paletteRelativeFileName(rootProfile, true), + Kst4ContestApplication.STYLE_DEFAULTCSSEVENING_FILE, + ) + } + + @Test + fun theCopySourceIsTheSheetTheThemeReads() { + // Written from one resource and read from another would be the same defect, inverted. + assertEquals( + JavaFxStylesheet.DAYLIGHT_RESOURCE, + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_RESOURCE, + ) + assertEquals( + JavaFxStylesheet.EVENING_RESOURCE, + Kst4ContestApplication.STYLE_DEFAULTCSSEVENING_RESOURCE, + ) + } + + @Test + fun theResourceIsTheFileNameWithALeadingSlash() { + assertEquals( + "/" + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_FILE, + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_RESOURCE, + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt new file mode 100644 index 00000000..2baf1b3a --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt @@ -0,0 +1,169 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.math.abs + +/** + * The readability measure, which only warns. + * + * A pure function, and therefore the part that can be wrong without anyone noticing: a + * warning that never fires is indistinguishable from a palette that is fine, and a warning + * that always fires teaches the operator to ignore warnings. The reference numbers below are + * the ones WCAG itself states for black and white. + * + * The comparison is against the shipped sheet rather than against the bare floor, because + * the shipped sheets do not meet that floor themselves -- measured: the evening accent sits + * at 2.26 on its own surface. What is reported is therefore "this change made a pair harder + * to read than it shipped", which is the question the operator is actually asking. + */ +class ContrastRuleTest { + + private val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + private val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + + private fun assertClose(expected: Double, actual: Double, what: String) { + assertTrue(abs(expected - actual) < 0.05, "$what: expected ~$expected but was $actual") + } + + @Test + fun blackOnWhiteIsTheMaximumOf21() { + assertClose(21.0, contrastRatio(Color.Black, Color.White), "black on white") + assertClose(21.0, contrastRatio(Color.White, Color.Black), "white on black") + } + + @Test + fun aColourAgainstItselfIsTheMinimumOf1() { + assertClose(1.0, contrastRatio(Color.Black, Color.Black), "black on black") + assertClose(1.0, contrastRatio(Color(0xFF8899AA), Color(0xFF8899AA)), "grey on grey") + } + + @Test + fun theRatioDoesNotDependOnWhichIsTheBackground() { + assertEquals( + contrastRatio(Color(0xFF123456), Color(0xFFEEDDCC)), + contrastRatio(Color(0xFFEEDDCC), Color(0xFF123456)), + ) + } + + @Test + fun midGreyOnWhiteIsAroundFourAndAHalf() { + // #767676 on white is the canonical WCAG AA boundary example: 4.54. + assertClose(4.54, contrastRatio(Color(0xFF767676), Color.White), "#767676 on white") + } + + @Test + fun aShippedSheetNeverWarnsAboutItself() { + /* + * The property the whole relative rule exists for. Both sheets have pairs under the + * floor -- the day accent at 2.86, the evening accent at 2.26 -- so an absolute rule + * would warn on a client nobody has touched. + */ + assertTrue(contrastWarnings(day, day).isEmpty(), "the daylight sheet warns about itself") + assertTrue( + contrastWarnings(evening, evening).isEmpty(), + "the evening sheet warns about itself", + ) + } + + @Test + fun theShippedSheetsReallyDoHavePairsUnderTheFloor() { + /* + * Measured here rather than asserted in prose, because it is the premise of the rule + * above. If a later change to the sheets makes every pair pass, the relative rule + * becomes equivalent to the absolute one and the deviation can be dropped. + */ + val accentOnSurface = contrastRatio(day.accent, day.base) + + assertTrue( + accentOnSurface < CONTRAST_FLOOR, + "the daylight accent now passes at $accentOnSurface; the relative rule may no " + + "longer be needed", + ) + } + + @Test + fun blackTextOnABlackSurfaceWarnsAboutEveryPairItAppearsIn() { + val unreadable = day.copy( + base = Color.Black, + windowBackground = Color.Black, + controlInnerBackground = Color.Black, + labelTextFill = Color.Black, + accent = Color.Black, + textAccent = Color.Black, + ) + + val warnings = contrastWarnings(unreadable, day) + + assertEquals(4, warnings.size, "every pair must warn: ${warnings.map { it.what }}") + warnings.forEach { + assertTrue(it.ratio < CONTRAST_FLOOR, it.what) + assertTrue(it.ratio < it.shippedRatio, "${it.what} is not actually worse than shipped") + } + } + + @Test + fun blackeningOnlyTheWindowSurfaceWarnsAboutTheTextOnIt() { + /* + * The pair that actually carries almost all the text, and the one the guard missed. + * Theme.kt maps background and surface onto windowBackground and onBackground and + * onSurface onto labelTextFill, so every label in every window sits on this pair -- + * while `base` reaches the screen only through the menu strip and the selection tint. + * Checking text against `base` alone let an operator black out every window and be + * told nothing. + */ + val blackWindows = day.copy(windowBackground = Color.Black) + + val warnings = contrastWarnings(blackWindows, day) + + assertTrue( + warnings.any { it.what.contains("window surface") }, + "no warning when every label in every window went black on black: " + + "${warnings.map { it.what }}", + ) + } + + @Test + fun theAccentIsMeasuredWhereItIsActuallyDrawn() { + /* + * Material's primary is textAccent, not accent, and it is drawn as text and as tab + * underlines on the window surface -- not as `accent` on `base`. Measuring the wrong + * colour on the wrong ground answers a question nobody is asking. + */ + val invisibleAccent = day.copy(textAccent = day.windowBackground) + + val warnings = contrastWarnings(invisibleAccent, day) + + assertTrue( + warnings.any { it.what.contains("Accent") }, + "an accent identical to the surface it is drawn on did not warn: " + + "${warnings.map { it.what }}", + ) + } + + @Test + fun anImprovementDoesNotWarnEvenWhileStillUnderTheFloor() { + /* + * The evening accent ships at 2.26. An operator who moves it to something lighter has + * made things better and must not be told off for it -- that is how a guard loses its + * authority. + */ + val better = evening.copy(accent = Color.White) + + val accentWarnings = contrastWarnings(better, evening).filter { it.what.contains("Accent") } + + assertTrue(accentWarnings.isEmpty(), "an improvement warned: $accentWarnings") + } + + @Test + fun aPairThatStaysComfortablyReadableDoesNotWarn() { + // Worse than shipped but still well over the floor: nothing to say. + val slightlyWorse = day.copy(labelTextFill = Color(0xFF444444)) + + val textWarnings = contrastWarnings(slightlyWorse, day).filter { it.what.contains("Text") } + + assertTrue(textWarnings.isEmpty(), "a readable pair warned: $textWarnings") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt new file mode 100644 index 00000000..fe50d960 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt @@ -0,0 +1,169 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The three layers and which one wins, role by role. + * + * The provenance is not decoration. Two sources that can decide the same colour is a place + * where nobody can later say why a colour is what it is, and saying so per role is the one + * thing that makes the arrangement defensible -- so it is pinned as hard as the colours. + */ +class PaletteResolutionTest { + + private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + + private val red = Color(0xFFFF0000) + private val green = Color(0xFF00FF00) + + @Test + fun withNothingSetEveryRoleComesFromTheShippedSheet() { + val resolved = resolvePalette(shipped, emptyMap(), emptyMap()) + + assertEquals(PaletteRole.values().toSet(), resolved.roles.keys) + resolved.roles.forEach { (role, it) -> + assertEquals(PaletteSource.SHIPPED, it.source, "$role") + } + assertEquals(shipped.base, resolved.palette.base) + assertEquals(shipped.accent, resolved.palette.accent) + } + + @Test + fun aFileOverridesTheShippedSheetForTheRolesItNames() { + val resolved = resolvePalette(shipped, mapOf(PaletteRole.SURFACE to red), emptyMap()) + + assertEquals(red, resolved.roles[PaletteRole.SURFACE]!!.colour) + assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.SURFACE]!!.source) + + // And only those roles. + assertEquals(PaletteSource.SHIPPED, resolved.roles[PaletteRole.ACCENT]!!.source) + assertEquals(shipped.accent, resolved.roles[PaletteRole.ACCENT]!!.colour) + } + + @Test + fun aChangeOverridesBothTheFileAndTheShippedSheet() { + val resolved = resolvePalette( + shipped, + mapOf(PaletteRole.SURFACE to red), + mapOf(PaletteRole.SURFACE to green), + ) + + assertEquals(green, resolved.roles[PaletteRole.SURFACE]!!.colour) + assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.SURFACE]!!.source) + } + + @Test + fun theResolvedRolesReachThePaletteTheWindowsDraw() { + val resolved = resolvePalette( + shipped, + emptyMap(), + mapOf( + PaletteRole.SURFACE to red, + PaletteRole.WINDOW_SURFACE to green, + PaletteRole.FIELD_INTERIOR to red, + PaletteRole.TEXT to green, + PaletteRole.ACCENT to red, + PaletteRole.SEPARATOR to green, + ), + ) + + assertEquals(red, resolved.palette.base) + assertEquals(green, resolved.palette.windowBackground) + assertEquals(red, resolved.palette.controlInnerBackground) + assertEquals(green, resolved.palette.labelTextFill) + assertEquals(red, resolved.palette.accent) + assertEquals(green, resolved.palette.separatorLine) + } + + @Test + fun theDerivedFieldsStayWithTheShippedSheet() { + // The hover gradient, the pressed border and the font size are not roles the operator + // sets here; they follow the shipped sheet rather than becoming further decisions. + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) + + assertEquals(shipped.buttonHoverGradient, resolved.palette.buttonHoverGradient) + assertEquals(shipped.buttonPressedBorder, resolved.palette.buttonPressedBorder) + assertEquals(shipped.fontSizePx, resolved.palette.fontSizePx) + } + + @Test + fun aChosenAccentIsAlsoTheAccentTheOperatorActuallySees() { + /* + * Theme.kt maps Material's primary onto textAccent and not onto accent, because + * -fx-accent only paints JavaFX's selection bar while the green of .text-field .text + * is what reads as the accent. Leave textAccent on the sheet's green and setting the + * accent changes almost nothing visible -- a setting written through and not drawn, + * which is precisely the fault class the drawing-layer tests exist for. + */ + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.ACCENT to red)) + + assertEquals(red, resolved.palette.accent) + assertEquals(red, resolved.palette.textAccent) + } + + @Test + fun anUntouchedAccentLeavesTheSheetsOwnTextAccentAlone() { + // Without an override the sheet's gradient midpoint stands; it is not the accent. + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) + + assertEquals(shipped.textAccent, resolved.palette.textAccent) + } + + @Test + fun aFileRestatingTheShippedAccentDoesNotRepaintTheTextAccent() { + /* + * The root profile's stylesheet is written by copyResourceIfRequired at startup and + * is the shipped sheet, so layer 2 declares the accent on every existing installation + * with nobody having chosen anything. Keying the text accent on provenance rather + * than on the value repainted the evening primary from green to blue on upgrade. + */ + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + + val resolved = resolvePalette( + evening, + mapOf(PaletteRole.ACCENT to evening.accent), + emptyMap(), + ) + + assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.ACCENT]!!.source) + assertEquals( + evening.textAccent, + resolved.palette.textAccent, + "a file that merely restates the shipped accent changed the accent that is seen", + ) + } + + @Test + fun resettingToTheShippedAccentRestoresTheShippedTextAccent() { + /* + * resetToShipped writes all six roles as explicit overrides, because it has to beat a + * stylesheet file. If the text accent follows provenance, the one button the spec + * names as producing "exactly the shipped colours" produces something else -- and + * disagrees with "discard my changes", which clears layer 3 and does restore it. + */ + val resolved = resolvePalette( + shipped, + emptyMap(), + mapOf(PaletteRole.ACCENT to shipped.accent), + ) + + assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.ACCENT]!!.source) + assertEquals(shipped.textAccent, resolved.palette.textAccent) + } + + @Test + fun theWindowSurfaceIsNotDraggedAlongByTheSurface() { + /* + * The reason these are two roles rather than one: the sheets state the window + * surface separately, and an operator who sets only the control surface must not + * have the window surface changed under them. + */ + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) + + assertEquals(red, resolved.palette.base) + assertEquals(shipped.windowBackground, resolved.palette.windowBackground) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt new file mode 100644 index 00000000..4ff99d7e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt @@ -0,0 +1,109 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatPreferences +import kst4contest.model.OperatorProfile +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * The factory binds the store to one profile's preferences and one profile's stylesheet. + * + * Two wires, and each of them is a place where one profile could end up reading or writing + * another's colours. The stylesheet path is injected here rather than derived inside, so the + * test does not have to write into the operator's home directory to check which file is read. + */ +class PaletteStoreFactoryTest { + + private val daylight = OperatorProfilePaletteFiles( + dayFile = File("/nonexistent/day.css"), + eveningFile = File("/nonexistent/evening.css"), + ) + + @Test + fun aChangeLandsInTheDesignsOwnPreference() { + val prefs = ChatPreferences() + + val store = PaletteStoreFactory.create(prefs, daylight) + store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals("accent=#FF0000", prefs.guiOptions_paletteOverridesDay) + assertTrue(prefs.guiOptions_paletteOverridesEvening.isEmpty()) + } + + @Test + fun anEveningChangeLandsInTheEveningPreference() { + val prefs = ChatPreferences() + + val store = PaletteStoreFactory.create(prefs, daylight) + store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals("accent=#FF0000", prefs.guiOptions_paletteOverridesEvening) + assertTrue(prefs.guiOptions_paletteOverridesDay.isEmpty()) + } + + @Test + fun whatIsAlreadyStoredIsInForceFromTheStart() { + // The store is built at startup; a colour saved last session has to be there at once. + val prefs = ChatPreferences() + prefs.guiOptions_paletteOverridesDay = "accent=#FF0000" + + val store = PaletteStoreFactory.create(prefs, daylight) + + assertEquals(PaletteSource.CHANGED, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) + } + + @Test + fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() { + val prefs = ChatPreferences() + + val store = PaletteStoreFactory.create(prefs, daylight) + + store.resolved(false).roles.forEach { (role, resolved) -> + assertEquals(PaletteSource.SHIPPED, resolved.source, "$role") + } + } + + @Test + fun aStylesheetInTheProfileSuppliesTheRolesItNames() { + val file = File.createTempFile("kst4contest-profile", ".css") + try { + file.writeText(".root { -fx-accent: #123456; }") + + val store = PaletteStoreFactory.create( + ChatPreferences(), + OperatorProfilePaletteFiles(dayFile = file, eveningFile = file), + ) + + assertEquals( + PaletteSource.FILE, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals(colourOf("#123456"), store.resolved(false).palette.accent) + } finally { + file.delete() + } + } + + @Test + fun theFilesOfAProfileFollowOperatorProfilePaths() { + /* + * The one assertion that ties the factory to the project's path convention. A + * different shape here would have one profile reading another profile's colours. + */ + val other = OperatorProfile("OP2", "DN9APW", false, false) + + val files = OperatorProfilePaletteFiles.of(other) + + assertTrue( + files.dayFile.path.endsWith("profiles/OP2/KST4ContestDefaultDay.css"), + "the daylight file is ${files.dayFile.path}", + ) + assertTrue( + files.eveningFile.path.endsWith("profiles/OP2/KST4ContestDefaultEvening.css"), + "the evening file is ${files.eveningFile.path}", + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt new file mode 100644 index 00000000..f45867da --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt @@ -0,0 +1,278 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteOverrides +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The store, and the three ways back. + * + * Built over four lambdas rather than over ChatPreferences, so none of this touches the + * operator's home directory: that constructor copies resources into it. + */ +class PaletteStoreTest { + + private val stored = mutableMapOf(false to "", true to "") + private var fileRoles: Map = emptyMap() + + private fun store() = PaletteStore( + shippedOf = { dark -> + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { fileRoles }, + storedOverridesOf = { dark -> stored.getValue(dark) }, + storeOverrides = { dark, encoded -> stored[dark] = encoded }, + ) + + @Test + fun theImmutableLayersAreReadOnceAndNotOnEveryResolve() { + /* + * resolved() is called from inside Kst4ContestTheme, so it runs on every recomposition + * of every window. Reading the classpath and the profile's stylesheet there means two + * CSS parses and a disk read per recomposition, on the UI thread -- and this project's + * standing lesson is that a green build says nothing about what the UI thread is doing. + * + * The override state is the live part; the two layers under it are immutable for the + * run, which is also what both manuals promise ("changes to the file take effect after + * a restart"). + */ + var shippedReads = 0 + var fileReads = 0 + + val store = PaletteStore( + shippedOf = { dark -> + shippedReads++ + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { fileReads++; emptyMap() }, + storedOverridesOf = { "" }, + storeOverrides = { _, _ -> }, + ) + + repeat(20) { store.resolved(false) } + store.set(false, PaletteRole.ACCENT, "#FF0000") + repeat(20) { store.resolved(false) } + + assertEquals(1, shippedReads, "the shipped sheet was parsed more than once") + assertEquals(1, fileReads, "the profile stylesheet was read more than once") + } + + @Test + fun eachDesignReadsItsOwnLayersOnceWhenItIsFirstNeeded() { + var reads = 0 + val store = PaletteStore( + shippedOf = { dark -> + reads++ + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { emptyMap() }, + storedOverridesOf = { "" }, + storeOverrides = { _, _ -> }, + ) + + repeat(5) { store.resolved(false) } + assertEquals(1, reads, "the daylight sheet was read more than once") + + repeat(5) { store.resolved(true) } + assertEquals(2, reads, "the evening sheet was not read, or was read more than once") + } + + @Test + fun aChangeIsVisibleInTheResolvedPaletteAndInTheStorage() { + val store = store() + + store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) + assertEquals( + PaletteSource.CHANGED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals("accent=#FF0000", stored.getValue(false)) + } + + @Test + fun anUnparsableColourLeavesTheRoleAsItWas() { + // The operator typed something that is not a colour. Nothing may turn black. + val store = store() + val before = store.resolved(false).palette.accent + + store.set(false, PaletteRole.ACCENT, "rot") + + assertEquals(before, store.resolved(false).palette.accent) + assertEquals("", stored.getValue(false)) + } + + @Test + fun theTwoDesignsDoNotDragEachOtherAlong() { + val store = store() + + store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals( + PaletteSource.CHANGED, + store.resolved(true).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals( + PaletteSource.SHIPPED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + } + + @Test + fun resetToShippedBeatsAFileAsWell() { + /* + * The acceptance criterion of this stage: "exactly the shipped colours". With a file + * in the middle, clearing the changes would not get there -- the file would. + */ + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val store = store() + val shippedAccent = store.shipped(false).accent + + store.resetToShipped(false) + + assertEquals(shippedAccent, store.resolved(false).palette.accent) + assertEquals( + PaletteSource.CHANGED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals(6, PaletteOverrides.parse(stored.getValue(false)).size) + } + + @Test + fun resetToShippedReproducesEveryShippedRoleExactly() { + /* + * The acceptance criterion says "exactly the shipped colours -- not something that + * resembles them", and the path goes through hex: resetToShipped writes each role as + * a six-digit string and reads it back. That is only lossless because Compose packs an + * sRGB Color at 8 bits per channel, so every channel is already an exact k/255. Pinned + * for all six roles and both designs, because the day the packing changes, nothing + * else would notice. + */ + for (dark in listOf(false, true)) { + val store = store() + val shipped = store.shipped(dark) + + store.resetToShipped(dark) + val after = store.resolved(dark).palette + + assertEquals(shipped.base, after.base, "surface, dark=$dark") + assertEquals(shipped.windowBackground, after.windowBackground, "window, dark=$dark") + assertEquals(shipped.controlInnerBackground, after.controlInnerBackground, "field, dark=$dark") + assertEquals(shipped.labelTextFill, after.labelTextFill, "text, dark=$dark") + assertEquals(shipped.accent, after.accent, "accent, dark=$dark") + assertEquals(shipped.separatorLine, after.separatorLine, "separator, dark=$dark") + assertEquals(shipped.textAccent, after.textAccent, "text accent, dark=$dark") + } + } + + @Test + fun resetToShippedAlsoWinsOverAFileThatRestatesTheShippedSheet() { + /* + * The root profile's real situation: its stylesheet is the shipped sheet, written by + * copyResourceIfRequired. Resetting there must land on the shipped palette and not on + * something the file nudged. + */ + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + fileRoles = JavaFxStylesheet.declaredRoles( + JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultEvening.css")!!.readText() + ) + val store = store() + + store.resetToShipped(true) + + assertEquals(evening.accent, store.resolved(true).palette.accent) + assertEquals(evening.textAccent, store.resolved(true).palette.textAccent) + assertEquals(evening.labelTextFill, store.resolved(true).palette.labelTextFill) + } + + @Test + fun discardingChangesLetsTheFileApplyAgain() { + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val store = store() + store.set(false, PaletteRole.ACCENT, "#FF0000") + + store.discardChanges(false) + + assertEquals(Color(0xFF00FF00), store.resolved(false).palette.accent) + assertEquals(PaletteSource.FILE, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) + assertTrue(stored.getValue(false).isEmpty()) + } + + @Test + fun theSnapshotRestoresWhatWasInForceWhenItWasTaken() { + val store = store() + store.set(false, PaletteRole.ACCENT, "#FF0000") + + store.takeSnapshot() + store.set(false, PaletteRole.ACCENT, "#0000FF") + store.set(false, PaletteRole.TEXT, "#00FF00") + store.restoreSnapshot() + + // Including the change that was already there: the snapshot is not "shipped". + assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) + assertEquals(PaletteSource.SHIPPED, store.resolved(false).roles[PaletteRole.TEXT]!!.source) + } + + @Test + fun theSnapshotRestoresBothDesignsAndNotOnlyTheOneInForce() { + /* + * takeSnapshot captures both, and the button says "back to how it was". An operator + * who changed a colour, switched day/evening from the main window's menu, changed + * another and then asked to go back means both -- undoing half would leave a state + * they never chose. + */ + val store = store() + store.takeSnapshot() + + store.set(false, PaletteRole.ACCENT, "#FF0000") + store.set(true, PaletteRole.ACCENT, "#00FF00") + store.restoreSnapshot() + + assertEquals( + PaletteSource.SHIPPED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + "the daylight change survived", + ) + assertEquals( + PaletteSource.SHIPPED, + store.resolved(true).roles[PaletteRole.ACCENT]!!.source, + "the evening change survived", + ) + } + + @Test + fun restoringWithoutASnapshotChangesNothing() { + val store = store() + store.set(false, PaletteRole.ACCENT, "#FF0000") + + store.restoreSnapshot() + + assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) + } + + @Test + fun theLifelineReadsTheShippedSheetAndNotTheResolvedPalette() { + /* + * The rescue button is drawn in these colours. If it ever read the resolved palette, + * it would vanish in exactly the palette it exists to undo -- and nobody would find + * out until they needed it. + */ + val store = store() + val shippedBase = store.shipped(false).base + + PaletteRole.values().forEach { store.set(false, it, "#000000") } + + assertEquals(shippedBase, store.shipped(false).base) + assertEquals(Color.Black, store.resolved(false).palette.base) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt new file mode 100644 index 00000000..d8748673 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt @@ -0,0 +1,163 @@ +package kst4contest.view.compose + +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Which roles a stylesheet actually names. + * + * Not the same question as `read`, and conflating the two is the trap of this stage: `read` + * fills a missing role from the Modena defaults, so a file saying only `-fx-base` would + * silently override all six. A layer needs presence, not a complete palette. + * + * How far apart the two questions are is visible in the shipped sheets themselves -- the + * daylight one states no colour at all, and `read` still returns a full palette for it. + */ +class StylesheetDeclaredRolesTest { + + private fun shipped(name: String): String = + JavaFxStylesheet::class.java.getResource(name)!!.readText() + + @Test + fun aSheetNamingOneRoleDeclaresOnlyThatRole() { + // The accent, deliberately: nothing is derived from it, so one named means one role. + val declared = JavaFxStylesheet.declaredRoles(".root { -fx-accent: #0096C9; }") + + assertEquals(setOf(PaletteRole.ACCENT), declared.keys) + } + + @Test + fun aRoleStatedOutsideRootIsFoundAtItsOwnSelector() { + /* + * Three of the six live outside .root. A reader that only looked there would never + * find the text colour or the separator line -- which is half the palette. + */ + val declared = JavaFxStylesheet.declaredRoles( + """ + .label { -fx-text-fill: #112233; } + .separator *.line { -fx-background-color: #445566; } + """.trimIndent() + ) + + assertEquals(setOf(PaletteRole.TEXT, PaletteRole.SEPARATOR), declared.keys) + } + + @Test + fun aSheetStatingTheBaseWithoutTheBackgroundStatesTheDerivedWindowSurfaceToo() { + /* + * What JavaFX itself does: Modena's -fx-background is derive(-fx-base, 26.4%), so a + * sheet that states -fx-base and says nothing about -fx-background has stated the + * window surface as well. Without this the most natural hand edit is nearly inert -- + * an operator sets -fx-base to #303030, restarts, and the windows stay light grey, + * because base reaches the screen only through the menu strip and the selection tint. + * + * The file layer only. In the settings tab the two stay independent, which is the + * whole reason the spec keeps them as separate roles: six fields are visible there and + * the operator can set both. + */ + val declared = JavaFxStylesheet.declaredRoles(".root { -fx-base: #303030; }") + + assertEquals(setOf(PaletteRole.SURFACE, PaletteRole.WINDOW_SURFACE), declared.keys) + assertEquals( + JavaFxStylesheet.derive(declared.getValue(PaletteRole.SURFACE), 26.4), + declared.getValue(PaletteRole.WINDOW_SURFACE), + ) + } + + @Test + fun aStatedBackgroundIsNotReplacedByADerivedOne() { + val declared = JavaFxStylesheet.declaredRoles( + ".root { -fx-base: #303030; -fx-background: #112233; }" + ) + + assertEquals(colourOf("#112233"), declared.getValue(PaletteRole.WINDOW_SURFACE)) + } + + @Test + fun aSheetWithNoBaseDerivesNoWindowSurface() { + val declared = JavaFxStylesheet.declaredRoles(".label { -fx-text-fill: #112233; }") + + assertEquals(setOf(PaletteRole.TEXT), declared.keys) + } + + @Test + fun theSamePropertyUnderAnotherSelectorIsNotARole() { + // -fx-background-color appears a dozen times in the evening sheet. One is the role. + val declared = JavaFxStylesheet.declaredRoles( + ".scroll-bar { -fx-background-color: #445566; } .button:hover { -fx-text-fill: red; }" + ) + + assertTrue(declared.isEmpty(), "found roles where there are none: ${declared.keys}") + } + + @Test + fun aSheetNamingNothingDeclaresNothing() { + assertTrue(JavaFxStylesheet.declaredRoles("").isEmpty()) + assertTrue(JavaFxStylesheet.declaredRoles("/* just a comment */").isEmpty()) + assertTrue(JavaFxStylesheet.declaredRoles(".button { -fx-padding: 2px; }").isEmpty()) + } + + @Test + fun theShippedDaylightSheetStatesNoColourAtAll() { + /* + * Measured, not assumed: its .root carries only -fx-font-size, and every colour in + * the daylight design comes from JavaFX's Modena defaults by way of `read`. If this + * ever starts declaring a role, the daylight palette has gained a second source and + * the resolver's layer 1 needs looking at. + */ + val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultDay.css")) + + assertTrue(declared.isEmpty(), "the daylight sheet now declares ${declared.keys}") + } + + @Test + fun theShippedEveningSheetStatesEveryRole() { + /* + * It states the surface, the field interior, the accent, the label text and the + * separator line outright, and the window surface by implication -- it names -fx-base + * and no -fx-background, which is how JavaFX derives one. This pins the + * selector-and-property mapping against a real file: get a selector wrong and a role + * drops out without any other test noticing. + */ + val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultEvening.css")) + + assertEquals(PaletteRole.values().toSet(), declared.keys) + } + + @Test + fun aMissingFileDeclaresNothingRatherThanThrowing() { + val absent = File("/nonexistent/kst4contest/does-not-exist.css") + + assertTrue(JavaFxStylesheet.declaredRolesOfFile(absent).isEmpty()) + } + + @Test + fun anUnreadableOrNonsenseFileDeclaresNothingRatherThanThrowing() { + /* + * A client that will not start because of a broken colour file is worse than one in + * the wrong colours. Half-written files and files that are not CSS at all both exist, + * and this one is hand-edited by the operator. + */ + val nonsense = File.createTempFile("kst4contest-palette", ".css") + try { + nonsense.writeText("this is not css { -fx-base") + assertTrue(JavaFxStylesheet.declaredRolesOfFile(nonsense).isEmpty()) + } finally { + nonsense.delete() + } + + val directory = File.createTempFile("kst4contest-palette-dir", "").let { + it.delete() + it.mkdirs() + it + } + try { + assertTrue(JavaFxStylesheet.declaredRolesOfFile(directory).isEmpty()) + } finally { + directory.delete() + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt new file mode 100644 index 00000000..0e994749 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * A colour change reaches a composed window without anything else happening. + * + * This is the one mechanism the stage's acceptance criterion hangs on, and it used not to + * work: `Kst4ContestTheme` took its palette from `remember(darkMode)`, so a change reached + * nothing until somebody toggled day/evening. A state-level test of the store cannot see + * that fault -- the store was always right, the theme never asked it again. + * + * Headless: no window is opened, only a composition. + */ +class ThemeReadsPaletteStoreTest { + + private var stored = "" + + private fun store() = PaletteStore( + shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") }, + fileRolesOf = { emptyMap() }, + storedOverridesOf = { stored }, + storeOverrides = { _, encoded -> stored = encoded }, + ) + + @OptIn(ExperimentalTestApi::class) + @Test + fun aColourSetAfterTheFirstDrawReachesTheComposition() = runComposeUiTest { + val store = store() + val seen = mutableListOf() + + setContent { + CompositionLocalProvider(LocalPaletteStore provides store) { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + // Reading the palette is what a control does; record what it was handed. + seen += LocalJavaFxPalette.current.base + } + } + } + + waitForIdle() + val firstDraw = seen.last() + + store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") + waitForIdle() + + assertEquals( + Color(0xFFFF0000), + seen.last(), + "the composition still holds the palette from before the change, which is the " + + "remember(darkMode) fault this store exists to fix", + ) + assertEquals(store.shipped(false).base, firstDraw, "the first draw was not the shipped base") + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun withoutAStoreTheShippedPaletteIsStillDrawn() { + /* + * Not caution: the operator profile picker composes before a profile, and therefore + * before a store, exists. It must still have colours. + */ + runComposeUiTest { + var seen: Color? = null + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + seen = LocalJavaFxPalette.current.base + } + } + + waitForIdle() + + assertEquals(JavaFxStylesheet.read("/KST4ContestDefaultDay.css").base, seen) + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColourShortcutsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColourShortcutsTest.kt new file mode 100644 index 00000000..a1e2f444 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColourShortcutsTest.kt @@ -0,0 +1,80 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.input.key.Key +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * The keyboard path to the three ways back. + * + * The spec requires one for all three, and says why: with no "not yet applied" state the ways + * back are the only way back, and they must be reachable without colour perception. Tabbing + * blindly through six text fields to find three buttons is reachable in the sense that a + * locked door with the key inside is openable. + * + * Only the decision is tested, not the KeyEvent plumbing -- the same split WindowShortcuts + * uses, and for the same reason: a desktop KeyEvent wraps a native AWT event and testing it + * would test Compose rather than this. + */ +class ColourShortcutsTest { + + @Test + fun theThreeWaysBackHaveTheirOwnKeys() { + assertEquals(ColourAction.BACK_TO_PREVIOUS, ColourShortcuts.actionFor(true, Key.Z)) + assertEquals(ColourAction.DISCARD_CHANGES, ColourShortcuts.actionFor(true, Key.D)) + assertEquals(ColourAction.RESET_TO_SHIPPED, ColourShortcuts.actionFor(true, Key.R)) + } + + @Test + fun eachActionHasExactlyOneKeyAndNoKeyHasTwoActions() { + val keys = listOf(Key.Z, Key.D, Key.R) + val actions = keys.map { ColourShortcuts.actionFor(true, it) } + + assertEquals(ColourAction.values().toList(), actions) + assertEquals(ColourAction.values().size, actions.toSet().size) + } + + @Test + fun withoutControlTheKeysAreJustTypingAndMustReachTheFields() { + /* + * Six hex fields sit in this tab. A bare letter shortcut would eat the typing it is + * meant to protect -- #DDEEFF contains a D. + */ + assertNull(ColourShortcuts.actionFor(false, Key.Z)) + assertNull(ColourShortcuts.actionFor(false, Key.D)) + assertNull(ColourShortcuts.actionFor(false, Key.R)) + } + + @Test + fun anyOtherKeyIsNotAShortcut() { + assertNull(ColourShortcuts.actionFor(true, Key.A)) + assertNull(ColourShortcuts.actionFor(true, Key.Enter)) + assertNull(ColourShortcuts.actionFor(true, Key.Escape)) + } + + @Test + fun theKeysDoNotCollideWithTheMainWindowsOwnShortcuts() { + /* + * The main window takes Enter, Escape and Ctrl+1 to Ctrl+0. None of the three here may + * be one of those, or an operator with the settings window focused would fire both. + */ + for (digit in listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")) { + assertNull( + kst4contest.view.compose.WindowShortcuts.snippetIndexFor("z"), + "a letter key became a snippet slot", + ) + } + + assertNull(ColourShortcuts.actionFor(true, Key.Enter)) + assertNull(ColourShortcuts.actionFor(true, Key.Escape)) + } + + @Test + fun everyActionIsLabelledForTheOperatorToRead() { + // A shortcut nobody is told about is not a second safety net. + ColourAction.values().forEach { + assert(ColourShortcuts.labelFor(it).isNotBlank()) { "$it has no label" } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt new file mode 100644 index 00000000..976563d3 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt @@ -0,0 +1,207 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteRole +import kst4contest.view.compose.JavaFxStylesheet +import kst4contest.view.compose.PaletteSource +import kst4contest.view.compose.OperatorProfilePaletteFiles +import kst4contest.view.compose.PaletteStore +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * The colours tab as a typed facade over the store. + * + * Built over a store made of lambdas, so nothing here touches the operator's home directory. + * What is pinned is what the tab shows and what it refuses: a role's colour, where that + * colour came from, and the fact that nonsense typed into a field changes nothing. + */ +class ColoursTabStateTest { + + private val stored = mutableMapOf(false to "", true to "") + private var fileRoles: Map = emptyMap() + private var darkMode = false + + private fun state(): ColoursTabState { + val store = PaletteStore( + shippedOf = { dark -> + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { fileRoles }, + storedOverridesOf = { dark -> stored.getValue(dark) }, + storeOverrides = { dark, encoded -> stored[dark] = encoded }, + ) + return ColoursTabState( + store, + { darkMode }, + OperatorProfilePaletteFiles( + dayFile = File("/nonexistent/day.css"), + eveningFile = File("/nonexistent/evening.css"), + ), + ) + } + + @Test + fun everyRoleIsShownWithItsColourAndItsProvenance() { + val tab = state() + + val rows = tab.roles() + + assertEquals(PaletteRole.values().toList(), rows.map { it.role }) + rows.forEach { + assertEquals(PaletteSource.SHIPPED, it.source, "${it.role}") + assertTrue(it.hex.matches(Regex("#[0-9A-F]{6}")), "${it.role} shows '${it.hex}'") + assertTrue(it.label.isNotBlank(), "${it.role} has no label") + } + } + + @Test + fun aSetColourIsShownAsChanged() { + val tab = state() + + val refusal = tab.set(PaletteRole.ACCENT, "#FF0000") + + val row = tab.roles().single { it.role == PaletteRole.ACCENT } + assertEquals("#FF0000", row.hex) + assertEquals(PaletteSource.CHANGED, row.source) + assertNull(refusal, "a valid colour was refused: $refusal") + } + + @Test + fun aRoleTakenFromTheFileSaysSo() { + /* + * The mitigation the spec calls mandatory: with two sources that can decide the same + * colour, "why is this colour what it is" has to be answerable per role. + */ + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val tab = state() + + assertEquals( + PaletteSource.FILE, + tab.roles().single { it.role == PaletteRole.ACCENT }.source, + ) + } + + @Test + fun nonsenseTypedIntoAFieldChangesNothingAndIsReported() { + val tab = state() + val before = tab.roles().single { it.role == PaletteRole.TEXT }.hex + + /* + * The refusal is returned rather than pushed, which is the contract Form.committed + * expects: it puts the field back to the stored value and shows the reason. + */ + for (nonsense in listOf("rot", "", "#GGG", "#1234567", "ECECEC", " ")) { + val refusal = tab.set(PaletteRole.TEXT, nonsense) + assertTrue( + refusal != null && refusal.isNotBlank(), + "'$nonsense' was accepted or refused without a reason", + ) + } + + assertEquals(before, tab.roles().single { it.role == PaletteRole.TEXT }.hex) + assertEquals(PaletteSource.SHIPPED, tab.roles().single { it.role == PaletteRole.TEXT }.source) + } + + @Test + fun settingARoleToTheColourItAlreadyHasIsNotARefusal() { + /* + * set() decides by comparing the colour before and after, so a valid colour that + * happens to equal the current one changes nothing -- and reporting that as "'#ECECEC' + * is not a colour" would be a lie the operator cannot argue with. The field commits on + * focus loss, so this happens whenever somebody tabs through without editing. + */ + val tab = state() + val unchanged = tab.roles().single { it.role == PaletteRole.SURFACE }.hex + + assertNull(tab.set(PaletteRole.SURFACE, unchanged), "an unchanged colour was refused") + assertNull(tab.set(PaletteRole.SURFACE, unchanged.lowercase()), "case alone was refused") + } + + @Test + fun theRowsFollowTheActiveDesign() { + val tab = state() + tab.set(PaletteRole.ACCENT, "#FF0000") + + darkMode = true + + // The evening design has its own overrides; the daylight change must not show here. + assertEquals( + PaletteSource.SHIPPED, + tab.roles().single { it.role == PaletteRole.ACCENT }.source, + ) + } + + @Test + fun theWarningsDescribeTheResolvedPaletteAgainstTheShippedOne() { + val tab = state() + assertTrue(tab.warnings().isEmpty(), "the untouched state warns: ${tab.warnings()}") + + tab.set(PaletteRole.TEXT, "#FFFFFF") + + // White label text on the light window surface is unreadable and worse than shipped. + assertTrue( + tab.warnings().any { it.what.contains("Text on the window surface") }, + "no warning for white on light grey: ${tab.warnings()}", + ) + } + + @Test + fun theLifelineColoursComeFromTheShippedSheet() { + /* + * The reason this accessor exists at all. A tab reading its rescue colours from the + * resolved palette would draw them in exactly the palette they exist to undo. + */ + val tab = state() + val shippedBase = tab.shipped().base + + PaletteRole.values().forEach { tab.set(it, "#000000") } + + assertEquals(shippedBase, tab.shipped().base) + assertFalse(shippedBase == Color.Black, "the shipped base is black, so this proves nothing") + } + + @Test + fun theThreeWaysBackDoTheThreeDifferentThings() { + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val tab = state() + + // Back to how it was when the tab opened, including a change already present. + tab.set(PaletteRole.ACCENT, "#FF0000") + tab.rememberCurrentState() + tab.set(PaletteRole.ACCENT, "#0000FF") + tab.restoreRemembered() + assertEquals("#FF0000", tab.roles().single { it.role == PaletteRole.ACCENT }.hex) + + // Discard my changes: the file applies again. + tab.discardChanges() + assertEquals( + PaletteSource.FILE, + tab.roles().single { it.role == PaletteRole.ACCENT }.source, + ) + + // Back to shipped: beats the file too. + tab.resetToShipped() + val row = tab.roles().single { it.role == PaletteRole.ACCENT } + assertEquals(PaletteSource.CHANGED, row.source) + assertEquals(tab.shipped().accent, kst4contest.view.compose.colourOf(row.hex)) + } + + @Test + fun theFileRouteNamesThisDesignsOwnStylesheet() { + val tab = state() + + assertTrue(tab.stylesheetPath().endsWith("day.css"), tab.stylesheetPath()) + assertFalse(tab.stylesheetExists(), "a nonexistent file was reported as present") + + darkMode = true + + assertTrue(tab.stylesheetPath().endsWith("evening.css"), tab.stylesheetPath()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LifelineColoursTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LifelineColoursTest.kt new file mode 100644 index 00000000..71879671 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LifelineColoursTest.kt @@ -0,0 +1,76 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.graphics.Color +import kst4contest.view.compose.JavaFxStylesheet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kst4contest.view.compose.CONTRAST_FLOOR +import kst4contest.view.compose.contrastRatio + +/** + * The colours of the one control that refuses the operator's palette. + * + * The fault this guards against closes itself in: an operator who sets text and surface to + * the same black can no longer see the buttons that would undo it. So this control is drawn + * from the shipped sheet, always, and that is checked here against a palette in which every + * role has been changed. + * + * The composable itself takes the shipped palette as a required parameter and never reads + * LocalJavaFxPalette, so it has no way to reach the resolved palette at all. These tests + * cover the colour decision; that it cannot be fed the wrong palette is structural. + */ +class LifelineColoursTest { + + private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + + @Test + fun theColoursComeFromThePaletteItIsGiven() { + val colours = lifelineColours(shipped) + + assertEquals(shipped.base, colours.container) + assertEquals(shipped.labelTextFill, colours.content) + } + + @Test + fun aFullyBlackedOutPaletteGivesDifferentColoursThanTheShippedOne() { + /* + * The palette an operator locks themselves out with. If the lifeline ever read it, + * these two would agree and the button would be invisible exactly when needed. + */ + val blackedOut = shipped.copy( + base = Color.Black, + windowBackground = Color.Black, + controlInnerBackground = Color.Black, + labelTextFill = Color.Black, + accent = Color.Black, + separatorLine = Color.Black, + ) + + assertNotEquals(lifelineColours(shipped), lifelineColours(blackedOut)) + } + + @Test + fun theShippedColoursAreReadableAgainstEachOther() { + /* + * A lifeline has to be legible on its own terms. Both shipped sheets pass here, which + * is not true of every pair in them -- the accent does not -- so this is a real check + * and not a restatement. + */ + for (sheet in listOf("/KST4ContestDefaultDay.css", "/KST4ContestDefaultEvening.css")) { + val colours = lifelineColours(JavaFxStylesheet.read(sheet)) + val ratio = contrastRatio(colours.content, colours.container) + + assertTrue(ratio >= CONTRAST_FLOOR, "$sheet gives the lifeline a ratio of $ratio") + } + } + + @Test + fun theBorderIsVisibleAgainstTheContainer() { + // The border is what makes it read as a control rather than as a patch of colour. + val colours = lifelineColours(shipped) + + assertNotEquals(colours.container, colours.border) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt new file mode 100644 index 00000000..556d8223 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt @@ -0,0 +1,124 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.compose.JavaFxStylesheet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Files + +/** + * Writing the shipped stylesheet into the profile, so it can be edited by hand. + * + * The one rule with teeth here is that an existing file is never overwritten. The file this + * writes to is the operator's own hand-edited work, and a button that silently replaced it + * would destroy an afternoon's work on one misclick. + */ +class StylesheetExportTest { + + private fun tempDirectory(): File = + Files.createTempDirectory("kst4contest-export").toFile() + + @Test + fun aMissingFileIsWrittenFromTheShippedSheet() { + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultDay.css") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.Written, "outcome was $outcome") + assertTrue(target.isFile, "nothing was written") + assertEquals( + JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultDay.css")!!.readText(), + target.readText(), + "the written file is not the shipped sheet", + ) + + target.delete() + directory.delete() + } + + @Test + fun theEveningDesignWritesTheEveningSheet() { + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultEvening.css") + + exportShippedStylesheet(target, darkMode = true) + + assertEquals( + JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultEvening.css")!!.readText(), + target.readText(), + ) + + target.delete() + directory.delete() + } + + @Test + fun anExistingFileIsLeftAloneAndReported() { + /* + * The operator's hand-edited sheet. Overwriting it on a misclick is the one failure + * this button could cause that cannot be undone from inside the application. + */ + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultDay.css") + target.writeText(".root { -fx-base: #112233; } /* mine */") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.AlreadyThere, "outcome was $outcome") + assertEquals(".root { -fx-base: #112233; } /* mine */", target.readText()) + + target.delete() + directory.delete() + } + + @Test + fun aMissingProfileDirectoryIsCreated() { + // Only the root profile's directory exists already; every other profile's may not. + val directory = tempDirectory() + val target = File(directory, "profiles/OP2/KST4ContestDefaultDay.css") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.Written, "outcome was $outcome") + assertTrue(target.isFile, "the directory was not created") + + target.delete() + target.parentFile.delete() + target.parentFile.parentFile.delete() + directory.delete() + } + + @Test + fun anUnwritablePlaceIsReportedRatherThanThrown() { + val target = File("/proc/kst4contest-cannot-write-here/day.css") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome") + assertNotNull((outcome as StylesheetExportOutcome.Failed).reason) + } + + @Test + fun whatIsWrittenCanBeReadBackAsRoles() { + /* + * The round trip the whole hand-editing route depends on: the file this writes has to + * be a file the layering can read. The evening sheet is the one that states colours. + */ + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultEvening.css") + + exportShippedStylesheet(target, darkMode = true) + + // All six: five stated outright, the window surface derived from the stated base. + assertEquals( + kst4contest.model.PaletteRole.values().toSet(), + JavaFxStylesheet.declaredRolesOfFile(target).keys, + ) + + target.delete() + directory.delete() + } +} diff --git a/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java b/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java index effdb2a6..810ba481 100644 --- a/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java +++ b/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java @@ -1,5 +1,6 @@ package kst4contest.controller; +import kst4contest.ApplicationConstants; import kst4contest.model.ChatPreferences; import kst4contest.model.OperatorProfile; import kst4contest.model.OperatorProfileSelection; @@ -88,6 +89,34 @@ public final class OperatorProfilePaths { return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE; } + /** + * Returns the stylesheet file name relative to the application directory. + * + *

The operator's own stylesheet for one design, which is the middle of the three + * palette layers. Shaped like the preferences and the worked database: the root profile + * keeps the historic flat name, every other profile gets it inside its own directory.

+ * + *

The root profile's name is deliberately the one copyResourceIfRequired already + * writes at startup. That copy has never been read by anything; reading it is what gives + * the operator the hand-editing route the file has been promising all along.

+ * + * @param profile profile to resolve + * @param darkMode true for the evening design, false for the daylight one + * @return relative stylesheet file name + */ + public static String paletteRelativeFileName(final OperatorProfile profile, final boolean darkMode) { + + final String fileName = darkMode + ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING + : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; + + if (profile.isRootProfile()) { + return fileName; + } + + return profileRelativeDirectory(profile) + "/" + fileName; + } + /** * Resolves a profile descriptor into the runtime selection used during startup. * diff --git a/core/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java index 62a9dd93..acb2a103 100644 --- a/core/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -50,7 +50,13 @@ public class ChatPreferences { /** - * Bump this when you change the XML schema written by {@link #writePreferencesToXmlFile()}. + * Bump this when a change to the XML schema needs a reader to act on the version. + *

+ * Adding an element does not: reading must stay backwards compatible, every setting falls + * back to its own default, and nothing in this application branches on the number -- the + * only use is a best-effort warning when a file is newer than the application. Release + * 1.50 added the two palette-override elements and deliberately left this at 7. A bump + * that no reader acts on only invites the next person to bump it again. *

* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. */ @@ -382,6 +388,17 @@ public class ChatPreferences { boolean guiOptions_showGrossFieldWorkedHintInBandColumns = true; // show "o" (grid square already worked on this band) in the band columns boolean guiOptions_showFreshCallHintInBandColumns = true; // show "a" (band available, call not worked on any band yet) instead of always "B+" in the band columns + /** + * The operator's colour overrides for the daylight design, encoded by PaletteOverrides. + * Per profile, like every other preference: the file this is written to is the profile's + * own. One string rather than six elements, because this file is written element by + * element by hand and twelve more would be twelve write blocks and twelve read blocks. + */ + private String guiOptions_paletteOverridesDay = ""; + + /** The same for the evening design. Two designs, two independent sets. */ + private String guiOptions_paletteOverridesEvening = ""; + private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; private boolean GUIstationMapPathAnalysisVisible = true; @@ -773,6 +790,44 @@ public class ChatPreferences { this.guiOptions_showFreshCallHintInBandColumns = guiOptions_showFreshCallHintInBandColumns; } + /** + * Returns the colour overrides of the daylight design. + * + * @return the encoded overrides, empty when the operator has changed nothing + */ + public String getGuiOptions_paletteOverridesDay() { + return guiOptions_paletteOverridesDay; + } + + /** + * Sets the colour overrides of the daylight design. + * + * @param guiOptions_paletteOverridesDay the encoded overrides; null becomes empty + */ + public void setGuiOptions_paletteOverridesDay(String guiOptions_paletteOverridesDay) { + this.guiOptions_paletteOverridesDay = + guiOptions_paletteOverridesDay == null ? "" : guiOptions_paletteOverridesDay; + } + + /** + * Returns the colour overrides of the evening design. + * + * @return the encoded overrides, empty when the operator has changed nothing + */ + public String getGuiOptions_paletteOverridesEvening() { + return guiOptions_paletteOverridesEvening; + } + + /** + * Sets the colour overrides of the evening design. + * + * @param guiOptions_paletteOverridesEvening the encoded overrides; null becomes empty + */ + public void setGuiOptions_paletteOverridesEvening(String guiOptions_paletteOverridesEvening) { + this.guiOptions_paletteOverridesEvening = + guiOptions_paletteOverridesEvening == null ? "" : guiOptions_paletteOverridesEvening; + } + public boolean isGuiOptions_defaultFilterPmToOther() { return guiOptions_defaultFilterPmToOther; } @@ -2086,6 +2141,14 @@ public class ChatPreferences { guiOptions_showFreshCallHintInBandColumns.setTextContent(this.isGuiOptions_showFreshCallHintInBandColumns()+""); guiSaveableOptions.appendChild(guiOptions_showFreshCallHintInBandColumns); + Element guiOptions_paletteOverridesDay = doc.createElement("guiOptions_paletteOverridesDay"); + guiOptions_paletteOverridesDay.setTextContent(this.getGuiOptions_paletteOverridesDay()); + guiSaveableOptions.appendChild(guiOptions_paletteOverridesDay); + + Element guiOptions_paletteOverridesEvening = doc.createElement("guiOptions_paletteOverridesEvening"); + guiOptions_paletteOverridesEvening.setTextContent(this.getGuiOptions_paletteOverridesEvening()); + guiSaveableOptions.appendChild(guiOptions_paletteOverridesEvening); + Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive"); guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + ""); guiSaveableOptions.appendChild(guiOptions_darkModeActive); @@ -3160,6 +3223,10 @@ public class ChatPreferences { this.setGuiOptions_showGrossFieldWorkedHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showGrossFieldWorkedHintInBandColumns(), "guiOptions_showGrossFieldWorkedHintInBandColumns")); this.setGuiOptions_showFreshCallHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showFreshCallHintInBandColumns(), "guiOptions_showFreshCallHintInBandColumns")); + // Added in release 1.50: the operator's own colours, one string per design. + this.setGuiOptions_paletteOverridesDay(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesDay(), "guiOptions_paletteOverridesDay")); + this.setGuiOptions_paletteOverridesEvening(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesEvening(), "guiOptions_paletteOverridesEvening")); + // Added in later versions: dark mode flags this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); this.GUI_darkModeActiveByDefault = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActiveByDefault, "guiOptions_darkModeActiveByDefault"); diff --git a/core/src/main/java/kst4contest/model/PaletteOverrides.java b/core/src/main/java/kst4contest/model/PaletteOverrides.java new file mode 100644 index 00000000..23b298c6 --- /dev/null +++ b/core/src/main/java/kst4contest/model/PaletteOverrides.java @@ -0,0 +1,120 @@ +package kst4contest.model; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A set of palette overrides, encoded as one string. + * + *

One string per design rather than twelve XML elements: the preferences file is written + * element by element by hand, and twelve more would be twelve write blocks, twelve read + * blocks and twelve fields in a class that is already three thousand lines long.

+ * + *

Reading is deliberately forgiving and never throws. The input is not always this + * version's own: a hand-edited XML, a file from an older release, or a write interrupted + * half way. An unknown key or an unparsable colour is skipped and the rest survives -- + * losing one colour is a nuisance, losing the readability of the client is not.

+ */ +public final class PaletteOverrides { + + private static final Pattern SIX_DIGIT_HEX = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private static final String PAIR_SEPARATOR = ";"; + private static final String KEY_VALUE_SEPARATOR = "="; + + private PaletteOverrides() { + // Utility class. + } + + /** + * Reads a stored set of overrides. + * + * @param stored the stored string, may be null or malformed + * @return the roles actually named with a usable colour, never null + */ + public static Map parse(final String stored) { + + Map overrides = new LinkedHashMap<>(); + + if (stored == null || stored.isBlank()) { + return overrides; + } + + for (String pair : stored.split(PAIR_SEPARATOR)) { + + int separator = pair.indexOf(KEY_VALUE_SEPARATOR); + + if (separator <= 0) { + continue; + } + + String key = pair.substring(0, separator).trim(); + String value = pair.substring(separator + 1).trim(); + + if (!isValidColour(value)) { + continue; + } + + for (PaletteRole role : PaletteRole.values()) { + if (role.key().equals(key)) { + overrides.put(role, normalise(value)); + break; + } + } + } + + return overrides; + } + + /** + * Writes a set of overrides for storage. + * + * @param overrides the roles to store; entries with an unusable colour are left out + * @return the string to store, empty when there is nothing to store + */ + public static String format(final Map overrides) { + + if (overrides == null || overrides.isEmpty()) { + return ""; + } + + StringBuilder stored = new StringBuilder(); + + for (PaletteRole role : PaletteRole.values()) { + + String colour = overrides.get(role); + + if (!isValidColour(colour)) { + continue; + } + + if (stored.length() > 0) { + stored.append(PAIR_SEPARATOR); + } + + stored.append(role.key()).append(KEY_VALUE_SEPARATOR).append(normalise(colour)); + } + + return stored.toString(); + } + + /** + * Returns whether a string is a colour this application can use. + * + *

Six hexadecimal digits with a leading hash, and nothing else. The stylesheets use + * named colours and functions too, but those are the shipped sheets' business; what an + * operator types has one form so that what they typed is what they get.

+ * + * @param colour the text to check, may be null + * @return true when it names a colour + */ + public static boolean isValidColour(final String colour) { + return colour != null && SIX_DIGIT_HEX.matcher(colour.trim()).matches(); + } + + private static String normalise(final String colour) { + return colour.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/core/src/main/java/kst4contest/model/PaletteRole.java b/core/src/main/java/kst4contest/model/PaletteRole.java new file mode 100644 index 00000000..52deee08 --- /dev/null +++ b/core/src/main/java/kst4contest/model/PaletteRole.java @@ -0,0 +1,97 @@ +package kst4contest.model; + +/** + * A colour the operator may set, and the stylesheet declaration it corresponds to. + * + *

In core and therefore free of any toolkit: a role is a name and three strings, and the + * colour itself travels as text. Turning text into a drawable colour is the user + * interface's job.

+ * + *

A declaration is a selector and a property together. Three of the six roles are stated + * outside {@code .root} by the shipped sheets, and {@code -fx-background-color} alone appears + * a dozen times in one of them -- on scroll bars, buttons and toggle buttons. Without the + * selector, half the roles would never be found and the rest would be found in the wrong + * places.

+ * + *

All three strings are part of a file format. The selector and the property are what a + * hand-edited stylesheet writes; {@link #key()} is what the stored overrides are written + * under. Renaming any of them silently drops a colour an operator chose.

+ */ +public enum PaletteRole { + + /** The control surface; the window surface is normally a {@code derive} of this. */ + SURFACE(".root", "-fx-base", "surface"), + + /** The window's own area. Neither shipped sheet states it, so both derive it. */ + WINDOW_SURFACE(".root", "-fx-background", "windowSurface"), + + /** Inside a text field or a list. */ + FIELD_INTERIOR(".root", "-fx-control-inner-background", "fieldInterior"), + + /** Label text. Stated on {@code .label} and not on {@code .root}. */ + TEXT(".label", "-fx-text-fill", "text"), + + ACCENT(".root", "-fx-accent", "accent"), + + /** The separator's line, whose selector is what tells it from a scroll bar. */ + SEPARATOR(".separator *.line", "-fx-background-color", "separator"); + + private final String selector; + private final String cssProperty; + private final String key; + + PaletteRole(final String selector, final String cssProperty, final String key) { + this.selector = selector; + this.cssProperty = cssProperty; + this.key = key; + } + + /** + * Returns the stylesheet selector this role is stated on. + * + * @return selector + */ + public String selector() { + return selector; + } + + /** + * Returns the stylesheet property this role is read from. + * + * @return CSS property name + */ + public String cssProperty() { + return cssProperty; + } + + /** + * Returns the name this role is stored under in the preferences. + * + * @return storage key + */ + public String key() { + return key; + } + + /** + * Returns the role a stylesheet declaration belongs to. + * + * @param selector a selector from a stylesheet, may be null + * @param cssProperty a property name from a stylesheet, may be null + * @return the role, or null for the hundreds of declarations that are not ours + */ + public static PaletteRole of(final String selector, final String cssProperty) { + + if (selector == null || cssProperty == null) { + return null; + } + + for (PaletteRole role : values()) { + if (role.selector.equals(selector) && role.cssProperty.equals(cssProperty)) { + return role; + } + } + + return null; + } +} diff --git a/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java b/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java index c71f339e..c6c4bb20 100644 --- a/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java +++ b/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java @@ -78,4 +78,32 @@ class OperatorProfilePathsTest { assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID, OperatorProfilePaths.toProfileId("default", Set.of())); } + + @Test + void theRootProfileKeepsTheHistoricFlatStylesheetNames() { + OperatorProfile root = OperatorProfilePaths.buildRootProfile("Default"); + + assertEquals( + "KST4ContestDefaultDay.css", + OperatorProfilePaths.paletteRelativeFileName(root, false)); + assertEquals( + "KST4ContestDefaultEvening.css", + OperatorProfilePaths.paletteRelativeFileName(root, true)); + } + + @Test + void anotherProfileGetsItsStylesheetInItsOwnDirectory() { + /* + * The same shape the preferences and the worked database already take. A different + * shape here would have one profile reading another profile's colours. + */ + OperatorProfile other = new OperatorProfile("OP2", "DN9APW", false, false); + + assertEquals( + "profiles/OP2/KST4ContestDefaultDay.css", + OperatorProfilePaths.paletteRelativeFileName(other, false)); + assertEquals( + "profiles/OP2/KST4ContestDefaultEvening.css", + OperatorProfilePaths.paletteRelativeFileName(other, true)); + } } diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java new file mode 100644 index 00000000..24739277 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java @@ -0,0 +1,46 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The two designs' colour overrides are stored independently. + * + *

That is the part worth pinning: two designs sharing one storage mechanism is exactly + * the shape in which a change to the evening palette quietly drags the daylight one with + * it.

+ */ +class ChatPreferencesPaletteTest { + + @Test + void theTwoDesignsAreStoredIndependently() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_paletteOverridesDay("surface=#FFFFFF"); + prefs.setGuiOptions_paletteOverridesEvening("surface=#000000"); + + assertEquals("surface=#FFFFFF", prefs.getGuiOptions_paletteOverridesDay()); + assertEquals("surface=#000000", prefs.getGuiOptions_paletteOverridesEvening()); + } + + @Test + void anUnsetOverrideReadsAsEmptyAndNotAsNull() { + // A preferences file from an older release has no such element at all. + ChatPreferences prefs = new ChatPreferences(); + + assertEquals("", prefs.getGuiOptions_paletteOverridesDay()); + assertEquals("", prefs.getGuiOptions_paletteOverridesEvening()); + } + + @Test + void aNullSetterValueBecomesEmpty() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_paletteOverridesDay(null); + prefs.setGuiOptions_paletteOverridesEvening(null); + + assertEquals("", prefs.getGuiOptions_paletteOverridesDay()); + assertEquals("", prefs.getGuiOptions_paletteOverridesEvening()); + } +} diff --git a/core/src/test/java/kst4contest/model/PaletteOverridesTest.java b/core/src/test/java/kst4contest/model/PaletteOverridesTest.java new file mode 100644 index 00000000..d30d1ae3 --- /dev/null +++ b/core/src/test/java/kst4contest/model/PaletteOverridesTest.java @@ -0,0 +1,101 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * How a set of colour overrides survives in the preferences XML. + * + *

One string per design rather than twelve elements. The parsing is where an operator's + * stored colours are either kept or quietly lost, and the input is not always ours: a + * hand-edited XML, a file written by an older version, or a half-finished write.

+ */ +class PaletteOverridesTest { + + @Test + void anEmptyStoreMeansNoOverridesRatherThanAFailure() { + assertTrue(PaletteOverrides.parse(null).isEmpty()); + assertTrue(PaletteOverrides.parse("").isEmpty()); + assertTrue(PaletteOverrides.parse(" ").isEmpty()); + } + + @Test + void onlyTheRolesActuallyNamedComeBack() { + Map parsed = PaletteOverrides.parse("surface=#ECECEC"); + + // The whole point of the layering: one named role overrides one role. + assertEquals(1, parsed.size()); + assertEquals("#ECECEC", parsed.get(PaletteRole.SURFACE)); + } + + @Test + void aRoundTripKeepsEveryRole() { + Map original = new LinkedHashMap<>(); + for (PaletteRole role : PaletteRole.values()) { + original.put(role, "#10203" + role.ordinal()); + } + + assertEquals(original, PaletteOverrides.parse(PaletteOverrides.format(original))); + } + + @Test + void anUnknownKeyIsSkippedAndTheRestSurvives() { + // An older or newer version may have written a role this one does not know. + Map parsed = + PaletteOverrides.parse("surface=#ECECEC;chrome=#123456;accent=#0096C9"); + + assertEquals(2, parsed.size()); + assertEquals("#ECECEC", parsed.get(PaletteRole.SURFACE)); + assertEquals("#0096C9", parsed.get(PaletteRole.ACCENT)); + } + + @Test + void anUnparsableColourIsSkippedRatherThanStored() { + /* + * This is the difference between an operator losing one colour and an operator + * losing the readability of the whole client: a bad value must not become black. + */ + Map parsed = + PaletteOverrides.parse("surface=rot;accent=#0096C9;text=#GGGGGG;separator="); + + assertEquals(1, parsed.size()); + assertEquals("#0096C9", parsed.get(PaletteRole.ACCENT)); + } + + @Test + void garbageIsSurvivedWithoutAnException() { + // Half-written files exist. None of these may throw. + for (String garbage : new String[] { + ";;;", "=", "surface", "surface=", "=#ECECEC", "surface==#ECECEC", + "surface=#ECECEC;", "\n\t", "a=b=c", + }) { + PaletteOverrides.parse(garbage); + } + } + + @Test + void whatCountsAsAColour() { + assertTrue(PaletteOverrides.isValidColour("#ECECEC")); + assertTrue(PaletteOverrides.isValidColour("#ececec")); + assertTrue(PaletteOverrides.isValidColour(" #ECECEC ")); + + assertFalse(PaletteOverrides.isValidColour(null)); + assertFalse(PaletteOverrides.isValidColour("")); + assertFalse(PaletteOverrides.isValidColour("ECECEC")); + assertFalse(PaletteOverrides.isValidColour("#ECECE")); + assertFalse(PaletteOverrides.isValidColour("#ECECECE")); + assertFalse(PaletteOverrides.isValidColour("#GGGGGG")); + assertFalse(PaletteOverrides.isValidColour("rot")); + } + + @Test + void formattingAnEmptyMapGivesAnEmptyString() { + assertEquals("", PaletteOverrides.format(new LinkedHashMap<>())); + } +} diff --git a/core/src/test/java/kst4contest/model/PaletteRoleTest.java b/core/src/test/java/kst4contest/model/PaletteRoleTest.java new file mode 100644 index 00000000..c36e8fbf --- /dev/null +++ b/core/src/test/java/kst4contest/model/PaletteRoleTest.java @@ -0,0 +1,87 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The six roles the operator may set. + * + *

A role is a selector and a property, not a property alone: the shipped sheets state + * three of the six outside `.root`, and `-fx-background-color` on its own appears a dozen + * times in one sheet. The selector is what tells the separator's line from a scroll bar.

+ * + *

All three strings are part of a file format -- two of them the operator's own + * hand-edited stylesheet, the third the preferences XML. Renaming any of them silently + * drops a colour somebody chose, which is why they are pinned here rather than inlined.

+ */ +class PaletteRoleTest { + + @Test + void thereAreSixRolesAndEachNamesOneDeclaration() { + assertEquals(6, PaletteRole.values().length); + + Set declarations = new HashSet<>(); + for (PaletteRole role : PaletteRole.values()) { + assertTrue(role.cssProperty().startsWith("-fx-"), role + " has no CSS property"); + assertTrue(role.selector().startsWith("."), role + " has no selector"); + assertTrue( + declarations.add(role.selector() + "|" + role.cssProperty()), + role + " names the same declaration as another role"); + } + } + + @Test + void theThreeRolesOutsideRootKeepTheirOwnSelectors() { + /* + * These three are the reason a role carries a selector at all. The values are the + * ones JavaFxStylesheet.read already uses, so the two must not drift apart. + */ + assertEquals(".label", PaletteRole.TEXT.selector()); + assertEquals("-fx-text-fill", PaletteRole.TEXT.cssProperty()); + + assertEquals(".separator *.line", PaletteRole.SEPARATOR.selector()); + assertEquals("-fx-background-color", PaletteRole.SEPARATOR.cssProperty()); + + assertEquals(".root", PaletteRole.SURFACE.selector()); + } + + @Test + void theStorageKeysAreDistinctAndStable() { + // These strings land in the operator's preferences XML. Renaming one drops a colour. + assertEquals("surface", PaletteRole.SURFACE.key()); + assertEquals("windowSurface", PaletteRole.WINDOW_SURFACE.key()); + assertEquals("fieldInterior", PaletteRole.FIELD_INTERIOR.key()); + assertEquals("text", PaletteRole.TEXT.key()); + assertEquals("accent", PaletteRole.ACCENT.key()); + assertEquals("separator", PaletteRole.SEPARATOR.key()); + } + + @Test + void aDeclarationResolvesBackToItsRole() { + assertEquals(PaletteRole.SURFACE, PaletteRole.of(".root", "-fx-base")); + assertEquals(PaletteRole.TEXT, PaletteRole.of(".label", "-fx-text-fill")); + } + + @Test + void theSamePropertyUnderAnotherSelectorIsNotThatRole() { + /* + * The whole point of carrying the selector. Both sheets set -fx-text-fill on a + * dozen selectors and -fx-background-color on more; only one of each is a role. + */ + assertNull(PaletteRole.of(".button:hover", "-fx-text-fill")); + assertNull(PaletteRole.of(".scroll-bar", "-fx-background-color")); + } + + @Test + void anUnknownDeclarationIsNotARole() { + assertNull(PaletteRole.of(".root", "-fx-font-size")); + assertNull(PaletteRole.of("", "")); + assertNull(PaletteRole.of(null, null)); + } +}