mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 02:02:04 +01:00
* 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:
co-authored by
Claude Opus 5.5
parent
2e242bde67
commit
523f1f06bc
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user