mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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 <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
f9c5486ffe
commit
e44d96e1d4
+1
-1
@@ -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) | <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`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.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/*.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 | <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/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 |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,13 @@
|
||||
<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"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
@@ -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<Theme>,
|
||||
/// 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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Μοντέλο
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 973 and column 18.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2510 and column 53.
|
@@ -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 モデル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 제조사
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3019 and column 53.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Модель
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Модель
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1329 and column 18.
|
@@ -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 制造商
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2448 and column 53.
|
@@ -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 製造商
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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")),
|
||||
|
||||
@@ -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).
|
||||
|
||||
+110
-32
@@ -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<ThemeKind>, Vec<ThemeKind>) {
|
||||
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<egui::Theme> {
|
||||
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<SavedTheme>, 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<String, Value>, 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<SavedTheme> = 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)));
|
||||
|
||||
+2
-2
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user