From e44d96e1d4671b8d9fd64fbabdf88fa8abf0441e Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:09:47 +0200 Subject: [PATCH] Favorite themes from the appearance button's right-click menu (#2445) * feat(ui): favorite themes from the appearance button's context menu Hovering a theme in Preferences > Interface shows a star; clicking it saves the theme to interface.favoriteThemes (filled accent star once favorited). Right-clicking the title-bar appearance button opens a menu with Appearance Settings, then the favorite light themes and the favorite dark themes. * Regenerate the scorecard for the favorite themes pref --------- Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 2 +- assets/icons/star-filled.svg | 13 +++ assets/icons/star.svg | 13 +++ crates/engine/src/prefs.rs | 17 ++++ crates/engine/src/prefs/tests.rs | 16 ++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/icon_data.rs | 2 + crates/ui-egui/src/panels.rs | 8 +- crates/ui-egui/src/prefs_ui.rs | 142 +++++++++++++++++++++------- docs/scorecard.md | 4 +- 25 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 assets/icons/star-filled.svg create mode 100644 assets/icons/star.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ed1e59365a..6b5623a115 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,7 +20,7 @@ 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) | | 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 | | 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`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`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`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.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 | 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/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/star-filled.svg b/assets/icons/star-filled.svg new file mode 100644 index 0000000000..d987922213 --- /dev/null +++ b/assets/icons/star-filled.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/star.svg b/assets/icons/star.svg new file mode 100644 index 0000000000..b35bedd29f --- /dev/null +++ b/assets/icons/star.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 902e89bf97..7c7d3b8080 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -218,6 +218,8 @@ pub struct Interface { pub appearance_mode: AppearanceMode, pub dark_theme: DarkTheme, pub light_theme: LightTheme, + /// Theme shortcuts shown in the appearance button's context menu. + pub favorite_themes: Vec, /// Pasteboard colour in standard screen mode (`canvasCustomColor` when "custom"). pub canvas_color: CanvasColor, pub canvas_custom_color: String, @@ -258,6 +260,7 @@ impl Default for Interface { appearance_mode: AppearanceMode::Dark, dark_theme: DarkTheme::ProMedium, light_theme: LightTheme::StudioLight, + favorite_themes: Vec::new(), canvas_color: CanvasColor::Default, canvas_custom_color: "#282828".into(), show_canvas_dots: true, @@ -1176,6 +1179,20 @@ fn set_path(root: &mut Value, path: &str, value: Value) -> std::result::Result<( /// Validate one value for `path` before it is stored (choices, ranges, colours). fn check_value(path: &str, v: &Value) -> std::result::Result<(), String> { + if path == "interface.favoriteThemes" { + let items = v.as_array().ok_or("`interface.favoriteThemes` must be a list of theme ids")?; + if items.len() > Theme::NAMES.len() { + return Err("`interface.favoriteThemes` contains too many themes".into()); + } + let mut seen = Vec::with_capacity(items.len()); + for item in items { + let theme = item.as_str().and_then(Theme::parse).ok_or_else(|| format!("unknown favorite theme `{item}`"))?; + if seen.contains(&theme) { + return Err(format!("duplicate favorite theme `{}`", theme.name())); + } + seen.push(theme); + } + } if path == "performance.renderingMode" && v.is_null() { return Ok(()); // Legacy policy, resolved from useGpu and gpuBackend. } diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index ed78dcaa0b..bc896f4efc 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -89,6 +89,22 @@ fn appearance_choices_validate_and_legacy_theme_still_selects() { assert_eq!(s.prefs().interface.dark_theme, DarkTheme::Pro); } +#[test] +fn favorite_themes_persist_and_reject_invalid_lists() { + let mut s = Session::new(); + assert!(s.prefs().interface.favorite_themes.is_empty()); + s.execute("prefs.set", json!({"path": "interface.favoriteThemes", "value": ["classic", "studio"]})).unwrap(); + assert_eq!(s.prefs().interface.favorite_themes, vec![Theme::Classic, Theme::Studio]); + s.execute("prefs.set", json!({"path": "interface.theme", "value": "pro"})).unwrap(); + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restored.prefs().interface.favorite_themes, vec![Theme::Classic, Theme::Studio]); + for invalid in [json!("classic"), json!(["unknown"]), json!(["classic", "classic"]), json!([23])] { + assert!(s.execute("prefs.set", json!({"path": "interface.favoriteThemes", "value": invalid})).is_err()); + assert_eq!(s.prefs().interface.favorite_themes, vec![Theme::Classic, Theme::Studio]); + } +} + #[test] fn get_set_reset_by_path() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c0912a5448..33f32c2d31 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2435,6 +2435,9 @@ fontWeight Italic Kurzíva Quick Selection found nothing there Rychlý výběr zde nic nenašel Object Selection found nothing there — try a tighter rectangle Výběr objektu zde nic nenašel — zkuste těsnější obdélník No subject found — try a rectangle around the object Nebyl nalezen žádný objekt — zkuste obdélník kolem objektu + Appearance Settings… Nastavení vzhledu… + Add to favorites Přidat do oblíbených + Remove from favorites Odebrat z oblíbených # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Údaje fotoaparátu cameraData Make Výrobce diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f51f35698d..27e71fe3fb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2473,6 +2473,9 @@ fontWeight Italic Kursiv Quick Selection found nothing there Die Schnellauswahl hat dort nichts gefunden Object Selection found nothing there — try a tighter rectangle Die Objektauswahl hat dort nichts gefunden — versuchen Sie ein engeres Rechteck No subject found — try a rectangle around the object Kein Motiv gefunden — versuchen Sie ein Rechteck um das Objekt + Appearance Settings… Erscheinungsbild-Einstellungen… + Add to favorites Zu Favoriten hinzufügen + Remove from favorites Aus Favoriten entfernen # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Kameradaten cameraData Make Hersteller diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bf9617d2c7..4821be08a8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3542,6 +3542,9 @@ fontWeight Thin Πολύ λεπτά Quick Selection found nothing there Η Γρήγορη επιλογή δεν βρήκε τίποτα εκεί Object Selection found nothing there — try a tighter rectangle Η Επιλογή αντικειμένου δεν βρήκε τίποτα εκεί — δοκιμάστε ένα πιο στενό ορθογώνιο No subject found — try a rectangle around the object Δεν βρέθηκε θέμα — δοκιμάστε ένα ορθογώνιο γύρω από το αντικείμενο + Appearance Settings… Ρυθμίσεις εμφάνισης… + Add to favorites Προσθήκη στα αγαπημένα + Remove from favorites Αφαίρεση από τα αγαπημένα # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Κατασκευαστής cameraData Model Μοντέλο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 42eab0e04a..bbaac744cf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2502,6 +2502,9 @@ fontWeight Italic Cursiva Quick Selection found nothing there Selección rápida no encontró nada ahí Object Selection found nothing there — try a tighter rectangle Selección de objeto no encontró nada ahí — pruebe un rectángulo más ajustado No subject found — try a rectangle around the object No se encontró ningún sujeto — pruebe un rectángulo alrededor del objeto + Appearance Settings… Ajustes de apariencia… + Add to favorites Añadir a favoritos + Remove from favorites Quitar de favoritos # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Datos de cámara cameraData Make Marca diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c4dd364a00..68a9dff4b4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2852,6 +2852,9 @@ fontWeight Italic Italique Quick Selection found nothing there La Sélection rapide n'a rien trouvé à cet endroit Object Selection found nothing there — try a tighter rectangle La Sélection d'objet n'a rien trouvé à cet endroit — essayez un rectangle plus serré No subject found — try a rectangle around the object Aucun sujet trouvé — essayez un rectangle autour de l'objet + Appearance Settings… Réglages d’apparence… + Add to favorites Ajouter aux favoris + Remove from favorites Retirer des favoris # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Marque cameraData Model Modèle diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b78e40ace7..9355f23685 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2839,6 +2839,9 @@ fontWeight Italic Miring Quick Selection found nothing there Seleksi Cepat tidak menemukan apa pun di sana Object Selection found nothing there — try a tighter rectangle Seleksi Objek tidak menemukan apa pun di sana — coba persegi panjang yang lebih rapat No subject found — try a rectangle around the object Subjek tidak ditemukan — coba persegi panjang di sekitar objek + Appearance Settings… Pengaturan Tampilan… + Add to favorites Tambahkan ke favorit + Remove from favorites Hapus dari favorit # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Merek cameraData Model Model diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 62526f3c7d..43e52295ae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2504,6 +2504,9 @@ fontWeight Italic Corsivo Quick Selection found nothing there Selezione rapida non ha trovato nulla lì Object Selection found nothing there — try a tighter rectangle Selezione oggetto non ha trovato nulla lì — prova un rettangolo più stretto No subject found — try a rectangle around the object Nessun soggetto trovato — prova un rettangolo attorno all'oggetto + Appearance Settings… Impostazioni aspetto… + Add to favorites Aggiungi ai preferiti + Remove from favorites Rimuovi dai preferiti # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Dati fotocamera cameraData Make Marca diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d4b422958e..1c66b52c8a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3453,6 +3453,9 @@ fontWeight Italic 斜体 Quick Selection found nothing there クイック選択ではそこに何も見つかりませんでした Object Selection found nothing there — try a tighter rectangle オブジェクト選択ではそこに何も見つかりませんでした — より狭い長方形で試してください No subject found — try a rectangle around the object 被写体が見つかりません — オブジェクトを囲む長方形を試してください + Appearance Settings… 外観設定… + Add to favorites お気に入りに追加 + Remove from favorites お気に入りから削除 # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make メーカー cameraData Model モデル diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca5b9d257d..8484888eed 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3013,6 +3013,9 @@ fontWeight Italic 기울임 Quick Selection found nothing there 빠른 선택으로 해당 위치에서 아무것도 찾지 못했습니다 Object Selection found nothing there — try a tighter rectangle 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요 No subject found — try a rectangle around the object 피사체를 찾을 수 없습니다 — 개체 주위에 사각형을 그려 보세요 + Appearance Settings… 화면 설정… + Add to favorites 즐겨찾기에 추가 + Remove from favorites 즐겨찾기에서 제거 # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data 카메라 데이터 cameraData Make 제조사 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4a8edcb3fe..173af2b176 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3611,6 +3611,9 @@ cameraRaw Whites Witten Quick Selection found nothing there Snelle selectie heeft daar niets gevonden Object Selection found nothing there — try a tighter rectangle Objectselectie heeft daar niets gevonden — probeer een krappere rechthoek No subject found — try a rectangle around the object Geen onderwerp gevonden — probeer een rechthoek rond het object + Appearance Settings… Weergave-instellingen… + Add to favorites Toevoegen aan favorieten + Remove from favorites Verwijderen uit favorieten # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Merk cameraData Model Model diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5b4ad11441..71b80b30bc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3453,6 +3453,9 @@ cameraRaw Roughness Chropowatość Quick Selection found nothing there Szybkie zaznaczanie nic tam nie znalazło Object Selection found nothing there — try a tighter rectangle Zaznaczanie obiektu nic tam nie znalazło — spróbuj ciaśniejszego prostokąta No subject found — try a rectangle around the object Nie znaleziono obiektu — spróbuj prostokąta wokół obiektu + Appearance Settings… Ustawienia wyglądu… + Add to favorites Dodaj do ulubionych + Remove from favorites Usuń z ulubionych # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Producent cameraData Model Model diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b24e76fbc1..ae367d5406 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2860,6 +2860,9 @@ fontWeight Italic Itálico Quick Selection found nothing there A Seleção rápida não encontrou nada ali Object Selection found nothing there — try a tighter rectangle A Seleção de objeto não encontrou nada ali — tente um retângulo mais justo No subject found — try a rectangle around the object Nenhum assunto encontrado — tente um retângulo ao redor do objeto + Appearance Settings… Configurações de aparência… + Add to favorites Adicionar aos favoritos + Remove from favorites Remover dos favoritos # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Fabricante cameraData Model Modelo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index db32045a95..27c8e43922 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2717,6 +2717,9 @@ fontWeight Italic Курсив Missing delete command Отсутствует команда удаления Invalid delete command Недопустимая команда удаления Layer deleted, but confirmation preference could not be saved: {error} Слой удалён, но не удалось сохранить настройку подтверждения: {error} + Appearance Settings… Настройки оформления… + Add to favorites Добавить в избранное + Remove from favorites Удалить из избранного # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Производитель cameraData Model Модель diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index da80fc3a6c..315a2b5fdb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3539,6 +3539,9 @@ cameraRaw Masking Маскування Quick Selection found nothing there Швидке виділення нічого тут не знайшло Object Selection found nothing there — try a tighter rectangle Виділення об'єктів нічого тут не знайшло — спробуйте вужчий прямокутник No subject found — try a rectangle around the object Об'єкт не знайдено — спробуйте прямокутник навколо об'єкта + Appearance Settings… Налаштування вигляду… + Add to favorites Додати до вибраного + Remove from favorites Видалити з вибраного # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Виробник cameraData Model Модель diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7b2fac1702..977736fc44 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2442,6 +2442,9 @@ fontWeight Italic 斜体 Quick Selection found nothing there 快速选择在该处未找到任何内容 Object Selection found nothing there — try a tighter rectangle 对象选择在该处未找到任何内容 — 请尝试更紧凑的矩形 No subject found — try a rectangle around the object 未找到主体 — 请尝试在对象周围绘制矩形 + Appearance Settings… 外观设置… + Add to favorites 添加到收藏 + Remove from favorites 从收藏中移除 # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data 相机数据 cameraData Make 制造商 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 38671e3cd4..049194ed6a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2412,6 +2412,9 @@ fontWeight Italic 斜體 Quick Selection found nothing there 快速選取在該處找不到任何內容 Object Selection found nothing there — try a tighter rectangle 物件選取在該處找不到任何內容 — 請嘗試更緊密的矩形 No subject found — try a rectangle around the object 找不到主體 — 請嘗試在物件周圍繪製矩形 + Appearance Settings… 外觀設定… + Add to favorites 加入最愛 + Remove from favorites 從最愛移除 # File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data 相機資料 cameraData Make 製造商 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index f452e1cbe3..26449b7b5f 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -110,6 +110,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("square-plus", include_bytes!("../../../assets/icons/square-plus.svg")), ("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")), ("stamp", include_bytes!("../../../assets/icons/stamp.svg")), + ("star", include_bytes!("../../../assets/icons/star.svg")), + ("star-filled", include_bytes!("../../../assets/icons/star-filled.svg")), ("sun", include_bytes!("../../../assets/icons/sun.svg")), ("symmetry-diagonal", include_bytes!("../../../assets/icons/symmetry-diagonal.svg")), ("symmetry-dual", include_bytes!("../../../assets/icons/symmetry-dual.svg")), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 444036c57f..7ebf7736cb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -589,8 +589,12 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 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()); + if icons_shown >= 2 { + let button = icons::button(ui, theme_icon, 28.0, false, &appearance_tip); + if button.clicked() { + crate::prefs_ui::cycle_appearance(app, ui.ctx()); + } + button.context_menu(|ui| crate::prefs_ui::appearance_context_menu(app, ui)); } // The community Discord, one click away while the bar has room for it // (narrow windows drop it first; it is also Help › Discord). diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 460fba699f..c2def4db7d 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -14,7 +14,7 @@ use photocraft_doc::DocId; use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started}; #[cfg(test)] use photocraft_engine::prefs::Theme; -use photocraft_engine::prefs::{self, AppearanceMode, DarkTheme, LightTheme, SECTIONS}; +use photocraft_engine::prefs::{self, AppearanceMode, DarkTheme, LightTheme, SECTIONS, Theme as SavedTheme}; use photocraft_engine::snap::{SnapLine, SnapTargets}; use serde_json::{Map, Value, json}; @@ -120,6 +120,52 @@ pub(crate) fn cycle_appearance(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +const LIGHT_THEME_OPTIONS: &[(&str, ThemeKind)] = + &[("studioLight", ThemeKind::StudioLight), ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita), ("breezeLight", ThemeKind::BreezeLight)]; +const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ + ("proMedium", ThemeKind::ProMedium), + ("pro", ThemeKind::Pro), + ("studio", ThemeKind::Studio), + ("solarizedDark", ThemeKind::SolarizedDark), + ("adwaitaDark", ThemeKind::AdwaitaDark), + ("breezeDark", ThemeKind::BreezeDark), +]; + +fn favorite_theme_groups(favorites: &[SavedTheme]) -> (Vec, Vec) { + let group = |options: &[(&str, ThemeKind)]| { + options.iter().filter_map(|(id, kind)| SavedTheme::parse(id).filter(|theme| favorites.contains(theme)).map(|_| *kind)).collect() + }; + (group(LIGHT_THEME_OPTIONS), group(DARK_THEME_OPTIONS)) +} + +/// Right-click menu on the top-right appearance button. A plain click still cycles modes. +pub(crate) fn appearance_context_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if ui.button(tl!("Appearance Settings…")).clicked() { + open_preferences(app, "interface"); + ui.close(); + } + let (light, dark) = favorite_theme_groups(&app.session.prefs().interface.favorite_themes); + let has_light = !light.is_empty(); + if !light.is_empty() || !dark.is_empty() { + ui.separator(); + } + for kind in light { + if ui.selectable_label(app.ui.theme == kind, tl!(kind.label())).clicked() { + app.set_theme(ui.ctx(), kind); + ui.close(); + } + } + if has_light && !dark.is_empty() { + ui.separator(); + } + for kind in dark { + if ui.selectable_label(app.ui.theme == kind, tl!(kind.label())).clicked() { + app.set_theme(ui.ctx(), kind); + ui.close(); + } + } +} + fn system_theme(app: &PhotocraftApp, ctx: &egui::Context) -> Option { app.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) } @@ -1201,7 +1247,7 @@ fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { 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) { +fn theme_card(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, favorites: &mut Vec, options: &[(&str, ThemeKind)], width: f32) { let Some((_, first_kind)) = options.first() else { return }; let t = Tokens::get(ui.ctx()); egui::Frame::new() @@ -1225,7 +1271,37 @@ fn theme_card(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut Strin ui.add_space(6.0); for (id, _) in options { let label = choice_label(id); - ui.radio_value(selected, (*id).to_string(), tl!(&label)); + let row = egui::Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + ui.radio_value(selected, (*id).to_string(), tl!(&label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(theme) = SavedTheme::parse(id) { + let favorite = favorites.contains(&theme); + let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; + let star = egui::Button::image(crate::icons::image(icon, 15.0, tint)).frame(false); + let response = ui.add_visible(favorite || hovered, star).on_hover_text(if favorite { + tl!("Remove from favorites") + } else { + tl!("Add to favorites") + }); + response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!("{}: {}", if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }, tl!(&label)), + ) + }); + if response.clicked() { + if favorite { + favorites.retain(|saved| *saved != theme); + } else { + favorites.push(theme); + } + } + } + }); + }); } }); } @@ -1246,44 +1322,20 @@ fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Opti 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(); + let mut favorites: Vec = obj.get("favoriteThemes").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); 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), - ("breezeLight", ThemeKind::BreezeLight), - ], - width, - ); + theme_card(ui, "Light Theme", light_active, &mut light, &mut favorites, LIGHT_THEME_OPTIONS, 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), - ("breezeDark", ThemeKind::BreezeDark), - ], - width, - ); + theme_card(ui, "Dark Theme", !light_active, &mut dark, &mut favorites, DARK_THEME_OPTIONS, width); }); }); obj.insert("lightTheme".into(), json!(light)); obj.insert("darkTheme".into(), json!(dark)); + obj.insert("favoriteThemes".into(), json!(favorites)); ui.add_space(12.0); } @@ -1326,7 +1378,7 @@ fn section_fields( } // Settings nothing reads yet stay out of the dialog (issue #204); their stored values // pass through untouched. - if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme")) + if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) || prefs::is_hidden(&path) || (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode")) || (section == "export" && !export_field_visible(obj, &k)) @@ -2161,6 +2213,29 @@ mod tests { assert_eq!(reloaded.lock().unwrap().as_ref(), Some(&saved)); } + #[test] + fn theme_star_saves_favorite_and_menu_groups_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, _) = app_with_store(); + open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + h.get_by_label("Classic").hover(); + h.run_steps(2); + h.get_by_label("Add to favorites: Classic").click(); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(2); + assert_eq!(h.state().session.prefs().interface.favorite_themes, vec![SavedTheme::Classic]); + assert!(h.query_by_label("Remove from favorites: Classic").is_some()); + let (light, dark) = favorite_theme_groups(&[SavedTheme::Studio, SavedTheme::Classic, SavedTheme::Adwaita]); + assert_eq!(light, vec![ThemeKind::Classic, ThemeKind::Adwaita]); + assert_eq!(dark, vec![ThemeKind::Studio]); + } + /// Every generated preference label, section title and choice label has an entry in each /// language that claims complete menus (Japanese, Traditional Chinese, ...). #[test] @@ -2175,6 +2250,9 @@ mod tests { } let Some(obj) = v.get(sec).and_then(Value::as_object) else { continue }; for k in obj.keys() { + if sec == "interface" && k == "favoriteThemes" { + continue; // Rendered as star controls beside the theme choices. + } let mut labels = vec![humanize(k)]; labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c))); missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l))); diff --git a/docs/scorecard.md b/docs/scorecard.md index 67789593e5..9e09010f8c 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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, 16 not run, 6 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 21 | 17 | 4 | | -| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 43 of 151 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 43 of 152 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -175,7 +175,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 43 of 151** (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: 43 of 152** (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.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `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`, `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_.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`.