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)); + } +}