import { HorizontalBox, VerticalBox } from "std-widgets.slint"; import { AppTheme, ThemeUtilities } from "../design-system.slint"; import { TypeScale } from "../typography.slint"; import { Button } from "button.slint"; import { LineEdit, RadioButton } from "controls.slint"; import { FocusVisibleScope } from "focus-visible-scope.slint"; component ThemePresetCard inherits FocusVisibleScope { in property name; in property preview-primary; in property preview-page; in property preview-surface; in property preview-text; in property selected: false; in property label-bottom-padding: 10px; callback chosen; height: 44px + preset-name.height + root.label-bottom-padding; min-width: 112px; focus-on-click: true; accessible-role: radio-button; accessible-label: root.name; accessible-checked: root.selected; accessible-action-default => { root.chosen(); } key-pressed(event) => { if event.text == Key.Return || event.text == Key.Space { root.chosen(); return accept; } reject } Rectangle { border-radius: 8px; border-width: root.selected || root.focus-visible ? 2px : 1px; border-color: root.selected || root.focus-visible ? AppTheme.accent : AppTheme.border; background: preset-hit.has-hover ? AppTheme.control-bg : AppTheme.surface; Rectangle { x: 9px; y: 9px; width: parent.width - 18px; height: 30px; border-radius: 5px; background: root.preview-page; border-width: 1px; border-color: root.preview-text.with-alpha(0.16); Rectangle { x: 5px; y: 5px; width: parent.width - 25px; height: 20px; border-radius: 4px; background: root.preview-surface; } Rectangle { x: parent.width - 15px; y: 9px; width: 9px; height: 9px; border-radius: 5px; background: root.preview-primary; } } preset-name := Text { x: 10px; y: 44px; width: parent.width - 20px; height: max(18px, self.preferred-height); text: root.name; color: root.selected ? AppTheme.accent : AppTheme.body; font-size: TypeScale.caption; font-weight: root.selected ? 700 : 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; } preset-hit := TouchArea { clicked => { root.pointer-activated(); root.chosen(); } mouse-cursor: pointer; } } } component ThemeModeCard inherits FocusVisibleScope { in property name; in property mode; in property selected: false; callback chosen; height: 51px + mode-name.height; min-width: 92px; focus-on-click: true; accessible-role: radio-button; accessible-label: root.name; accessible-checked: root.selected; accessible-action-default => { root.chosen(); } key-pressed(event) => { if event.text == Key.Return || event.text == Key.Space { root.chosen(); return accept; } reject } Rectangle { border-radius: 8px; border-width: root.selected || root.focus-visible ? 2px : 1px; border-color: root.selected || root.focus-visible ? AppTheme.accent : AppTheme.border; background: mode-hit.has-hover ? AppTheme.control-bg : AppTheme.surface; Rectangle { x: 9px; y: 8px; width: parent.width - 18px; height: 32px; border-radius: 5px; border-width: 1px; border-color: AppTheme.border; clip: true; background: root.mode == "dark" ? #18191a : #f2f2f0; if root.mode == "system" : Rectangle { x: parent.width / 2; width: parent.width / 2; height: parent.height; background: #18191a; } Rectangle { x: 6px; y: 6px; width: parent.width - 12px; height: 20px; border-radius: 3px; background: root.mode == "dark" ? #242526 : root.mode == "light" ? #ffffff : @linear-gradient(90deg, #ffffff 0%, #ffffff 50%, #242526 50%, #242526 100%); } Rectangle { x: 10px; y: 11px; width: 28%; height: 3px; border-radius: 2px; background: #3b82f6; } } mode-name := Text { x: 8px; y: 44px; width: parent.width - 16px; height: max(17px, self.preferred-height); text: root.name; color: root.selected ? AppTheme.accent : AppTheme.body; font-size: TypeScale.caption; font-weight: root.selected ? 700 : 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; } mode-hit := TouchArea { clicked => { root.pointer-activated(); root.chosen(); } mouse-cursor: pointer; } } } component ThemeColorChip inherits FocusVisibleScope { in property name; in property value; in property selected: false; callback chosen; height: 48px; min-width: 104px; focus-on-click: true; accessible-role: radio-button; accessible-label: root.name + " " + ThemeUtilities.color-to-hex(root.value); accessible-checked: root.selected; accessible-action-default => { root.chosen(); } key-pressed(event) => { if event.text == Key.Return || event.text == Key.Space { root.chosen(); return accept; } reject } Rectangle { border-radius: 7px; border-width: root.selected || root.focus-visible ? 2px : 1px; border-color: root.selected || root.focus-visible ? AppTheme.accent : AppTheme.border-subtle; background: chip-hit.has-hover ? AppTheme.control-bg : AppTheme.surface-raised; HorizontalBox { padding-left: 8px; padding-right: 8px; spacing: 7px; Rectangle { width: 24px; height: 24px; border-radius: 6px; border-width: 1px; border-color: AppTheme.border; background: root.value; } Text { horizontal-stretch: 1; text: root.name; color: AppTheme.body; font-size: TypeScale.caption; font-weight: root.selected ? 700 : 500; vertical-alignment: center; overflow: elide; } } chip-hit := TouchArea { clicked => { root.pointer-activated(); root.chosen(); } mouse-cursor: pointer; } } } export component RuntimeColorPicker inherits Rectangle { in-out property value; callback edited(color); private property hue: root.value.to-hsv().hue; private property saturation: root.value.to-hsv().saturation; private property brightness: root.value.to-hsv().value; private property hex-value: ThemeUtilities.color-to-hex(root.value); private property hex-valid: ThemeUtilities.valid-color(root.hex-value); height: 222px; border-radius: 9px; border-width: 1px; border-color: AppTheme.border-subtle; background: AppTheme.surface-raised; init => { // External bindings are resolved before the component is shown, but // an in-out string keeps its type default until explicitly synced. root.hex-value = ThemeUtilities.color-to-hex(root.value); } changed value => { root.hex-value = ThemeUtilities.color-to-hex(root.value); } function update-saturation-value(x: length, y: length) { let saturation = (x / sv-map.width).clamp(0, 1); let brightness = (1 - y / sv-map.height).clamp(0, 1); root.value = hsv(root.hue, saturation, brightness); root.edited(root.value); } function update-hue(x: length) { let hue = (x / hue-strip.width * 360).clamp(0, 360); root.value = hsv(hue, root.saturation, root.brightness); root.edited(root.value); } VerticalBox { padding: 12px; spacing: 10px; sv-map := FocusVisibleScope { height: 136px; focus-on-click: true; accessible-role: slider; accessible-label: @tr("Color saturation and brightness"); accessible-value: @tr("Saturation {}%, brightness {}%", (root.saturation * 100).round(), (root.brightness * 100).round()); key-pressed(event) => { if event.text == Key.LeftArrow { root.value = hsv(root.hue, (root.saturation - 0.02).clamp(0, 1), root.brightness); } else if event.text == Key.RightArrow { root.value = hsv(root.hue, (root.saturation + 0.02).clamp(0, 1), root.brightness); } else if event.text == Key.UpArrow { root.value = hsv(root.hue, root.saturation, (root.brightness + 0.02).clamp(0, 1)); } else if event.text == Key.DownArrow { root.value = hsv(root.hue, root.saturation, (root.brightness - 0.02).clamp(0, 1)); } else { return reject; } root.edited(root.value); return accept; } Rectangle { border-radius: 6px; background: hsv(root.hue, 1, 1); clip: true; Rectangle { background: @linear-gradient(90deg, white 0%, #ffffff00 100%); } Rectangle { background: @linear-gradient(180deg, #00000000 0%, black 100%); } sv-hit := TouchArea { pointer-event(event) => { if event.kind == PointerEventKind.down || (event.kind == PointerEventKind.move && self.pressed) { sv-map.focus(); sv-map.pointer-activated(); root.update-saturation-value(self.mouse-x, self.mouse-y); } } mouse-cursor: crosshair; } Rectangle { x: (root.saturation * parent.width - self.width / 2).clamp(-self.width / 2, parent.width - self.width / 2); y: ((1 - root.brightness) * parent.height - self.height / 2).clamp(-self.height / 2, parent.height - self.height / 2); width: 14px; height: 14px; border-radius: 7px; border-width: 2px; border-color: white; background: transparent; drop-shadow-blur: 2px; drop-shadow-color: #00000099; } Rectangle { border-radius: 6px; border-width: sv-map.focus-visible ? 2px : 1px; border-color: sv-map.focus-visible ? AppTheme.accent : #ffffff44; background: transparent; } } } hue-strip := FocusVisibleScope { height: 20px; focus-on-click: true; accessible-role: slider; accessible-label: @tr("Hue"); accessible-value: @tr("{} degrees", root.hue.round()); key-pressed(event) => { if event.text == Key.LeftArrow { root.value = hsv(root.hue - 3, root.saturation, root.brightness); } else if event.text == Key.RightArrow { root.value = hsv(root.hue + 3, root.saturation, root.brightness); } else { return reject; } root.edited(root.value); return accept; } Rectangle { border-radius: 6px; background: @linear-gradient(90deg, #ff0000 0%, #ffff00 16.67%, #00ff00 33.33%, #00ffff 50%, #0000ff 66.67%, #ff00ff 83.33%, #ff0000 100%); hue-hit := TouchArea { pointer-event(event) => { if event.kind == PointerEventKind.down || (event.kind == PointerEventKind.move && self.pressed) { hue-strip.focus(); hue-strip.pointer-activated(); root.update-hue(self.mouse-x); } } mouse-cursor: pointer; } Rectangle { x: (root.hue / 360 * parent.width - self.width / 2).clamp(-self.width / 2, parent.width - self.width / 2); y: -2px; width: 8px; height: parent.height + 4px; border-radius: 4px; border-width: 2px; border-color: white; background: transparent; drop-shadow-blur: 2px; drop-shadow-color: #00000099; } Rectangle { border-radius: 6px; border-width: hue-strip.focus-visible ? 2px : 1px; border-color: hue-strip.focus-visible ? AppTheme.accent : #ffffff44; background: transparent; } } } HorizontalBox { height: 32px; spacing: 9px; Rectangle { width: 32px; height: 32px; border-radius: 7px; border-width: 1px; border-color: AppTheme.border; background: root.value; } // As tall as the swatch and field beside it, so all three share a center. Text { width: max(30px, self.preferred-width); height: max(32px, self.preferred-height); text: "HEX"; color: AppTheme.secondary; font-size: TypeScale.caption; font-weight: 700; vertical-alignment: center; } hex-field := LineEdit { horizontal-stretch: 1; height: 32px; text <=> root.hex-value; invalid: !root.hex-valid; placeholder-text: "#RRGGBB"; edited(value) => { if ThemeUtilities.valid-color(value) { root.value = ThemeUtilities.parse-color(value, root.value); root.edited(root.value); } } accepted(value) => { if ThemeUtilities.valid-color(value) { root.value = ThemeUtilities.parse-color(value, root.value); root.edited(root.value); } } } } } } struct ThemeContrastCheck { label: string, ratio: float, } // The custom palette as last saved while the editor previews unsaved // changes live. It outlives the editor, which closing Settings or switching // to another tab destroys, so the window can still discard the draft. export global ThemeDraft { in-out property dirty: false; in-out property light-primary; in-out property light-page-bg; in-out property light-surface; in-out property light-text; in-out property light-border; in-out property dark-primary; in-out property dark-page-bg; in-out property dark-surface; in-out property dark-text; in-out property dark-border; // Remember the saved palette before the first unsaved change. public function begin() { if !root.dirty { root.light-primary = AppTheme.custom-light-primary; root.light-page-bg = AppTheme.custom-light-page-bg; root.light-surface = AppTheme.custom-light-surface; root.light-text = AppTheme.custom-light-text; root.light-border = AppTheme.custom-light-border; root.dark-primary = AppTheme.custom-dark-primary; root.dark-page-bg = AppTheme.custom-dark-page-bg; root.dark-surface = AppTheme.custom-dark-surface; root.dark-text = AppTheme.custom-dark-text; root.dark-border = AppTheme.custom-dark-border; root.dirty = true; } } // Put the saved palette back. public function revert() { if root.dirty { AppTheme.custom-light-primary = root.light-primary; AppTheme.custom-light-page-bg = root.light-page-bg; AppTheme.custom-light-surface = root.light-surface; AppTheme.custom-light-text = root.light-text; AppTheme.custom-light-border = root.light-border; AppTheme.custom-dark-primary = root.dark-primary; AppTheme.custom-dark-page-bg = root.dark-page-bg; AppTheme.custom-dark-surface = root.dark-surface; AppTheme.custom-dark-text = root.dark-text; AppTheme.custom-dark-border = root.dark-border; root.dirty = false; } } } export component ThemeEditor inherits Rectangle { in property compact: false; in-out property theme-mode: "system"; callback save-theme-mode(string); callback save-preset(string); callback save-custom-theme(color, color, color, color, color, color, color, color, color, color) -> bool; callback discard-changes; private property selected-token: "primary"; private property editing-dark: AppTheme.dark; private property current-primary: root.editing-dark ? AppTheme.custom-dark-primary : AppTheme.custom-light-primary; private property current-page-bg: root.editing-dark ? AppTheme.custom-dark-page-bg : AppTheme.custom-light-page-bg; private property current-surface: root.editing-dark ? AppTheme.custom-dark-surface : AppTheme.custom-light-surface; private property current-text: root.editing-dark ? AppTheme.custom-dark-text : AppTheme.custom-light-text; private property current-border: root.editing-dark ? AppTheme.custom-dark-border : AppTheme.custom-light-border; private property current-value: root.selected-token == "primary" ? root.current-primary : root.selected-token == "page" ? root.current-page-bg : root.selected-token == "surface" ? root.current-surface : root.selected-token == "text" ? root.current-text : root.current-border; private property light-text-page-contrast: ThemeUtilities.contrast(AppTheme.custom-light-text, AppTheme.custom-light-page-bg); private property light-text-surface-contrast: ThemeUtilities.contrast(AppTheme.custom-light-text, AppTheme.custom-light-surface); private property light-action-contrast: ThemeUtilities.contrast(ThemeUtilities.contrasting-foreground(AppTheme.custom-light-primary), AppTheme.custom-light-primary); private property light-faint-text: AppTheme.custom-light-text.mix(AppTheme.custom-light-page-bg, 0.68); private property light-faint-page-contrast: ThemeUtilities.contrast(root.light-faint-text, AppTheme.custom-light-page-bg); private property light-faint-surface-contrast: ThemeUtilities.contrast(root.light-faint-text, AppTheme.custom-light-surface); private property dark-text-page-contrast: ThemeUtilities.contrast(AppTheme.custom-dark-text, AppTheme.custom-dark-page-bg); private property dark-text-surface-contrast: ThemeUtilities.contrast(AppTheme.custom-dark-text, AppTheme.custom-dark-surface); private property dark-action-contrast: ThemeUtilities.contrast(ThemeUtilities.contrasting-foreground(AppTheme.custom-dark-primary), AppTheme.custom-dark-primary); private property dark-faint-text: AppTheme.custom-dark-text.mix(AppTheme.custom-dark-page-bg, 0.68); private property dark-faint-page-contrast: ThemeUtilities.contrast(root.dark-faint-text, AppTheme.custom-dark-page-bg); private property dark-faint-surface-contrast: ThemeUtilities.contrast(root.dark-faint-text, AppTheme.custom-dark-surface); private property minimum-contrast: min( min( min(min(root.light-text-page-contrast, root.light-text-surface-contrast), root.light-action-contrast), min(root.light-faint-page-contrast, root.light-faint-surface-contrast), ), min( min(min(root.dark-text-page-contrast, root.dark-text-surface-contrast), root.dark-action-contrast), min(root.dark-faint-page-contrast, root.dark-faint-surface-contrast), ), ); private property contrast-valid: root.minimum-contrast >= 4.5; private property <[ThemeContrastCheck]> contrast-checks: [ { label: @tr("Light · Text on canvas"), ratio: root.light-text-page-contrast }, { label: @tr("Light · Text on surface"), ratio: root.light-text-surface-contrast }, { label: @tr("Light · Secondary text on canvas"), ratio: root.light-faint-page-contrast }, { label: @tr("Light · Secondary text on surface"), ratio: root.light-faint-surface-contrast }, { label: @tr("Light · Primary button text"), ratio: root.light-action-contrast }, { label: @tr("Dark · Text on canvas"), ratio: root.dark-text-page-contrast }, { label: @tr("Dark · Text on surface"), ratio: root.dark-text-surface-contrast }, { label: @tr("Dark · Secondary text on canvas"), ratio: root.dark-faint-page-contrast }, { label: @tr("Dark · Secondary text on surface"), ratio: root.dark-faint-surface-contrast }, { label: @tr("Dark · Primary button text"), ratio: root.dark-action-contrast }, ]; vertical-stretch: 0; height: max( AppTheme.preset == "custom" ? (root.compact ? 970px : 690px) : (root.compact ? 420px : 272px), editor-content.preferred-height); border-radius: 8px; border-width: 1px; border-color: AppTheme.border-subtle; background: AppTheme.surface; function begin-edit() { ThemeDraft.begin(); } function revert() { ThemeDraft.revert(); } function choose-mode(mode: string) { root.theme-mode = mode; if mode == "dark" { root.editing-dark = true; } else if mode == "light" { root.editing-dark = false; } root.save-theme-mode(mode); } function choose-preset(preset: string) { AppTheme.preset = preset; if preset == "custom" { root.editing-dark = AppTheme.dark; } root.save-preset(preset); } function set-current-value(value: color) { root.begin-edit(); if root.selected-token == "primary" { if root.editing-dark { AppTheme.custom-dark-primary = value; } else { AppTheme.custom-light-primary = value; } } else if root.selected-token == "page" { if root.editing-dark { AppTheme.custom-dark-page-bg = value; } else { AppTheme.custom-light-page-bg = value; } } else if root.selected-token == "surface" { if root.editing-dark { AppTheme.custom-dark-surface = value; } else { AppTheme.custom-light-surface = value; } } else if root.selected-token == "text" { if root.editing-dark { AppTheme.custom-dark-text = value; } else { AppTheme.custom-light-text = value; } } else { if root.editing-dark { AppTheme.custom-dark-border = value; } else { AppTheme.custom-light-border = value; } } } function reset-current-palette() { root.begin-edit(); if root.editing-dark { AppTheme.custom-dark-primary = #0969da; AppTheme.custom-dark-page-bg = #111213; AppTheme.custom-dark-surface = #18191a; AppTheme.custom-dark-text = #f3f3f2; AppTheme.custom-dark-border = #3a3b3c; } else { AppTheme.custom-light-primary = #0969da; AppTheme.custom-light-page-bg = #f2f2f0; AppTheme.custom-light-surface = #ffffff; AppTheme.custom-light-text = #202120; AppTheme.custom-light-border = #d9d9d6; } } function persist() { if ThemeDraft.dirty { let saved = root.save-custom-theme( AppTheme.custom-light-primary, AppTheme.custom-light-page-bg, AppTheme.custom-light-surface, AppTheme.custom-light-text, AppTheme.custom-light-border, AppTheme.custom-dark-primary, AppTheme.custom-dark-page-bg, AppTheme.custom-dark-surface, AppTheme.custom-dark-text, AppTheme.custom-dark-border, ); if saved { ThemeDraft.dirty = false; } } } discard-changes => { root.revert(); } editor-content := VerticalBox { padding-top: 16px; padding-right: 18px; padding-bottom: 16px; padding-left: 18px; spacing: 10px; Text { height: max(22px, self.preferred-height); text: @tr("Theme"); color: AppTheme.heading; font-size: TypeScale.subtitle; font-weight: 700; vertical-alignment: center; } Text { height: max(18px, self.preferred-height); text: @tr("Color scheme"); color: AppTheme.secondary; font-size: TypeScale.secondary; vertical-alignment: center; } HorizontalBox { padding: 0px; spacing: 8px; ThemeModeCard { horizontal-stretch: 1; min-width: 0px; name: @tr("System"); mode: "system"; selected: root.theme-mode == "system"; chosen => { root.choose-mode("system"); } } ThemeModeCard { horizontal-stretch: 1; min-width: 0px; name: @tr("Dark"); mode: "dark"; selected: root.theme-mode == "dark"; chosen => { root.choose-mode("dark"); } } ThemeModeCard { horizontal-stretch: 1; min-width: 0px; name: @tr("Light"); mode: "light"; selected: root.theme-mode == "light"; chosen => { root.choose-mode("light"); } } } Rectangle { height: 1px; background: AppTheme.border-subtle; } Text { height: max(18px, self.preferred-height); text: @tr("Palette"); color: AppTheme.secondary; font-size: TypeScale.caption; font-weight: 700; vertical-alignment: center; } if !root.compact : HorizontalBox { padding: 0px; spacing: 8px; ThemePresetCard { horizontal-stretch: 1; name: @tr("Default"); preview-primary: #0969da; preview-page: AppTheme.dark ? #111213 : #f2f2f0; preview-surface: AppTheme.dark ? #18191a : white; preview-text: AppTheme.dark ? #f3f3f2 : #202120; selected: AppTheme.preset == "default"; chosen => { root.choose-preset("default"); } } ThemePresetCard { horizontal-stretch: 1; name: @tr("Teal"); preview-primary: #006b7a; preview-page: AppTheme.dark ? #0b1519 : #edf5f7; preview-surface: AppTheme.dark ? #122026 : #fbfefe; preview-text: AppTheme.dark ? #e5f0f2 : #173039; selected: AppTheme.preset == "teal"; chosen => { root.choose-preset("teal"); } } ThemePresetCard { horizontal-stretch: 1; name: @tr("Green"); preview-primary: #2f6f44; preview-page: AppTheme.dark ? #101711 : #f1f5ef; preview-surface: AppTheme.dark ? #182019 : #fcfdfb; preview-text: AppTheme.dark ? #e9f0e6 : #213026; selected: AppTheme.preset == "green"; chosen => { root.choose-preset("green"); } } ThemePresetCard { horizontal-stretch: 1; name: @tr("Purple"); preview-primary: #78418a; preview-page: AppTheme.dark ? #171119 : #f6f1f7; preview-surface: AppTheme.dark ? #201722 : #fdfbfe; preview-text: AppTheme.dark ? #f1e8f3 : #332537; selected: AppTheme.preset == "purple"; chosen => { root.choose-preset("purple"); } } ThemePresetCard { horizontal-stretch: 1; name: @tr("Custom"); preview-primary: root.current-primary; preview-page: root.current-page-bg; preview-surface: root.current-surface; preview-text: root.current-text; selected: AppTheme.preset == "custom"; chosen => { root.choose-preset("custom"); } } } if root.compact : VerticalBox { padding: 0px; spacing: 8px; HorizontalBox { padding: 0px; spacing: 8px; ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Default"); preview-primary: #0969da; preview-page: AppTheme.dark ? #111213 : #f2f2f0; preview-surface: AppTheme.dark ? #18191a : white; preview-text: AppTheme.dark ? #f3f3f2 : #202120; selected: AppTheme.preset == "default"; chosen => { root.choose-preset("default"); } } ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Teal"); preview-primary: #006b7a; preview-page: AppTheme.dark ? #0b1519 : #edf5f7; preview-surface: AppTheme.dark ? #122026 : #fbfefe; preview-text: AppTheme.dark ? #e5f0f2 : #173039; selected: AppTheme.preset == "teal"; chosen => { root.choose-preset("teal"); } } } HorizontalBox { padding: 0px; spacing: 8px; ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Green"); preview-primary: #2f6f44; preview-page: AppTheme.dark ? #101711 : #f1f5ef; preview-surface: AppTheme.dark ? #182019 : #fcfdfb; preview-text: AppTheme.dark ? #e9f0e6 : #213026; selected: AppTheme.preset == "green"; chosen => { root.choose-preset("green"); } } ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Purple"); preview-primary: #78418a; preview-page: AppTheme.dark ? #171119 : #f6f1f7; preview-surface: AppTheme.dark ? #201722 : #fdfbfe; preview-text: AppTheme.dark ? #f1e8f3 : #332537; selected: AppTheme.preset == "purple"; chosen => { root.choose-preset("purple"); } } } HorizontalBox { padding: 0px; spacing: 8px; Rectangle { horizontal-stretch: 1; } ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Custom"); preview-primary: root.current-primary; preview-page: root.current-page-bg; preview-surface: root.current-surface; preview-text: root.current-text; selected: AppTheme.preset == "custom"; chosen => { root.choose-preset("custom"); } } Rectangle { horizontal-stretch: 1; } } } if AppTheme.preset == "custom" : Rectangle { height: 1px; background: AppTheme.border-subtle; } // A phone row only fits the mode choice, so the heading and the save // state get their own lines there. if AppTheme.preset == "custom" && root.compact : Text { text: @tr("Custom palette"); color: AppTheme.heading; font-size: TypeScale.body; font-weight: 700; wrap: word-wrap; } // The radio buttons set the row height; vertical padding would leave // the texts beside them too short to show. if AppTheme.preset == "custom" : HorizontalBox { height: max(26px, self.preferred-height); padding-top: 0px; padding-bottom: 0px; if !root.compact : Text { text: @tr("Custom palette"); color: AppTheme.heading; font-size: TypeScale.body; font-weight: 700; vertical-alignment: center; } RadioButton { height: 26px; text: @tr("Light"); checked: !root.editing-dark; clicked => { root.editing-dark = false; } } RadioButton { height: 26px; text: @tr("Dark"); checked: root.editing-dark; clicked => { root.editing-dark = true; } } Rectangle { horizontal-stretch: 1; } if !root.compact : Text { text: ThemeDraft.dirty ? @tr("Unsaved changes") : @tr("Saved"); color: ThemeDraft.dirty ? AppTheme.accent : AppTheme.secondary; font-size: TypeScale.caption; vertical-alignment: center; } } if AppTheme.preset == "custom" && root.compact : Text { text: ThemeDraft.dirty ? @tr("Unsaved changes") : @tr("Saved"); color: ThemeDraft.dirty ? AppTheme.accent : AppTheme.secondary; font-size: TypeScale.caption; wrap: word-wrap; } if AppTheme.preset == "custom" && !root.compact : HorizontalBox { height: 48px; spacing: 8px; ThemeColorChip { horizontal-stretch: 1; name: @tr("Primary"); value: root.current-primary; selected: root.selected-token == "primary"; chosen => { root.selected-token = "primary"; } } ThemeColorChip { horizontal-stretch: 1; name: @tr("Canvas"); value: root.current-page-bg; selected: root.selected-token == "page"; chosen => { root.selected-token = "page"; } } ThemeColorChip { horizontal-stretch: 1; name: @tr("Surface"); value: root.current-surface; selected: root.selected-token == "surface"; chosen => { root.selected-token = "surface"; } } ThemeColorChip { horizontal-stretch: 1; name: @tr("Text"); value: root.current-text; selected: root.selected-token == "text"; chosen => { root.selected-token = "text"; } } ThemeColorChip { horizontal-stretch: 1; name: @tr("Border"); value: root.current-border; selected: root.selected-token == "border"; chosen => { root.selected-token = "border"; } } } if AppTheme.preset == "custom" && root.compact : VerticalBox { height: 160px; padding: 0px; spacing: 8px; HorizontalBox { height: 48px; padding: 0px; spacing: 8px; ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Primary"); value: root.current-primary; selected: root.selected-token == "primary"; chosen => { root.selected-token = "primary"; } } ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Canvas"); value: root.current-page-bg; selected: root.selected-token == "page"; chosen => { root.selected-token = "page"; } } } HorizontalBox { height: 48px; padding: 0px; spacing: 8px; ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Surface"); value: root.current-surface; selected: root.selected-token == "surface"; chosen => { root.selected-token = "surface"; } } ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Text"); value: root.current-text; selected: root.selected-token == "text"; chosen => { root.selected-token = "text"; } } } HorizontalBox { height: 48px; padding: 0px; spacing: 8px; Rectangle { horizontal-stretch: 1; } ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Border"); value: root.current-border; selected: root.selected-token == "border"; chosen => { root.selected-token = "border"; } } Rectangle { horizontal-stretch: 1; } } } if AppTheme.preset == "custom" : RuntimeColorPicker { height: 222px; value: root.current-value; edited(value) => { root.set-current-value(value); } } if AppTheme.preset == "custom" : contrast-status := Rectangle { height: max(root.compact ? 54px : 34px, warning-layout.preferred-height); border-radius: 6px; private property fill-color: root.contrast-valid ? AppTheme.accent-soft : AppTheme.destructive.mix(AppTheme.surface, 0.12); private property foreground: ThemeUtilities.contrasting-foreground(self.fill-color); background: self.fill-color; warning-layout := VerticalLayout { padding: 10px; spacing: 0px; Text { text: root.contrast-valid ? @tr("Checked text contrast meets 4.5:1 · minimum {}:1", root.minimum-contrast.to-fixed(2)) : @tr("Low contrast · 4.5:1 is recommended for text. You can still save this palette."); color: contrast-status.foreground; font-size: TypeScale.caption; font-weight: 600; wrap: word-wrap; } for check in root.contrast-checks : Rectangle { height: check.ratio < 4.5 ? check-label.preferred-height + 4px : 0px; visible: check.ratio < 4.5; check-label := Text { y: 4px; width: parent.width; height: self.preferred-height; text: @tr("{} · {}:1", check.label, check.ratio.to-fixed(2)); color: contrast-status.foreground; font-size: TypeScale.caption; wrap: word-wrap; } } } } // A phone row may be too narrow for three long translated labels. if AppTheme.preset == "custom" : HorizontalBox { height: 32px; spacing: 8px; Button { text: root.compact ? @tr("Reset") : @tr("Reset this palette"); label-overflow: elide; clicked => { root.reset-current-palette(); } } Rectangle { horizontal-stretch: 1; } Button { text: @tr("Revert"); label-overflow: elide; enabled: ThemeDraft.dirty; clicked => { root.revert(); } } Button { text: root.compact ? @tr("Save") : @tr("Save palette"); label-overflow: elide; primary: true; enabled: ThemeDraft.dirty; clicked => { root.persist(); } } } } }