diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9ee090d830..037363d1dc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 649daa5d86..8be365543c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ab76e42789..6f2b9bb951 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5de43b5b45..e2284c70f4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4925a59070..c51aa6aa84 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ab85d1dfd2..4e9a4fbe3c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6e8de38ba7..10d4ef2053 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d27912a1b1..7358e966e6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffde2fd5e4..f5d6104ea7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 キーを押しながらクリック、またはカンバス外へドラッグで削除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 038888f0a9..0547c56181 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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-클릭 또는 캔버스 밖으로 드래그하여 제거 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 22adb12a20..66db0dba6a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a366eaba0f..d8df0afc9d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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ąć diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9a112170e6..5f76c76498 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 697bbd9c5a..b99fed9343 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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-щелчок или перетаскивание за пределы холста для удаления diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 71ea488cc8..b2da769bf1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2a69c36bbc..e499fa162a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dd75907d14..8bb409d8c1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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-клацання або перетягування за межі полотна для видалення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 035743f344..72ce77b630 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b8157e0669..f57dee7f2b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 单击或拖到画布外以移除 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e3a6a4b4fa..ec32d0dbd0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 按一下或拖曳至畫布外以移除 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index f1da8775a9..997bc3be36 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1301,133 +1301,170 @@ fn export_field_visible(obj: &Map, 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) { + 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, 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, 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, system: Option) { - 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, system: Option, 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 = 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, keys: &[String], lang: crate::i18n::Lang, system: Option, 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::>(); + 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, Vec) = 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, 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, 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 = 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 = 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 = 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, keys: Vec, 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 = 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]