Make the colours adjustable per operator profile

Six colour roles per design, per profile: surface, window surface, field interior,
text, accent and separator line. A change applies at once in every open window and
survives a restart once the settings are saved, like any other preference.

Three sources, later wins role by role: the shipped stylesheet from the classpath,
then the profile's own stylesheet file if there is one, then the overrides stored in
the profile's preferences. resolvePalette is a pure function and reports where every
role's colour came from, which the tab shows per field -- with two sources able to
decide one colour, that is the only thing keeping "why is this colour what it is"
answerable, and the spec calls it mandatory rather than decorative.

Four things this had to get right, each learned the hard way and each pinned:

A role is a selector and a property, not a property alone. Three of the six are
stated outside .root -- text on .label, the separator on .separator *.line -- and
-fx-background-color alone appears a dozen times in the evening sheet. Measured: the
shipped daylight sheet states no colour at all, the evening sheet states five and
implies the sixth.

Kst4ContestTheme reads the palette from the store's state and not from remember.
That read is what makes a change repaint every open window; remember(darkMode) held
the palette until somebody toggled day/evening, so a change reached nothing.

A derived colour follows its source's value, never its provenance. The root
profile's own stylesheet is the one copyResourceIfRequired writes at every startup --
the shipped sheet itself -- so layer 2 declares the accent on every existing
installation with nobody having chosen anything. Keying textAccent on "does this come
from a file" repainted the evening accent from green to blue on upgrade and made
"reset to shipped" disagree with "discard my changes".

The contrast guard measures where colours are actually drawn, read off Theme.kt's
Material mapping rather than off the role names: background and surface are both the
window surface, and primary is textAccent, not accent. It reports a pair only when it
is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have
three pairs under 4.5 themselves, so an absolute floor would warn on an untouched
client and teach the operator to ignore warnings.

The way back out of an unreadable palette is deliberately overbuilt, because it is
the one failure that locks itself in. "Reset to the shipped colours" takes the
shipped palette as a required parameter and reads no composition local, so there is
no path by which it could be drawn in the colours it exists to undo; it looks out of
place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D
and Ctrl+R reach the three ways back even with the caret in a colour field. A
mistyped colour is refused and the field snaps back.

The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has
been promising since it was first written and never read. It is read now. A button
writes the shipped template as a starting point and never replaces a file that is
already there. An unreadable, truncated or non-CSS file does not prevent startup --
the role falls back to the shipped design. A sheet stating -fx-base and no
-fx-background states the derived window surface too, the way JavaFX does, or the
most natural hand edit would leave every window the shipped light grey.

101 new tests. All of it runs headless.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent 83e23f55a3
commit 12b2d37ce2
39 files changed
+3373 -28

No files matched your search

@@ -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<java.util.List<ChatMember>> composeMemberListener;
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
private java.util.function.Consumer<java.util.List<ClusterMessage>> 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) -> {
@@ -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)
}
}
}
}
@@ -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<ContrastWarning> {
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
}
}
}
@@ -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<PaletteRole, Color> {
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<PaletteRole, Color> =
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
@@ -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 {
@@ -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,
@@ -46,6 +46,11 @@ object MonitorWindow {
qsoTable: DataTableState<ChatMessage>,
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,
@@ -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<PaletteRole, ResolvedRole>,
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<PaletteRole, Color>,
changed: Map<PaletteRole, Color>,
): 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),
),
)
}
@@ -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<PaletteRole, Color>,
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<String, String>? = 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<PaletteRole, Color> =
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<PaletteStore?> { null }
@@ -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))
}
@@ -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<SettingsTab> {
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)
},
)
}
@@ -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,
@@ -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
/**
@@ -73,11 +73,17 @@ object TopPriorityCandidatesWindow {
onPicked: Consumer<ScoreService.TopCandidate>,
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 ->
@@ -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<String>,
@@ -65,6 +70,7 @@ object UpdateWindow {
title = "Update information",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
@@ -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 -> }
@@ -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
}
}
@@ -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"
}
@@ -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<RoleRow> {
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<ContrastWarning> = 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",
)
}
}
@@ -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
)
}
}
@@ -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,
)
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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}",
)
}
}
@@ -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<PaletteRole, Color> = 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)
}
}
@@ -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()
}
}
}
@@ -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<Color>()
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)
}
}
}
@@ -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" }
}
}
}
@@ -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<PaletteRole, Color> = 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())
}
}
@@ -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)
}
}
@@ -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()
}
}