Themes: add Breeze Light and Breeze Dark (#2276) (#2335)

Two Studio-layout themes for KDE Plasma desktops: original colour values
chosen to sit close to Breeze's cool blue-grey surfaces and blue accent
(and its 5 px radius) so the app blends into a Plasma desktop, without
copying Breeze's colour schemes. Text readability comes first.

- theme.rs: ThemeKind::BreezeLight / BreezeDark. Every text colour (text,
  secondary, faint, accent, danger, warning) holds 4.5:1 on every surface
  and on hovered, pressed and selected states; the primary button is a
  deeper blue that takes white text and stays above 4.5:1 while it
  darkens on hover and press. Breeze's own inactive text and link blue
  are only about 2.8:1 on its light surfaces, so ours are darker. The
  close button goes red on hover.
- prefs: Theme/LightTheme::BreezeLight ("breezeLight") and
  Theme/DarkTheme::BreezeDark ("breezeDark"): Preferences > Interface,
  `prefs.set`.
- Window > Theme > Breeze Light/Dark Theme (`window.theme.breezeLight`,
  `window.theme.breezeDark`) and `ui.set {"theme": ...}` (aliases:
  breeze, kde, plasma for the light one; ...dark for the dark one).
- i18n: the four new labels in all 16 catalogs.
- docs/ui-design.md and ATTRIBUTION.md (original work, credits Breeze as
  the inspiration; Breeze's schemes are LGPL-2.0-or-later and none of
  their values, files or code are copied).
- Tests: names/aliases, surface order and blue-grey character, the
  contrast matrix for both themes, and the preference/mode selection.
This commit is contained in:
Sir Starch 2026-10-11 01:01:53 +02:00 committed by GitHub
parent 0c4016a4db
commit 67e34a1d4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 294 additions and 12 deletions

View File

@ -36,6 +36,7 @@ next to them.
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
### Optional build input: craft-fonts (not files in this repo)

View File

@ -229,7 +229,7 @@ pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark), brushSection, brushTab,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),

View File

@ -55,10 +55,10 @@ choice!(TypeUnit { Points = "points", Pixels = "pixels", Millimeters = "mm" } de
choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } default PostScript);
choice!(Interpolation { BicubicAutomatic = "bicubicAutomatic", Nearest = "nearestNeighbor", Bilinear = "bilinear", Bicubic = "bicubic", BicubicSmoother = "bicubicSmoother", BicubicSharper = "bicubicSharper", PreserveDetails = "preserveDetails" } default BicubicAutomatic);
choice!(ColorPicker { Adobe = "adobe", System = "system" } default Adobe);
choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark" } default ProMedium);
choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark" } default ProMedium);
choice!(AppearanceMode { Auto = "auto", Dark = "dark", Light = "light" } default Dark);
choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark" } default ProMedium);
choice!(LightTheme { StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita" } default StudioLight);
choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark" } default ProMedium);
choice!(LightTheme { StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight" } default StudioLight);
choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default);
choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow);
choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto);
@ -1462,8 +1462,8 @@ impl Session {
&& let Some(theme) = interface.get("theme").and_then(Value::as_str).and_then(Theme::parse)
{
let (mode, slot) = match theme {
Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark => ("dark", "darkTheme"),
Theme::StudioLight | Theme::Classic | Theme::Adwaita => ("light", "lightTheme"),
Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark => ("dark", "darkTheme"),
Theme::StudioLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight => ("light", "lightTheme"),
};
interface.insert("appearanceMode".into(), json!(mode));
interface.insert(slot.into(), json!(theme.name()));
@ -1564,6 +1564,14 @@ impl Session {
next.interface.dark_theme = DarkTheme::AdwaitaDark;
next.interface.appearance_mode = AppearanceMode::Dark;
}
Theme::BreezeDark => {
next.interface.dark_theme = DarkTheme::BreezeDark;
next.interface.appearance_mode = AppearanceMode::Dark;
}
Theme::BreezeLight => {
next.interface.light_theme = LightTheme::BreezeLight;
next.interface.appearance_mode = AppearanceMode::Light;
}
}
}
self.prefs.edit(|p| *p = next);

View File

@ -696,6 +696,10 @@
Classic Theme Klasický motiv
Solarized dark Solarized tmavý
Solarized Dark Theme Tmavý motiv Solarized
Breeze dark Breeze tmavý
Breeze Dark Theme Tmavý motiv Breeze
Breeze light Breeze světlý
Breeze Light Theme Světlý motiv Breeze
Next Theme Další motiv
Actions Akce
Brush Settings Nastavení štětce

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -682,6 +682,10 @@
Classic Theme Klassisches Design
Solarized dark Solarized dunkel
Solarized Dark Theme Solarized-Design (Dunkel)
Breeze dark Breeze dunkel
Breeze Dark Theme Breeze-Design (Dunkel)
Breeze light Breeze hell
Breeze Light Theme Breeze-Design (Hell)
Next Theme Nächstes Design
Actions Aktionen
Brush Settings Pinseleinstellungen

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2070,6 +2070,10 @@
Classic Theme Κλασικό θέμα
Solarized dark Solarized σκούρο
Solarized Dark Theme Σκούρο θέμα Solarized
Breeze dark Breeze σκούρο
Breeze Dark Theme Σκούρο θέμα Breeze
Breeze light Breeze φωτεινό
Breeze Light Theme Φωτεινό θέμα Breeze
Clear Canvas Guides Απαλοιφή οδηγών καμβά
Clear Override Απαλοιφή παράκαμψης
Clear Samples Απαλοιφή δειγμάτων

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -688,6 +688,10 @@
Classic Theme Tema Clásico
Solarized dark Solarized oscuro
Solarized Dark Theme Tema Solarized oscuro
Breeze dark Breeze oscuro
Breeze Dark Theme Tema Breeze oscuro
Breeze light Breeze claro
Breeze Light Theme Tema Breeze claro
Next Theme Tema siguiente
Actions Acciones
Brush Settings Ajustes del pincel

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -680,6 +680,10 @@
Classic Theme Thème Classique
Solarized dark Solarized sombre
Solarized Dark Theme Thème Solarized sombre
Breeze dark Breeze sombre
Breeze Dark Theme Thème Breeze sombre
Breeze light Breeze clair
Breeze Light Theme Thème Breeze clair
Next Theme Thème suivant
Actions Actions
Brush Settings Paramètres du pinceau

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -673,6 +673,10 @@
Classic Theme Tema Klasik
Solarized dark Solarized gelap
Solarized Dark Theme Tema Solarized Gelap
Breeze dark Breeze gelap
Breeze Dark Theme Tema Breeze Gelap
Breeze light Breeze terang
Breeze Light Theme Tema Breeze Terang
Next Theme Tema Berikutnya
Actions Action
Brush Settings Pengaturan Kuas

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -686,6 +686,10 @@
Classic Theme Tema classico
Solarized dark Solarized scuro
Solarized Dark Theme Tema Solarized scuro
Breeze dark Breeze scuro
Breeze Dark Theme Tema Breeze scuro
Breeze light Breeze chiaro
Breeze Light Theme Tema Breeze chiaro
Next Theme Tema successivo
Actions Azioni
Brush Settings Impostazioni pennello

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -692,6 +692,10 @@
Classic Theme クラシックテーマ
Solarized dark Solarized ダーク
Solarized Dark Theme Solarized ダークテーマ
Breeze dark Breeze ダーク
Breeze Dark Theme Breeze ダークテーマ
Breeze light Breeze ライト
Breeze Light Theme Breeze ライトテーマ
Next Theme 次のテーマ
Actions アクション
Brush Settings ブラシ設定

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -664,6 +664,10 @@
Classic Theme 클래식 테마
Solarized dark Solarized 어두움
Solarized Dark Theme Solarized 어두운 테마
Breeze dark Breeze 어두움
Breeze Dark Theme Breeze 어두운 테마
Breeze light Breeze 밝게
Breeze Light Theme Breeze 밝은 테마
Next Theme 다음 테마
Actions 액션
Brush Settings 브러시 설정

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -672,6 +672,10 @@
Classic Theme Klassiek thema
Solarized dark Solarized donker
Solarized Dark Theme Solarized-thema donker
Breeze dark Breeze donker
Breeze Dark Theme Breeze-thema donker
Breeze light Breeze licht
Breeze Light Theme Breeze-thema licht
Adwaita Light Theme Adwaita-thema licht
Adwaita Dark Theme Adwaita-thema donker
Adwaita Adwaita

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -690,6 +690,10 @@
Classic Theme Motyw klasyczny
Solarized dark Solarized ciemny
Solarized Dark Theme Ciemny motyw Solarized
Breeze dark Breeze ciemny
Breeze Dark Theme Ciemny motyw Breeze
Breeze light Breeze jasny
Breeze Light Theme Jasny motyw Breeze
Next Theme Następny motyw
Actions Akcje
Brush Settings Ustawienia pędzla

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -677,6 +677,10 @@
Classic Theme Tema clássico
Solarized dark Solarized escuro
Solarized Dark Theme Tema Solarized Escuro
Breeze dark Breeze escuro
Breeze Dark Theme Tema Breeze Escuro
Breeze light Breeze claro
Breeze Light Theme Tema Breeze Claro
Next Theme Próximo tema
Actions Ações
Brush Settings Configurações do pincel

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1393,6 +1393,10 @@
Classic Theme Классическая тема
Solarized dark Solarized тёмная
Solarized Dark Theme Тёмная тема Solarized
Breeze dark Breeze тёмная
Breeze Dark Theme Тёмная тема Breeze
Breeze light Breeze светлая
Breeze Light Theme Светлая тема Breeze
Studio Theme Тема Studio
Studio Light Theme Светлая тема Studio
Adwaita Light Theme Светлая тема Adwaita

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1374,6 +1374,10 @@
Classic Theme Класична тема
Solarized dark Solarized темна
Solarized Dark Theme Темна тема Solarized
Breeze dark Breeze темна
Breeze Dark Theme Темна тема Breeze
Breeze light Breeze світла
Breeze Light Theme Світла тема Breeze
Adwaita Light Theme Світла тема Adwaita
Adwaita Dark Theme Темна тема Adwaita
Adwaita Adwaita

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -692,6 +692,10 @@
Classic Theme 经典主题
Solarized dark Solarized 深色
Solarized Dark Theme Solarized 深色主题
Breeze dark Breeze 深色
Breeze Dark Theme Breeze 深色主题
Breeze light Breeze 浅色
Breeze Light Theme Breeze 浅色主题
Next Theme 下一代主题
Actions 动作
Brush Settings 画笔设置

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -696,6 +696,10 @@
Classic Theme 經典主題
Solarized dark Solarized 深色
Solarized Dark Theme Solarized 深色主題
Breeze dark Breeze 深色
Breeze Dark Theme Breeze 深色主題
Breeze light Breeze 淺色
Breeze Light Theme Breeze 淺色主題
Next Theme 下一個主題
Actions 動作
Brush Settings 筆刷設定

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -75,6 +75,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
@ -329,7 +331,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
| "window.theme.classic"
| "window.theme.solarizedDark"
| "window.theme.adwaita"
| "window.theme.adwaitaDark" => {
| "window.theme.adwaitaDark"
| "window.theme.breezeLight"
| "window.theme.breezeDark" => {
let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default();
app.set_theme(ctx, k);
Ok(Value::Null)

View File

@ -90,6 +90,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option<egui::Theme>) ->
LightTheme::StudioLight => ThemeKind::StudioLight,
LightTheme::Classic => ThemeKind::Classic,
LightTheme::Adwaita => ThemeKind::Adwaita,
LightTheme::BreezeLight => ThemeKind::BreezeLight,
}
} else {
match interface.dark_theme {
@ -98,6 +99,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option<egui::Theme>) ->
DarkTheme::Studio => ThemeKind::Studio,
DarkTheme::SolarizedDark => ThemeKind::SolarizedDark,
DarkTheme::AdwaitaDark => ThemeKind::AdwaitaDark,
DarkTheme::BreezeDark => ThemeKind::BreezeDark,
}
}
}
@ -1209,7 +1211,7 @@ fn theme_card(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut Strin
.inner_margin(10.0)
.show(ui, |ui| {
ui.set_width(width - 20.0);
// Room for the longer list (five dark themes), so both cards line up.
// Room for the longer list (six dark themes), so both cards line up.
ui.set_min_height(298.0);
ui.horizontal(|ui| {
ui.label(RichText::new(tl!(title)).strong().color(t.text));
@ -1252,7 +1254,12 @@ fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map<String, Value>, system: Opti
"Light Theme",
light_active,
&mut light,
&[("studioLight", ThemeKind::StudioLight), ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita)],
&[
("studioLight", ThemeKind::StudioLight),
("classic", ThemeKind::Classic),
("adwaita", ThemeKind::Adwaita),
("breezeLight", ThemeKind::BreezeLight),
],
width,
);
});
@ -1269,6 +1276,7 @@ fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map<String, Value>, system: Opti
("studio", ThemeKind::Studio),
("solarizedDark", ThemeKind::SolarizedDark),
("adwaitaDark", ThemeKind::AdwaitaDark),
("breezeDark", ThemeKind::BreezeDark),
],
width,
);
@ -1921,6 +1929,23 @@ mod tests {
assert_eq!(selected_theme(&interface, Some(egui::Theme::Dark)), ThemeKind::Classic);
}
#[test]
fn breeze_themes_are_chosen_by_preference_and_fix_the_matching_mode() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for (id, kind, mode) in [("breezeLight", ThemeKind::BreezeLight, AppearanceMode::Light), ("breezeDark", ThemeKind::BreezeDark, AppearanceMode::Dark)] {
app.run("prefs.set", json!({"path": "interface.theme", "value": id})).unwrap();
tick(&mut app, &ctx);
let interface = app.session.prefs().interface.clone();
assert_eq!(interface.appearance_mode, mode, "{id}");
assert_eq!(selected_theme(&interface, None), kind, "{id}");
assert_eq!(app.ui.theme, kind, "{id}");
}
// The two slots are independent: choosing the dark one left the light choice alone.
assert_eq!(app.session.prefs().interface.light_theme, LightTheme::BreezeLight);
assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::BreezeDark);
}
#[test]
fn auto_uses_native_system_theme_when_egui_does_not_report_one() {
let (mut app, _) = app_with_store();

View File

@ -9,6 +9,9 @@
//! with the rest of a GNOME desktop.
//! - **Solarized Dark**: Ethan Schoonover's Solarized palette (base03/base02 surfaces, base0 text,
//! Solarized blue accent) with the Studio layout.
//! - **Breeze** (Light and Dark): the Studio layout adapted to the look of KDE Plasma's Breeze
//! (blue-grey surfaces, Breeze blue accent, 5 px corners), so the app sits with the rest of a
//! Plasma desktop.
//!
//! Widgets read [`Tokens::get`] instead of hard-coding colours, so every theme applies everywhere.
@ -34,10 +37,14 @@ pub enum ThemeKind {
/// Solarized Dark (Ethan Schoonover's palette): the base03 background, base02 panels and
/// base0 body text, with the Solarized blue accent.
SolarizedDark,
/// An adaptation of KDE Plasma's Breeze Light look.
BreezeLight,
/// An adaptation of KDE Plasma's Breeze Dark look.
BreezeDark,
}
impl ThemeKind {
pub const ALL: [ThemeKind; 8] = [
pub const ALL: [ThemeKind; 10] = [
ThemeKind::Pro,
ThemeKind::ProMedium,
ThemeKind::Studio,
@ -46,6 +53,8 @@ impl ThemeKind {
ThemeKind::SolarizedDark,
ThemeKind::Adwaita,
ThemeKind::AdwaitaDark,
ThemeKind::BreezeLight,
ThemeKind::BreezeDark,
];
pub fn label(self) -> &'static str {
match self {
@ -57,6 +66,8 @@ impl ThemeKind {
ThemeKind::Adwaita => "Adwaita (Light)",
ThemeKind::AdwaitaDark => "Adwaita (Dark)",
ThemeKind::SolarizedDark => "Solarized Dark",
ThemeKind::BreezeLight => "Breeze Light",
ThemeKind::BreezeDark => "Breeze Dark",
}
}
/// The canonical name: `ui.set {theme}` accepts it and the Window › Theme commands are
@ -71,6 +82,8 @@ impl ThemeKind {
ThemeKind::Adwaita => "adwaita",
ThemeKind::AdwaitaDark => "adwaitaDark",
ThemeKind::SolarizedDark => "solarizedDark",
ThemeKind::BreezeLight => "breezeLight",
ThemeKind::BreezeDark => "breezeDark",
}
}
pub fn next(self) -> Self {
@ -87,6 +100,8 @@ impl ThemeKind {
"adwaita" | "adwaitalight" | "gnome" | "gnomelight" => Some(ThemeKind::Adwaita),
"adwaitadark" | "gnomedark" => Some(ThemeKind::AdwaitaDark),
"solarizeddark" | "solarized" => Some(ThemeKind::SolarizedDark),
"breezelight" | "breeze" | "kde" | "kdelight" | "plasma" | "plasmalight" => Some(ThemeKind::BreezeLight),
"breezedark" | "kdedark" | "plasmadark" => Some(ThemeKind::BreezeDark),
_ => None,
}
}
@ -422,6 +437,102 @@ impl Tokens {
caption_close_text: Color32::WHITE,
scrim: Color32::from_black_alpha(110),
},
// The light counterpart, adapted the same way: our own values, chosen a few levels
// from Breeze Light's surfaces so the app blends into a Plasma desktop without being a
// copy. Breeze's own inactive text and link blue are only about 2.8:1 on such
// surfaces, so the secondary and faint text, the accent and danger text and the status
// hues are darker to hold 4.5:1 (checked by the tests), and the primary button is a
// deeper blue that takes white text.
ThemeKind::BreezeLight => Tokens {
kind,
chrome: Color32::from_rgb(219, 222, 226),
canvas: Color32::from_rgb(219, 222, 226),
canvas_dot: Color32::from_rgb(219, 222, 226),
dock: Color32::from_rgb(224, 227, 231),
card: Color32::from_rgb(236, 238, 241),
card_border: Color32::from_rgb(203, 208, 213),
field: Color32::from_rgb(249, 250, 252),
field_border: Color32::from_rgb(184, 191, 198),
hover: Color32::from_rgb(226, 230, 235),
pressed: Color32::from_rgb(208, 214, 221),
text: Color32::from_rgb(33, 37, 42),
text_dim: Color32::from_rgb(54, 63, 72),
text_faint: Color32::from_rgb(81, 90, 100),
icon: Color32::from_rgb(52, 58, 64),
accent: Color32::from_rgb(58, 172, 232),
accent_soft: Color32::from_rgba_unmultiplied(58, 172, 232, 48),
accent_border: Color32::from_rgba_unmultiplied(58, 172, 232, 170),
accent_text: Color32::from_rgb(30, 94, 136),
separator: Color32::from_rgb(214, 218, 222),
shadow: Color32::from_black_alpha(45),
primary_bg: Color32::from_rgb(35, 122, 180),
primary_text: Color32::WHITE,
danger: Color32::from_rgb(170, 50, 64),
warning: Color32::from_rgb(153, 72, 0),
radius_sm: 5.0,
radius: 5.0,
radius_lg: 6.0,
bevel: false,
pro: false,
round_chips: true,
tab_strip: Color32::TRANSPARENT,
row_selected: Color32::TRANSPARENT,
histogram_bg: Color32::from_rgb(33, 37, 42),
histogram_level: 240,
caption_close: Color32::from_rgb(213, 51, 60),
caption_close_text: Color32::WHITE,
scrim: Color32::from_black_alpha(110),
},
// An adaptation of KDE Plasma's Breeze Dark look on the Studio layout, not a copy: our
// own values, chosen a few levels from Breeze Dark's surfaces (the darkest canvas, then
// the dock, the cards, and the chrome and controls) so the app blends into a Plasma
// desktop. Near-white text, the blue accent and the orange warning hue sit close to
// Breeze's, with its 5 px frame radius.
// Readability wins over fidelity: every text colour holds 4.5:1 on every surface and on
// the hover, pressed and selected states (checked by the tests), so the secondary and
// faint text, the accent and danger text and the status hues are lighter than Breeze's,
// hover and pressed are small steps, and the primary button is a deeper blue that takes
// white text. Outlines and those steps are our own tints.
ThemeKind::BreezeDark => Tokens {
kind,
chrome: Color32::from_rgb(38, 42, 48),
canvas: Color32::from_rgb(17, 20, 24),
canvas_dot: Color32::from_rgb(17, 20, 24),
dock: Color32::from_rgb(26, 29, 34),
card: Color32::from_rgb(29, 33, 38),
card_border: Color32::from_rgb(52, 56, 61),
field: Color32::from_rgb(38, 42, 48),
field_border: Color32::from_rgb(68, 73, 79),
hover: Color32::from_rgb(50, 54, 59),
pressed: Color32::from_rgb(58, 63, 69),
text: Color32::from_rgb(250, 251, 253),
text_dim: Color32::from_rgb(200, 206, 213),
text_faint: Color32::from_rgb(164, 170, 177),
icon: Color32::from_rgb(236, 238, 240),
accent: Color32::from_rgb(58, 172, 232),
accent_soft: Color32::from_rgba_unmultiplied(58, 172, 232, 40),
accent_border: Color32::from_rgba_unmultiplied(58, 172, 232, 160),
accent_text: Color32::from_rgb(80, 184, 238),
separator: Color32::from_rgb(46, 50, 54),
shadow: Color32::from_black_alpha(150),
primary_bg: Color32::from_rgb(30, 118, 176),
primary_text: Color32::WHITE,
danger: Color32::from_rgb(227, 111, 123),
warning: Color32::from_rgb(244, 120, 6),
radius_sm: 5.0,
radius: 5.0,
radius_lg: 6.0,
bevel: false,
pro: false,
round_chips: true,
tab_strip: Color32::TRANSPARENT,
row_selected: Color32::TRANSPARENT,
histogram_bg: Color32::from_rgb(17, 20, 24),
histogram_level: 225,
caption_close: Color32::from_rgb(213, 51, 60),
caption_close_text: Color32::WHITE,
scrim: Color32::from_black_alpha(110),
},
// Ethan Schoonover's Solarized Dark palette: base03 canvas, base02 surfaces, base0
// body text and the Solarized blue accent. https://ethanschoonover.com/solarized/
// (MIT; see LICENSE-solarized.txt)
@ -523,7 +634,10 @@ impl Tokens {
}
pub fn dark(&self) -> bool {
matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark)
matches!(
self.kind,
ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark | ThemeKind::BreezeDark
)
}
}
@ -934,6 +1048,68 @@ mod tests {
}
assert_eq!(ThemeKind::from_name("gnome dark"), Some(ThemeKind::AdwaitaDark));
}
#[test]
fn breeze_themes_parse_and_meet_text_contrast() {
for (kind, aliases) in [
(ThemeKind::BreezeLight, ["breeze", "Breeze Light", "breeze_light", "kde", "plasma"]),
(ThemeKind::BreezeDark, ["breeze dark", "Breeze Dark", "breeze_dark", "kde dark", "plasma-dark"]),
] {
assert_eq!(ThemeKind::from_name(kind.id()), Some(kind));
assert_eq!(ThemeKind::from_name(kind.label()), Some(kind));
for alias in aliases {
assert_eq!(ThemeKind::from_name(alias), Some(kind), "{alias}");
}
assert!(ThemeKind::ALL.contains(&kind));
let t = Tokens::for_kind(kind);
assert_eq!(t.kind, kind);
assert_eq!(t.dark(), kind == ThemeKind::BreezeDark);
assert!(!t.pro && !t.bevel, "{kind:?} uses the Studio layout");
// The design intent, not exact values (the palette is an adaptation and may be tuned):
// the canvas is the most recessed surface, then the dock and the cards, and controls
// sit above the cards they are on; the accent is a blue.
let lum = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b());
assert!(lum(t.canvas) < lum(t.dock) && lum(t.dock) < lum(t.card) && lum(t.card) < lum(t.field), "{kind:?}: surface order");
assert!(t.accent.b() > t.accent.g() && t.accent.g() > t.accent.r(), "blue accent: {:?}", t.accent);
// Plasma's surfaces are cool blue-greys: dark ones stay below 64 per channel and light
// ones above 205, all with a little more blue than red (our own values, close to it).
for c in [t.chrome, t.canvas, t.dock, t.card, t.field] {
assert!(if t.dark() { c.r().max(c.g()).max(c.b()) < 64 } else { c.r().min(c.g()).min(c.b()) > 205 }, "{kind:?}: {c:?}");
assert!(c.b() > c.r(), "{kind:?}: blue-grey {c:?}");
}
// All text must read: AA (4.5:1) for every text colour on every surface it can sit on,
// including a hovered or pressed control and a selected (accent-tinted) row.
let tint = |under: Color32| {
let k = f32::from(t.accent_soft.a()) / 255.0;
let ch = |over: u8, under: u8| (f32::from(over) * k + f32::from(under) * (1.0 - k)).round() as u8;
Color32::from_rgb(ch(t.accent.r(), under.r()), ch(t.accent.g(), under.g()), ch(t.accent.b(), under.b()))
};
let resting = [t.chrome, t.canvas, t.dock, t.card, t.field];
let active = [t.hover, t.pressed, tint(t.chrome), tint(t.dock), tint(t.card), tint(t.field)];
for surface in resting.into_iter().chain(active) {
for (name, colour) in [("text", t.text), ("dim text", t.text_dim), ("faint text", t.text_faint), ("accent text", t.accent_text)] {
assert!(contrast(colour, surface) >= 4.5, "{kind:?}: {name} on {surface:?}");
}
}
// The text must also keep a hierarchy, not collapse into one grey.
assert!(contrast(t.text, t.card) > contrast(t.text_dim, t.card) && contrast(t.text_dim, t.card) > contrast(t.text_faint, t.card), "{kind:?}");
// Danger and warning colours carry text on the resting surfaces; on hovered, pressed and
// selected ones they are status marks, held to the 3:1 of graphical objects.
for surface in resting {
assert!(contrast(t.danger, surface) >= 4.5, "{kind:?}: danger on {surface:?}");
assert!(contrast(t.warning, surface) >= 4.5, "{kind:?}: warning on {surface:?}");
}
for surface in active {
assert!(contrast(t.danger, surface) >= 3.0, "{kind:?}: danger on {surface:?}");
assert!(contrast(t.warning, surface) >= 3.0, "{kind:?}: warning on {surface:?}");
}
// A primary button darkens while hovered and pressed (button_impl), so check all three.
for k in [1.0, 0.93, 0.85] {
assert!(contrast(t.primary_text, t.primary_bg.gamma_multiply(k)) >= 4.5, "{kind:?}: primary button label at {k}");
}
assert!(contrast(t.caption_close_text, t.caption_close) >= 4.5, "{kind:?}: close button glyph");
}
}
}
/// Development feature: live design-token overrides.

View File

@ -12,12 +12,16 @@
| Adwaita | GNOME's libadwaita light palette on the Studio layout: white header bar and cards on a grey sidebar, GNOME blue accent (#3584e4), 6 / 9 / 12 px radii, grey (not red) close button |
| Adwaita Dark | The libadwaita dark palette on the same layout |
| Solarized Dark | Ethan Schoonover's Solarized palette: base03 canvas, base02 panels, base0 text, Solarized blue accent |
| Breeze Light | An adaptation of KDE Plasma's Breeze Light on the Studio layout: cool light blue-grey surfaces a few levels from Breeze's so the app blends into a Plasma desktop, Breeze-style blue accent, 5 px radii, red close button on hover; text and status colours are darker than Breeze's for contrast |
| Breeze Dark | The same for Breeze Dark: cool dark blue-grey surfaces (darkest canvas, then dock, cards, chrome and controls) near Breeze's, blue accent, 5 px radii; secondary text and status colours are lighter than Breeze's for contrast |
Both Breeze themes are our own values close to Plasma's (not a copy of its colour schemes) and put text readability first: every text colour holds 4.5:1 on every surface and on hovered, pressed and selected states (`theme.rs` tests), so their secondary text, accent and status colours are darker (Light) or lighter (Dark) than Breeze's own, and the primary button is a deeper blue with white text.
Edit → Preferences → Interface shows an Appearance Mode selector (Sync with system, Dark, Light) above separate light and dark theme cards with PhotoCraft editor previews and radio choices. Auto is opt-in and follows the operating system while the app is running. On Linux it reads the desktop portal once and then listens for its `SettingChanged` signal (no polling, and the UI repaints only when the value changes); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. A missing system appearance falls back to Dark. New installs keep Photoshop's default: Dark mode with Pro Medium (Studio Light is the saved light theme). Existing saved single-theme preferences migrate to a fixed Dark or Light mode with their chosen theme. The header appearance button cycles Auto → Light → Dark; its icon shows a monitor, moon or sun for the selected mode. Window → Theme and `ui.set {"theme":"classic"}` select a theme and fix the mode to its light or dark family.
Window → Theme and macOS PhotoCraft → Appearance also offer Sync with system. Its checkmark follows the saved Auto mode rather than the displayed palette; choosing a manual theme selects its fixed appearance mode.
The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita and Adwaita Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag.
The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light and Breeze Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag.
## Rules