Appearance modes: sync with system, separate dark and light themes (#1981)

* Appearance modes: sync with system, separate dark and light themes

* Appearance button cycles Auto, Light, Dark

* Appearance: default stays Dark, follow the portal signal instead of polling, split out the Preferences resize

* Preferences: size both appearance cards for the five dark themes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Luniiya
2026-10-09 21:34:49 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 782bcb67fa
commit 8c3b6f8e5f
33 changed files with 637 additions and 59 deletions
+2 -2
View File
@@ -20,8 +20,8 @@ next to them.
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+140
View File
@@ -0,0 +1,140 @@
//! Linux desktop colour scheme, including Wayland compositors that do not report a winit theme.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
// The Settings.Read result is a variant; some portals nest another variant inside it.
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, Ordering},
};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("appearance-portal".into())
.spawn(move || {
let proxy = portal_proxy();
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
worker_value.store(code_of(initial), Ordering::Relaxed);
let _ = ready.send(());
let Some(proxy) = proxy else { return };
let Ok(signals) = proxy.receive_signal("SettingChanged") else { return };
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
if worker_value.swap(code, Ordering::Relaxed) != code
&& let Some(ctx) = worker_wake.get()
{
ctx.request_repaint();
}
}
})
.is_err()
{
return None;
}
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn portal_scheme_values() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
}
}
+1
View File
@@ -22,6 +22,7 @@
mod app_dirs;
mod app_icon;
mod appearance;
#[cfg(target_os = "macos")]
mod apple_events;
mod caps_lock;
+1
View File
@@ -359,6 +359,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
system_theme: crate::appearance::service(),
append_text: Some(Box::new(|path: &str, text: &str| {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

+64
View File
@@ -56,6 +56,9 @@ choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } def
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!(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!(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", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto);
@@ -207,7 +210,11 @@ impl Default for General {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Interface {
/// Legacy single-theme preference, retained for old automation and saved settings.
pub theme: Theme,
pub appearance_mode: AppearanceMode,
pub dark_theme: DarkTheme,
pub light_theme: LightTheme,
/// Pasteboard colour in standard screen mode (`canvasCustomColor` when "custom").
pub canvas_color: CanvasColor,
pub canvas_custom_color: String,
@@ -237,6 +244,9 @@ impl Default for Interface {
fn default() -> Self {
Self {
theme: Theme::ProMedium,
appearance_mode: AppearanceMode::Dark,
dark_theme: DarkTheme::ProMedium,
light_theme: LightTheme::StudioLight,
canvas_color: CanvasColor::Default,
canvas_custom_color: "#282828".into(),
canvas_border: CanvasBorder::DropShadow,
@@ -896,6 +906,9 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
"general.colorPicker" => ColorPicker::NAMES,
"general.imageInterpolation" => Interpolation::NAMES,
"interface.theme" => Theme::NAMES,
"interface.appearanceMode" => AppearanceMode::NAMES,
"interface.darkTheme" => DarkTheme::NAMES,
"interface.lightTheme" => LightTheme::NAMES,
"interface.canvasColor" => CanvasColor::NAMES,
"interface.canvasBorder" => CanvasBorder::NAMES,
"interface.uiScale" => UiScale::NAMES,
@@ -1301,6 +1314,19 @@ impl Session {
/// defaults and unknown keys are ignored, so files from older and newer versions load.
pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result<(), String> {
let mut v: Value = serde_json::from_str(s).map_err(|e| format!("preferences: {e}"))?;
// Saved preferences before appearance modes had one concrete theme. Preserve its
// appearance instead of silently switching established users to Auto.
if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut)
&& !interface.contains_key("appearanceMode")
&& 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"),
};
interface.insert("appearanceMode".into(), json!(mode));
interface.insert(slot.into(), json!(theme.name()));
}
let color = v.as_object_mut().and_then(|m| m.remove("colorSettings"));
let presets = v.as_object_mut().and_then(|m| m.remove("presets"));
let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?;
@@ -1361,6 +1387,44 @@ impl Session {
} else {
next.set(path, value)?;
}
// Old `prefs.set interface.theme` clients (by path or inside a section object) still
// select a visible theme.
if path == "interface.theme" || next.interface.theme != self.prefs().interface.theme {
match next.interface.theme {
Theme::Pro => {
next.interface.dark_theme = DarkTheme::Pro;
next.interface.appearance_mode = AppearanceMode::Dark;
}
Theme::ProMedium => {
next.interface.dark_theme = DarkTheme::ProMedium;
next.interface.appearance_mode = AppearanceMode::Dark;
}
Theme::Studio => {
next.interface.dark_theme = DarkTheme::Studio;
next.interface.appearance_mode = AppearanceMode::Dark;
}
Theme::StudioLight => {
next.interface.light_theme = LightTheme::StudioLight;
next.interface.appearance_mode = AppearanceMode::Light;
}
Theme::Classic => {
next.interface.light_theme = LightTheme::Classic;
next.interface.appearance_mode = AppearanceMode::Light;
}
Theme::Adwaita => {
next.interface.light_theme = LightTheme::Adwaita;
next.interface.appearance_mode = AppearanceMode::Light;
}
Theme::SolarizedDark => {
next.interface.dark_theme = DarkTheme::SolarizedDark;
next.interface.appearance_mode = AppearanceMode::Dark;
}
Theme::AdwaitaDark => {
next.interface.dark_theme = DarkTheme::AdwaitaDark;
next.interface.appearance_mode = AppearanceMode::Dark;
}
}
}
self.prefs.edit(|p| *p = next);
Ok(())
}
+40
View File
@@ -22,6 +22,46 @@ fn defaults_match_photoshop() {
}
}
#[test]
fn appearance_defaults_and_legacy_theme_migrate() {
let p = Preferences::default();
// New users keep Photoshop's default; following the system is opt-in.
assert_eq!(p.interface.appearance_mode, AppearanceMode::Dark);
assert_eq!(p.interface.dark_theme, DarkTheme::ProMedium);
assert_eq!(p.interface.light_theme, LightTheme::StudioLight);
for (theme, mode, dark, light) in
[("pro", AppearanceMode::Dark, DarkTheme::Pro, LightTheme::StudioLight), ("classic", AppearanceMode::Light, DarkTheme::ProMedium, LightTheme::Classic)]
{
let mut s = Session::new();
s.load_prefs_json(&json!({"interface": {"theme": theme}}).to_string()).unwrap();
assert_eq!(s.prefs().interface.appearance_mode, mode);
assert_eq!(s.prefs().interface.dark_theme, dark);
assert_eq!(s.prefs().interface.light_theme, light);
}
}
#[test]
fn appearance_choices_validate_and_legacy_theme_still_selects() {
let mut s = Session::new();
s.execute("prefs.set", json!({"values": {"interface.appearanceMode": "auto", "interface.darkTheme": "studio", "interface.lightTheme": "classic"}}))
.unwrap();
assert_eq!(s.prefs().interface.appearance_mode, AppearanceMode::Auto);
assert_eq!(s.prefs().interface.dark_theme, DarkTheme::Studio);
assert_eq!(s.prefs().interface.light_theme, LightTheme::Classic);
let mut restarted = Session::new();
restarted.load_prefs_json(&s.prefs_to_json()).unwrap();
assert_eq!(restarted.prefs().interface, s.prefs().interface);
assert!(s.execute("prefs.set", json!({"path": "interface.darkTheme", "value": "classic"})).is_err());
s.execute("prefs.set", json!({"path": "interface.theme", "value": "pro"})).unwrap();
assert_eq!(s.prefs().interface.appearance_mode, AppearanceMode::Dark);
assert_eq!(s.prefs().interface.dark_theme, DarkTheme::Pro);
assert_eq!(s.prefs().interface.light_theme, LightTheme::Classic);
s.execute("prefs.set", json!({"path": "interface", "value": {"theme": "studioLight"}})).unwrap();
assert_eq!(s.prefs().interface.appearance_mode, AppearanceMode::Light);
assert_eq!(s.prefs().interface.light_theme, LightTheme::StudioLight);
assert_eq!(s.prefs().interface.dark_theme, DarkTheme::Pro);
}
#[test]
fn get_set_reset_by_path() {
let mut s = session();
+8
View File
@@ -2309,6 +2309,14 @@ fontWeight Italic Kurzíva
Cycle Orientation Přepnout orientaci
Set the overlay options for the Crop tool Nastavit volby překryvu pro nástroj Oříznutí
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+klik převzalo pracovní prostředí, které pomocí Alt+tažení přesouvá okna. Nastavte jeho klávesu pro přesun oken na Super, nebo zamkněte Alt v nabídce Okno › Modifikační klávesy.
Appearance Mode Režim vzhledu
Dark Theme Tmavý motiv
Light Theme Světlý motiv
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Další režim vzhledu
Sync with system Synchronizovat se systémem
Active Aktivní
Color Picker (Solid Color) Výběr barvy (plná barva)
Straighten the image by drawing a line on it Narovnat obraz nakreslením čáry
Set additional Crop options Nastavit další volby oříznutí
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -2348,6 +2348,14 @@ fontWeight Italic Kursiv
Cycle Orientation Ausrichtung wechseln
Set the overlay options for the Crop tool Überlagerungsoptionen für das Freistellungswerkzeug festlegen
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Der Alt-Klick ging an die Arbeitsumgebung, die Fenster mit Alt-Ziehen verschiebt. Stelle dort die Taste zum Verschieben von Fenstern auf Super oder raste Alt unter Fenster › Modifikatortasten ein.
Appearance Mode Erscheinungsmodus
Dark Theme Dunkles Design
Light Theme Helles Design
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Nächster Erscheinungsmodus
Sync with system Mit System synchronisieren
Active Aktiv
Color Picker (Solid Color) Farbwähler (Volltonfarbe)
Straighten the image by drawing a line on it Bild durch Ziehen einer Linie gerade ausrichten
Set additional Crop options Zusätzliche Freistellungsoptionen festlegen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -3419,6 +3419,12 @@ fontWeight Thin Πολύ λεπτά
Cycle Orientation Εναλλαγή προσανατολισμού
Set the overlay options for the Crop tool Ορισμός επιλογών επικάλυψης για το εργαλείο Περικοπή
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Το Alt-κλικ πήγε στο περιβάλλον εργασίας, που μετακινεί παράθυρα με Alt-σύρσιμο. Ορίστε το πλήκτρο μετακίνησης παραθύρων του σε Super ή κλειδώστε το Alt στο Παράθυρο › Πλήκτρα τροποποίησης.
Appearance Mode Λειτουργία εμφάνισης
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Επόμενη λειτουργία εμφάνισης
Sync with system Συγχρονισμός με το σύστημα
Active Ενεργό
Color Picker (Solid Color) Επιλογέας χρώματος (συμπαγές χρώμα)
Straighten the image by drawing a line on it Ευθυγράμμιση της εικόνας σχεδιάζοντας μια γραμμή πάνω της
Set additional Crop options Ορισμός πρόσθετων επιλογών περικοπής
Can't render this file because it contains an unexpected character in line 965 and column 18.
+8
View File
@@ -2377,6 +2377,14 @@ fontWeight Italic Cursiva
Cycle Orientation Alternar orientación
Set the overlay options for the Crop tool Definir las opciones de superposición de la herramienta Recortar
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. El Alt+clic lo recibió el escritorio, que mueve ventanas con Alt+arrastrar. Cambia su tecla para mover ventanas a Super o bloquea Alt en Ventana › Teclas modificadoras.
Appearance Mode Modo de apariencia
Dark Theme Tema oscuro
Light Theme Tema claro
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Siguiente modo de apariencia
Sync with system Sincronizar con el sistema
Active Activo
Color Picker (Solid Color) Selector de color (color uniforme)
Straighten the image by drawing a line on it Enderezar la imagen dibujando una línea en ella
Set additional Crop options Definir opciones de recorte adicionales
Can't render this file because it contains an unexpected character in line 4 and column 47.
+8
View File
@@ -2730,6 +2730,14 @@ fontWeight Italic Italique
Cycle Orientation Changer d’orientation
Set the overlay options for the Crop tool Définir les options de superposition de l’outil Recadrage
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-clic a été intercepté par le bureau, qui déplace les fenêtres avec Alt-glisser. Réglez sa touche de déplacement des fenêtres sur Super, ou verrouillez Alt dans Fenêtre › Touches de modification.
Appearance Mode Mode d’apparence
Dark Theme Thème sombre
Light Theme Thème clair
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Mode d’apparence suivant
Sync with system Synchroniser avec le système
Active Actif
Color Picker (Solid Color) Sélecteur de couleurs (couleur unie)
Straighten the image by drawing a line on it Redresser l’image en traçant une ligne dessus
Set additional Crop options Définir d’autres options de recadrage
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -2717,6 +2717,14 @@ fontWeight Italic Miring
Cycle Orientation Ganti Orientasi
Set the overlay options for the Crop tool Atur opsi overlay untuk alat Crop
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik diambil oleh desktop, yang memindahkan jendela dengan Alt-seret. Atur tombol pemindah jendelanya ke Super, atau kunci Alt di Jendela › Tombol Pengubah.
Appearance Mode Mode tampilan
Dark Theme Tema gelap
Light Theme Tema terang
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Mode tampilan berikutnya
Sync with system Sinkronkan dengan sistem
Active Aktif
Color Picker (Solid Color) Pemilih Warna (Warna Solid)
Straighten the image by drawing a line on it Luruskan gambar dengan menggambar garis di atasnya
Set additional Crop options Atur opsi Crop tambahan
Can't render this file because it contains an unexpected character in line 4 and column 47.
+8
View File
@@ -2379,6 +2379,14 @@ fontWeight Italic Corsivo
Cycle Orientation Cambia orientamento
Set the overlay options for the Crop tool Imposta le opzioni di sovrapposizione dello strumento Taglierina
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-click è andato al desktop, che sposta le finestre con Alt-trascina. Imposta il suo tasto per spostare le finestre su Super, oppure blocca Alt in Finestra › Tasti modificatori.
Appearance Mode Modalità aspetto
Dark Theme Tema scuro
Light Theme Tema chiaro
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Modalità aspetto successiva
Sync with system Sincronizza con il sistema
Active Attivo
Color Picker (Solid Color) Selettore colore (tinta unita)
Straighten the image by drawing a line on it Raddrizza l’immagine tracciando una linea
Set additional Crop options Imposta opzioni di ritaglio aggiuntive
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
+8
View File
@@ -3333,6 +3333,14 @@ fontWeight Italic 斜体
Cycle Orientation 方向を切り替え
Set the overlay options for the Crop tool 切り抜きツールのオーバーレイオプションを設定
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+クリックはデスクトップに取られました(Alt+ドラッグでウィンドウを移動する設定です)。デスクトップのウィンドウ移動キーを Super に変更するか、ウィンドウ › 修飾キーで Alt を固定してください。
Appearance Mode 外観モード
Dark Theme ダークテーマ
Light Theme ライトテーマ
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode 次の外観モード
Sync with system システムと同期
Active 適用中
Color Picker (Solid Color) カラーピッカー(べた塗り)
Straighten the image by drawing a line on it 画像上に線を描いて角度を補正
Set additional Crop options 切り抜きの追加オプションを設定
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -2888,6 +2888,14 @@ fontWeight Italic 기울임
Cycle Orientation 방향 순환
Set the overlay options for the Crop tool 자르기 도구의 오버레이 옵션 설정
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 클릭이 데스크톱으로 전달되었습니다. 데스크톱이 Alt 드래그로 창을 이동합니다. 창 이동 키를 Super로 바꾸거나 창 › 보조 키에서 Alt를 고정하세요.
Appearance Mode 화면 모드
Dark Theme 어두운 테마
Light Theme 밝은 테마
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode 다음 화면 모드
Sync with system 시스템과 동기화
Active 활성
Color Picker (Solid Color) 색상 피커 (단색)
Straighten the image by drawing a line on it 이미지에 선을 그려 수평 맞추기
Set additional Crop options 추가 자르기 옵션 설정
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
+1 -1
View File
@@ -263,7 +263,7 @@ fn preferences_preview_does_not_commit_until_applied_and_survives_reload() {
dialog.fields.get_mut("values").expect("values")["interface"]["language"] = json!("fr");
h.run_steps(3);
let text = drawn_text(&h);
assert!(text.iter().any(|text| text == "Langue"), "the draft language must translate the actual dialog");
assert!(text.iter().any(|text| text == "Apparence"), "the draft language must translate the visible dialog");
assert!(text.iter().any(|text| text == "Annuler"), "the dialog buttons must preview the language too");
assert!(text.iter().any(|text| text == "File"), "the main window keeps its committed language");
assert_eq!(h.state().session.prefs().interface.language, "auto");
+7
View File
@@ -3494,6 +3494,11 @@ cameraRaw Whites Witten
Cycle Orientation Oriëntatie wisselen
Set the overlay options for the Crop tool Overlay-opties voor het gereedschap Uitsnijden instellen
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. De Alt-klik ging naar het bureaublad, dat vensters verplaatst met Alt-slepen. Stel daar de toets voor het verplaatsen van vensters in op Super, of vergrendel Alt via Venster › Wijzigingstoetsen.
Appearance Mode Weergavemodus
Next Appearance Mode Volgende weergavemodus
Sync with system Synchroniseren met systeem
Active Actief
Color Picker (Solid Color) Kleurkiezer (effen kleur)
Straighten the image by drawing a line on it Recht de afbeelding uit door er een lijn op te tekenen
Set additional Crop options Aanvullende uitsnijopties instellen
@@ -3522,3 +3527,5 @@ cameraRaw Whites Witten
XOR XOR
Color Burn (Paint.NET) Kleur doordrukken (Paint.NET)
Color Dodge (Paint.NET) Kleur tegenhouden (Paint.NET)
Pro Medium Pro Medium
Studio Light Studio Light
Can't render this file because it contains an unexpected character in line 8 and column 34.
+8
View File
@@ -3332,6 +3332,14 @@ cameraRaw Roughness Chropowatość
Cycle Orientation Przełącz orientację
Set the overlay options for the Crop tool Ustaw opcje nakładki narzędzia Kadrowanie
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+kliknięcie przejęło środowisko pulpitu, które przesuwa okna przez Alt+przeciąganie. Ustaw w nim klawisz przesuwania okien na Super lub zablokuj Alt w Okno › Klawisze modyfikujące.
Appearance Mode Tryb wyglądu
Dark Theme Ciemny motyw
Light Theme Jasny motyw
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Następny tryb wyglądu
Sync with system Synchronizuj z systemem
Active Aktywny
Color Picker (Solid Color) Próbnik kolorów (kolor jednolity)
Straighten the image by drawing a line on it Wyprostuj obraz, rysując na nim linię
Set additional Crop options Ustaw dodatkowe opcje kadrowania
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -2738,6 +2738,14 @@ fontWeight Italic Itálico
Cycle Orientation Alternar orientação
Set the overlay options for the Crop tool Definir as opções de sobreposição da ferramenta Corte demarcado
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. O Alt-clique foi para o desktop, que move janelas com Alt-arrastar. Mude a tecla de mover janelas dele para Super ou trave o Alt em Janela › Teclas modificadoras.
Appearance Mode Modo de aparência
Dark Theme Tema escuro
Light Theme Tema claro
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Próximo modo de aparência
Sync with system Sincronizar com o sistema
Active Ativo
Color Picker (Solid Color) Seletor de Cores (Cor sólida)
Straighten the image by drawing a line on it Endireitar a imagem desenhando uma linha sobre ela
Set additional Crop options Definir opções adicionais de corte
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -2584,6 +2584,14 @@ fontWeight Italic Курсив
Cycle Orientation Сменить ориентацию
Set the overlay options for the Crop tool Задать параметры перекрытия для инструмента «Рамка»
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+щелчок перехватило окружение рабочего стола: оно перемещает окна через Alt+перетаскивание. Назначьте для перемещения окон клавишу Super или зафиксируйте Alt в меню Окно › Клавиши-модификаторы.
Appearance Mode Режим оформления
Dark Theme Тёмная тема
Light Theme Светлая тема
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode Следующий режим оформления
Sync with system Синхронизировать с системой
Active Активна
Color Picker (Solid Color) Выбор цвета (сплошной цвет)
Straighten the image by drawing a line on it Выровнять изображение, нарисовав на нем линию
Set additional Crop options Задать дополнительные параметры кадрирования
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -3419,6 +3419,13 @@ cameraRaw Masking Маскування
Cycle Orientation Змінити орієнтацію
Set the overlay options for the Crop tool Задати параметри накладання для інструмента «Рамка»
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-клік перехопило робоче середовище, яке переміщує вікна через Alt-перетягування. Призначте для переміщення вікон клавішу Super або зафіксуйте Alt у меню Вікно › Клавіші-модифікатори.
Appearance Mode Режим вигляду
Dark Theme Темна тема
Light Theme Світла тема
Next Appearance Mode Наступний режим вигляду
Sync with system Синхронізувати із системою
Active Активна
Color Picker (Solid Color) Вибір кольору (суцільний колір)
Straighten the image by drawing a line on it Вирівняти зображення, намалювавши на ньому лінію
Set additional Crop options Задати додаткові параметри кадрування
@@ -3447,3 +3454,5 @@ cameraRaw Masking Маскування
XOR XOR
Color Burn (Paint.NET) Затемнення основи (Paint.NET)
Color Dodge (Paint.NET) Освітлення основи (Paint.NET)
Pro Medium Pro Medium
Studio Light Studio Light
Can't render this file because it contains an unexpected character in line 1306 and column 18.
+8
View File
@@ -2317,6 +2317,14 @@ fontWeight Italic 斜体
Cycle Orientation 循环切换取向
Set the overlay options for the Crop tool 设置裁剪工具的叠加选项
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 单击被桌面环境截获了,它用 Alt 拖动来移动窗口。请把桌面的窗口移动键改为 Super,或在 窗口 › 修饰键 中锁定 Alt。
Appearance Mode 外观模式
Dark Theme 深色主题
Light Theme 浅色主题
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode 下一个外观模式
Sync with system 与系统同步
Active 当前使用
Color Picker (Solid Color) 拾色器(纯色)
Straighten the image by drawing a line on it 通过在图像上画一条线来拉直图像
Set additional Crop options 设置其他裁剪选项
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
+8
View File
@@ -2286,6 +2286,14 @@ fontWeight Italic 斜體
Cycle Orientation 循環切換方向
Set the overlay options for the Crop tool 設定裁切工具的覆蓋選項
Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt 按一下被桌面環境攔截了,它用 Alt 拖曳來移動視窗。請把桌面的視窗移動鍵改為 Super,或在 視窗 › 輔助鍵 中鎖定 Alt。
Appearance Mode 外觀模式
Dark Theme 深色主題
Light Theme 淺色主題
Pro Medium Pro Medium
Studio Light Studio Light
Next Appearance Mode 下一個外觀模式
Sync with system 與系統同步
Active 目前使用
Color Picker (Solid Color) 檢色器(純色)
Straighten the image by drawing a line on it 在影像上繪製一條線以拉直影像
Set additional Crop options 設定其他裁切選項
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -67,6 +67,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("message-square", include_bytes!("../../../assets/icons/message-square.svg")),
("minus", include_bytes!("../../../assets/icons/minus.svg")),
("moon", include_bytes!("../../../assets/icons/moon.svg")),
("monitor", include_bytes!("../../../assets/icons/monitor.svg")),
("mouse-pointer-2", include_bytes!("../../../assets/icons/mouse-pointer-2.svg")),
("move", include_bytes!("../../../assets/icons/move.svg")),
("navigation", include_bytes!("../../../assets/icons/navigation.svg")),
+11
View File
@@ -227,6 +227,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
pub type LoadTextFn = Box<dyn FnMut() -> Option<String>>;
/// Persist the preferences text.
pub type SaveTextFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Platform appearance when egui cannot detect it (for example, Wayland without a theme event).
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// Autosave a document snapshot for crash recovery: (snapshot, revision, original path).
pub type AutosaveFn = Box<dyn FnMut(&std::sync::Arc<Document>, u64, Option<&str>) -> Result<(), String>>;
/// Poll successful or failed background writes: (document id, revision, result).
@@ -300,6 +302,7 @@ pub struct Services {
/// the web (see `prefs_ui`).
pub load_prefs: Option<LoadTextFn>,
pub save_prefs: Option<SaveTextFn>,
pub system_theme: Option<SystemThemeFn>,
/// The native window is connected directly to a Wayland compositor.
pub is_wayland: bool,
/// On Wayland, the shell command that starts this install under XWayland, where native file
@@ -1414,6 +1417,14 @@ impl PhotocraftApp {
}
pub fn set_theme(&mut self, ctx: &egui::Context, kind: theme::ThemeKind) {
if let Err(e) = self.run("prefs.set", serde_json::json!({"path": "interface.theme", "value": kind.id()})) {
self.ui.status = e;
} else {
self.apply_theme(ctx, kind);
}
}
pub(crate) fn apply_theme(&mut self, ctx: &egui::Context, kind: theme::ThemeKind) {
self.ui.theme = kind;
theme::apply(ctx, kind);
self.checker = None;
+2 -3
View File
@@ -63,7 +63,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
("window.toggle.options", "Options", &["Window"], None),
("window.theme.toggle", "Next Theme", &["Window"], None),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
@@ -304,8 +304,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
Ok(json!({"window": wid}))
}
"window.theme.toggle" => {
let next = app.ui.theme.next();
app.set_theme(ctx, next);
crate::prefs_ui::cycle_appearance(app, ctx);
Ok(Value::Null)
}
"window.theme.pro"
+2 -2
View File
@@ -814,7 +814,7 @@ mod tests {
assert!(!file.children.contains(&Node::Standard(Standard::CloseWindow)), "⌘W stays File › Close");
}
/// Themes and Next Theme move from Window to the app menu's Appearance, checked by the theme.
/// Themes and the appearance-mode cycle move from Window to the app menu's Appearance.
#[test]
fn themes_move_to_the_app_menus_appearance() {
let mut a = app(false);
@@ -829,7 +829,7 @@ mod tests {
assert!(themes.contains(&"window.theme.pro".to_string()) && themes.contains(&"window.theme.classic".to_string()));
assert_eq!(l.bar.find("window.theme.classic").unwrap().checked, Some(true));
assert_eq!(l.bar.find("window.theme.pro").unwrap().checked, Some(false));
assert_eq!(l.bar.find("window.theme.toggle").unwrap().label, "Next Theme");
assert_eq!(l.bar.find("window.theme.toggle").unwrap().label, "Next Appearance Mode");
let window = ids(&l.bar.menu(MenuRole::Window).unwrap().children);
assert!(!window.iter().any(|id| id.starts_with("window.theme.") || id == "[Theme]"), "{window:?}");
}
+8 -4
View File
@@ -566,10 +566,14 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
{
app.ui.palette_open = !app.ui.palette_open;
}
let theme_icon = if t.dark() { "sun" } else { "moon" };
if icons_shown >= 2 && icons::button(ui, theme_icon, 28.0, false, tl!("Switch theme")).clicked() {
let next = app.ui.theme.next();
app.set_theme(ui.ctx(), next);
let (theme_icon, appearance_label) = match app.session.prefs().interface.appearance_mode {
photocraft_engine::prefs::AppearanceMode::Auto => ("monitor", tl!("Auto")),
photocraft_engine::prefs::AppearanceMode::Dark => ("moon", tl!("Dark")),
photocraft_engine::prefs::AppearanceMode::Light => ("sun", tl!("Light")),
};
let appearance_tip = format!("{}: {}", tl!("Appearance Mode"), appearance_label);
if icons_shown >= 2 && icons::button(ui, theme_icon, 28.0, false, &appearance_tip).clicked() {
crate::prefs_ui::cycle_appearance(app, ui.ctx());
}
// The community Discord, one click away while the bar has room for it
// (narrow windows drop it first; it is also Help › Discord).
+217 -42
View File
@@ -11,8 +11,10 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use egui::{Color32, RichText, Sense, vec2};
use photocraft_doc::DocId;
#[cfg(test)]
use photocraft_engine::prefs::Theme;
use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started};
use photocraft_engine::prefs::{self, SECTIONS, Theme};
use photocraft_engine::prefs::{self, AppearanceMode, DarkTheme, LightTheme, SECTIONS};
use photocraft_engine::snap::{SnapLine, SnapTargets};
use serde_json::{Map, Value, json};
@@ -29,7 +31,6 @@ pub struct Runtime {
/// This instance's preferences as of `saved_rev`: the base of the three-way merge on save.
saved_value: Option<Value>,
save_retry: SaveRetry,
theme_pref: Option<Theme>,
next_autosave_ms: f64,
/// Last revision whose recovery file was actually written successfully.
autosaved: HashMap<DocId, u64>,
@@ -78,30 +79,47 @@ pub fn pasteboard_color(app: &PhotocraftApp) -> Option<Color32> {
})
}
fn theme_kind(t: Theme) -> ThemeKind {
match t {
Theme::Pro => ThemeKind::Pro,
Theme::ProMedium => ThemeKind::ProMedium,
Theme::Studio => ThemeKind::Studio,
Theme::StudioLight => ThemeKind::StudioLight,
Theme::Classic => ThemeKind::Classic,
Theme::Adwaita => ThemeKind::Adwaita,
Theme::AdwaitaDark => ThemeKind::AdwaitaDark,
Theme::SolarizedDark => ThemeKind::SolarizedDark,
fn selected_theme(interface: &prefs::Interface, system: Option<egui::Theme>) -> ThemeKind {
let light = match interface.appearance_mode {
AppearanceMode::Light => true,
AppearanceMode::Dark => false,
AppearanceMode::Auto => system == Some(egui::Theme::Light),
};
if light {
match interface.light_theme {
LightTheme::StudioLight => ThemeKind::StudioLight,
LightTheme::Classic => ThemeKind::Classic,
LightTheme::Adwaita => ThemeKind::Adwaita,
}
} else {
match interface.dark_theme {
DarkTheme::Pro => ThemeKind::Pro,
DarkTheme::ProMedium => ThemeKind::ProMedium,
DarkTheme::Studio => ThemeKind::Studio,
DarkTheme::SolarizedDark => ThemeKind::SolarizedDark,
DarkTheme::AdwaitaDark => ThemeKind::AdwaitaDark,
}
}
}
fn theme_pref(k: ThemeKind) -> Theme {
match k {
ThemeKind::Pro => Theme::Pro,
ThemeKind::ProMedium => Theme::ProMedium,
ThemeKind::Studio => Theme::Studio,
ThemeKind::StudioLight => Theme::StudioLight,
ThemeKind::Classic => Theme::Classic,
ThemeKind::Adwaita => Theme::Adwaita,
ThemeKind::AdwaitaDark => Theme::AdwaitaDark,
ThemeKind::SolarizedDark => Theme::SolarizedDark,
pub(crate) fn cycle_appearance(app: &mut PhotocraftApp, ctx: &egui::Context) {
let next = match app.session.prefs().interface.appearance_mode {
AppearanceMode::Auto => AppearanceMode::Light,
AppearanceMode::Light => AppearanceMode::Dark,
AppearanceMode::Dark => AppearanceMode::Auto,
};
if let Err(e) = app.run("prefs.set", json!({"path": "interface.appearanceMode", "value": next.name()})) {
app.ui.status = e;
return;
}
let selected = selected_theme(&app.session.prefs().interface, system_theme(app, ctx));
if app.ui.theme != selected {
app.apply_theme(ctx, selected);
}
}
fn system_theme(app: &PhotocraftApp, ctx: &egui::Context) -> Option<egui::Theme> {
app.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme())
}
// ------------------------------------------------------------------ lifecycle
@@ -287,18 +305,10 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
recovery(app);
sync_display_scale(app, ctx);
// Interface theme: a preference change applies to the UI; a theme picked from the Window
// menu is stored as the preference.
let pref = app.session.prefs().interface.theme;
if app.prefs_rt.theme_pref != Some(pref) {
app.prefs_rt.theme_pref = Some(pref);
if app.ui.theme != theme_kind(pref) {
app.set_theme(ctx, theme_kind(pref));
}
} else if theme_pref(app.ui.theme) != pref {
let t = theme_pref(app.ui.theme);
app.session.prefs.edit(|p| p.interface.theme = t);
app.prefs_rt.theme_pref = Some(t);
// The native service also covers Wayland sessions where winit has no system theme.
let selected = selected_theme(&app.session.prefs().interface, system_theme(app, ctx));
if app.ui.theme != selected {
app.apply_theme(ctx, selected);
}
crate::theme::set_ui_font_size(ctx, app.session.prefs().interface.ui_font_size);
presets_store(app);
@@ -865,7 +875,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
match f.get("__prefsui").and_then(Value::as_str).unwrap_or("") {
"prefs" => {
f.insert("__gpuInfo".into(), json!(app.perf.gpu_info.lines()));
prefs_body(ui, f);
let system = system_theme(app, ui.ctx());
prefs_body(ui, f, system);
}
"shortcuts" => shortcuts_body(app, ui, f),
"presets" => presets_body(app, ui, f),
@@ -876,6 +887,12 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
}
fn humanize(key: &str) -> String {
match key {
"appearanceMode" => return "Appearance Mode".into(),
"darkTheme" => return "Dark Theme".into(),
"lightTheme" => return "Light Theme".into(),
_ => {}
}
// These controls appear only for WebP, so reuse the existing translated labels.
if key == "webpLossless" {
return "Lossless".into();
@@ -939,7 +956,7 @@ fn color_of(s: &str) -> Color32 {
}
/// Preferences: section list on the left, the section's settings on the right.
fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>, system: Option<egui::Theme>) {
let t = Tokens::get(ui.ctx());
let mut section = f.get("section").and_then(Value::as_str).unwrap_or("general").to_string();
let mut values = f.get("values").cloned().unwrap_or(Value::Null);
@@ -991,7 +1008,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.add_space(4.0);
ui.label(RichText::new(tl!("These settings aren't available in PhotoCraft yet.")).color(t.text_faint));
} else if let Some(obj) = values.get_mut(&section).and_then(Value::as_object_mut) {
section_fields(ui, &section, obj, &order, lang);
section_fields(ui, &section, obj, &order, lang, system);
if section == "performance" {
gpu_status_rows(ui, f.get("__gpuInfo"), obj);
}
@@ -1078,10 +1095,123 @@ fn export_field_visible(obj: &Map<String, Value>, key: &str) -> bool {
}
}
fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
let p = Tokens::for_kind(kind);
let (rect, _) = ui.allocate_exact_size(vec2(width, 108.0), Sense::hover());
let painter = ui.painter();
painter.rect_filled(rect, 3.0, p.dock);
let bar = egui::Rect::from_min_size(rect.min, vec2(rect.width(), 16.0));
painter.rect_filled(bar, 0.0, p.chrome);
for (x, w) in [(8.0, 18.0), (31.0, 13.0), (49.0, 16.0), (70.0, 12.0)] {
painter.rect_filled(egui::Rect::from_min_size(bar.min + vec2(x, 6.0), vec2(w, 3.0)), 1.0, p.text_dim);
}
let tools = egui::Rect::from_min_max(bar.left_bottom(), egui::pos2(rect.left() + 23.0, rect.bottom()));
painter.rect_filled(tools, 0.0, p.chrome);
for y in [24.0, 39.0, 54.0, 69.0, 84.0] {
// The first tool is the active one.
let color = if y == 24.0 { p.accent } else { p.icon };
painter.rect_filled(egui::Rect::from_min_size(rect.min + vec2(7.0, y), vec2(9.0, 9.0)), 2.0, color);
}
let dock = egui::Rect::from_min_max(egui::pos2(rect.right() - 75.0, bar.bottom()), rect.max);
painter.rect_filled(dock, 0.0, p.dock);
painter.rect_filled(egui::Rect::from_min_size(dock.min, vec2(dock.width(), 12.0)), 0.0, p.tab_strip);
painter.rect_filled(egui::Rect::from_min_size(dock.min + vec2(6.0, 4.0), vec2(31.0, 3.0)), 1.0, p.text_dim);
for y in [36.0, 55.0, 74.0] {
let row = egui::Rect::from_min_size(rect.min + vec2(rect.width() - 71.0, y), vec2(67.0, 16.0));
// The middle row is the selected layer.
let selected = y == 55.0;
painter.rect_filled(row, 1.0, if selected { p.accent } else { p.card });
painter.rect_filled(egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(13.0, 10.0)), 1.0, p.canvas);
let label = if selected { egui::Color32::from_white_alpha(210) } else { p.text_dim };
painter.rect_filled(egui::Rect::from_min_size(row.min + vec2(22.0, 6.0), vec2(35.0, 3.0)), 1.0, label);
}
let canvas = egui::Rect::from_min_max(egui::pos2(tools.right(), bar.bottom()), egui::pos2(dock.left(), rect.bottom()));
painter.rect_filled(canvas, 0.0, p.canvas);
let art = canvas.shrink2(vec2(15.0, 13.0));
painter.rect_filled(art, 0.0, p.card);
painter.rect_stroke(art, 0.0, egui::Stroke::new(1.0, p.card_border), egui::StrokeKind::Inside);
}
fn theme_card(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, options: &[(&str, ThemeKind)], width: f32) {
let Some((_, first_kind)) = options.first() else { return };
let t = Tokens::get(ui.ctx());
egui::Frame::new()
.fill(t.card)
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.card_border }))
.corner_radius(t.radius)
.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.
ui.set_min_height(298.0);
ui.horizontal(|ui| {
ui.label(RichText::new(tl!(title)).strong().color(t.text));
if active {
ui.label(RichText::new(tl!("Active")).color(t.accent_text).small());
}
});
ui.add_space(5.0);
let preview = options.iter().find(|(id, _)| *id == selected.as_str()).map_or(*first_kind, |(_, kind)| *kind);
theme_preview(ui, preview, width - 20.0);
ui.add_space(6.0);
for (id, _) in options {
let label = choice_label(id);
ui.radio_value(selected, (*id).to_string(), tl!(&label));
}
});
}
fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map<String, Value>, system: Option<egui::Theme>) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(tl!("Appearance")).strong().color(t.text));
let mut mode = obj.get("appearanceMode").and_then(Value::as_str).unwrap_or("auto").to_string();
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Appearance Mode")).color(t.text_dim));
let choices: [(String, &str); 3] = [("auto".into(), tl!("Sync with system")), ("dark".into(), tl!("Dark")), ("light".into(), tl!("Light"))];
let pairs: Vec<(String, &str)> = choices.iter().map(|(id, label)| (id.clone(), *label)).collect();
crate::widgets::dropdown(ui, "appearance-mode", &mut mode, &pairs, 190.0);
});
obj.insert("appearanceMode".into(), json!(mode));
ui.add_space(8.0);
let light_active = mode == "light" || (mode == "auto" && system == Some(egui::Theme::Light));
let width = ((ui.available_width() - 10.0) / 2.0).max(190.0);
let mut light = obj.get("lightTheme").and_then(Value::as_str).unwrap_or("studioLight").to_string();
let mut dark = obj.get("darkTheme").and_then(Value::as_str).unwrap_or("proMedium").to_string();
ui.horizontal_top(|ui| {
ui.set_min_width(width * 2.0 + 10.0);
ui.allocate_ui_with_layout(vec2(width, 300.0), egui::Layout::top_down(egui::Align::Min), |ui| {
theme_card(ui, "Light Theme", light_active, &mut light, &[("studioLight", ThemeKind::StudioLight), ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita)], width);
});
ui.add_space(10.0);
ui.allocate_ui_with_layout(vec2(width, 300.0), egui::Layout::top_down(egui::Align::Min), |ui| {
theme_card(
ui,
"Dark Theme",
!light_active,
&mut dark,
&[
("proMedium", ThemeKind::ProMedium),
("pro", ThemeKind::Pro),
("studio", ThemeKind::Studio),
("solarizedDark", ThemeKind::SolarizedDark),
("adwaitaDark", ThemeKind::AdwaitaDark),
],
width,
);
});
});
obj.insert("lightTheme".into(), json!(light));
obj.insert("darkTheme".into(), json!(dark));
ui.add_space(12.0);
}
/// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches,
/// number fields with the preference's range, text fields.
fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, order: &[String], lang: crate::i18n::Lang) {
fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, order: &[String], lang: crate::i18n::Lang, system: Option<egui::Theme>) {
let t = Tokens::get(ui.ctx());
if section == "interface" {
appearance_rows(ui, obj, system);
}
if section == "performance" {
rendering_mode_row(ui, obj);
}
@@ -1092,7 +1222,8 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>
let path = format!("{section}.{k}");
// Settings nothing reads yet stay out of the dialog (issue #204); their stored values
// pass through untouched.
if prefs::is_hidden(&path)
if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme"))
|| prefs::is_hidden(&path)
|| (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode"))
|| (section == "export" && !export_field_visible(obj, &k))
{
@@ -1640,6 +1771,48 @@ mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn appearance_resolves_both_saved_themes_and_follows_system() {
let mut interface = prefs::Interface { appearance_mode: AppearanceMode::Auto, ..prefs::Interface::default() };
assert_eq!(selected_theme(&interface, Some(egui::Theme::Dark)), ThemeKind::ProMedium);
assert_eq!(selected_theme(&interface, Some(egui::Theme::Light)), ThemeKind::StudioLight);
interface.dark_theme = DarkTheme::Studio;
interface.light_theme = LightTheme::Classic;
assert_eq!(selected_theme(&interface, Some(egui::Theme::Dark)), ThemeKind::Studio);
assert_eq!(selected_theme(&interface, Some(egui::Theme::Light)), ThemeKind::Classic);
interface.appearance_mode = AppearanceMode::Dark;
assert_eq!(selected_theme(&interface, Some(egui::Theme::Light)), ThemeKind::Studio);
interface.appearance_mode = AppearanceMode::Light;
assert_eq!(selected_theme(&interface, Some(egui::Theme::Dark)), ThemeKind::Classic);
}
#[test]
fn auto_uses_native_system_theme_when_egui_does_not_report_one() {
let (mut app, _) = app_with_store();
app.services.system_theme = Some(Box::new(|_| Some(egui::Theme::Light)));
app.run("prefs.set", json!({"path": "interface.appearanceMode", "value": "auto"})).unwrap();
app.run("prefs.set", json!({"path": "interface.appearanceMode", "value": "auto"})).unwrap();
let ctx = egui::Context::default();
tick(&mut app, &ctx);
assert_eq!(app.ui.theme, ThemeKind::StudioLight);
}
#[test]
fn appearance_button_cycles_modes_without_losing_theme_choices() {
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("prefs.set", json!({"values": {"interface.darkTheme": "studio", "interface.lightTheme": "classic"}})).unwrap();
for (mode, visible) in
[(AppearanceMode::Auto, ThemeKind::Studio), (AppearanceMode::Light, ThemeKind::Classic), (AppearanceMode::Dark, ThemeKind::Studio)]
{
cycle_appearance(&mut app, &ctx);
assert_eq!(app.session.prefs().interface.appearance_mode, mode);
assert_eq!(app.ui.theme, visible);
assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::Studio);
assert_eq!(app.session.prefs().interface.light_theme, LightTheme::Classic);
}
}
/// Every generated preference label, section title and choice label has an entry in each
/// language that claims complete menus (Japanese, Traditional Chinese, ...).
#[test]
@@ -2050,7 +2223,8 @@ mod tests {
assert!(h.get_by_label("Apply").accesskit_node().is_disabled());
let values = h.state_mut().ui.dialog_mut(id).unwrap().fields.get_mut("values").unwrap();
values["interface"]["theme"] = json!("studioLight");
values["interface"]["appearanceMode"] = json!("light");
values["interface"]["lightTheme"] = json!("studioLight");
values["interface"]["uiFontSize"] = json!("large");
values["performance"]["historyStates"] = json!(12);
h.run_steps(2);
@@ -2068,7 +2242,8 @@ mod tests {
assert!(h.get_by_label("Apply").accesskit_node().is_disabled());
let saved: Value = serde_json::from_str(store.lock().unwrap().as_ref().unwrap()).unwrap();
assert_eq!(saved["performance"]["historyStates"], 12);
assert_eq!(saved["interface"]["theme"], "studioLight");
assert_eq!(saved["interface"]["appearanceMode"], "light");
assert_eq!(saved["interface"]["lightTheme"], "studioLight");
assert_eq!(saved["interface"]["uiFontSize"], "large");
// Repeated Apply starts from the validated values, not the dialog's original snapshot.
@@ -2140,7 +2315,7 @@ mod tests {
use egui_kittest::{Harness, kittest::Queryable};
let obj = json!({"showTooltips": true}).as_object().unwrap().clone();
let mut h =
Harness::new_ui_state(|ui, obj: &mut Map<String, Value>| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en")), obj);
Harness::new_ui_state(|ui, obj: &mut Map<String, Value>| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None), obj);
h.run_steps(4);
assert!(h.query_by_label("Applies at next launch.").is_none());
h.get_by_label("Show tooltips").click();
+1 -1
View File
@@ -1,6 +1,6 @@
//! Design system: themes, colour tokens, radii, typography.
//!
//! - **Studio** (default): near-black surfaces, rounded cards, Inter + JetBrains Mono, soft violet
//! - **Studio**: near-black surfaces, rounded cards, Inter + JetBrains Mono, soft violet
//! accent. Modelled on the look of modern pro editors (such as Photoshop 2025).
//! - **Studio Light**: the same system on light surfaces.
//! - **Classic**: a deliberately Windows-2000-era look (grey bevels, square corners, navy selection)
+2 -2
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 17 | 14 | 8 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 48 of 141 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 48 of 144 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 0 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -172,7 +172,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 48 of 141** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 48 of 144** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
+3 -2
View File
@@ -4,7 +4,8 @@
| Theme | Intent |
|---|---|
| **Pro** (default) | Photoshop-style Spectrum dark: flat charcoal panels (#323232), dark tab strips, Spectrum blue accent (#378ef0), pill buttons, checkboxes, compact 12 px type |
| Pro | Photoshop-style Spectrum dark: flat charcoal panels (#323232), dark tab strips, Spectrum blue accent (#378ef0), pill buttons, checkboxes, compact 12 px type |
| Pro Medium | Photoshop-style medium-gray panels and a dark canvas |
| Studio | Dark studio style: near-black, rounded cards, pill tabs, violet accent, toggles |
| Studio Light | Studio on light surfaces |
| Classic | Windows-2000 bevels, square corners, navy selection |
@@ -12,7 +13,7 @@
| 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 |
Switch themes with the sun icon, Window → Theme, or `ui.set {"theme":"classic"}` over the control channel.
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.
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.