Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532) (#2589)

* Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532)

Replace the ~300 px theme preview cards with Color Theme swatches (a light
and a dark row, since we follow the system appearance) and group the page
like Photoshop: Appearance (mode, themes, canvas colour and border),
Presentation (UI language, font size, scaling) and Options (checkboxes).
At 1000×750 the language now shows without scrolling, in Photoshop's
place rather than moved to the top.

The swatch rows come from LIGHT_THEME_OPTIONS / DARK_THEME_OPTIONS, so
Breeze Light and Breeze Dark show up. Each swatch keeps the favourite star
(#2445) as a badge on its corner, shown on hover and kept for a favourite,
with the same "Add to favorites: <theme>" / "Remove from favorites: <theme>"
labels. Preferences search filters the grouped page: rows that don't match
are hidden and a group left empty is skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Preferences: rustfmt the grouped Interface page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tests: 32-bit destructive Levels keep the HDR range (main is red since #2592)

#2592 made float Levels keep values outside 0..1 and updated compose's
levels_dont_clip_in_32_bit, but the engine-side copy still expected 0 and 255
at 32 bits, so test (ubuntu/windows) and corpus tests fail on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1ebaf7612e)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-13 12:41:08 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2e242bde67
commit 523f1f06bc
21 changed files with 354 additions and 230 deletions
+1
View File
@@ -3510,6 +3510,7 @@ curves Smooth Vyhladit
Paint over hair or fur on the image; Alt paints the other mode Malujte přes vlasy nebo srst v obraze; Alt maluje v opačném režimu
Clear Brush Strokes Vymazat tahy štětce
Show rulers Zobrazit pravítka
Presentation Prezentace
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2702,6 +2702,7 @@ curves Smooth Glätten
Paint over hair or fur on the image; Alt paints the other mode Über Haare oder Fell im Bild malen; Alt malt im anderen Modus
Clear Brush Strokes Pinselstriche löschen
Show rulers Lineale anzeigen
Presentation Präsentation
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3758,6 +3758,7 @@ curves Smooth Εξομάλυνση
Paint over hair or fur on the image; Alt paints the other mode Βάψτε πάνω σε μαλλιά ή γούνα στην εικόνα· το Alt βάφει με την άλλη λειτουργία
Clear Brush Strokes Απαλοιφή πινελιών
Show rulers Εμφάνιση χαράκων
Presentation Παρουσίαση
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση
Can't render this file because it contains an unexpected character in line 1029 and column 18.
+1
View File
@@ -2743,6 +2743,7 @@ curves Smooth Suavizar
Paint over hair or fur on the image; Alt paints the other mode Pinte sobre el pelo o el pelaje de la imagen; Alt pinta en el otro modo
Clear Brush Strokes Borrar trazos de pincel
Show rulers Mostrar reglas
Presentation Presentación
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -3076,6 +3076,7 @@ curves Smooth Lisser
Paint over hair or fur on the image; Alt paints the other mode Peignez sur les cheveux ou la fourrure dans l'image ; Alt peint dans l'autre mode
Clear Brush Strokes Effacer les traits de pinceau
Show rulers Afficher les règles
Presentation Présentation
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1715,6 +1715,7 @@ layerLabel Magenta Bíbor
Always use pressure for opacity Mindig nyomásfüggő átlátszatlanság
Always use pressure for size Mindig nyomásfüggő méret
Appearance Mode Megjelenési mód
Presentation Megjelenítés
Auto Erase Automatikus radírozás
Auto Hide Automatikus elrejtés
Auto-Select: Automatikus kijelölés:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3063,6 +3063,7 @@ curves Smooth Haluskan
Paint over hair or fur on the image; Alt paints the other mode Lukis di atas rambut atau bulu pada gambar; Alt melukis dalam mode lainnya
Clear Brush Strokes Hapus Goresan Kuas
Show rulers Tampilkan Penggaris
Presentation Presentasi
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -2731,6 +2731,7 @@ curves Smooth Leviga
Paint over hair or fur on the image; Alt paints the other mode Dipingi su capelli o pelo nell'immagine; Alt dipinge nell'altra modalità
Clear Brush Strokes Cancella tracce del pennello
Show rulers Mostra righelli
Presentation Presentazione
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dal quadro per rimuovere
Can't render this file because it contains an unexpected character in line 2639 and column 53.
+1
View File
@@ -3669,6 +3669,7 @@ curves Smooth スムーズ
Paint over hair or fur on the image; Alt paints the other mode 画像の髪や毛の上をペイントします。Alt で反対のモードでペイント
Clear Brush Strokes ブラシストロークを消去
Show rulers 定規を表示
Presentation プレゼンテーション
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3248,6 +3248,7 @@ curves Smooth 매끄럽게
Paint over hair or fur on the image; Alt paints the other mode 이미지의 머리카락이나 털 위를 칠합니다. Alt는 다른 모드로 칠합니다
Clear Brush Strokes 브러시 획 지우기
Show rulers 눈금자 표시
Presentation 프레젠테이션
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거
Can't render this file because it contains an unexpected character in line 3152 and column 53.
+1
View File
@@ -3820,6 +3820,7 @@ curves Smooth Vloeiend maken
Include in playback Opnemen in afspelen
Scale: Schaal:
Snap to Origin Uitlijnen op oorsprong
Presentation Presentatie
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -3672,6 +3672,7 @@ curves Smooth Wygładź
Paint over hair or fur on the image; Alt paints the other mode Maluj po włosach lub sierści na obrazie; Alt maluje w drugim trybie
Clear Brush Strokes Wyczyść pociągnięcia pędzla
Show rulers Pokaż linijki
Presentation Prezentacja
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3081,6 +3081,7 @@ curves Smooth Suavizar
Paint over hair or fur on the image; Alt paints the other mode Pinte sobre cabelo ou pelo na imagem; Alt pinta no outro modo
Clear Brush Strokes Limpar traços do pincel
Show rulers Mostrar réguas
Presentation Apresentação
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3005,6 +3005,7 @@ curves Smooth Сгладить
Select the crop presets to delete Выберите наборы параметров кадрирования для удаления
Enter a name for the crop preset Введите имя набора параметров кадрирования
There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения
Presentation Представление
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2892,6 +2892,7 @@ fontWeight Italic Kurziv
Auto Hide Notifications Automatski sakrij obaveštenja
Notification Duration (seconds) Trajanje obaveštenja (sekunde)
Appearance Mode Režim izgleda
Presentation Prikaz
Dark Theme Tamna tema
Light Theme Svetla tema
Pro Medium Pro srednja
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3637,6 +3637,7 @@ cameraRaw Whites Beyazlar
Auto Hide Notifications Bildirimleri Otomatik Gizle
Notification Duration (seconds) Bildirim Süresi (saniye)
Appearance Mode Görünüm Modu
Presentation Sunum
Next Appearance Mode Sonraki Görünüm Modu
Sync with system Sistemle senkronizasyon
Active Aktif
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -3758,6 +3758,7 @@ curves Smooth Згладити
There is no crop size or ratio to save Немає розміру чи співвідношення сторін кадрування для збереження
Scale: Масштаб:
Snap to Origin Прив'язати до початку координат
Presentation Представлення
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення
Can't render this file because it contains an unexpected character in line 1384 and column 18.
+1
View File
@@ -3608,6 +3608,7 @@ curves Smooth Làm mượt
Action playback stopped Đã dừng phát hành động
Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó
@plural {n} action|{n} actions {n} hành động
Presentation Trình bày
Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc
Import .GRD Nhập .GRD
Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd).
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2683,6 +2683,7 @@ curves Smooth 平滑
Select the crop presets to delete 选择要删除的裁剪预设
Enter a name for the crop preset 输入裁剪预设的名称
There is no crop size or ratio to save 没有可保存的裁剪大小或比例
Presentation 演示
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除
Can't render this file because it contains an unexpected character in line 2585 and column 53.
+1
View File
@@ -2653,6 +2653,7 @@ curves Smooth 平滑
Select the crop presets to delete 選取要刪除的裁切預設集
Enter a name for the crop preset 輸入裁切預設集的名稱
There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例
Presentation 呈現
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除
Can't render this file because it contains an unexpected character in line 6 and column 47.
+334 -230
View File
@@ -1301,133 +1301,170 @@ fn export_field_visible(obj: &Map<String, Value>, key: &str) -> bool {
}
}
fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
/// One theme as a Color Theme swatch: its panel colour under a chrome strip with an accent dot,
/// outlined when chosen. The name is the tooltip and the accessible label. A star badge on its
/// corner adds the theme to (or removes it from) the favourites listed by the appearance
/// button's menu; it shows while the swatch is hovered, and stays for a favourite.
fn theme_swatch(ui: &mut egui::Ui, kind: ThemeKind, id: &str, selected: &mut String, favorites: &mut Vec<SavedTheme>) {
const BADGE: f32 = 7.0;
let t = Tokens::get(ui.ctx());
let p = Tokens::for_kind(kind);
let (rect, _) = ui.allocate_exact_size(vec2(width, 108.0), Sense::hover());
let label = tl!(&choice_label(id)).to_string();
let on = selected.as_str() == id;
let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 26.0), Sense::click());
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 inner = rect.shrink(3.0);
painter.rect_filled(inner, 2.0, p.dock);
painter.rect_filled(egui::Rect::from_min_size(inner.min, vec2(inner.width(), 5.0)), 0.0, p.chrome);
painter.circle_filled(inner.right_bottom() - vec2(6.0, 6.0), 2.5, p.accent);
let stroke = if on {
egui::Stroke::new(2.0, t.accent)
} else if resp.hovered() {
egui::Stroke::new(1.0, t.text_dim)
} else {
egui::Stroke::new(1.0, t.card_border)
};
painter.rect_stroke(rect.shrink(1.0), 3.0, stroke, egui::StrokeKind::Inside);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, &label));
if resp.on_hover_text(&label).clicked() {
*selected = id.to_string();
}
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 Some(theme) = SavedTheme::parse(id) else { return };
let favorite = favorites.contains(&theme);
let center = rect.right_top() + vec2(-3.0, 3.0);
let badge = egui::Rect::from_center_size(center, vec2(BADGE * 2.0, BADGE * 2.0));
if !(favorite || ui.rect_contains_pointer(rect.union(badge))) {
return;
}
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);
ui.painter().circle(center, BADGE, t.card, egui::Stroke::new(1.0, t.card_border));
let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) };
let action = if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") };
let star = egui::Button::image(crate::icons::image(icon, 10.0, tint)).frame(false);
let response = ui.put(badge, star).on_hover_text(action);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action}: {label}")));
if response.clicked() {
if favorite {
favorites.retain(|saved| *saved != theme);
} else {
favorites.push(theme);
}
}
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, favorites: &mut Vec<SavedTheme>, options: &[(&str, ThemeKind)], width: f32) {
let Some((_, first_kind)) = options.first() else { return };
/// A grid row of theme swatches, marked Active when the appearance mode shows this family.
fn theme_row(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, favorites: &mut Vec<SavedTheme>, options: &[(&str, ThemeKind)]) {
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 (six dark themes), so both cards line up.
ui.set_min_height(298.0);
ui.horizontal(|ui| {
ui.label(RichText::new(tl!(title)).strong().color(t.text));
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);
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);
}
}
}
});
});
}
});
}
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.label(RichText::new(tl!(title)).color(t.text_dim));
ui.horizontal(|ui| {
// Room for the favourite star badges on the swatch corners.
ui.spacing_mut().item_spacing.x = 6.0;
for (id, kind) in options {
theme_swatch(ui, *kind, id, selected, favorites);
}
if active {
ui.add_space(6.0);
ui.label(RichText::new(tl!("Active")).color(t.accent_text).small());
}
});
ui.end_row();
}
/// Which of the Appearance rows drawn by `appearance_rows` a search shows.
#[derive(Clone, Copy)]
struct AppearanceRows {
mode: bool,
light: bool,
dark: bool,
}
impl AppearanceRows {
/// `query` is lowercased; empty shows every row.
fn matching(query: &str) -> Self {
let shown = |key: &str| query.is_empty() || preference_matches("interface", key, query);
Self { mode: shown("appearanceMode"), light: shown("lightTheme"), dark: shown("darkTheme") }
}
fn any(self) -> bool {
self.mode || self.light || self.dark
}
}
/// Appearance Mode and the light and dark Color Theme swatches, as rows of the Appearance grid.
fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map<String, Value>, system: Option<egui::Theme>, rows: AppearanceRows) {
let t = Tokens::get(ui.ctx());
let mut mode = obj.get("appearanceMode").and_then(Value::as_str).unwrap_or("auto").to_string();
if rows.mode {
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);
crate::widgets::dropdown(ui, "appearance-mode", &mut mode, &pairs, 220.0);
ui.end_row();
}
let light_active = mode == "light" || (mode == "auto" && system == Some(egui::Theme::Light));
let width = ((ui.available_width() - 10.0) / 2.0).max(190.0);
obj.insert("appearanceMode".into(), json!(mode));
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, &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, &mut favorites, DARK_THEME_OPTIONS, width);
});
});
if rows.light {
theme_row(ui, "Light Theme", light_active, &mut light, &mut favorites, LIGHT_THEME_OPTIONS);
}
if rows.dark {
theme_row(ui, "Dark Theme", !light_active, &mut dark, &mut favorites, DARK_THEME_OPTIONS);
}
obj.insert("lightTheme".into(), json!(light));
obj.insert("darkTheme".into(), json!(dark));
obj.insert("favoriteThemes".into(), json!(favorites));
ui.add_space(12.0);
}
/// A titled, outlined group of settings (Photoshop's Interface page has Appearance,
/// Presentation and Options).
fn pref_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ui.ctx());
egui::Frame::new().stroke(egui::Stroke::new(1.0, t.card_border)).corner_radius(t.radius).inner_margin(10.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(tl!(title)).strong().color(t.text));
ui.add_space(4.0);
add(ui);
});
ui.add_space(8.0);
}
/// Preferences › Interface laid out like Photoshop's: the theme and canvas settings, then UI
/// language, font size and scaling, then everything else. Compact enough that the language
/// shows without scrolling in a small window (#2532). A search (`query`, lowercased) hides the
/// rows that don't match and skips a group left empty.
fn interface_groups(ui: &mut egui::Ui, obj: &mut Map<String, Value>, keys: &[String], lang: crate::i18n::Lang, system: Option<egui::Theme>, query: &str) {
const APPEARANCE: [&str; 3] = ["canvasColor", "canvasCustomColor", "canvasBorder"];
const PRESENTATION: [&str; 3] = ["language", "uiFontSize", "uiScale"];
let shown = |k: &str| keys.iter().any(|key| key == k) && field_visible("interface", obj, k, query);
// In Photoshop's order, not the stored one.
let pick = |names: &[&str]| names.iter().filter(|n| shown(n)).map(|n| n.to_string()).collect::<Vec<_>>();
let appearance = pick(&APPEARANCE);
let presentation = pick(&PRESENTATION);
let rest = keys.iter().filter(|k| !APPEARANCE.contains(&k.as_str()) && !PRESENTATION.contains(&k.as_str()) && shown(k));
// Checkboxes line up at the left edge, as in Photoshop; the other options keep a label column.
let (checks, other): (Vec<String>, Vec<String>) = rest.cloned().partition(|k| obj.get(k).is_some_and(Value::is_boolean));
let rows = AppearanceRows::matching(query);
let grid = |id: &str| egui::Grid::new(("prefs-grid", id.to_string())).num_columns(2).spacing([14.0, 7.0]).min_col_width(150.0);
if rows.any() || !appearance.is_empty() {
pref_group(ui, "Appearance", |ui| {
grid("interface-appearance").show(ui, |ui| {
appearance_rows(ui, obj, system, rows);
field_rows(ui, "interface", obj, appearance, lang);
});
});
}
if !presentation.is_empty() {
pref_group(ui, "Presentation", |ui| {
grid("interface-presentation").show(ui, |ui| field_rows(ui, "interface", obj, presentation, lang));
});
}
if !checks.is_empty() || !other.is_empty() {
pref_group(ui, "Options", |ui| {
egui::Grid::new("prefs-grid-interface-checks").num_columns(2).spacing([0.0, 7.0]).show(ui, |ui| field_rows(ui, "interface", obj, checks, lang));
grid("interface-options").show(ui, |ui| field_rows(ui, "interface", obj, other, lang));
});
}
}
/// Where a clicked colour swatch leaves its preference path and label for the dialog body, which
@@ -1439,6 +1476,22 @@ fn ctx_take_pick(ui: &egui::Ui) -> Option<(String, String)> {
ui.ctx().data_mut(|d| d.remove_temp::<(String, String)>(egui::Id::new(PICK_ID)))
}
/// Whether `section.key` gets a generic row for this search (`query`, lowercased; empty matches
/// everything). Settings nothing reads yet stay out of the dialog (issue #204), as do those drawn
/// by their own widgets; their stored values pass through untouched.
fn field_visible(section: &str, obj: &Map<String, Value>, k: &str, query: &str) -> bool {
let path = format!("{section}.{k}");
let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes"))
// View › Rulers (⌘R) sets it, as in Photoshop.
|| path == "unitsAndRulers.showRulers"
|| (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode"))
|| (section == "export" && !export_field_visible(obj, k));
(query.is_empty() || preference_matches(section, k, query))
&& !drawn_elsewhere
&& !prefs::is_hidden(&path)
&& obj.get(k).is_some_and(|v| !matches!(v, Value::Null | Value::Object(_)))
}
/// 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(
@@ -1450,138 +1503,128 @@ fn section_fields(
system: Option<egui::Theme>,
search: &str,
) {
let t = Tokens::get(ui.ctx());
let query = search.to_lowercase();
if section == "interface" && (query.is_empty() || ["appearanceMode", "darkTheme", "lightTheme"].iter().any(|key| preference_matches(section, key, &query)))
{
appearance_rows(ui, obj, system);
let mut keys: Vec<String> = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect();
keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned());
if section == "interface" {
interface_groups(ui, obj, &keys, lang, system, &query);
return;
}
if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) {
rendering_mode_row(ui, obj);
}
let mut keys: Vec<String> = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect();
keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned());
egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| {
for k in keys {
let path = format!("{section}.{k}");
if !query.is_empty() && !preference_matches(section, &k, &query) {
continue;
}
// 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" | "favoriteThemes"))
// View › Rulers (⌘R) sets it, as in Photoshop.
|| path == "unitsAndRulers.showRulers"
|| prefs::is_hidden(&path)
|| (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode"))
|| (section == "export" && !export_field_visible(obj, &k))
{
continue;
}
let v = obj.get(&k).cloned().unwrap_or(Value::Null);
let human = humanize(&k);
let label = tl!(&human).to_string();
match &v {
Value::Bool(b) => {
ui.label("");
let mut b = *b;
if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
ui.vertical(|ui| {
crate::widgets::checkbox(ui, &mut b, &label);
ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim));
});
} else {
crate::widgets::checkbox(ui, &mut b, &label);
}
obj.insert(k, json!(b));
}
Value::String(s) if path == "interface.language" => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))];
pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name())));
let mut cur = s.clone();
crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0);
obj.insert(k, json!(cur));
}
Value::String(s) if prefs::choices(&path).is_some() => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let opts = prefs::choices(&path).unwrap_or(&[]);
let labels: Vec<String> = opts.iter().map(|o| choice_label(o)).collect();
let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect();
let mut cur = s.clone();
crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0);
obj.insert(k, json!(cur));
}
Value::String(s) if prefs::is_color(&path) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]);
let mut rgb = c;
ui.horizontal(|ui| {
// PhotoCraft's Color Picker, as everywhere else (#2144).
if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone())));
}
hex_field(ui, &path, &mut rgb);
});
obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
let _ = color_of(s);
}
Value::String(s) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let mut s = s.clone();
ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0));
obj.insert(k, json!(s));
}
Value::Number(n) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let (lo, hi) = prefs::range(&path).unwrap_or((-1e9, 1e9));
if n.is_u64() || n.is_i64() {
let mut x = n.as_i64().unwrap_or(0);
ui.add(egui::DragValue::new(&mut x).range(lo as i64..=hi as i64).custom_parser(crate::widgets::parse_num));
obj.insert(k, json!(x));
} else {
let mut x = n.as_f64().unwrap_or(0.0);
ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num));
obj.insert(k, json!(x));
}
}
Value::Array(_) if path == "tools.pressureCurve" => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
if let Some(next) = crate::pressure_curve_ui::editor(ui, &v) {
obj.insert(k, next);
}
}
Value::Array(items) if k == "disks" => {
ui.label(RichText::new(tl!("Scratch disks")).color(t.text_dim));
let mut items = items.clone();
keys.retain(|k| field_visible(section, obj, k, &query));
egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang));
}
/// One grid row per setting in `keys` (already filtered by `field_visible`).
fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, keys: Vec<String>, lang: crate::i18n::Lang) {
let t = Tokens::get(ui.ctx());
for k in keys {
let path = format!("{section}.{k}");
let v = obj.get(&k).cloned().unwrap_or(Value::Null);
let human = humanize(&k);
let label = tl!(&human).to_string();
match &v {
Value::Bool(b) => {
ui.label("");
let mut b = *b;
if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
ui.vertical(|ui| {
for d in &mut items {
ui.horizontal(|ui| {
let mut on = d.get("enabled").and_then(Value::as_bool).unwrap_or(false);
let mut path = d.get("path").and_then(Value::as_str).unwrap_or("").to_string();
crate::widgets::checkbox(ui, &mut on, "");
ui.add(egui::TextEdit::singleline(&mut path).desired_width(240.0));
*d = json!({"enabled": on, "path": path});
});
}
if ui.small_button(tl!("Add disk")).clicked() {
items.push(json!({"enabled": true, "path": ""}));
}
crate::widgets::checkbox(ui, &mut b, &label);
ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim));
});
obj.insert(k, Value::Array(items));
} else {
crate::widgets::checkbox(ui, &mut b, &label);
}
Value::Array(items) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
ui.label(RichText::new(crate::i18n::trn(lang, items.len() as u64, "{n} item", "{n} items")).color(t.text_faint));
if !items.is_empty() && ui.small_button(tl!("Clear")).clicked() {
obj.insert(k, json!([]));
}
}
_ => continue,
obj.insert(k, json!(b));
}
ui.end_row();
Value::String(s) if path == "interface.language" => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))];
pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name())));
let mut cur = s.clone();
crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0);
obj.insert(k, json!(cur));
}
Value::String(s) if prefs::choices(&path).is_some() => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let opts = prefs::choices(&path).unwrap_or(&[]);
let labels: Vec<String> = opts.iter().map(|o| choice_label(o)).collect();
let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect();
let mut cur = s.clone();
crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0);
obj.insert(k, json!(cur));
}
Value::String(s) if prefs::is_color(&path) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]);
let mut rgb = c;
ui.horizontal(|ui| {
// PhotoCraft's Color Picker, as everywhere else (#2144).
if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone())));
}
hex_field(ui, &path, &mut rgb);
});
obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
let _ = color_of(s);
}
Value::String(s) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let mut s = s.clone();
ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0));
obj.insert(k, json!(s));
}
Value::Number(n) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let (lo, hi) = prefs::range(&path).unwrap_or((-1e9, 1e9));
if n.is_u64() || n.is_i64() {
let mut x = n.as_i64().unwrap_or(0);
ui.add(egui::DragValue::new(&mut x).range(lo as i64..=hi as i64).custom_parser(crate::widgets::parse_num));
obj.insert(k, json!(x));
} else {
let mut x = n.as_f64().unwrap_or(0.0);
ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num));
obj.insert(k, json!(x));
}
}
Value::Array(_) if path == "tools.pressureCurve" => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
if let Some(next) = crate::pressure_curve_ui::editor(ui, &v) {
obj.insert(k, next);
}
}
Value::Array(items) if k == "disks" => {
ui.label(RichText::new(tl!("Scratch disks")).color(t.text_dim));
let mut items = items.clone();
ui.vertical(|ui| {
for d in &mut items {
ui.horizontal(|ui| {
let mut on = d.get("enabled").and_then(Value::as_bool).unwrap_or(false);
let mut path = d.get("path").and_then(Value::as_str).unwrap_or("").to_string();
crate::widgets::checkbox(ui, &mut on, "");
ui.add(egui::TextEdit::singleline(&mut path).desired_width(240.0));
*d = json!({"enabled": on, "path": path});
});
}
if ui.small_button(tl!("Add disk")).clicked() {
items.push(json!({"enabled": true, "path": ""}));
}
});
obj.insert(k, Value::Array(items));
}
Value::Array(items) => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
ui.label(RichText::new(crate::i18n::trn(lang, items.len() as u64, "{n} item", "{n} items")).color(t.text_faint));
if !items.is_empty() && ui.small_button(tl!("Clear")).clicked() {
obj.insert(k, json!([]));
}
}
_ => continue,
}
});
ui.end_row();
}
}
/// Keyboard Shortcuts and Menus: a searchable list of commands by menu with editable shortcuts
@@ -2349,6 +2392,67 @@ mod tests {
assert_eq!(dark, vec![ThemeKind::Studio]);
}
#[test]
fn preferences_interface_shows_language_without_scrolling() {
// #2532: in a small window the theme cards filled the page and Language sat out of view
// below them. Like Photoshop, Presentation follows Appearance and both fit.
use egui_kittest::{Harness, kittest::Queryable};
let (mut app, _) = app_with_store();
app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap();
open_preferences(&mut app, "interface");
let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
app
});
h.run_steps(4);
let language = h.get_by_label("Language").rect();
let dark = h.get_by_label("Dark Theme").rect();
let ok = h.get_by_label("OK").rect();
assert!(dark.bottom() < language.top(), "Presentation {language:?} follows the theme swatches {dark:?}");
assert!(language.bottom() < ok.top() - 20.0, "Language {language:?} must show above the buttons {ok:?} without scrolling");
// Every theme in the option lists gets a swatch, Breeze included.
for (id, _) in LIGHT_THEME_OPTIONS.iter().chain(DARK_THEME_OPTIONS) {
let label = choice_label(id);
assert!(h.query_by_label(&label).is_some(), "missing theme swatch {label}");
}
assert!(h.query_by_label("Breeze light").is_some() && h.query_by_label("Breeze dark").is_some());
}
#[test]
fn preferences_interface_search_hides_rows_and_skips_empty_groups() {
use egui_kittest::{Harness, kittest::Queryable};
let render = |search: &str| {
let (mut app, _) = app_with_store();
app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap();
let id = open_preferences(&mut app, "interface");
app.ui.dialog_mut(id).unwrap().fields.insert("__search".into(), json!(search));
let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
app
});
h.run_steps(4);
h
};
let shown = |h: &Harness<'_, PhotocraftApp>, label: &str| h.query_all_by_label(label).next().is_some();
let h = render("language");
assert!(shown(&h, "Language") && shown(&h, "Presentation"));
for gone in ["Appearance", "Appearance Mode", "Dark Theme", "Options", "UI scale"] {
assert!(!shown(&h, gone), "\"language\" must not show {gone}");
}
let h = render("theme");
assert!(shown(&h, "Appearance") && shown(&h, "Light Theme") && shown(&h, "Dark Theme") && shown(&h, "Breeze dark"));
for gone in ["Appearance Mode", "Presentation", "Language", "Options"] {
assert!(!shown(&h, gone), "\"theme\" must not show {gone}");
}
let h = render("");
for group in ["Appearance", "Appearance Mode", "Presentation", "Options", "UI scale"] {
assert!(shown(&h, group), "an empty search shows {group}");
}
}
/// Every generated preference label, section title and choice label has an entry in each
/// language that claims complete menus (Japanese, Traditional Chinese, ...).
#[test]