UI.9: preserve PhotoCraft controls in shared UI migration

This commit is contained in:
skylar taylor-barrick committed 2026-10-10 12:51:56 -06:00
1 parent 3e000bc1d5
commit 99a3970399
51 files changed
+2818 -323

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
Generated
+10
View File
@@ -1121,6 +1121,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f#b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -4554,6 +4563,7 @@ name = "photocraft-ui-egui"
version = "0.6.0"
dependencies = [
"base64",
"craft-ui",
"eframe",
"egui",
"egui_extras",
+1
View File
@@ -10,6 +10,7 @@ license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
+1 -1
View File
@@ -377,7 +377,7 @@ impl ColorState {
let size = size.max(2);
let pv = self.proof(doc.id);
let display = self.canvas_display_for(doc, on)?;
if !pv.enabled && !pv.gamut_warning && !(hdr && crate::proof_sim::hdr_active(self, doc)) {
if !(pv.enabled || pv.gamut_warning || hdr && crate::proof_sim::hdr_active(self, doc)) {
return Ok(display.transform.as_ref().map(|t| Lut3d::from_transform(t, size).to_rgba8()));
}
let lut = self.display_lut_with(doc, size, hdr, on)?;
+2 -2
View File
@@ -331,7 +331,7 @@ fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rec
let mut bytes = vec![0u8; bpp];
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), &mut bytes);
// Keeping alpha: copy the colour samples only (alpha is the last sample).
let keep = if (keep_alpha || !fmt.alpha) && fmt.alpha { bpp - bpp / n.max(1) } else { bpp };
let keep = if keep_alpha && fmt.alpha { bpp - bpp / n.max(1) } else { bpp };
Some((bytes, keep))
}
_ => None,
@@ -442,7 +442,7 @@ mod tests {
assert!(blacks > 300 && blacks < 900, "{blacks}");
assert!((0..40).all(|x| {
let v = surf.rgba(x, 3)[0];
v < 0.01 || v > 0.99
!v.is_nan() && !(0.01..=0.99).contains(&v)
}));
}
+1 -1
View File
@@ -757,7 +757,7 @@ mod tests {
let surf = st.doc.layers[0].surface().unwrap();
for (x, y) in [(0, 0), (1, 1), (2, 3)] {
let v = surf.rgba(x, y)[0];
assert!(v < 0.01 || v > 0.99, "nearest produced an averaged value {v} at ({x},{y})");
assert!(!v.is_nan() && !(0.01..=0.99).contains(&v), "nearest produced an averaged value {v} at ({x},{y})");
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ impl SampleLayers {
/// centred on the pixel under the point, clipped to `canvas`. `None` when the point is off the
/// canvas (or not finite), or `size` is not one of [`SAMPLE_SIZES`].
pub fn sample_rect(x: f64, y: f64, size: u32, canvas: Rect) -> Option<Rect> {
if !(x.is_finite() && y.is_finite()) || !SAMPLE_SIZES.contains(&size) {
if !(x.is_finite() && y.is_finite() && SAMPLE_SIZES.contains(&size)) {
return None;
}
let (fx, fy) = (x.floor(), y.floor());
+1 -1
View File
@@ -68,7 +68,7 @@ pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] {
"zh" | "yue" => {
let hant = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo"));
let hans = rest.iter().any(|p| matches!(*p, "hans" | "cn" | "sg"));
if hant && !hans || lang == "yue" && !hans {
if (hant || lang == "yue") && !hans {
[TraditionalChinese, SimplifiedChinese, Japanese, Korean]
} else {
[SimplifiedChinese, TraditionalChinese, Japanese, Korean]
+1
View File
@@ -8,6 +8,7 @@ license.workspace = true
workspace = true
[dependencies]
craft-ui = { workspace = true }
photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
photocraft-cms = { workspace = true }
+11
View File
@@ -466,6 +466,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
None => None,
};
let bridge_pro = matches!(theme.unwrap_or(app.ui.theme), crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium);
let legacy_update = crate::panel_docking::prepare_legacy_update(app, p, panels.as_ref(), dock_tabs.as_ref(), dock.as_ref(), bridge_pro)?;
// Apply (nothing below can fail).
if let Some(stroke) = shape_stroke {
if let Some(stroke) = stroke {
@@ -534,6 +537,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(v) = dock {
app.ui.dock = v;
}
if let Some(update) = legacy_update {
crate::panel_docking::commit_legacy_update(app, update, p);
}
if let Some(v) = color_panel {
app.ui.color_panel = v;
}
@@ -988,6 +994,11 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
})
}),
"panels": app.ui.panels,
"dockTabs": app.ui.dock_tabs,
"dock": app.ui.dock,
"docking": app.ui.docking,
"dockingHidden": app.ui.docking_hidden,
"dockingCollapsed": app.ui.docking_collapsed,
"view": app.ui.view,
"views": app.ui.views,
"dialogs": dialogs,
+1 -1
View File
@@ -645,7 +645,7 @@ pub fn turn_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> {
Some(_) => false,
None => hit_turned(r, deg, p, tolerance(app)) == Hit::Outside,
};
if !turning || !(p[0].is_finite() && p[1].is_finite()) {
if !(turning && p[0].is_finite() && p[1].is_finite()) {
return None;
}
let c = center(r);
+17 -3
View File
@@ -115,7 +115,7 @@ impl Group {
matches!((self, tab), (Group::Layers, 0) | (Group::History, 0))
}
fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize {
pub(crate) fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize {
match self {
Group::Color => &mut tabs.color,
Group::Properties => &mut tabs.properties,
@@ -142,7 +142,7 @@ impl Group {
}
}
fn shown_mut(self, panels: &mut Panels) -> &mut bool {
pub(crate) fn shown_mut(self, panels: &mut Panels) -> &mut bool {
match self {
Group::Color => &mut panels.color,
Group::Properties => &mut panels.properties,
@@ -426,6 +426,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
.show(ui, |ui| body(app, ui, g, tab));
}
});
for (index, response) in &resp.responses {
if let Some(panel) = tabs.get(*index) {
crate::panel_docking::legacy_tab(app, &mut child, panel, response);
}
}
strips.push(StripRects {
group: g,
tabs: resp.tabs.iter().filter_map(|(i, r)| indices.get(*i).map(|original| (*original, *r))).collect(),
@@ -473,6 +478,9 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
crate::swatches_ui::panel_menu(app, ui);
ui.separator();
}
if let Some(label) = tabs.get(sel) {
crate::panel_docking::legacy_menu(app, ui, label);
}
if ui.button(if collapsed { tl!("Expand Panel Group") } else { tl!("Collapse Panel Group") }).clicked() {
actions.push(Action::ToggleCollapse(g));
ui.close();
@@ -600,7 +608,7 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32)
/// What `prefs.panelLayout` holds: the live layout and open panels.
fn snapshot(app: &PhotocraftApp) -> Value {
json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open})
json!({"workspace": app.ui.workspace, "panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingHidden": app.ui.docking_hidden, "dockingCollapsed": app.ui.docking_collapsed})
}
/// Remember the layout in the preferences once the user lets go of the mouse (Workspace ›
@@ -631,6 +639,11 @@ pub fn restore(app: &mut PhotocraftApp) {
/// (a workspace or `panelLayout`). Missing or invalid dock parts are left alone; old
/// layouts without Timeline visibility restore it closed.
pub fn apply(app: &mut PhotocraftApp, v: &Value) {
app.ui.docking_collapsed = v.get("dockingCollapsed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
app.ui.docking_reveal = None;
app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1);
app.ui.docking_hidden = v.get("dockingHidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
app.ui.docking = v.get("docking").and_then(|d| serde_json::from_value(d.clone()).ok()).filter(crate::panel_docking::valid);
if let Some(p) = v.get("panels").and_then(|p| serde_json::from_value(p.clone()).ok()) {
app.ui.panels = p;
}
@@ -640,6 +653,7 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) {
if let Some(d) = v.get("dock").and_then(|d| serde_json::from_value(d.clone()).ok()) {
app.ui.dock = d;
}
crate::panel_docking::sync_visibility(app);
app.ui.timeline.open = v.get("timelineOpen").and_then(Value::as_bool).unwrap_or(false);
if !app.ui.timeline.open {
app.ui.timeline.playing = false;
+1 -1
View File
@@ -257,7 +257,7 @@ fn the_pattern_is_picked_from_its_swatches() {
let sky = ids[h.state().session.patterns.items.iter().position(|p| p.name == "Sky").unwrap()].clone();
assert_ne!(pattern(&h), json!(sky), "starts on another pattern");
assert!(h.query_by_label("Sky").is_none(), "the grid opens on a click");
h.get_by_label("Patterns").click();
h.get_by_role_and_label(egui::accesskit::Role::Button, "Patterns").click();
h.run_steps(3);
h.get_by_label("Sky").click();
h.run_steps(3);
+12
View File
@@ -2406,3 +2406,15 @@ fontWeight Italic Kurzíva
Quick Selection found nothing there Rychlý výběr zde nic nenašel
Object Selection found nothing there — try a tighter rectangle Výběr objektu zde nic nenašel — zkuste těsnější obdélník
No subject found — try a rectangle around the object Nebyl nalezen žádný objekt — zkuste obdélník kolem objektu
# Shared panel docking
Activate Panel Aktivovat panel
Change Panel Group Změnit skupinu panelů
Change Panel Layout Změnit rozložení panelů
Close Panel Zavřít panel
Dock Panel Ukotvit panel
Float Panel Plovoucí panel
Move Panel Přesunout panel
Group with Seskupit s
Panel menu Nabídka panelu
Resize panel window Změnit velikost okna panelu
Resize panels Změnit velikost panelů
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -2445,3 +2445,15 @@ fontWeight Italic Kursiv
Quick Selection found nothing there Die Schnellauswahl hat dort nichts gefunden
Object Selection found nothing there — try a tighter rectangle Die Objektauswahl hat dort nichts gefunden — versuchen Sie ein engeres Rechteck
No subject found — try a rectangle around the object Kein Motiv gefunden — versuchen Sie ein Rechteck um das Objekt
# Shared panel docking
Activate Panel Bedienfeld aktivieren
Change Panel Group Bedienfeldgruppe ändern
Change Panel Layout Bedienfeldlayout ändern
Close Panel Bedienfeld schließen
Dock Panel Bedienfeld andocken
Float Panel Schwebendes Bedienfeld
Move Panel Bedienfeld verschieben
Group with Gruppieren mit
Panel menu Bedienfeldmenü
Resize panel window Bedienfeldfenstergröße ändern
Resize panels Bedienfeldgröße ändern
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -3514,3 +3514,15 @@ fontWeight Thin Πολύ λεπτά
Quick Selection found nothing there Η Γρήγορη επιλογή δεν βρήκε τίποτα εκεί
Object Selection found nothing there — try a tighter rectangle Η Επιλογή αντικειμένου δεν βρήκε τίποτα εκεί — δοκιμάστε ένα πιο στενό ορθογώνιο
No subject found — try a rectangle around the object Δεν βρέθηκε θέμα — δοκιμάστε ένα ορθογώνιο γύρω από το αντικείμενο
# Shared panel docking
Activate Panel Ενεργοποίηση πάνελ
Change Panel Group Αλλαγή ομάδας πάνελ
Change Panel Layout Αλλαγή διάταξης πάνελ
Close Panel Κλείσιμο πάνελ
Dock Panel Αγκύρωση πάνελ
Float Panel Αιωρούμενο πάνελ
Move Panel Μετακίνηση πάνελ
Group with Ομαδοποίηση με
Panel menu Μενού πάνελ
Resize panel window Αλλαγή μεγέθους παραθύρου πάνελ
Resize panels Αλλαγή μεγέθους πάνελ
Can't render this file because it contains an unexpected character in line 966 and column 18.
+12
View File
@@ -2474,3 +2474,15 @@ fontWeight Italic Cursiva
Quick Selection found nothing there Selección rápida no encontró nada ahí
Object Selection found nothing there — try a tighter rectangle Selección de objeto no encontró nada ahí — pruebe un rectángulo más ajustado
No subject found — try a rectangle around the object No se encontró ningún sujeto — pruebe un rectángulo alrededor del objeto
# Shared panel docking
Activate Panel Activar panel
Change Panel Group Cambiar grupo de paneles
Change Panel Layout Cambiar disposición de paneles
Close Panel Cerrar panel
Dock Panel Acoplar panel
Float Panel Panel flotante
Move Panel Mover panel
Group with Agrupar con
Panel menu Menú del panel
Resize panel window Cambiar tamaño de la ventana del panel
Resize panels Cambiar tamaño de los paneles
Can't render this file because it contains an unexpected character in line 4 and column 47.
+12
View File
@@ -2825,3 +2825,15 @@ fontWeight Italic Italique
Quick Selection found nothing there La Sélection rapide n'a rien trouvé à cet endroit
Object Selection found nothing there — try a tighter rectangle La Sélection d'objet n'a rien trouvé à cet endroit — essayez un rectangle plus serré
No subject found — try a rectangle around the object Aucun sujet trouvé — essayez un rectangle autour de l'objet
# Shared panel docking
Activate Panel Activer le panneau
Change Panel Group Modifier le groupe de panneaux
Change Panel Layout Modifier la disposition des panneaux
Close Panel Fermer le panneau
Dock Panel Ancrer le panneau
Float Panel Panneau flottant
Move Panel Déplacer le panneau
Group with Grouper avec
Panel menu Menu du panneau
Resize panel window Redimensionner la fenêtre du panneau
Resize panels Redimensionner les panneaux
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -2812,3 +2812,15 @@ fontWeight Italic Miring
Quick Selection found nothing there Seleksi Cepat tidak menemukan apa pun di sana
Object Selection found nothing there — try a tighter rectangle Seleksi Objek tidak menemukan apa pun di sana — coba persegi panjang yang lebih rapat
No subject found — try a rectangle around the object Subjek tidak ditemukan — coba persegi panjang di sekitar objek
# Shared panel docking
Activate Panel Aktifkan Panel
Change Panel Group Ubah Grup Panel
Change Panel Layout Ubah Tata Letak Panel
Close Panel Tutup Panel
Dock Panel Tambatkan Panel
Float Panel Panel Mengambang
Move Panel Pindahkan Panel
Group with Kelompokkan dengan
Panel menu Menu panel
Resize panel window Ubah ukuran jendela panel
Resize panels Ubah ukuran panel
Can't render this file because it contains an unexpected character in line 4 and column 47.
+12
View File
@@ -2476,3 +2476,15 @@ fontWeight Italic Corsivo
Quick Selection found nothing there Selezione rapida non ha trovato nulla lì
Object Selection found nothing there — try a tighter rectangle Selezione oggetto non ha trovato nulla lì — prova un rettangolo più stretto
No subject found — try a rectangle around the object Nessun soggetto trovato — prova un rettangolo attorno all'oggetto
# Shared panel docking
Activate Panel Attiva pannello
Change Panel Group Cambia gruppo di pannelli
Change Panel Layout Cambia disposizione dei pannelli
Close Panel Chiudi pannello
Dock Panel Aggancia pannello
Float Panel Pannello mobile
Move Panel Sposta pannello
Group with Raggruppa con
Panel menu Menu del pannello
Resize panel window Ridimensiona finestra del pannello
Resize panels Ridimensiona pannelli
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
+12
View File
@@ -3426,3 +3426,15 @@ fontWeight Italic 斜体
Quick Selection found nothing there クイック選択ではそこに何も見つかりませんでした
Object Selection found nothing there — try a tighter rectangle オブジェクト選択ではそこに何も見つかりませんでした — より狭い長方形で試してください
No subject found — try a rectangle around the object 被写体が見つかりません — オブジェクトを囲む長方形を試してください
# Shared panel docking
Activate Panel パネルをアクティブにする
Change Panel Group パネルグループを変更
Change Panel Layout パネルレイアウトを変更
Close Panel パネルを閉じる
Dock Panel パネルをドッキング
Float Panel パネルをフロート
Move Panel パネルを移動
Group with グループ化するパネル
Panel menu パネルメニュー
Resize panel window パネルウィンドウのサイズを変更
Resize panels パネルのサイズを変更
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -2984,3 +2984,15 @@ fontWeight Italic 기울임
Quick Selection found nothing there 빠른 선택으로 해당 위치에서 아무것도 찾지 못했습니다
Object Selection found nothing there — try a tighter rectangle 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요
No subject found — try a rectangle around the object 피사체를 찾을 수 없습니다 — 개체 주위에 사각형을 그려 보세요
# Shared panel docking
Activate Panel 패널 활성화
Change Panel Group 패널 그룹 변경
Change Panel Layout 패널 레이아웃 변경
Close Panel 패널 닫기
Dock Panel 패널 도킹
Float Panel 패널 띄우기
Move Panel 패널 이동
Group with 다음과 그룹화
Panel menu 패널 메뉴
Resize panel window 패널 창 크기 조절
Resize panels 패널 크기 조절
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
+12
View File
@@ -3585,3 +3585,15 @@ cameraRaw Whites Witten
Quick Selection found nothing there Snelle selectie heeft daar niets gevonden
Object Selection found nothing there — try a tighter rectangle Objectselectie heeft daar niets gevonden — probeer een krappere rechthoek
No subject found — try a rectangle around the object Geen onderwerp gevonden — probeer een rechthoek rond het object
# Shared panel docking
Activate Panel Paneel activeren
Change Panel Group Paneelgroep wijzigen
Change Panel Layout Paneelindeling wijzigen
Close Panel Paneel sluiten
Dock Panel Paneel vastzetten
Float Panel Zwevend paneel
Move Panel Paneel verplaatsen
Group with Groeperen met
Panel menu Paneelmenu
Resize panel window Paneelvenster vergroten of verkleinen
Resize panels Panelen vergroten of verkleinen
Can't render this file because it contains an unexpected character in line 8 and column 34.
+12
View File
@@ -3426,3 +3426,15 @@ cameraRaw Roughness Chropowatość
Quick Selection found nothing there Szybkie zaznaczanie nic tam nie znalazło
Object Selection found nothing there — try a tighter rectangle Zaznaczanie obiektu nic tam nie znalazło — spróbuj ciaśniejszego prostokąta
No subject found — try a rectangle around the object Nie znaleziono obiektu — spróbuj prostokąta wokół obiektu
# Shared panel docking
Activate Panel Aktywuj panel
Change Panel Group Zmień grupę paneli
Change Panel Layout Zmień układ paneli
Close Panel Zamknij panel
Dock Panel Dokuj panel
Float Panel Panel pływający
Move Panel Przenieś panel
Group with Grupuj z
Panel menu Menu panelu
Resize panel window Zmień rozmiar okna panelu
Resize panels Zmień rozmiar paneli
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -2833,3 +2833,15 @@ fontWeight Italic Itálico
Quick Selection found nothing there A Seleção rápida não encontrou nada ali
Object Selection found nothing there — try a tighter rectangle A Seleção de objeto não encontrou nada ali — tente um retângulo mais justo
No subject found — try a rectangle around the object Nenhum assunto encontrado — tente um retângulo ao redor do objeto
# Shared panel docking
Activate Panel Ativar painel
Change Panel Group Alterar grupo de painéis
Change Panel Layout Alterar disposição dos painéis
Close Panel Fechar painel
Dock Panel Encaixar painel
Float Panel Painel flutuante
Move Panel Mover painel
Group with Agrupar com
Panel menu Menu do painel
Resize panel window Redimensionar janela do painel
Resize panels Redimensionar painéis
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -2690,3 +2690,15 @@ fontWeight Italic Курсив
Missing delete command Отсутствует команда удаления
Invalid delete command Недопустимая команда удаления
Layer deleted, but confirmation preference could not be saved: {error} Слой удалён, но не удалось сохранить настройку подтверждения: {error}
# Shared panel docking
Activate Panel Активировать панель
Change Panel Group Изменить группу панелей
Change Panel Layout Изменить расположение панелей
Close Panel Закрыть панель
Dock Panel Закрепить панель
Float Panel Сделать панель плавающей
Move Panel Переместить панель
Group with Сгруппировать с
Panel menu Меню панели
Resize panel window Изменить размер окна панели
Resize panels Изменить размер панелей
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -3513,3 +3513,15 @@ cameraRaw Masking Маскування
Quick Selection found nothing there Швидке виділення нічого тут не знайшло
Object Selection found nothing there — try a tighter rectangle Виділення об'єктів нічого тут не знайшло — спробуйте вужчий прямокутник
No subject found — try a rectangle around the object Об'єкт не знайдено — спробуйте прямокутник навколо об'єкта
# Shared panel docking
Activate Panel Активувати панель
Change Panel Group Змінити групу панелей
Change Panel Layout Змінити розташування панелей
Close Panel Закрити панель
Dock Panel Закріпити панель
Float Panel Зробити панель плаваючою
Move Panel Перемістити панель
Group with Згрупувати з
Panel menu Меню панелі
Resize panel window Змінити розмір вікна панелі
Resize panels Змінити розмір панелей
Can't render this file because it contains an unexpected character in line 1317 and column 18.
+12
View File
@@ -2414,3 +2414,15 @@ fontWeight Italic 斜体
Quick Selection found nothing there 快速选择在该处未找到任何内容
Object Selection found nothing there — try a tighter rectangle 对象选择在该处未找到任何内容 — 请尝试更紧凑的矩形
No subject found — try a rectangle around the object 未找到主体 — 请尝试在对象周围绘制矩形
# Shared panel docking
Activate Panel 激活面板
Change Panel Group 更改面板组
Change Panel Layout 更改面板布局
Close Panel 关闭面板
Dock Panel 停靠面板
Float Panel 浮动面板
Move Panel 移动面板
Group with 与此面板分组
Panel menu 面板菜单
Resize panel window 调整面板窗口大小
Resize panels 调整面板大小
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
+12
View File
@@ -2383,3 +2383,15 @@ fontWeight Italic 斜體
Quick Selection found nothing there 快速選取在該處找不到任何內容
Object Selection found nothing there — try a tighter rectangle 物件選取在該處找不到任何內容 — 請嘗試更緊密的矩形
No subject found — try a rectangle around the object 找不到主體 — 請嘗試在物件周圍繪製矩形
# Shared panel docking
Activate Panel 啟用面板
Change Panel Group 變更面板群組
Change Panel Layout 變更面板配置
Close Panel 關閉面板
Dock Panel 停駐面板
Float Panel 浮動面板
Move Panel 移動面板
Group with 與此面板分組
Panel menu 面板選單
Resize panel window 調整面板視窗大小
Resize panels 調整面板大小
Can't render this file because it contains an unexpected character in line 6 and column 47.
+109 -28
View File
@@ -4,36 +4,28 @@
//! (History: the first shell used Unicode glyphs; half of them rendered as tofu boxes because the
//! bundled fonts lacked them. Vector icons fix that for good.)
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::sync::OnceLock;
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
use craft_ui::icons::{SvgIconSet, SvgStyle};
use egui::{Color32, Rect, Response, Sense, Vec2};
use crate::icon_data::ICONS;
use crate::state::Tool;
use crate::theme::Tokens;
fn white_icons() -> &'static HashMap<&'static str, Arc<[u8]>> {
static MAP: OnceLock<HashMap<&'static str, Arc<[u8]>>> = OnceLock::new();
MAP.get_or_init(|| {
ICONS
.iter()
.map(|(name, bytes)| {
let svg = String::from_utf8_lossy(bytes).replace("currentColor", "#ffffff").replace("stroke-width=\"2\"", "stroke-width=\"1.75\"");
(*name, Arc::from(svg.into_bytes().into_boxed_slice()))
})
.collect()
})
fn white_icons() -> &'static SvgIconSet {
static MAP: OnceLock<SvgIconSet> = OnceLock::new();
MAP.get_or_init(|| SvgIconSet::new(ICONS, SvgStyle { current_color: "#ffffff", stroke_width: Some("1.75") }, "bytes://icons/", "square"))
}
pub fn exists(name: &str) -> bool {
white_icons().contains_key(name)
white_icons().contains(name)
}
/// An egui image for an icon, tinted.
pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> {
let bytes = white_icons().get(name).or_else(|| white_icons().get("square")).cloned().unwrap_or_default();
egui::Image::from_bytes(format!("bytes://icons/{name}.svg"), egui::load::Bytes::Shared(bytes)).fit_to_exact_size(Vec2::splat(size)).tint(tint)
egui::Image::new(white_icons().source(name)).fit_to_exact_size(Vec2::splat(size)).tint(tint)
}
/// Paint an icon centred in `rect`.
@@ -171,10 +163,20 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool
/// Square icon button with an explicit glyph size, independent of its hit area.
pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click());
let tint = button_chrome(ui, rect, selected, resp.hovered());
paint(ui, rect, name, icon_size, tint);
if tooltip.is_empty() { resp } else { resp.on_hover_text(tl!(tooltip)) }
let t = Tokens::get(ui.ctx());
let style = IconButtonStyle {
normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon },
hovered: ButtonVisuals { fill: t.hover, stroke: egui::Stroke::NONE, icon: t.text },
pressed: None,
selected: ButtonVisuals { fill: t.accent_soft, stroke: egui::Stroke::new(1.0, t.accent_border), icon: t.accent_text },
corner_radius: t.radius_sm.into(),
focus_stroke: egui::Stroke::new(1.0, t.accent),
};
let label = if tooltip.is_empty() { name } else { tl!(tooltip) };
let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |ui, rect, tint| {
paint(ui, rect, name, icon_size, tint);
});
if tooltip.is_empty() { resp } else { resp.on_hover_text(label) }
}
/// Rail toggle: "on" gets a quiet filled background and full-strength icon (no accent).
@@ -189,6 +191,7 @@ pub fn rail_button(ui: &mut egui::Ui, name: &str, box_size: f32, on: bool, toolt
}
let tint = if on || resp.hovered() { t.text } else { t.text_faint };
paint(ui, rect, name, (box_size * 0.52).round(), tint);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, tl!(tooltip)));
resp.on_hover_text(tl!(tooltip))
}
@@ -204,12 +207,89 @@ mod tests {
}
#[test]
fn icons_are_recoloured() {
let m = white_icons();
assert!(m.len() >= 60);
for (name, b) in m.iter() {
let s = std::str::from_utf8(b).unwrap();
assert!(!s.contains("currentColor"), "{name}");
fn shared_catalog_preserves_existing_icons() {
// Characterize the pre-extraction loader across every real asset. The UI's tint,
// stroke weight and cache keys must survive shared-crate upgrades unchanged.
assert!(ICONS.len() >= 60);
for (name, original) in ICONS {
let expected = String::from_utf8_lossy(original).replace("currentColor", "#ffffff").replace("stroke-width=\"2\"", "stroke-width=\"1.75\"");
let egui::ImageSource::Bytes { uri, bytes } = white_icons().source(name) else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), format!("bytes://icons/{name}.svg"));
assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}");
assert!(!std::str::from_utf8(&bytes).unwrap().contains("currentColor"), "{name}");
}
}
#[test]
fn shared_catalog_preserves_fallback() {
let egui::ImageSource::Bytes { uri, bytes } = white_icons().source("missing-icon") else { panic!("expected SVG bytes") };
let egui::ImageSource::Bytes { bytes: fallback, .. } = white_icons().source("square") else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), "bytes://icons/missing-icon.svg");
assert_eq!(bytes.as_ref(), fallback.as_ref());
assert!(!exists("missing-icon"));
}
#[test]
fn shared_button_preserves_theme_painting_and_pointer_responses() {
// The unchanged chrome helper is the pre-extraction paint oracle. Keyboard focus
// and accessibility metadata are deliberate additions, covered in craft-ui.
fn frame(
ctx: &egui::Context,
events: Vec<egui::Event>,
selected: bool,
enabled: bool,
legacy: bool,
) -> (egui::Response, Vec<egui::epaint::ClippedShape>) {
let mut response = None;
let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
response = Some(
ui.add_enabled_ui(enabled, |ui| {
if legacy {
let (rect, response) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
let tint = button_chrome(ui, rect, selected, response.hovered());
paint(ui, rect, "square", (28.0_f32 * 0.52).round(), tint);
response
} else {
button(ui, "square", 28.0, selected, "")
}
})
.inner,
);
});
output.textures_delta.clear();
(response.unwrap(), output.shapes)
}
for theme in crate::theme::ThemeKind::ALL {
for selected in [false, true] {
for enabled in [false, true] {
let old = egui::Context::default();
let new = egui::Context::default();
for ctx in [&old, &new] {
crate::theme::apply(ctx, theme);
egui_extras::install_image_loaders(ctx);
}
let mut center = egui::Pos2::ZERO;
for step in 0..7 {
let events = match step {
2 => vec![egui::Event::PointerMoved(center)],
3 | 4 => vec![egui::Event::PointerButton {
pos: center,
button: egui::PointerButton::Primary,
pressed: step == 3,
modifiers: egui::Modifiers::NONE,
}],
5 => vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))],
_ => vec![],
};
let (a, before) = frame(&old, events.clone(), selected, enabled, true);
let (b, after) = frame(&new, events, selected, enabled, false);
center = a.rect.center();
assert_eq!(a.rect, b.rect);
assert_eq!((a.clicked(), a.hovered(), a.is_pointer_button_down_on()), (b.clicked(), b.hovered(), b.is_pointer_button_down_on()));
assert_eq!(before, after, "{theme:?}, selected={selected}, enabled={enabled}, step={step}");
}
}
}
}
}
@@ -218,9 +298,10 @@ mod tests {
for size in [14_u32, 28] {
let mut coverage = Vec::new();
for name in ["symmetry-vertical", "symmetry-horizontal", "symmetry-diagonal", "symmetry-dual"] {
let bytes = white_icons().get(name).unwrap();
assert!(white_icons().contains(name));
let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { panic!("icon source must be embedded bytes") };
let image = egui_extras::image::load_svg_bytes_with_size(
bytes,
bytes.as_ref(),
egui::load::SizeHint::Size { width: size, height: size, maintain_aspect_ratio: true },
&Default::default(),
)
+1 -1
View File
@@ -38,7 +38,7 @@ fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32) ->
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover());
ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, tl!(label), egui::FontId::proportional(12.0), t.text_dim);
let key = egui::Id::new(("layer-props-field", id));
let key = ui.make_persistent_id(("layer-props-field", id));
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or(current);
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", width);
if resp.dragged() || resp.has_focus() {
+6 -1
View File
@@ -109,6 +109,7 @@ mod opacity_keys;
pub mod outline;
pub mod paint_mouse;
pub mod palette;
mod panel_docking;
pub mod panels;
pub mod parity;
pub mod patch_preview;
@@ -764,6 +765,9 @@ impl PhotocraftApp {
{
authorize(id, &params)?;
}
if let Some(result) = panel_docking::command(self, id, &params) {
return result;
}
if let Some(r) = transform_tool::intercept(self, id) {
return r;
}
@@ -1464,7 +1468,7 @@ impl PhotocraftApp {
if self.ui.vector_mask_target && !mask_thumbs_ui::has_vector_mask(st) {
self.ui.vector_mask_target = false;
}
if self.ui.mask_target && !st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()) {
if self.ui.mask_target && st.active_layer.and_then(|id| st.doc.layer(id)).is_none_or(|l| l.mask.is_none()) {
self.ui.mask_target = false;
}
}
@@ -1513,6 +1517,7 @@ impl PhotocraftApp {
pub(crate) fn apply_theme(&mut self, ctx: &egui::Context, kind: theme::ThemeKind) {
self.ui.theme = kind;
panel_docking::sync_visibility(self);
theme::apply(ctx, kind);
self.checker = None;
}
+20
View File
@@ -22,6 +22,13 @@ pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Se
/// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut.
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.symmetryTransform", "Transform Symmetry", &[], None),
("window.panel.group", "Change Panel Group", &[], None),
("window.panel.layout", "Change Panel Layout", &[], None),
("window.panel.move", "Move Panel", &[], None),
("window.panel.float", "Float Panel", &[], None),
("window.panel.dock", "Dock Panel", &[], None),
("window.panel.activate", "Activate Panel", &[], None),
("window.panel.close", "Close Panel", &[], None),
("file.open", "Open…", &["File"], Some("Cmd+O")),
("file.save", "Save", &["File"], Some("Cmd+S")),
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
@@ -152,6 +159,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
crate::canvas::commit_crop(app);
return Ok(json!({"committed": true}));
}
if let Some(result) = crate::panel_docking::command(app, id, &params) {
return result;
}
// Help › Discord, website, GitHub, Report an Issue.
if let Some(url) = crate::links::url_for(id) {
return Ok(crate::links::open(app, ctx, url));
@@ -582,6 +592,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
return e;
}
match id {
"window.panel.activate" | "window.panel.layout" => true,
"window.panel.group" | "window.panel.move" | "window.panel.float" | "window.panel.dock" | "window.panel.close" => !app.session.prefs().workspace_locked,
"file.open" | "file.exit" | "file.clearRecent" | "help.about" | "help.systemInfo" | "edit.search" => true,
i if i.starts_with("file.openRecent.") => true,
i if crate::links::url_for(i).is_some() => true,
@@ -633,6 +645,9 @@ const MODE_CHECKS: [&str; 11] = ["rgb", "grayscale", "cmyk", "lab", "multichanne
/// Is a UI-level panel toggle currently on (for checkmarks)?
fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
if let Some(checked) = crate::panel_docking::checked(app, id) {
return Some(checked);
}
use photocraft_doc::{ColorMode, SampleType};
if let Some(name) = id.strip_prefix("window.theme.").filter(|n| *n != "toggle") {
let auto = app.session.prefs().interface.appearance_mode == photocraft_engine::prefs::AppearanceMode::Auto;
@@ -1266,6 +1281,11 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
}
// Presets use the default group order, heights and tabs (Reset brings everything back).
app.ui.dock = Default::default();
app.ui.docking_reveal = None;
app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1);
app.ui.docking = None;
app.ui.docking_hidden.clear();
app.ui.docking_collapsed.clear();
app.ui.dock_tabs = Default::default();
let p = &mut app.ui.panels;
let (nav, color, layers, history, props) = match app.ui.workspace.as_str() {
+996
View File
@@ -0,0 +1,996 @@
//! Application-owned panel IDs and commands around the shared docking layout.
use crate::dock::Group;
use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde_json::{Value, json};
use crate::PhotocraftApp;
const PANELS: &[(&str, &str, Group)] = &[
("color", "Color", Group::Color),
("swatches", "Swatches", Group::Color),
("gradients", "Gradients", Group::Color),
("patterns", "Patterns", Group::Color),
("properties", "Properties", Group::Properties),
("adjustments", "Adjustments", Group::Properties),
("character", "Character", Group::Character),
("paragraph", "Paragraph", Group::Character),
("navigator", "Navigator", Group::Navigator),
("histogram", "Histogram", Group::Navigator),
("info", "Info", Group::Navigator),
("history", "History", Group::History),
("actions", "Actions", Group::History),
("layerComps", "Layer Comps", Group::History),
("layers", "Layers", Group::Layers),
("channels", "Channels", Group::Layers),
("paths", "Paths", Group::Layers),
];
#[derive(Clone, Default)]
struct LegacyDrag {
generation: u64,
layout: Option<Layout<String>>,
tabs: crate::state::DockTabs,
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
}
fn cancel_legacy_drag(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
let key = egui::Id::new("photocraft-panel-docking").with("legacy-origin");
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
if let Some(previous) = previous {
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("photocraft-panel-docking"));
if previous.generation != app.ui.docking_generation {
return false;
}
app.ui.dock_tabs = previous.tabs;
app.ui.docking = previous.layout;
app.ui.docking_hidden = previous.hidden;
true
} else {
false
}
}
fn normalize(raw: &str) -> Option<&'static str> {
PANELS.iter().find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim())).map(|p| p.0)
}
pub(crate) fn valid(layout: &Layout<String>) -> bool {
layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str()))
}
fn legacy(app: &PhotocraftApp) -> Layout<String> {
legacy_layout(app, false)
}
fn legacy_layout(app: &PhotocraftApp, all: bool) -> Layout<String> {
let pro = matches!(app.ui.theme, crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium);
let mut layout = Layout::default();
for group in app.ui.dock.order().into_iter().rev().filter(|g| all || g.shown(&app.ui.panels)) {
let labels = group.tabs(pro);
let visible = if all { group.tabs(pro).iter().copied().enumerate().collect() } else { app.ui.dock.visible_tabs(group, pro) };
let panels: Vec<String> = visible.iter().filter_map(|(_, label)| normalize(label).map(str::to_owned)).collect();
if panels.is_empty() {
continue;
}
let selected = match group {
Group::Color => app.ui.dock_tabs.color,
Group::Properties => app.ui.dock_tabs.properties,
Group::Character => app.ui.dock_tabs.character,
Group::Navigator => app.ui.dock_tabs.navigator,
Group::History => app.ui.dock_tabs.history,
Group::Layers => app.ui.dock_tabs.layers,
};
let active = labels.get(selected).and_then(|label| normalize(label)).and_then(|id| panels.iter().position(|p| p == id)).unwrap_or(0);
if group == Group::Properties && !pro {
layout.floating.push(Floating { panels, active, rect: [120.0, 160.0, 320.0, 440.0] });
continue;
}
let node = Node::Tabs { panels, active };
layout.root = Some(match layout.root.take() {
Some(second) => Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::FixedFirst(app.ui.dock.height(group)),
first: Box::new(node),
second: Box::new(second),
},
None => node,
});
}
layout
}
// Native collapse flags remain app-owned. An intact native tab group keeps its collapsed
// presentation when the workspace first becomes customizable; mixed custom groups are expanded.
fn collapsed_members(app: &PhotocraftApp, layout: &Layout<String>) -> std::collections::HashSet<String> {
let mut collapsed: std::collections::HashSet<String> = app.ui.docking_collapsed.iter().cloned().collect();
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
Node::Tabs { panels, .. } => {
let group = panels.first().and_then(|id| PANELS.iter().find(|p| p.0 == id)).map(|p| p.2);
if let Some(group) = group.filter(|group| app.ui.dock.is_collapsed(*group)) {
let pro = matches!(app.ui.theme, crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium);
let native: Vec<_> = app.ui.dock.visible_tabs(group, pro).iter().filter_map(|(_, name)| normalize(name)).collect();
if panels.len() == native.len() && panels.iter().all(|panel| native.contains(&panel.as_str())) {
collapsed.extend(panels.iter().cloned());
}
}
}
_ => {}
}
}
collapsed
}
fn first_root(layout: &Layout<String>, except: &str) -> Option<String> {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
root.panels().into_iter().find(|p| p.as_str() != except).cloned()
}
fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> {
let anchor = first_root(layout, panel);
layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string())
}
fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
match params.get(key) {
None => Ok(default),
Some(v) => v.as_f64().filter(|v| v.is_finite() && v.abs() <= 1_000_000.0).map(|v| v as f32).ok_or_else(|| format!("{key} must be a finite coordinate")),
}
}
fn placement(params: &Value) -> Result<Placement<String>, String> {
if let Some(before) = params.get("before") {
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
return Ok(Placement::Tab { before: Some(id.into()) });
}
let zone = match params.get("zone") {
None => "tab",
Some(value) => value.as_str().ok_or("zone must be a string")?,
};
Ok(match zone {
"tab" => Placement::Tab { before: None },
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be tab, center, left, right, top or bottom".into()),
})
}
pub(crate) fn command(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if id == "window.panel.group" {
return Some(group_command(app, params));
}
if id == "window.panel.layout" {
return Some(
params
.get("action")
.ok_or_else(|| "action is required".to_string())
.and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string()))
.and_then(|action| apply_action(app, action)),
);
}
if let Some(layout) = &app.ui.docking
&& let Some(panel) = id.strip_prefix("window.panel.").or_else(|| id.strip_prefix("window.toggle.")).and_then(normalize)
{
let showing = layout.contains(&panel.to_string());
let show = match params.get("show").or_else(|| params.get("on")) {
None => !showing || !panel_active(layout, panel) || collapsed_members(app, layout).contains(panel),
Some(value) => match value.as_bool() {
Some(show) => show,
None => return Some(Err("show must be a boolean".into())),
},
};
return Some(change(app, if show { "activate" } else { "close" }, &json!({"panel":panel})));
}
let operation = match id {
"window.panel.move" => "move",
"window.panel.float" => "float",
"window.panel.dock" => "dock",
"window.panel.activate" => "activate",
"window.panel.close" => "close",
"window.panel" if app.ui.docking.is_some() => "activate",
_ => return None,
};
Some(change(app, operation, params))
}
pub(crate) fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
let layout = app.ui.docking.as_ref()?;
let panel = id.strip_prefix("window.panel.").or_else(|| id.strip_prefix("window.toggle.")).and_then(normalize)?;
Some(layout.contains(&panel.to_string()))
}
// UI output and automation share this atomic dispatcher. The application owns the registry
// and saved return locations; craft-ui owns tree validation and structural changes.
fn apply_action(app: &mut PhotocraftApp, action: Action<String>) -> Result<Value, String> {
if app.session.prefs().workspace_locked && !matches!(action, Action::Activate { .. }) {
return Err("workspace is locked".into());
}
let registered = |id: &String| normalize(id) == Some(id.as_str());
let known = match &action {
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
registered(panel)
&& registered(anchor)
&& match placement {
Placement::Tab { before: Some(id) } => registered(id),
_ => true,
}
}
Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered),
Action::Float { panel, .. }
| Action::Close { panel }
| Action::Activate { panel }
| Action::MoveFloating { panel, .. }
| Action::SetStackOpen { panel, .. }
| Action::ResizeStack { panel, .. } => registered(panel),
Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH,
};
if !known {
return Err("docking action contains an unknown panel or an excessive split path".into());
}
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let returning = match &action {
Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)),
Action::Float { panel, .. } => layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)),
_ => None,
};
let reveal = match &action {
Action::Activate { panel } => Some(panel.clone()),
_ => None,
};
let activate = match &action {
Action::Activate { panel } => PANELS.iter().find(|p| p.0 == panel).map(|p| p.2),
_ => None,
};
layout.apply(action).map_err(|error| error.to_string())?;
if let Some(group) = activate {
app.ui.dock.set_collapsed(group, false);
}
if let Some((panel, location)) = returning {
app.ui.docking_hidden.insert(panel, location);
}
if app.ui.docking.is_none() {
app.ui.docking_hidden.extend(migration_hidden(app, &layout));
}
app.ui.docking = Some(layout);
app.ui.panels.dock = true;
sync_visibility(app);
if let Some(panel) = reveal {
app.ui.docking_reveal = Some(panel.clone());
reveal_group(app, &panel);
}
Ok(json!({"layout": app.ui.docking}))
}
fn change(app: &mut PhotocraftApp, operation: &str, params: &Value) -> Result<Value, String> {
if app.session.prefs().workspace_locked && operation != "activate" {
return Err("workspace is locked".into());
}
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
return Err("saved panel layout is invalid; reset the workspace first".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let migrated = if app.ui.docking.is_none() { migration_hidden(app, &layout) } else { app.ui.docking_hidden.clone() };
let mut hidden: std::collections::BTreeMap<_, _> =
migrated.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
let mut restored = false;
if operation == "dock"
&& params.get("anchor").or_else(|| params.get("onto")).is_none()
&& let Some(location) = hidden.get(panel).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor))
})
{
let mut candidate = layout.clone();
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
if candidate.restore(panel.into(), location).is_ok() {
layout = candidate;
restored = true;
hidden.remove(panel);
}
}
match operation {
"dock" if restored => {}
"float" => {
let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?];
ensure(&mut layout, panel)?;
if let Ok(location) = layout.location(&panel.to_string())
&& location.floating.is_none()
{
hidden.insert(panel.into(), location);
}
layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?;
}
"move" | "dock" => {
if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
return Err("move requires a destination panel".into());
}
let anchor = match params.get("anchor").or_else(|| params.get("onto")) {
Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()),
None => first_root(&layout, panel),
};
let placement = placement(params)?;
if let Some(anchor) = anchor {
ensure(&mut layout, panel)?;
layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?;
} else if operation == "dock" {
if layout.contains(&panel.to_string()) {
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?;
} else {
return Err("move requires an existing destination panel".into());
}
}
"close" => {
hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?);
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
}
_ => {
if !layout.contains(&panel.to_string()) && hidden.contains_key(panel) {
// A missing neighbor is allowed: the normal visible dock becomes the fallback.
restore_hidden(&mut layout, &mut hidden, panel);
}
ensure(&mut layout, panel)?;
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
hidden.remove(panel);
}
}
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
app.ui.panels.dock = true;
sync_visibility(app);
if operation == "activate"
&& let Some((_, _, group)) = PANELS.iter().find(|p| p.0 == panel)
{
app.ui.dock.set_collapsed(*group, false);
app.ui.docking_reveal = Some(panel.to_owned());
reveal_group(app, panel);
}
Ok(json!({"panel": panel, "layout": app.ui.docking}))
}
pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool {
let area = egui::Id::new("photocraft-panel-docking");
let stale_origin =
ui.ctx().data_mut(|data| data.get_temp::<LegacyDrag>(area.with("legacy-origin")).is_some_and(|origin| origin.generation != app.ui.docking_generation));
let changed_workspace = ui.ctx().data_mut(|data| {
let previous = data.get_temp::<u64>(area.with("generation"));
data.insert_temp(area.with("generation"), app.ui.docking_generation);
previous.is_some_and(|generation| generation != app.ui.docking_generation)
});
if changed_workspace || stale_origin {
craft_ui::docking::cancel_drag::<String>(ui.ctx(), area);
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin")));
}
let cancelled =
ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released()));
if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() {
return false;
}
let Some(layout) = app.ui.docking.as_ref() else { return false };
if !valid(layout) {
app.ui.docking = None;
app.ui.status = "Invalid panel layout; restored the default dock".into();
return false;
}
let layout = layout.clone();
let reveal = app.ui.docking_reveal.take();
let t = crate::theme::Tokens::get(ui.ctx());
let locked = app.session.prefs().workspace_locked;
let collapsed = collapsed_members(app, &layout);
let requested_width = ui.ctx().data_mut(|data| data.remove_temp::<f32>(crate::panels::dock_width_id()));
let mut commands = Vec::new();
let mut targets = Vec::new();
let mut draw = |ui: &mut egui::Ui| {
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
style.tab_height = if t.pro { 28.0 } else { 40.0 };
style.min_pane = 0.0;
style.background = t.card;
style.tab_background = t.tab_strip;
style.active_background = t.card;
style.text = t.text;
style.inactive_text = t.text_dim;
style.border = egui::Stroke::new(1.0, t.card_border);
style.accent = t.accent;
style.float_label = tl!("Float Panel").to_string();
style.close_label = tl!("Close Panel").to_string();
style.move_label = tl!("Group with").to_string();
style.panels_label = tl!("Panels").to_string();
style.resize_label = tl!("Resize panels").to_string();
style.resize_window_label = tl!("Resize panel window").to_string();
let mut area = craft_ui::docking::DockArea::new(egui::Id::new("photocraft-panel-docking"));
if let Some(panel) = &reveal {
area = area.raise_panel(panel.clone());
}
area.show_customized(
ui,
&layout,
&style,
|id| PANELS.iter().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()),
|_| craft_ui::docking::Permissions { movable: !locked, floatable: !locked, closable: !locked, accepts_tabs: !locked },
|id| {
if collapsed.contains(id) {
craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 0.0), max: egui::vec2(1_000_000.0, 0.0) }
} else {
// Layers owns its row scroll and footer; leave room for its fixed controls
// plus a scrolling row area instead of clipping the footer after a split.
let height = if id == "layers" { 220.0 } else { 80.0 };
craft_ui::docking::PanelLimits { min: egui::vec2(220.0, height), ..Default::default() }
}
},
&mut PanelContent { app, collapsed: &collapsed, commands: &mut commands, targets: &mut targets },
)
};
let output = if layout.root.is_some() {
let mut panel = egui::Panel::right("shared-panel-dock")
.default_size(if t.pro { 290.0 } else { 300.0 })
.size_range(crate::panels::DOCK_WIDTH)
.resizable(!locked)
.frame(egui::Frame::NONE.fill(t.card));
if let Some(width) = requested_width {
panel = panel.exact_size(width);
}
panel.show(ui, &mut draw).inner
} else {
draw(ui)
};
if let Some(error) = output.error {
app.ui.status = error.to_string();
}
if ui.input(|input| input.pointer.any_released()) {
if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) {
return app.ui.docking.is_some();
}
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("photocraft-panel-docking").with("legacy-origin")));
}
if let Some(panel) = output.group_drag.as_ref()
&& let Some(pointer) = ui.ctx().pointer_interact_pos()
{
let members = group_members(&layout, panel);
let eligible: Vec<_> = targets.iter().filter(|target| !members.contains(&target.anchor)).cloned().collect();
if let Some(proposal) = craft_ui::docking::resolve_drop(panel, pointer, &eligible) {
ui.painter().rect_stroke(proposal.preview, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
}
}
if let Some((panel, pointer)) = output.group_drop {
let members = group_members(&layout, &panel);
let eligible: Vec<_> = targets.iter().filter(|target| !members.contains(&target.anchor)).cloned().collect();
if let Some(proposal) = craft_ui::docking::resolve_drop(&panel, pointer, &eligible) {
commands.push(("window.panel.group", json!({"panel":panel,"operation":"move","anchor":proposal.anchor,"placement":proposal.placement})));
}
}
for action in output.actions {
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
app.ui.status = error;
}
}
for (id, params) in commands {
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
crate::dock::persist(app, ui.ctx());
true
}
pub(crate) fn legacy_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
if app.session.prefs().workspace_locked {
return;
}
let Some(panel) = normalize(panel) else { return };
if response.drag_started()
&& let Some(id) = normalize(panel)
{
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
let bounds = ui.max_rect();
let source = egui::Rect::from_min_size(
egui::pos2(bounds.left(), response.rect.top()),
egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)),
);
if ensure(&mut layout, id).is_ok() && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("photocraft-panel-docking"), id.to_string(), source) {
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("photocraft-panel-docking").with("legacy-origin"),
LegacyDrag {
generation: app.ui.docking_generation,
layout: app.ui.docking.clone(),
tabs: app.ui.dock_tabs,
hidden: app.ui.docking_hidden.clone(),
},
)
});
if app.ui.docking.is_none() {
app.ui.docking_hidden = migration_hidden(app, &layout);
}
app.ui.docking = Some(layout);
}
}
}
fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: &str) {
let Some((_, label, group)) = PANELS.iter().find(|p| p.0 == id) else { return };
let pro = crate::theme::Tokens::get(ui.ctx()).pro;
let Some(tab) = group.tabs(pro).iter().position(|name| name == label) else { return };
*group.tab_mut(&mut app.ui.dock_tabs) = tab;
if group.scrolls_itself(tab) {
crate::panels::dock_body(app, ui, *group, tab);
} else {
egui::ScrollArea::vertical()
.id_salt(("shared-panel-body", id))
.auto_shrink([false, false])
.show(ui, |ui| crate::panels::dock_body(app, ui, *group, tab));
}
let selected = *group.tab_mut(&mut app.ui.dock_tabs);
if selected != tab
&& let Some(label) = group.tabs(pro).get(selected)
{
let _ = app.run("window.panel.activate", json!({"panel":label}));
}
}
#[cfg(test)]
#[path = "panel_docking_tests.rs"]
mod tests;
/// Return positions for panels already hidden in a legacy workspace.
fn migration_hidden(app: &PhotocraftApp, visible: &Layout<String>) -> std::collections::BTreeMap<String, craft_ui::docking::Location<String>> {
let full = legacy_layout(app, true);
PANELS
.iter()
.filter(|(id, _, _)| !visible.contains(&id.to_string()))
.filter_map(|(id, _, _)| full.location(&id.to_string()).ok().map(|at| ((*id).to_owned(), at)))
.collect()
}
fn group_members(layout: &Layout<String>, panel: &str) -> Vec<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(),
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel) => return vec![panel.to_owned()],
_ => {}
}
}
layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel)).map(|group| group.panels.clone()).unwrap_or_default()
}
fn reveal_group(app: &mut PhotocraftApp, panel: &str) {
if let Some(layout) = &app.ui.docking {
let members = group_members(layout, panel);
app.ui.docking_collapsed.retain(|id| !members.contains(id));
for id in members {
if let Some((_, _, group)) = PANELS.iter().find(|p| p.0 == id) {
app.ui.dock.set_collapsed(*group, false);
}
}
}
}
pub(crate) fn rail_on(app: &PhotocraftApp, group: Group) -> bool {
app.ui.docking.as_ref().is_some_and(|layout| {
let collapsed = collapsed_members(app, layout);
PANELS.iter().filter(|p| p.2 == group).any(|p| layout.contains(&p.0.to_owned()) && !collapsed.contains(p.0))
})
}
pub(crate) fn rail_click(app: &mut PhotocraftApp, group: Group) {
let Some(layout) = app.ui.docking.as_ref() else { return };
let existing = PANELS.iter().find(|p| p.2 == group && layout.contains(&p.0.to_owned())).map(|p| p.0);
let panel = existing.or_else(|| {
group
.tabs(matches!(app.ui.theme, crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium))
.get(*group.tab_mut(&mut app.ui.dock_tabs))
.and_then(|label| normalize(label))
});
if let Some(panel) = panel {
let result = if existing.is_some() && rail_on(app, group) {
group_command(app, &json!({"panel": panel, "operation":"collapse"}))
} else {
change(app, "activate", &json!({"panel":panel}))
};
if let Err(error) = result {
app.ui.status = error;
}
}
}
fn root_groups(layout: &Layout<String>) -> Vec<Vec<String>> {
let mut out = Vec::new();
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
Node::Tabs { panels, .. } => out.push(panels.clone()),
Node::Stack { entries } => out.extend(entries.iter().map(|e| vec![e.panel.clone()])),
}
}
out
}
fn group_command(app: &mut PhotocraftApp, params: &Value) -> Result<Value, String> {
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
if app.session.prefs().workspace_locked {
return Err("workspace is locked".into());
}
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
if !valid(&layout) {
return Err("invalid panel layout".into());
}
let members = group_members(&layout, panel);
if members.is_empty() {
return Err("panel is hidden".into());
}
let mut hidden = if app.ui.docking.is_none() { migration_hidden(app, &layout) } else { app.ui.docking_hidden.clone() };
let mut collapsed = collapsed_members(app, &layout);
match operation {
"close" => {
for id in &members {
hidden.insert(id.clone(), layout.location(id).map_err(|e| e.to_string())?);
layout.apply(Action::Close { panel: id.clone() }).map_err(|e| e.to_string())?;
}
collapsed.retain(|id| !members.contains(id));
}
"collapse" => {
collapsed.extend(members.iter().cloned());
}
"expand" => {
collapsed.retain(|id| !members.contains(id));
}
"move" => {
let anchor = params.get("anchor").and_then(Value::as_str).and_then(normalize).ok_or("anchor must name a registered panel")?;
let placement = params
.get("placement")
.map(|v| serde_json::from_value::<Placement<String>>(v.clone()).map_err(|e| e.to_string()))
.unwrap_or_else(|| placement(params))?;
if members.iter().any(|id| id == anchor) {
return Err("group cannot be moved into itself".into());
}
let first = members.first().ok_or("group is empty")?;
layout.apply(Action::Move { panel: first.clone(), anchor: anchor.to_owned(), placement }).map_err(|e| e.to_string())?;
let destination = group_members(&layout, first);
let before = destination.iter().position(|id| id == first).and_then(|index| destination.get(index + 1)).cloned();
for id in members.iter().skip(1) {
layout
.apply(Action::Move { panel: id.clone(), anchor: first.clone(), placement: Placement::Tab { before: before.clone() } })
.map_err(|e| e.to_string())?;
}
layout.apply(Action::Activate { panel: panel.to_owned() }).map_err(|e| e.to_string())?;
}
"up" | "down" => {
let groups = root_groups(&layout);
let index = groups.iter().position(|g| g.contains(&panel.to_owned())).ok_or("floating groups cannot move within the dock")?;
let neighbor = if operation == "up" { index.checked_sub(1) } else { index.checked_add(1).filter(|i| *i < groups.len()) };
let anchor = neighbor.and_then(|i| groups.get(i)).and_then(|g| g.first()).ok_or("group is already at the edge")?;
let first = members.first().ok_or("group is empty")?;
layout
.apply(Action::Move {
panel: first.clone(),
anchor: anchor.clone(),
placement: Placement::Split(if operation == "up" { Zone::Top } else { Zone::Bottom }),
})
.map_err(|e| e.to_string())?;
for id in members.iter().skip(1) {
layout
.apply(Action::Move { panel: id.clone(), anchor: first.clone(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
layout.apply(Action::Activate { panel: panel.to_owned() }).map_err(|e| e.to_string())?;
}
_ => return Err("operation must be close, collapse, expand, move, up or down".into()),
}
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
app.ui.docking_collapsed = collapsed.into_iter().collect();
sync_visibility(app);
// Native collapse flags must not re-collapse a custom group after explicit expansion.
if operation == "expand" || operation == "move" {
reveal_group(app, panel);
}
Ok(json!({"layout":app.ui.docking}))
}
pub(crate) struct LegacyUpdate {
layout: Layout<String>,
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
clear_collapse: Vec<Group>,
}
/// Validate and stage the legacy controls before ui.set commits any fields.
pub(crate) fn prepare_legacy_update(
app: &PhotocraftApp,
request: &Value,
panels: Option<&crate::state::Panels>,
tabs: Option<&crate::state::DockTabs>,
dock: Option<&crate::dock::DockLayout>,
pro: bool,
) -> Result<Option<LegacyUpdate>, String> {
let Some(current) = &app.ui.docking else { return Ok(None) };
if panels.is_none() && tabs.is_none() && dock.is_none() {
return Ok(None);
}
if !valid(current) {
return Err("invalid panel layout; reset the workspace first".into());
}
// A legacy flat-column replacement cannot represent arbitrary custom splits. Reject it
// explicitly instead of accepting a command whose requested order/height is ignored.
if let Some(dock) = dock
&& (dock.order != app.ui.dock.order || dock.heights != app.ui.dock.heights)
{
return Err("dock order/heights cannot replace a customized layout; use window.panel.layout".into());
}
if app.session.prefs().workspace_locked {
return Err("workspace is locked".into());
}
let clear_collapse: Vec<_> =
dock.map(|d| Group::ALL.into_iter().filter(|g| d.is_collapsed(*g) != app.ui.dock.is_collapsed(*g)).collect()).unwrap_or_default();
if !clear_collapse.is_empty()
&& root_groups(current).iter().any(|ids| {
let groups: std::collections::HashSet<_> = ids.iter().filter_map(|id| PANELS.iter().find(|p| p.0 == id).map(|p| p.2)).collect();
groups.len() > 1
})
{
return Err("legacy collapse settings cannot replace mixed custom groups; use window.panel.group".into());
}
let mut layout = current.clone();
let mut hidden = app.ui.docking_hidden.clone();
for group in Group::ALL {
let visibility = request.get("panels").and_then(|p| p.get(group.key())).and_then(Value::as_bool);
let selected = request.get("dockTabs").and_then(|p| p.get(group.key())).is_some();
let group_panels: Vec<_> = PANELS.iter().filter(|p| p.2 == group).collect();
for &&(id, label, _) in &group_panels {
let explicitly_hidden = dock.is_some_and(|d| d.hidden_tabs.get(&group).is_some_and(|names| names.iter().any(|name| name == label)));
let explicitly_shown = dock.is_some() && !explicitly_hidden;
if visibility == Some(false) || explicitly_hidden {
if layout.contains(&id.to_owned()) {
hidden.insert(id.to_owned(), layout.location(&id.to_owned()).map_err(|e| e.to_string())?);
layout.apply(Action::Close { panel: id.to_owned() }).map_err(|e| e.to_string())?;
}
} else if (visibility == Some(true) || explicitly_shown) && !layout.contains(&id.to_owned()) {
if hidden.contains_key(id) {
restore_hidden(&mut layout, &mut hidden, id);
}
ensure(&mut layout, id)?;
}
}
if selected {
let tabs = tabs.ok_or("dockTabs is missing")?;
let index = match group {
Group::Color => tabs.color,
Group::Properties => tabs.properties,
Group::Character => tabs.character,
Group::Navigator => tabs.navigator,
Group::History => tabs.history,
Group::Layers => tabs.layers,
};
let id = group.tabs(pro).get(index).and_then(|label| normalize(label)).ok_or("dockTabs index is outside the panel group")?;
if visibility == Some(false) {
return Err("cannot select a tab in a hidden group".into());
}
if !layout.contains(&id.to_owned()) {
if hidden.contains_key(id) {
restore_hidden(&mut layout, &mut hidden, id);
}
ensure(&mut layout, id)?;
}
layout.apply(Action::Activate { panel: id.to_owned() }).map_err(|e| e.to_string())?;
}
}
Ok(Some(LegacyUpdate { layout, hidden, clear_collapse }))
}
pub(crate) fn commit_legacy_update(app: &mut PhotocraftApp, update: LegacyUpdate, request: &Value) {
for group in update.clear_collapse {
let ids: Vec<_> = PANELS.iter().filter(|p| p.2 == group).map(|p| p.0).collect();
app.ui.docking_collapsed.retain(|id| !ids.contains(&id.as_str()));
}
app.ui.docking = Some(update.layout);
app.ui.docking_hidden = update.hidden;
sync_visibility(app);
for group in Group::ALL {
if request.get("dockTabs").and_then(|p| p.get(group.key())).is_some()
|| request.get("panels").and_then(|p| p.get(group.key())).and_then(Value::as_bool) == Some(true)
{
let ids: Vec<_> = PANELS.iter().filter(|p| p.2 == group).map(|p| p.0).collect();
app.ui.docking_collapsed.retain(|id| !ids.contains(&id.as_str()));
app.ui.dock.set_collapsed(group, false);
}
}
}
struct PanelContent<'a> {
app: &'a mut PhotocraftApp,
collapsed: &'a std::collections::HashSet<String>,
commands: &'a mut Vec<(&'static str, Value)>,
targets: &'a mut Vec<craft_ui::docking::DropTarget<String>>,
}
fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) {
match panel {
"layers" => {
crate::layer_row_ui::panel_menu(app, ui);
ui.separator();
}
"swatches" => {
crate::swatches_ui::panel_menu(app, ui);
ui.separator();
}
_ => {}
}
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, id: &String) {
if let Some(target) = self.targets.iter_mut().find(|target| target.anchor == *id) {
target.rect = target.rect.union(ui.max_rect());
if let Some(strip) = target.tab_strip.as_mut() {
strip.min.x = target.rect.left();
strip.max.x = target.rect.right();
}
let members = self.app.ui.docking.as_ref().map(|layout| group_members(layout, id)).unwrap_or_default();
target.tabs = members
.into_iter()
.filter_map(|panel| {
ui.ctx().read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &panel))).map(|response| (panel, response.rect))
})
.collect();
}
if !self.collapsed.contains(id) {
egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| body(self.app, ui, id));
}
}
fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) {
panel_menu(self.app, ui, id);
}
fn group_header_width(&self, _panels: &[String], _active: &String, _floating: bool) -> f32 {
28.0
}
fn group_header(&mut self, ui: &mut egui::Ui, panels: &[String], active: &String, floating: bool) {
if !floating {
let rect = ui.max_rect();
self.targets.push(craft_ui::docking::DropTarget {
anchor: active.clone(),
rect,
tab_strip: Some(rect),
tabs: Vec::new(),
allow_splits: true,
accepts_tabs: !self.app.session.prefs().workspace_locked,
});
}
let collapsed = panels.iter().all(|id| self.collapsed.contains(id));
let locked = self.app.session.prefs().workspace_locked;
let resp = crate::icons::button(ui, "ellipsis", ui.available_height().min(28.0), false, tl!("Panel menu"));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(180.0);
panel_menu(self.app, ui, active);
if ui.add_enabled(!locked, egui::Button::new(if collapsed { tl!("Expand Panel Group") } else { tl!("Collapse Panel Group") })).clicked() {
self.commands.push(("window.panel.group", json!({"panel":active,"operation":if collapsed {"expand"} else {"collapse"}})));
ui.close();
}
if !floating {
let groups = self.app.ui.docking.as_ref().map(root_groups).unwrap_or_default();
let index = groups.iter().position(|g| g.contains(active));
for (operation, label, enabled) in
[("up", tl!("Move Group Up"), index.is_some_and(|i| i > 0)), ("down", tl!("Move Group Down"), index.is_some_and(|i| i + 1 < groups.len()))]
{
if ui.add_enabled(!locked && enabled, egui::Button::new(label)).clicked() {
self.commands.push(("window.panel.group", json!({"panel":active,"operation":operation})));
ui.close();
}
}
}
ui.separator();
if ui.add_enabled(!locked, egui::Button::new(tl!("Close Tab Group"))).clicked() {
self.commands.push(("window.panel.group", json!({"panel":active,"operation":"close"})));
ui.close();
}
});
}
}
pub(crate) fn sync_visibility(app: &mut PhotocraftApp) {
if let Some(layout) = &app.ui.docking {
if !valid(layout) {
return;
}
let pro = matches!(app.ui.theme, crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium);
for group in Group::ALL {
*group.shown_mut(&mut app.ui.panels) = PANELS.iter().filter(|p| p.2 == group).any(|p| layout.contains(&p.0.to_owned()));
for (index, label) in group.tabs(pro).iter().enumerate() {
if let Some(id) = normalize(label) {
if layout.contains(&id.to_owned()) {
app.ui.dock.show_tab(group, index, pro);
} else {
app.ui.dock.hide_tab(group, index, pro);
}
}
}
let previous = *group.tab_mut(&mut app.ui.dock_tabs);
if !group.tabs(pro).get(previous).and_then(|name| normalize(name)).is_some_and(|id| panel_active(layout, id))
&& let Some(selected) = group.tabs(pro).iter().position(|name| normalize(name).is_some_and(|id| panel_active(layout, id)))
{
*group.tab_mut(&mut app.ui.dock_tabs) = selected;
}
}
}
}
fn panel_active(layout: &Layout<String>, panel: &str) -> bool {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return panels.get(*active).is_some_and(|p| p == panel),
Node::Stack { entries } if entries.iter().any(|e| e.panel == panel) => return true,
_ => {}
}
}
layout.floating.iter().any(|g| g.panels.get(g.active).is_some_and(|p| p == panel))
}
/// Extra legacy menu choices use the same commands as the customized renderer.
pub(crate) fn legacy_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, label: &str) {
let Some(panel) = normalize(label) else { return };
let locked = app.session.prefs().workspace_locked;
ui.add_enabled_ui(!locked, |ui| {
if ui.button(tl!("Float Panel")).clicked() {
if let Err(error) = app.run("window.panel.float", json!({"panel":panel})) {
app.ui.status = error;
}
ui.close();
}
ui.menu_button(tl!("Group with"), |ui| {
for &(anchor, label, _) in PANELS {
if anchor != panel && ui.button(tl!(label)).clicked() {
let result = app
.run("window.panel.activate", json!({"panel":anchor}))
.and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor})));
if let Err(error) = result {
app.ui.status = error;
}
ui.close();
}
}
});
});
}
// A closed group forms a bounded chain of hidden tab neighbors. Restore just the requested
// panel at the first surviving return position, then let the remaining tabs rejoin it.
fn restore_hidden(layout: &mut Layout<String>, hidden: &mut std::collections::BTreeMap<String, craft_ui::docking::Location<String>>, panel: &str) {
let Some(mut at) = hidden.get(panel).cloned() else { return };
let mut terminal = panel.to_owned();
let mut seen = vec![terminal.clone()];
while let Some(anchor) = at.anchor.as_ref().filter(|id| !layout.contains(id)).cloned() {
if seen.len() >= PANELS.len() || seen.contains(&anchor) {
return;
}
let Some(next) = hidden.get(&anchor).cloned() else { return };
seen.push(anchor.clone());
terminal = anchor;
at = next;
}
if layout.restore(panel.to_owned(), &at).is_ok()
&& terminal != panel
&& let Some(last) = hidden.get_mut(&terminal)
{
last.anchor = Some(panel.to_owned());
last.placement = Placement::Tab { before: None };
last.size = None;
last.sibling.clear();
}
}
+628
View File
@@ -0,0 +1,628 @@
use super::*;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> PhotocraftApp {
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default())
}
#[test]
fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap();
let before_float = app.ui.docking.clone().unwrap();
app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"]));
app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into()));
app.run("window.panel.activate", json!({"panel":"properties"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
let layout = app.ui.docking.as_ref().unwrap();
assert!(valid(layout));
let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect();
assert_eq!(unique.len(), layout.panels().len());
let saved = serde_json::to_value(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert_eq!(restored.docking, app.ui.docking);
assert_eq!(restored.docking_hidden, app.ui.docking_hidden);
}
#[test]
fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() {
let mut app = app();
for (id, params) in [
("window.panel.move", json!({"panel":"layers","anchor":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})),
("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})),
("window.panel.float", json!({"panel":"layers","width":-10})),
("window.panel.float", json!({"panel":"unknown"})),
("window.panel.float", json!({"panel":"layers","x":"invalid"})),
] {
let old = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
assert!(app.run(id, params).is_err(), "{id}");
assert_eq!(app.ui.docking, old);
assert_eq!(app.ui.docking_hidden, hidden);
}
}
fn harness(app: PhotocraftApp, size: egui::Vec2) -> Harness<'static, PhotocraftApp> {
let mut ready = false;
let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium);
ready = true;
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
harness.run_steps(4);
harness
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(1);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for step in 1..=8 {
h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
drag(&mut h, source, egui::pos2(160.0, 140.0));
let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking");
assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}");
let source = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
let destination = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
drag(&mut h, source, destination);
let layout = h.state().ui.docking.as_ref().unwrap();
assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into())));
assert!(valid(layout));
}
#[test]
fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() {
let mut app = app();
let ctx = egui::Context::default();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap();
app.run("window.panel.close", json!({"panel":"swatches"})).unwrap();
let layout = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name":"Custom docking"})).unwrap();
crate::menus::invoke(&mut app, &ctx, "window.workspace.essentials", json!({})).unwrap();
assert!(app.ui.docking.is_none());
crate::menus::invoke(&mut app, &ctx, "window.workspace.select", json!({"name":"Custom docking"})).unwrap();
assert_eq!(app.ui.docking, layout);
assert_eq!(app.ui.docking_hidden, hidden);
}
#[test]
fn shared_layout_keeps_panel_identity_across_themes_and_respects_workspace_lock() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"color","anchor":"layers"})).unwrap();
let layout = app.ui.docking.clone();
app.ui.theme = crate::theme::ThemeKind::Studio;
assert_eq!(app.ui.docking, layout);
app.session.prefs.edit(|prefs| prefs.workspace_locked = true);
assert!(app.run("window.panel.float", json!({"panel":"color"})).is_err());
assert_eq!(app.ui.docking, layout);
assert!(app.run("window.panel.activate", json!({"panel":"color"})).is_ok());
}
/// Explicit offscreen evidence; this creates no native windows.
#[test]
fn capture_custom_panel_docking_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for theme in crate::theme::ThemeKind::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
let mut ready = false;
let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), theme);
app.ui.theme = theme;
ready = true;
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Custom panel workspace");
});
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(5);
assert!(valid(harness.state().ui.docking.as_ref().unwrap()));
harness.render().unwrap().save(directory.join(format!("photocraft-shared-docking-{}-{width}-{scale}x.png", theme.id()))).unwrap();
}
}
}
}
#[test]
fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() {
let mut h = harness(app(), vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
assert!(h.state().ui.docking.is_some());
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.docking.is_none());
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().ui.docking.is_none());
}
#[test]
fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap();
assert!(
matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001)
);
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();
assert_eq!(
app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect,
[44.0, 66.0, 360.0, 450.0]
);
app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})).unwrap();
assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]);
let before = app.ui.docking.clone();
let hidden = app.ui.docking_hidden.clone();
for action in [
json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}),
json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}),
json!({"Open":{"panel":"unknown","anchor":null}}),
json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}),
] {
assert!(app.run("window.panel.layout", json!({"action":action})).is_err());
assert_eq!(app.ui.docking, before);
assert_eq!(app.ui.docking_hidden, hidden);
}
app.ui.docking = Some(Layout {
root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }),
floating: Vec::new(),
});
app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap();
app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap();
let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") };
assert!(!entries[0].open);
assert_eq!(entries[0].height, Some(150.0));
}
fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(),
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
_ => {}
}
}
Vec::new()
}
#[test]
fn customization_retains_native_collapsed_groups_until_their_tab_is_activated() {
let mut app = app();
app.ui.dock.set_collapsed(Group::Color, true);
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(collapsed_members(&app, layout).contains("swatches"));
let saved = serde_json::to_value(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert!(restored.dock.is_collapsed(Group::Color));
app.run("window.panel.layout", json!({"action":{"Activate":{"panel":"swatches"}}})).unwrap();
assert!(!app.ui.dock.is_collapsed(Group::Color));
}
#[test]
fn splitting_a_short_column_keeps_the_layers_footer_visible_and_usable() {
let mut app = app();
app.run("file.new", json!({"width":480,"height":360})).unwrap();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
let mut h = harness(app, vec2(800.0, 800.0));
let button = h.get_by_label("Create a new layer").rect();
assert!(button.bottom() <= 800.0, "Layers footer is clipped below the viewport: {button:?}");
let before = h.state().session.active().unwrap().doc.layers.len();
let at = button.center();
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before + 1);
}
#[test]
fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() {
let mut app = app();
app.ui.theme = crate::theme::ThemeKind::Studio;
app.run("file.new", json!({"width":480,"height":360})).unwrap();
app.run("layer.new.layer", json!({"name":"Docking sample"})).unwrap();
app.run("select.rect", json!({"x":80,"y":70,"width":250,"height":160})).unwrap();
app.run("edit.fill", json!({"color":"#5297bb"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
app.run("window.panel.float", json!({"panel":"properties","x":40,"y":140,"width":310,"height":490})).unwrap();
let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from);
let builder = Harness::builder().with_size(vec2(1280.0, 800.0));
let builder = if directory.is_some() { builder.wgpu() } else { builder };
let mut ready = false;
let mut h = builder.build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::Studio);
ready = true;
return;
}
crate::panels::title_bar(app, ui);
crate::panels::options_bar(app, ui);
crate::panels::toolbar(app, ui);
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
h.input_mut().max_texture_side = Some(8192);
h.run_steps(5);
let floating = h.ctx.memory(|m| m.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"properties".to_string())))).unwrap();
let mut sliders: Vec<_> = h.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, widgets)| widgets.iter())
.filter(|widget| widget.sense.senses_drag() && widget.rect.height() == 18.0 && floating.contains_rect(widget.rect))
.map(|widget| widget.rect)
.collect()
});
sliders.sort_by(|a, b| a.top().total_cmp(&b.top()));
let at = sliders.first().expect("Properties opacity slider").center();
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
let opacity = h.state().session.active().unwrap().doc.layer(layer).unwrap().opacity;
assert!((opacity - 0.5).abs() < 0.03, "actual opacity {opacity}");
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("photocraft-populated-opacity-floating.png")).unwrap();
}
let from = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
let to = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
drag(&mut h, from, to);
assert!(h.state().ui.docking.as_ref().unwrap().floating.iter().all(|group| !group.panels.iter().any(|panel| panel == "properties")));
assert_eq!(h.state().session.active().unwrap().doc.layer(layer).unwrap().opacity, opacity);
if let Some(directory) = &directory {
h.render().unwrap().save(directory.join("photocraft-populated-opacity-redocked.png")).unwrap();
}
}
#[test]
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
let destination = app.ui.docking.clone();
let ctx = egui::Context::default();
crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name":"Destination"})).unwrap();
crate::menus::invoke(&mut app, &ctx, "window.workspace.essentials", json!({})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
h.run_steps(2);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.select", json!({"name":"Destination"})).unwrap();
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
assert_eq!(h.state().ui.docking, destination);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.docking, destination);
}
#[test]
fn first_legacy_drag_workspace_switch_preserves_custom_and_legacy_destinations() {
let layout_state = |app: &PhotocraftApp| json!({"panels":app.ui.panels,"dock":app.ui.dock,"tabs":app.ui.dock_tabs,"layout":app.ui.docking,"hidden":app.ui.docking_hidden,"collapsed":app.ui.docking_collapsed,"timeline":app.ui.timeline.open});
for customized_destination in [true, false] {
for cancellation in ["release", "escape", "focus-loss"] {
let mut app = app();
let ctx = egui::Context::default();
if customized_destination {
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name":"Destination"})).unwrap();
crate::menus::invoke(&mut app, &ctx, "window.workspace.essentials", json!({})).unwrap();
}
let mut h = harness(app, vec2(1280.0, 800.0));
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
h.event(Event::PointerMoved(source));
h.run_steps(1);
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
// One frame installs the first legacy drag; no shared dock has rendered yet.
h.run_steps(1);
assert!(h.state().ui.docking.is_some());
let origin = egui::Id::new("photocraft-panel-docking").with("legacy-origin");
assert!(h.ctx.data_mut(|data| data.get_temp::<LegacyDrag>(origin)).unwrap().layout.is_none());
let ctx = h.ctx.clone();
let command = if customized_destination { "window.workspace.select" } else { "window.workspace.painting" };
crate::menus::invoke(h.state_mut(), &ctx, command, json!({"name":"Destination"})).unwrap();
assert_eq!(h.state().ui.docking.is_some(), customized_destination);
let destination = layout_state(h.state());
let tabs = serde_json::to_value(h.state().ui.dock_tabs).unwrap();
match cancellation {
"escape" => h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }),
"focus-loss" => h.event(Event::WindowFocused(false)),
_ => {
h.event(Event::PointerButton { pos: source - vec2(90.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE })
}
}
h.run_steps(1);
assert_eq!(layout_state(h.state()), destination, "{customized_destination}/{cancellation}");
assert_eq!(serde_json::to_value(h.state().ui.dock_tabs).unwrap(), tabs);
assert!(h.ctx.data_mut(|data| data.get_temp::<LegacyDrag>(origin)).is_none(), "stale origin: {customized_destination}/{cancellation}");
h.event(Event::WindowFocused(true));
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(layout_state(h.state()), destination);
}
}
}
fn control_set(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Value {
let (request, _) = crate::control::ControlRequest::new("ui.set", params);
match crate::control::handle(app, ctx, &request) {
crate::control::Outcome::Done(value) => value,
_ => panic!("ui.set must reply synchronously"),
}
}
#[test]
fn current_main_hidden_tabs_migrate_by_name_and_selected_identity() {
for theme in [crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Studio] {
let mut app = app();
app.ui.theme = theme;
let pro = matches!(theme, crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium);
let color = Group::Color.tabs(pro).iter().position(|name| *name == "Color").unwrap();
let swatches = Group::Color.tabs(pro).iter().position(|name| *name == "Swatches").unwrap();
app.ui.dock.hide_tab(Group::Color, swatches, pro);
app.ui.dock.hide_tab(Group::Layers, 2, pro);
app.ui.dock_tabs.color = color;
let saved = json!({"panels":app.ui.panels,"dock":app.ui.dock,"dockTabs":app.ui.dock_tabs});
assert_eq!(saved["dock"]["hidden_tabs"]["layers"], json!(["Paths"]));
let mut restored = self::app();
restored.ui.theme = theme;
crate::dock::apply(&mut restored, &saved);
restored.run("window.panel.float", json!({"panel":"properties"})).unwrap();
let layout = restored.ui.docking.as_ref().unwrap();
assert!(!layout.contains(&"swatches".into()) && !layout.contains(&"paths".into()));
assert!(panel_active(layout, "color"));
assert!(restored.ui.docking_hidden.contains_key("swatches") && restored.ui.docking_hidden.contains_key("paths"));
restored.run("window.panel.swatches", json!({"show":true})).unwrap();
assert!(panel_active(restored.ui.docking.as_ref().unwrap(), "swatches"));
}
}
#[test]
fn legacy_control_updates_authoritative_custom_visibility_and_selection_atomically() {
let mut app = app();
app.ui.theme = crate::theme::ThemeKind::ProMedium;
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
let ctx = egui::Context::default();
assert_eq!(control_set(&mut app, &ctx, json!({"dockTabs":{"layers":1}}))["ok"], true);
assert!(panel_active(app.ui.docking.as_ref().unwrap(), "channels"));
assert_eq!(control_set(&mut app, &ctx, json!({"panels":{"layers":false}}))["ok"], true);
assert!(PANELS.iter().filter(|p| p.2 == Group::Layers).all(|p| !app.ui.docking.as_ref().unwrap().contains(&p.0.into())));
assert!(!app.ui.panels.layers);
assert_eq!(control_set(&mut app, &ctx, json!({"panels":{"layers":true},"dockTabs":{"layers":2}}))["ok"], true);
assert!(panel_active(app.ui.docking.as_ref().unwrap(), "paths"));
let before = serde_json::to_value(&app.ui).unwrap();
for request in [
json!({"panels":{"color":false},"dockTabs":{"layers":999}}),
json!({"tool":"brush","dock":{"order":["layers","color"]}}),
json!({"panels":{"layers":false},"dockTabs":{"layers":1}}),
] {
assert_eq!(control_set(&mut app, &ctx, request)["ok"], false);
assert_eq!(serde_json::to_value(&app.ui).unwrap(), before);
}
}
#[test]
fn legacy_dock_width_is_consumed_by_custom_renderer_and_clamped_to_existing_range() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let ctx = h.ctx.clone();
for (requested, expected) in [(410.0, 410.0), (900.0, 520.0), (20.0, 250.0)] {
assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true);
h.run_steps(2);
let state = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap();
assert!((state.outer_rect.width() - expected).abs() < 1.0, "{} / {expected}", state.outer_rect.width());
assert!(h.ctx.data(|data| data.get_temp::<f32>(crate::panels::dock_width_id())).is_none());
}
}
#[test]
fn explicit_reveal_activates_existing_tab_and_expands_custom_group() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"properties","anchor":"layers"})).unwrap();
app.run("window.panel.group", json!({"panel":"layers","operation":"collapse"})).unwrap();
app.run("window.panel.layers", json!({"show":true})).unwrap();
assert!(panel_active(app.ui.docking.as_ref().unwrap(), "layers"));
assert!(!collapsed_members(&app, app.ui.docking.as_ref().unwrap()).contains("layers"));
app.run("window.panel.layers", json!({"show":true})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().contains(&"layers".into()));
let before = app.ui.docking.clone();
assert!(app.run("window.panel.layers", json!({"show":"yes"})).is_err());
assert_eq!(app.ui.docking, before);
}
#[test]
fn custom_group_commands_preserve_members_and_workspace_timeline() {
let mut app = app();
app.run("window.panel.move", json!({"panel":"properties","anchor":"layers"})).unwrap();
let members = group_members(app.ui.docking.as_ref().unwrap(), "layers");
app.run("window.panel.group", json!({"panel":"layers","operation":"up"})).unwrap();
assert_eq!(group_members(app.ui.docking.as_ref().unwrap(), "layers"), members);
app.ui.timeline.open = true;
app.run("window.panel.group", json!({"panel":"layers","operation":"collapse"})).unwrap();
let saved = json!({"panels":app.ui.panels,"dock":app.ui.dock,"dockTabs":app.ui.dock_tabs,"docking":app.ui.docking,"dockingHidden":app.ui.docking_hidden,"dockingCollapsed":app.ui.docking_collapsed,"timelineOpen":true});
let mut restored = self::app();
crate::dock::apply(&mut restored, &saved);
assert_eq!(restored.ui.docking, app.ui.docking);
assert!(restored.ui.timeline.open);
assert!(collapsed_members(&restored, restored.ui.docking.as_ref().unwrap()).contains("layers"));
app.run("window.panel.group", json!({"panel":"layers","operation":"close"})).unwrap();
assert!(members.iter().all(|id| !app.ui.docking.as_ref().unwrap().contains(id)));
assert!(members.iter().all(|id| app.ui.docking_hidden.contains_key(id)));
app.session.prefs.edit(|p| p.workspace_locked = true);
let before = serde_json::to_value(&app.ui).unwrap();
assert!(app.run("window.panel.group", json!({"panel":"color","operation":"close"})).is_err());
assert_eq!(serde_json::to_value(&app.ui).unwrap(), before);
restored.ui.timeline.playing = true;
crate::dock::apply(&mut restored, &json!({"dock":{}}));
assert!(!restored.ui.timeline.open && !restored.ui.timeline.playing);
}
#[test]
fn customized_panel_menus_and_rail_remain_reachable_after_float_and_restore() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"swatches","x":20,"y":40})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
assert!(h.query_all_by_label("Panel menu").count() > 0);
assert!(h.query_all_by_label("Color & Swatches").count() > 0);
let floating = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"swatches".to_string())))).unwrap();
let first = h.query_all_by_label("Panel menu").find(|node| floating.contains_rect(node.rect())).unwrap().rect().center();
h.hover_at(first);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: first, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert!(h.query_all_by_label("Close Tab Group").count() > 0);
assert!(h.query_all_by_label("New Swatch…").count() > 0);
// Panel-specific menu body is shared by both its context and group menu seams.
let mut ready = false;
let mut menu = Harness::builder().build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium);
ready = true;
}
panel_menu(app, ui, "swatches");
},
self::app(),
);
menu.run_steps(2);
assert!(menu.query_all_by_label("New Swatch…").count() > 0);
}
#[test]
fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
app.ui.timeline.open = true;
app.ui.panels.rail = false;
app.ui.panels.menu_bar = false;
let ctx = egui::Context::default();
crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name":"Docked Motion"})).unwrap();
let saved = app.session.prefs().workspaces.get("Docked Motion").unwrap().clone();
assert!(saved["docking"].is_object() && saved["timelineOpen"] == true);
assert!(saved["panels"].get("rail").is_none() && saved["panels"].get("menu_bar").is_none());
let layout = app.ui.docking.clone();
app.ui.workspace = "Motion".into();
crate::menus::apply_workspace(&mut app);
assert!(app.ui.docking.is_none() && app.ui.timeline.open);
crate::dock::apply(&mut app, &saved);
assert_eq!(app.ui.docking, layout);
assert!(app.ui.timeline.open);
}
#[test]
fn closing_and_reopening_a_whole_group_retains_its_return_position_and_tab_order() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
let before = app.ui.docking.clone().unwrap();
let members = group_members(&before, "layers");
app.run("window.panel.group", json!({"panel":"layers","operation":"close"})).unwrap();
for panel in &members {
app.run("window.panel.activate", json!({"panel":panel})).unwrap();
}
let after = app.ui.docking.as_ref().unwrap();
assert_eq!(group_members(after, "layers"), members);
assert_eq!(root_groups(after), root_groups(&before));
assert!(valid(after));
}
#[test]
fn floating_group_grip_redocks_all_tabs_as_one_atomic_move() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"swatches","x":30,"y":60})).unwrap();
app.run("window.panel.move", json!({"panel":"patterns","anchor":"swatches"})).unwrap();
let mut h = harness(app, vec2(1280.0, 800.0));
let from = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("move-group", Some(&"swatches".to_string())))).unwrap().rect.center();
let to = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
drag(&mut h, from, to);
let layout = h.state().ui.docking.as_ref().unwrap();
assert!(layout.floating.iter().all(|group| !group.panels.iter().any(|id| id == "swatches" || id == "patterns")));
let members = group_members(layout, "layers");
let swatches = members.iter().position(|id| id == "swatches").unwrap();
assert_eq!(members.get(swatches + 1).map(String::as_str), Some("patterns"));
assert!(valid(layout));
let before = h.state().ui.docking.clone();
assert!(h.state_mut().run("window.panel.group", json!({"panel":"swatches","operation":"move","anchor":"patterns"})).is_err());
assert_eq!(h.state().ui.docking, before);
}
#[test]
fn mixed_theme_and_legacy_selection_projects_the_selected_panel_in_the_new_theme() {
let mut app = app();
app.ui.theme = crate::theme::ThemeKind::ProMedium;
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
let ctx = egui::Context::default();
assert_eq!(control_set(&mut app, &ctx, json!({"theme":"studio","dockTabs":{"color":0}}))["ok"], true);
assert!(panel_active(app.ui.docking.as_ref().unwrap(), "swatches"));
assert_eq!(app.ui.dock_tabs.color, 0);
assert_eq!(control_set(&mut app, &ctx, json!({"theme":"proMedium","dockTabs":{"color":0}}))["ok"], true);
assert!(panel_active(app.ui.docking.as_ref().unwrap(), "color"));
assert_eq!(app.ui.dock_tabs.color, 0);
}
+564 -167
View File
@@ -117,177 +117,189 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if ui.input(|i| i.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
egui::ScrollArea::vertical().id_salt("toolbar-columns").show(ui, |ui| {
// Pro has no group dividers, so its two columns fill every row across groups.
let sections: Vec<Vec<(usize, Vec<Tool>)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections };
for (si, section) in sections.iter().enumerate() {
// Photoshop 2026 draws one uninterrupted column (no group dividers).
if si > 0 && !t.pro {
ui.add_space(4.0);
let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover());
ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator));
ui.add_space(4.0);
}
let rows: Vec<&[(usize, Vec<Tool>)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
for row in rows {
ui.horizontal(|ui| {
for (slot_index, slot) in row.iter() {
let key = egui::Id::new(("tool-slot", *slot_index));
let tool = slot_tool(ui, app.ui.tool, slot, key);
let sel = slot.contains(&app.ui.tool);
let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) };
let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &tip);
if slot.len() > 1 {
paint_toolbar_flyout_indicator(ui, &t, &resp);
}
if resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) != Some(key) {
app.ui.tool = tool;
}
// Double-clicking the Hand tool fits the image on screen (Photoshop).
if resp.double_clicked() && tool == Tool::Hand {
app.ui.tool = tool;
// With no document open there is nothing to fit.
let _ = app.run("view.fitOnScreen", json!({}));
}
// Right-click or long-press opens the flyout (Photoshop).
let held_for =
resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten();
if slot.len() > 1
&& let Some(seconds) = held_for
&& seconds < 0.35
{
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
}
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35);
if slot.len() > 1 && (resp.secondary_clicked() || long_press) {
ui.data_mut(|d| {
d.insert_temp(flyout_id, (key, resp.rect));
if long_press {
d.insert_temp(held_id, key);
}
});
}
if slot.len() > 1 && long_press {
ui.ctx().request_repaint();
}
if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id))
&& open_key == key
{
let area = egui::Area::new(key.with("flyout"))
.order(egui::Order::Foreground)
.fixed_pos(anchor.right_top() + vec2(6.0, 0.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
let label_w = slot
.iter()
.map(|it| {
ui.painter()
.layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text)
.size()
.x
})
.fold(0.0f32, f32::max);
let fw = (label_w + 42.0 + 40.0).max(180.0);
for &item in slot.iter() {
let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click());
if ir.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
if item == tool {
ui.painter().rect_filled(
Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)),
0.0,
let ctx = ui.ctx().clone();
let footer = (if t.pro { 8.0 } else { 14.0 })
+ chips_height(t.pro, t.round_chips, t.round_chips && double)
+ if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 }
+ 6.0;
egui::ScrollArea::vertical()
.id_salt("toolbar-columns")
.auto_shrink([false, false])
.max_height((ui.available_height() - footer).max(0.0))
.min_scrolled_height(0.0)
.show(ui, |ui| {
// Pro has no group dividers, so its two columns fill every row across groups.
let sections: Vec<Vec<(usize, Vec<Tool>)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections };
for (si, section) in sections.iter().enumerate() {
// Photoshop 2026 draws one uninterrupted column (no group dividers).
if si > 0 && !t.pro {
ui.add_space(4.0);
let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover());
ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator));
ui.add_space(4.0);
}
let rows: Vec<&[(usize, Vec<Tool>)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
for row in rows {
ui.horizontal(|ui| {
for (slot_index, slot) in row.iter() {
let key = egui::Id::new(("tool-slot", *slot_index));
let tool = slot_tool(ui, app.ui.tool, slot, key);
let sel = slot.contains(&app.ui.tool);
let tip =
if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) };
let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &tip);
if slot.len() > 1 {
paint_toolbar_flyout_indicator(ui, &t, &resp);
}
if resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) != Some(key) {
app.ui.tool = tool;
}
// Double-clicking the Hand tool fits the image on screen (Photoshop).
if resp.double_clicked() && tool == Tool::Hand {
app.ui.tool = tool;
// With no document open there is nothing to fit.
let _ = app.run("view.fitOnScreen", json!({}));
}
// Right-click or long-press opens the flyout (Photoshop).
let held_for =
resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten();
if slot.len() > 1
&& let Some(seconds) = held_for
&& seconds < 0.35
{
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
}
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35);
if slot.len() > 1 && (resp.secondary_clicked() || long_press) {
ui.data_mut(|d| {
d.insert_temp(flyout_id, (key, resp.rect));
if long_press {
d.insert_temp(held_id, key);
}
});
}
if slot.len() > 1 && long_press {
ui.ctx().request_repaint();
}
if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id))
&& open_key == key
{
let area = egui::Area::new(key.with("flyout"))
.order(egui::Order::Foreground)
.fixed_pos(anchor.right_top() + vec2(6.0, 0.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
let label_w = slot
.iter()
.map(|it| {
ui.painter()
.layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text)
.size()
.x
})
.fold(0.0f32, f32::max);
let fw = (label_w + 42.0 + 40.0).max(180.0);
for &item in slot.iter() {
let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click());
if ir.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
if item == tool {
ui.painter().rect_filled(
Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)),
0.0,
t.text,
);
}
icons::paint(
ui,
Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)),
icons::tool_icon(item),
14.0,
t.icon,
);
ui.painter().text(
pos2(r.left() + 42.0, r.center().y),
Align2::LEFT_CENTER,
tl!(item.label()),
egui::FontId::proportional(12.5),
t.text,
);
if item.key() != '\0' {
ui.painter().text(
pos2(r.right() - 8.0, r.center().y),
Align2::RIGHT_CENTER,
item.key().to_string(),
egui::FontId::proportional(12.0),
t.text_dim,
);
}
if ir.clicked() {
app.ui.tool = item;
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
}
}
icons::paint(
ui,
Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)),
icons::tool_icon(item),
14.0,
t.icon,
);
ui.painter().text(
pos2(r.left() + 42.0, r.center().y),
Align2::LEFT_CENTER,
tl!(item.label()),
egui::FontId::proportional(12.5),
t.text,
);
if item.key() != '\0' {
ui.painter().text(
pos2(r.right() - 8.0, r.center().y),
Align2::RIGHT_CENTER,
item.key().to_string(),
egui::FontId::proportional(12.0),
t.text_dim,
);
}
if ir.clicked() {
app.ui.tool = item;
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
}
}
});
});
});
let held_release = resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(key);
let clicked_outside = ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered();
if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
let held_release = resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(key);
let clicked_outside =
ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered();
if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
}
}
}
}
});
});
}
}
}
let ctx = ui.ctx().clone();
if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() {
let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({}));
}
ui.add_space(if t.pro { 8.0 } else { 14.0 });
color_chips(app, ui, double);
if t.pro {
ui.add_space(8.0);
if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() {
let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({}));
}
reveal_focused_control(ui);
});
ui.add_space(if t.pro { 8.0 } else { 14.0 });
color_chips(app, ui, double);
if t.pro {
ui.add_space(8.0);
let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) };
let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) };
ui.with_layout(layout, |ui| {
let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some());
if icons::button(
ui,
"square-dashed",
bx,
quick_mask,
if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") },
)
.clicked()
{
let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({}));
}
let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)"));
paint_toolbar_flyout_indicator(ui, &t, &sm);
ui.with_layout(layout, |ui| {
let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some());
if icons::button(
ui,
"square-dashed",
bx,
quick_mask,
if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") },
)
.clicked()
{
let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({}));
}
let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)"));
paint_toolbar_flyout_indicator(ui, &t, &sm);
if sm.clicked() {
let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({}));
}
// Right-click (or long-press) lists the modes, like Photoshop's flyout.
egui::Popup::context_menu(&sm).show(|ui| {
ui.set_min_width(220.0);
for (id, label) in [
("view.screenMode.standard", tl!("Standard Screen Mode")),
("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")),
("view.screenMode.fullScreen", tl!("Full Screen Mode")),
] {
let on = crate::view_cmds::checked(app, id).unwrap_or(false);
if ui.add(egui::Button::selectable(on, label)).clicked() {
let _ = crate::menus::invoke(app, &ctx, id, json!({}));
ui.close();
}
if sm.clicked() {
let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({}));
}
// Right-click (or long-press) lists the modes, like Photoshop's flyout.
egui::Popup::context_menu(&sm).show(|ui| {
ui.set_min_width(220.0);
for (id, label) in [
("view.screenMode.standard", tl!("Standard Screen Mode")),
("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")),
("view.screenMode.fullScreen", tl!("Full Screen Mode")),
] {
let on = crate::view_cmds::checked(app, id).unwrap_or(false);
if ui.add(egui::Button::selectable(on, label)).clicked() {
let _ = crate::menus::invoke(app, &ctx, id, json!({}));
ui.close();
}
});
}
});
}
});
});
}
},
);
}
@@ -770,6 +782,29 @@ mod title_tests {
}
}
// Custom controls also need to bring keyboard focus into a scroll viewport.
fn reveal_focused_control(ui: &egui::Ui) {
// read_response reprocesses pointer events. Reading a popup's newly focused field
// during its opening click can undo the focus that the popup just requested.
if ui.input(|input| input.pointer.any_pressed() || input.pointer.any_released()) {
return;
}
let focused = ui.memory(|memory| memory.focused());
let key = ui.id().with("scroll-focused-control");
let changed = ui.data_mut(|data| {
let previous = data.get_temp::<Option<egui::Id>>(key).flatten();
data.insert_temp(key, focused);
previous != focused
});
if changed
&& let Some(response) = focused.and_then(|id| ui.ctx().read_response(id))
&& response.layer_id == ui.layer_id()
&& ui.min_rect().contains_rect(response.rect)
{
ui.scroll_to_rect(response.rect, Some(egui::Align::Center));
}
}
// ----------------------------------------------------------------------------- options bar
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
@@ -778,6 +813,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.exact_size(if t.pro { 36.0 } else { 42.0 })
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)))
.show(ui, |ui| {
let r = ui.max_rect();
ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator));
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator));
ui.style_mut().spacing.scroll = egui::style::ScrollStyle { bar_width: 4.0, ..egui::style::ScrollStyle::floating() };
egui::ScrollArea::horizontal().id_salt("tool-options").auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_centered(|ui| {
if t.pro {
crate::chrome_ui::home_button(app, ui);
@@ -1268,6 +1308,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush);
});
// Font ink can extend beyond its advance width at fractional positions.
ui.expand_to_include_x(ui.min_rect().right() + 2.0);
reveal_focused_control(ui);
});
});
}
@@ -1372,9 +1416,11 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// ----------------------------------------------------------------------------- dock
pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Even legacy destinations must discard gestures captured by an earlier workspace.
let customized = crate::panel_docking::show(app, ui);
let t = Tokens::get(ui.ctx());
let p = app.ui.panels.clone();
if t.pro {
if t.pro && !customized {
dock_panels(app, ui, &p, &t);
}
// Narrow icon rail, unless an embedding app hides it: shows, expands or collapses panel groups.
@@ -1397,24 +1443,28 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
for (icon, name, g) in entries {
// Studio floats Properties outside the dock.
let docked = t.pro || g != Group::Properties;
let on = g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g));
let on = if customized { crate::panel_docking::rail_on(app, g) } else { g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g)) };
if icons::rail_button(ui, icon, rb, on, name).clicked() {
crate::dock::rail_click(app, g, docked);
if customized {
crate::panel_docking::rail_click(app, g);
} else {
crate::dock::rail_click(app, g, docked);
}
}
}
},
);
}
if !t.pro {
if !t.pro && !customized {
dock_panels(app, ui, &p, &t);
}
crate::dock::persist(app, ui.ctx());
}
/// The right dock's width range (points).
const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
pub(crate) const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
fn dock_width_id() -> egui::Id {
pub(crate) fn dock_width_id() -> egui::Id {
egui::Id::new("dock-width-request")
}
@@ -1453,7 +1503,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
}
/// One dock group's tab content; `dock` bounds it and scrolls it when it's taller.
fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Group, tab: usize) {
pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Group, tab: usize) {
use crate::dock::Group;
let pro = Tokens::get(ui.ctx()).pro;
match (group, tab) {
@@ -2515,6 +2565,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// Floating Properties card anchored to the canvas' top-right corner.
pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
if app.ui.docking.is_some() {
return;
}
// Pro (Photoshop) docks Properties; Studio floats it over the canvas.
if !app.ui.panels.properties || Tokens::get(ctx).pro {
return;
@@ -2585,7 +2638,7 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::hairline(ui);
ui.add_space(8.0);
// Per-layer ids, so text still being typed for one layer can't commit to the next.
ui.push_id(id, |ui| {
ui.push_id((doc.id, id, mask_target), |ui| {
if mask_target {
crate::mask_props_ui::properties(app, ui, layer);
} else if let LayerContent::Adjustment(adj) = &layer.content {
@@ -2658,6 +2711,11 @@ fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
/// Docked Properties body (Pro theme).
fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let identity = app.session.active().map(|st| (st.doc.id, st.active_layer, app.ui.mask_target, app.ui.vector_mask_target));
ui.push_id(("properties-target", identity), |ui| properties_body_inner(app, ui));
}
fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
empty(ui, tl!("No properties"));
@@ -4118,3 +4176,342 @@ mod group_drag_selection_tests {
#[cfg(test)]
#[path = "mask_props_ui_tests.rs"]
mod mask_properties_tests;
#[cfg(test)]
mod compact_visual_regressions {
use super::*;
use egui_kittest::kittest::Queryable;
fn input_at_scale(screen: Rect, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
input
}
fn toolbar_harness(kind: theme::ThemeKind, scale: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 360.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
theme::install_fonts(ui.ctx());
theme::apply(ui.ctx(), kind);
app.session.prefs.edit(|prefs| prefs.shortcuts.insert("window.togglePanels".into(), String::new()));
ready = true;
return;
}
crate::shortcuts::handle(app, ui.ctx());
toolbar(app, ui);
},
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()),
);
harness.run_steps(4);
harness
}
#[test]
fn compact_toolbar_scrolls_to_and_activates_the_last_tool() {
for kind in theme::ThemeKind::ALL {
for scale in [1.0, 1.5, 2.0] {
let mut harness = toolbar_harness(kind, scale);
harness.event(egui::Event::PointerMoved(pos2(25.0, 100.0)));
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -1500.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
harness.run_steps(30);
let node = harness.get_by_label("Zoom Tool (Z)");
let rect = node.rect();
assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: last tool remains outside the toolbar: {rect:?}");
node.click();
harness.run_steps(3);
assert_eq!(harness.state().ui.tool, Tool::Zoom, "the scrolled button must select its real tool");
}
}
}
#[test]
fn compact_toolbar_tab_reveals_and_activates_the_last_tool() {
for kind in theme::ThemeKind::ALL {
for scale in [1.0, 1.5, 2.0] {
let mut harness = toolbar_harness(kind, scale);
// The supported shortcut preference clears the canvas Tab binding. From no
// focus, every step below is a real Tab processed by the app's dispatcher.
let mut reached = false;
for _ in 0..80 {
harness.key_press(egui::Key::Tab);
harness.run_steps(3);
reached = harness.get_by_label("Zoom Tool (Z)").is_focused();
if reached {
break;
}
}
assert!(reached, "{kind:?} at {scale}x: Tab must reach the last tool");
harness.run_steps(20);
let rect = harness.get_by_label("Zoom Tool (Z)").rect();
assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: focused tool remains clipped: {rect:?}");
harness.key_press(egui::Key::Space);
harness.run_steps(3);
assert_eq!(harness.state().ui.tool, Tool::Zoom, "Space must select the revealed tool");
}
}
}
#[test]
fn capture_compact_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for kind in crate::theme::ThemeKind::ALL {
for (width, height) in [(800.0, 480.0), (1280.0, 720.0)] {
for scale in [1.0, 1.5, 2.0] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.tools.foreground = [1.0, 0.0, 1.0, 1.0];
app.session.tools.background = [0.0, 1.0, 0.0, 1.0];
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(width, height)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), kind);
ready = true;
return;
}
app.ui.tool = if Tokens::get(ui.ctx()).pro { Tool::Pencil } else { Tool::Brush };
options_bar(app, ui);
toolbar(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("photocraft-compact-{}-{width}-{height}-{scale}x", kind.id());
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let mut regions = serde_json::Map::new();
for id in ["options_bar", "toolbar"] {
let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
regions.insert(id.into(), json!([r.left(), r.top(), r.width(), r.height()]));
}
let metadata = json!({"screen": [width, height], "scale": scale, "regions": regions});
std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
for _ in 0..6 {
harness.input_mut().events.extend([
egui::Event::PointerMoved(pos2(width * 0.5, 20.0)),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-1500.0, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
},
]);
harness.run_steps(1);
}
harness.render().unwrap().save(directory.join(format!("{stem}-scrolled.png"))).unwrap();
std::fs::write(directory.join(format!("{stem}-scrolled.json")), serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
}
}
}
}
#[test]
fn compact_toolbar_keeps_both_color_chips_visible() {
for kind in crate::theme::ThemeKind::ALL {
for height in [480.0, 720.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, kind);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.tools.foreground = [1.0, 0.0, 1.0, 1.0];
app.session.tools.background = [0.0, 1.0, 0.0, 1.0];
let mut output = ctx.run_ui(input_at_scale(Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, height)), scale), |ui| toolbar(&mut app, ui));
output.textures_delta.clear();
for color in [Color32::from_rgb(255, 0, 255), Color32::GREEN] {
let (clip, chip) = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.fill == color => Some((shape.clip_rect, rect.rect)),
egui::Shape::Circle(circle) if circle.fill == color => {
Some((shape.clip_rect, Rect::from_center_size(circle.center, vec2(circle.radius * 2.0, circle.radius * 2.0))))
}
_ => None,
})
.unwrap_or_else(|| panic!("{kind:?} at {height}/{scale}: both color chips are painted"));
assert!(clip.contains_rect(chip) && chip.bottom() <= height, "{kind:?} at {height}: color chip {chip:?} clipped by {clip:?}");
}
}
}
}
}
#[test]
fn tab_focus_reveals_the_final_narrow_tool_option() {
for kind in theme::ThemeKind::ALL {
for scale in [1.0, 1.5, 2.0] {
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(360.0, 120.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
theme::install_fonts(ui.ctx());
theme::apply(ui.ctx(), kind);
app.ui.tool = if Tokens::get(ui.ctx()).pro { Tool::Pencil } else { Tool::Brush };
app.session.prefs.edit(|prefs| prefs.shortcuts.insert("window.togglePanels".into(), String::new()));
ready = true;
return;
}
crate::shortcuts::handle(app, ui.ctx());
options_bar(app, ui);
},
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()),
);
harness.run_steps(4);
let pro = Tokens::get(&harness.ctx).pro;
let last_label = if pro { "Auto Erase" } else { "Pressure for Opacity" };
// Only the canvas Tab shortcut is cleared, using its public preference;
// no response is assigned focus programmatically.
let mut reached = false;
for _ in 0..80 {
harness.key_press(egui::Key::Tab);
harness.run_steps(3);
let label = harness.output().shapes.iter().find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text.contains(last_label) => Some(text.visual_bounding_rect()),
_ => None,
});
reached = label.is_some_and(|label| {
harness.ctx.memory(|memory| memory.focused()).and_then(|id| harness.ctx.read_response(id)).is_some_and(|response| {
response.rect.intersects(label)
|| ((label.left() - response.rect.right()).abs() <= 12.0
&& response.rect.top() < label.bottom()
&& response.rect.bottom() > label.top())
})
});
if reached {
break;
}
}
assert!(reached, "{kind:?} at {scale}x: Tab must reach the final tool option");
harness.run_steps(20);
let (clip, label) = harness
.output()
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text.contains(last_label) => Some((shape.clip_rect, text.visual_bounding_rect())),
_ => None,
})
.expect("the final option remains painted");
assert!(clip.contains_rect(label), "{kind:?} at {scale}x: Tab leaves the focused final option outside {clip:?}: {label:?}");
let previous = if pro { harness.state().ui.tool_options.pencil_auto_erase } else { harness.state().session.tools.brush.pressure_opacity };
harness.key_press(egui::Key::Space);
harness.run_steps(3);
let current = if pro { harness.state().ui.tool_options.pencil_auto_erase } else { harness.state().session.tools.brush.pressure_opacity };
assert_ne!(current, previous, "{kind:?} at {scale}x: Space must activate the revealed option");
assert!(harness.state().ui.panels.options_bar, "widget traversal must preserve the options bar");
}
}
}
#[test]
fn narrow_tool_options_can_scroll_to_the_last_control() {
for kind in crate::theme::ThemeKind::ALL {
for width in [360.0, 800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, kind);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let (tool, last_label) = if Tokens::get(&ctx).pro { (Tool::Pencil, "Auto Erase") } else { (Tool::Brush, "Pressure for Opacity") };
app.ui.tool = tool;
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 120.0));
let mut output = ctx.run_ui(input_at_scale(screen, scale), |ui| options_bar(&mut app, ui));
output.textures_delta.clear();
for frame in 1..=6 {
output = ctx.run_ui(
egui::RawInput {
time: Some(f64::from(frame) * 0.1),
events: vec![
egui::Event::PointerMoved(pos2(width * 0.5, 20.0)),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-1500.0, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
},
],
..input_at_scale(screen, scale)
},
|ui| options_bar(&mut app, ui),
);
output.textures_delta.clear();
}
let (clip, label) = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text.contains(last_label) => Some((shape.clip_rect, text)),
_ => None,
})
.expect("scrolling reaches the final tool control");
assert!(
clip.contains_rect(label.visual_bounding_rect()),
"{kind:?} at {width}: final control is clipped: {:?} / {clip:?}",
label.visual_bounding_rect()
);
assert!(label.visual_bounding_rect().right() <= width, "the final option stays inside the window");
}
}
}
}
}
#[cfg(test)]
mod properties_target_regressions {
use super::*;
use egui_kittest::{Harness, kittest::Queryable};
#[test]
fn pending_transform_arithmetic_does_not_apply_to_another_document_with_the_same_layer_id() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":200,"height":200,"background":"transparent"})).unwrap();
app.run("select.rect", json!({"x":10,"y":20,"width":100,"height":50})).unwrap();
app.run("edit.fill", json!({"color":"#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
let duplicate = (*app.session.active().unwrap().doc).clone();
let second = app.session.add_document(duplicate, None);
app.session.active_mut().unwrap().active_layer = Some(layer);
assert!(app.session.set_active(0));
let mut ready = false;
let mut h = Harness::builder().with_size(vec2(360.0, 650.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium);
ready = true;
return;
}
properties_body(app, ui);
},
app,
);
h.run_steps(3);
h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("transform width").click();
h.run_steps(1);
for c in "50/2".chars() {
h.event(egui::Event::Text(c.to_string()));
h.run_steps(1);
}
let before = h.state().session.documents()[second].doc.layer(layer).unwrap().surface().unwrap().content_bounds();
assert!(h.state_mut().session.set_active(second));
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(2);
let after = h.state().session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().content_bounds();
assert_eq!(after, before, "pending width belongs to the old document/layer");
}
}
+19
View File
@@ -956,6 +956,20 @@ pub struct UiState {
/// Right-dock group order, heights and collapsed groups (see `dock`).
#[serde(default)]
pub dock: crate::dock::DockLayout,
/// Authoritative custom panel membership and placement; legacy fields are compatibility projections.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// Panels belonging to explicitly collapsed custom tab groups.
#[serde(default)]
pub docking_collapsed: Vec<String>,
/// One-frame focus/raise request from an explicit panel reveal.
#[serde(skip)]
pub docking_reveal: Option<String>,
/// Which chip the Color panel edits.
#[serde(default)]
pub color_panel: ColorPanelState,
@@ -1062,6 +1076,11 @@ impl Default for UiState {
palette_open: false,
dock_tabs: DockTabs::default(),
dock: Default::default(),
docking: None,
docking_hidden: Default::default(),
docking_generation: 0,
docking_collapsed: Vec::new(),
docking_reveal: None,
color_panel: Default::default(),
brush_section: 0,
brush_tab: 0,
+85 -76
View File
@@ -7,74 +7,12 @@ use egui::{CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, ve
use crate::theme::{self, Tokens};
/// Which tabs a strip shows, at what widths, and which overflow into the chevron menu.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TabFit {
/// `(tab index, width)` for the tabs on the strip, in tab order.
pub shown: Vec<(usize, f32)>,
/// Tabs in the chevron menu, in tab order (empty: no chevron).
pub overflow: Vec<usize>,
}
pub use craft_ui::tabs::TabFit;
/// Fit tabs with `natural` widths into `avail` points. Tabs shrink (never below `min_w`, or
/// their natural width if smaller) before any overflow; overflowing tabs leave `chevron_w`
/// for the chevron. `selected` is kept on the strip whatever happens.
/// Fit tabs to a strip using the shared, bounded width policy. Local wrappers retain the
/// application's themes, translated labels and panel/document overflow menus.
pub fn fit(natural: &[f32], selected: usize, avail: f32, min_w: f32, chevron_w: f32) -> TabFit {
let clean = |w: f32| if w.is_finite() { w.max(0.0) } else { 0.0 };
let avail = clean(avail);
let min_w = clean(min_w);
let natural: Vec<f32> = natural.iter().map(|w| clean(*w)).collect();
let floor = |w: f32| w.min(min_w);
let all: Vec<usize> = (0..natural.len()).collect();
if natural.iter().sum::<f32>() <= avail {
return TabFit { shown: all.iter().map(|&i| (i, natural.get(i).copied().unwrap_or(0.0))).collect(), overflow: Vec::new() };
}
if natural.iter().map(|w| floor(*w)).sum::<f32>() <= avail {
return TabFit { shown: squeeze(&natural, &all, avail, min_w), overflow: Vec::new() };
}
// Overflow: the selected tab first, then the others in order, while they fit at their minimum.
let budget = (avail - clean(chevron_w)).max(0.0);
let sel = selected.min(natural.len().saturating_sub(1));
let mut keep: Vec<usize> = Vec::new();
let mut used = 0.0;
for i in std::iter::once(sel).chain(all.iter().copied().filter(|i| *i != sel)) {
let w = natural.get(i).map_or(0.0, |w| floor(*w));
if used + w <= budget || (i == sel && !natural.is_empty()) {
keep.push(i);
used += w;
}
}
keep.sort_unstable();
let mut shown = squeeze(&natural, &keep, budget, min_w);
// A strip too narrow even for the selected tab at its minimum: it takes what there is.
if used > budget
&& let Some(s) = shown.iter_mut().find(|(i, _)| *i == sel)
{
s.1 = budget;
}
let overflow = all.into_iter().filter(|i| !keep.contains(i)).collect();
TabFit { shown, overflow }
}
/// Widths for the `idx` tabs filling `total`: the widest shrink first (a common cap), none
/// below `min(natural, min_w)`.
fn squeeze(natural: &[f32], idx: &[usize], total: f32, min_w: f32) -> Vec<(usize, f32)> {
let ws: Vec<f32> = idx.iter().map(|&i| natural.get(i).copied().unwrap_or(0.0)).collect();
let sum_at = |cap: f32| ws.iter().map(|w| w.min(cap.max(w.min(min_w)))).sum::<f32>();
let (mut lo, mut hi) = (min_w, ws.iter().copied().fold(min_w, f32::max));
if sum_at(hi) > total {
for _ in 0..32 {
let mid = (lo + hi) / 2.0;
if sum_at(mid) <= total {
lo = mid;
} else {
hi = mid;
}
}
} else {
lo = hi;
}
idx.iter().zip(ws).map(|(&i, w)| (i, w.min(lo.max(w.min(min_w))))).collect()
craft_ui::tabs::fit(natural, selected, avail, min_w, chevron_w)
}
/// A label laid out on one row no wider than `max_w`, cut with '…' when it doesn't fit.
@@ -99,6 +37,7 @@ pub struct StripOut {
pub context: Option<TabContextAction>,
/// Rects of the tabs on the strip, `(tab index, rect)`.
pub tabs: Vec<(usize, Rect)>,
pub responses: Vec<(usize, Response)>,
/// The » overflow button, when some tabs didn't fit.
pub chevron: Option<Rect>,
}
@@ -111,11 +50,13 @@ pub const CHEVRON_W: f32 = 18.0;
pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[&str], overflow: &[usize], picked: &mut Option<usize>) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(r, id, Sense::click());
let mut paint_ui = ui.new_child(egui::UiBuilder::new().max_rect(r));
paint_ui.set_clip_rect(r.intersect(ui.clip_rect()));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6));
paint_ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6));
}
crate::icons::paint(ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
crate::icons::paint(&paint_ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip));
let resp = resp.on_hover_text(tip);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(140.0);
@@ -152,15 +93,24 @@ fn tabs_in(
let natural: Vec<f32> = tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect();
let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W);
let mut x = area.left();
let mut out = StripOut { clicked: false, double_clicked: false, context: None, tabs: Vec::with_capacity(f.shown.len()), chevron: None };
let mut out = StripOut {
clicked: false,
double_clicked: false,
context: None,
tabs: Vec::with_capacity(f.shown.len()),
responses: Vec::with_capacity(f.shown.len()),
chevron: None,
};
for &(i, w) in &f.shown {
let Some(name) = tabs.get(i) else { continue };
let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height()));
let resp = ui.interact(r, id.with(("tab", i)), Sense::click());
// The padding gives way (down to a third) before the label is cut.
let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0));
let cut = galley.size().x + pad + 0.5 < natural.get(i).copied().unwrap_or(0.0) && galley.size().x + pad / 3.0 >= w - 0.5;
paint_tab(ui, r, i, galley, &resp, active(i));
let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected)
.sense(Sense::click_and_drag())
.focus_stroke(Stroke::new(1.0, t.accent))
.show_at(ui, r, |ui, resp| paint_tab(ui, r, i, galley, resp, active(i)));
let resp = if cut { resp.on_hover_text(*name) } else { resp };
out.double_clicked |= resp.double_clicked();
if resp.clicked() {
@@ -180,10 +130,11 @@ fn tabs_in(
}
});
out.tabs.push((i, r));
out.responses.push((i, resp));
x = r.right();
}
if !f.overflow.is_empty() {
let r = Rect::from_min_size(pos2(x, area.top()), vec2(CHEVRON_W, area.height()));
if !f.overflow.is_empty() && f.overflow_width > 0.0 {
let r = Rect::from_min_size(pos2(x, area.top()), vec2(f.overflow_width, area.height()));
let mut picked = None;
overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), tabs, &f.overflow, &mut picked);
if let Some(i) = picked {
@@ -224,7 +175,7 @@ pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[
} else {
t.text_faint
};
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
},
)
}
@@ -255,7 +206,7 @@ pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected:
ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.6));
}
let color = if active { t.text } else { t.text_dim };
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
},
)
}
@@ -310,4 +261,62 @@ mod tests {
}
}
}
#[test]
fn tiny_panel_strips_keep_tabs_and_overflow_inside_the_available_area() {
let ctx = egui::Context::default();
crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] {
let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(width, 26.0));
let mut selected = 3;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let out = pro_tabs(ui, ui.id().with("tiny-tabs"), area, area.right(), &["Color", "Swatches", "Gradients", "Patterns"], &mut selected, false);
assert!(out.tabs.iter().any(|(i, _)| *i == 3));
for (_, rect) in &out.tabs {
assert!(rect.left() >= area.left() && rect.right() <= area.right() + 0.001, "{width}: {rect:?}");
}
if let Some(chevron) = out.chevron {
assert!(chevron.left() >= area.left() && chevron.right() <= area.right() + 0.001, "{width}: {chevron:?}");
}
});
output.textures_delta.clear();
assert_eq!(selected, 3);
for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) {
assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: text is clipped to its tab");
}
}
}
#[test]
fn overflow_icon_and_hover_paint_stay_inside_a_tiny_button() {
for scale in [1.0, 2.0] {
let ctx = egui::Context::default();
crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
ctx.set_pixels_per_point(scale);
for width in [1.0, 5.0, 11.0, 12.0, 18.0] {
let rect = Rect::from_min_size(pos2(20.25, 20.25), vec2(width, 26.0));
for _ in 0..3 {
let mut picked = None;
let input = egui::RawInput { events: vec![egui::Event::PointerMoved(rect.center())], ..Default::default() };
let mut output = ctx.run_ui(input, |ui| {
overflow_button(ui, ui.id().with("tiny-overflow"), rect, "More panels", &["Layers"], &[0], &mut picked);
});
output.textures_delta.clear();
let mut icons = 0;
for shape in &output.shapes {
// egui paints an unrotated image as a textured rectangle, not a mesh.
if matches!(&shape.shape, egui::Shape::Rect(rect) if rect.brush.is_some()) {
icons += 1;
}
let painted = shape.shape.visual_bounding_rect().intersect(shape.clip_rect);
if painted.is_positive() {
assert!(rect.contains_rect(painted), "{scale}x, width {width}: {painted:?}");
}
}
assert!(icons > 0, "the actual SVG icon was painted");
assert!(picked.is_none());
}
}
}
}
}
+1 -1
View File
@@ -927,7 +927,7 @@ fn keep_convex(from: [[f64; 2]; 4], to: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
let sign = t0[0].signum();
// A sliver: a thousandth of the starting box's smallest turn, so the stop is short of flat.
let min_turn = t0.iter().map(|t| t.abs()).fold(f64::INFINITY, f64::min) * 1e-3;
if !(min_turn.is_finite() && min_turn > 0.0) || !convex(&from, sign, 0.0) || convex(&to, sign, min_turn) {
if !(min_turn.is_finite() && min_turn > 0.0 && convex(&from, sign, 0.0)) || convex(&to, sign, min_turn) {
return to;
}
let lerp = |t: f64| -> [[f64; 2]; 4] { std::array::from_fn(|k| [from[k][0] + (to[k][0] - from[k][0]) * t, from[k][1] + (to[k][1] - from[k][1]) * t]) };
+1 -2
View File
@@ -1300,8 +1300,7 @@ mod multi_layer_formatting {
h.run();
h.get_by_label("Sharp").click();
h.run();
// The options bar's leading orientation icon has a tooltip but no accessibility label.
h.get_all_by_role(Role::Unknown).next().expect("orientation icon").click();
h.get_by_role_and_label(Role::Button, "Toggle text orientation").click();
h.run();
for id in &ids[..2] {
let t = text(h.state(), *id);
+1 -1
View File
@@ -482,7 +482,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
};
// View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays.
// Free Transform Path draws the path through its box instead (transform_tool).
let paths = app.ui.view.shows(app.ui.view.show.target_path) && !app.ui.transform.as_ref().is_some_and(|t| t.path.is_some());
let paths = app.ui.view.shows(app.ui.view.show.target_path) && app.ui.transform.as_ref().is_none_or(|t| t.path.is_none());
if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) {
// Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790).
crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths);
+30 -14
View File
@@ -474,13 +474,24 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res
let group = crate::dock::Group::from_key(panel);
let collapsed = group.is_some_and(|g| app.ui.dock.is_collapsed(g));
let (vis, cur) = panel_state(app, panel);
// Like Photoshop: choosing a visible panel's menu item again hides it. A collapsed
// group is expanded instead, so the menu item always brings the panel back (#129).
if *vis && *cur == tab && !collapsed && !id.starts_with("type.panels.") {
*vis = false;
let requested = match p.get("show").or_else(|| p.get("on")) {
None => None,
Some(value) => Some(value.as_bool().ok_or("show must be a boolean")?),
};
if let Some(show) = requested {
*vis = show;
if show {
*cur = tab;
}
} else {
*vis = true;
*cur = tab;
// Like Photoshop: choosing a visible panel's menu item again hides it. A collapsed
// group is expanded instead, so the menu item always brings the panel back (#129).
if *vis && *cur == tab && !collapsed && !id.starts_with("type.panels.") {
*vis = false;
} else {
*vis = true;
*cur = tab;
}
}
let visible = *vis;
if let Some(g) = group.filter(|_| visible) {
@@ -675,6 +686,8 @@ fn arrange(app: &mut PhotocraftApp, k: &str) -> Result<Value, String> {
/// Screen rectangles for `n` documents in an arrangement (`None` = tabs). The active document
/// always gets a cell; n-up layouts show that many documents starting from the active one.
pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option<Vec<egui::Rect>> {
use craft_ui::layout::{SplitAxis, SplitSize, split_rect};
if n < 2 || !LAYOUTS.contains(&layout) {
return None;
}
@@ -687,17 +700,20 @@ pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option<Vec<egui::Rect>
Some(match layout {
"tileAllVertically" => grid(n, 1, n),
"tileAllHorizontally" => grid(1, n, n),
"twoUpVertical" => grid(2, 1, 2),
"twoUpHorizontal" => grid(1, 2, 2),
"twoUpVertical" => {
let panes = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?;
vec![panes.first, panes.second]
}
"twoUpHorizontal" => {
let panes = split_rect(rect, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?;
vec![panes.first, panes.second]
}
"threeUpVertical" => grid(3, 1, 3),
"threeUpHorizontal" => grid(1, 3, 3),
"threeUpStacked" => {
let half = rect.width() / 2.0;
let left = egui::Rect::from_min_size(rect.min, egui::vec2(half, rect.height()));
let r = egui::Rect::from_min_max(egui::pos2(rect.min.x + half, rect.min.y), rect.max);
let top = egui::Rect::from_min_max(r.min, egui::pos2(r.max.x, r.center().y));
let bottom = egui::Rect::from_min_max(egui::pos2(r.min.x, r.center().y), r.max);
vec![left, top, bottom]
let columns = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?;
let rows = split_rect(columns.second, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?;
vec![columns.first, rows.first, rows.second]
}
"fourUp" => grid(2, 2, 4),
"sixUp" => grid(3, 2, 6),
+13
View File
@@ -129,6 +129,19 @@ fn arrange_layouts_floating_windows_and_matching() {
assert_eq!(app.ui.windows.len(), 1);
}
#[test]
fn two_up_and_stacked_cells_keep_their_existing_boundaries() {
let rect = egui::Rect::from_min_max(egui::pos2(40.0, 75.0), egui::pos2(940.0, 675.0));
let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
assert_eq!(cells("twoUpVertical", rect, 2).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]);
assert_eq!(cells("twoUpHorizontal", rect, 2).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]);
assert_eq!(
cells("threeUpStacked", rect, 3).unwrap(),
vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 375.0), region(490.0, 375.0, 940.0, 675.0)]
);
assert!(cells("twoUpVertical", rect, 1).is_none());
}
#[test]
fn window_panels_select_dock_tabs() {
let (mut app, ctx) = app_with(1);
+3
View File
@@ -56,6 +56,7 @@ pub struct CardResponse {
/// Rects of the tabs on the strip, `(tab index, rect)`; tabs that don't fit are in the
/// chevron menu instead (#151).
pub tabs: Vec<(usize, Rect)>,
pub responses: Vec<(usize, Response)>,
/// The » overflow button, when some tabs didn't fit.
pub chevron: Option<Rect>,
}
@@ -96,6 +97,7 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla
tab_double_clicked: tabs_out.double_clicked,
tab_context: tabs_out.context,
tabs: tabs_out.tabs,
responses: tabs_out.responses,
chevron: tabs_out.chevron,
}
})
@@ -168,6 +170,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
tab_double_clicked: tabs_out.double_clicked,
tab_context: tabs_out.context,
tabs: tabs_out.tabs,
responses: tabs_out.responses,
chevron: tabs_out.chevron,
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ fn new_workspace(app: &mut PhotocraftApp, p: &Value) -> Result<Value, String> {
return Err(format!("\"{name}\" is a built-in workspace"));
}
let prefs = app.session.prefs().clone();
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open});
let mut ws = json!({"panels": app.ui.panels, "dockTabs": app.ui.dock_tabs, "dock": app.ui.dock, "timelineOpen": app.ui.timeline.open, "docking": app.ui.docking, "dockingHidden": app.ui.docking_hidden, "dockingCollapsed": app.ui.docking_collapsed});
// The icon rail and menu bar flags belong to an embedding app's session, not to a saved
// layout: a workspace never brings back a window without its menus.
if let Some(panels) = ws.get_mut("panels").and_then(Value::as_object_mut) {
+6 -6
View File
@@ -1,5 +1,5 @@
//! Camera Raw through the real app shell and its existing agent entry point.
use egui::{Event, Modifiers, MouseWheelUnit, pos2, vec2};
use egui::{Event, Modifiers, MouseWheelUnit, accesskit::Role, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
use serde_json::{Value, json};
@@ -97,7 +97,7 @@ fn settings_sections_group_existing_controls_without_rendering_or_document_edits
let tr = |s| photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", s);
let mut y = 0.0;
for label in [light, tr("Color"), tr("Effects"), tr("Curve"), tr("Color Mixer"), tr("Color Grading"), tr("Detail")] {
let next = h.get_by_label(label).rect().top();
let next = h.get_by_role_and_label(Role::Button, label).rect().top();
assert!(next > y, "section order: {label}");
y = next;
}
@@ -105,17 +105,17 @@ fn settings_sections_group_existing_controls_without_rendering_or_document_edits
assert!(h.query_by_label(tr("Temperature")).is_none());
assert!(h.query_by_label(tr("Texture")).is_none());
assert!(h.query_by_label(tr("Exposure")).is_some());
h.get_by_label(light).click();
h.get_by_role_and_label(Role::Button, light).click();
h.run_steps(12);
h.get_by_label(tr("Color")).click();
h.get_by_role_and_label(Role::Button, tr("Color")).click();
h.run_steps(12);
for label in ["Temperature", "Tint", "Vibrance", "Saturation"] {
assert!(h.query_by_label(tr(label)).is_some(), "color control: {label}");
}
assert!(h.query_by_label(tr("Exposure")).is_none());
h.get_by_label(tr("Color")).click();
h.get_by_role_and_label(Role::Button, tr("Color")).click();
h.run_steps(12);
h.get_by_label(tr("Effects")).click();
h.get_by_role_and_label(Role::Button, tr("Effects")).click();
h.run_steps(12);
for label in ["Texture", "Clarity", "Dehaze", "Vignetting", "Grain"] {
assert!(h.query_by_label(tr(label)).is_some(), "effects control: {label}");
+11 -11
View File
@@ -1,5 +1,5 @@
//! Camera Raw labels and language changes through the real shared native/WASM shell.
use egui::{pos2, vec2};
use egui::{accesskit::Role, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, i18n, theme::ThemeKind};
use serde_json::json;
@@ -27,19 +27,19 @@ fn all_seven_languages_render_camera_raw_and_switch_without_resetting_view_or_fi
let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s);
let before = control::inspect(h.state(), &ctx)["cameraRaw"].clone();
for section in ["Light", "Color", "Effects", "Curve", "Color Mixer", "Color Grading", "Detail"] {
assert!(h.query_by_label(tr(section)).is_some(), "{}: {section}", lang.code());
assert!(h.query_by_role_and_label(Role::Button, tr(section)).is_some(), "{}: {section}", lang.code());
}
assert!(h.query_by_label(tr("Blacks")).is_some());
h.get_by_label(tr("Light")).click();
h.get_by_role_and_label(Role::Button, tr("Light")).click();
h.run_steps(12);
h.get_by_label(tr("Color")).click();
h.get_by_role_and_label(Role::Button, tr("Color")).click();
h.run_steps(12);
for label in ["White Balance: As Shot", "Temperature", "Tint", "Vibrance", "Saturation"] {
assert!(h.query_by_label(tr(label)).is_some(), "{}: {label}", lang.code());
}
h.get_by_label(tr("Color")).click();
h.get_by_role_and_label(Role::Button, tr("Color")).click();
h.run_steps(12);
h.get_by_label(tr("Color Mixer")).click();
h.get_by_role_and_label(Role::Button, tr("Color Mixer")).click();
h.run_steps(12);
for tab in ["Hue", "Saturation", "Luminance"] {
assert!(h.get_by_label(tr(tab)).rect().right() <= 1426.0, "{}: mixer tabs overflow the panel", lang.code());
@@ -47,7 +47,7 @@ fn all_seven_languages_render_camera_raw_and_switch_without_resetting_view_or_fi
for band in ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"] {
assert!(h.query_by_label(tr(band)).is_some(), "{}: {band}", lang.code());
}
h.get_by_label(tr("Color Mixer")).click();
h.get_by_role_and_label(Role::Button, tr("Color Mixer")).click();
h.run_steps(12);
for (section, labels) in [
("Effects", vec!["Texture", "Clarity", "Dehaze", "Vignetting", "Grain", "Roughness"]),
@@ -55,16 +55,16 @@ fn all_seven_languages_render_camera_raw_and_switch_without_resetting_view_or_fi
("Color Grading", vec!["Midtones", "Global", "Hue", "Saturation", "Luminance", "Blending", "Balance"]),
("Detail", vec!["Sharpening", "Masking", "Noise Reduction", "Luminance Detail", "Color Detail"]),
] {
h.query_all_by_label(tr(section)).next().unwrap().click();
h.get_by_role_and_label(Role::Button, tr(section)).click();
h.run_steps(12);
for label in labels {
assert!(h.query_all_by_label(tr(label)).next().is_some(), "{}: {section}/{label}", lang.code());
}
// Detail is also the sharpening slider label; the section header comes first.
h.query_all_by_label(tr(section)).next().unwrap().click();
// Detail is also the sharpening slider label; target the section button.
h.get_by_role_and_label(Role::Button, tr(section)).click();
h.run_steps(12);
}
h.get_by_label(tr("Color Mixer")).click();
h.get_by_role_and_label(Role::Button, tr("Color Mixer")).click();
h.run_steps(12);
let graph = h.get_by_label(tr("Tone Histogram")).rect();
h.event(egui::Event::PointerMoved(graph.center()));
+53
View File
@@ -501,3 +501,56 @@ Missing actions, recursive calls and nesting beyond 16 levels fail visibly; a ch
the parent before its next step. Every nested command retains the normal automation permission check.
Existing recordings containing expanded commands remain unchanged; remove those steps and record
the named call again if desired.
## Shared panel docking
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
and movable, resizable floating groups inside the application window. Drag a panel tab,
or use its context menu. Floating groups remain within the application viewport.
The shared layout begins with the existing workspace on the first docking operation.
Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the
positions of hidden panels. Resetting a built-in workspace restores its default dock.
- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group.
`zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders
tabs by panel ID. The destination must already be visible.
- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel.
- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous
dock placement when possible, or to the supplied destination.
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
position when possible. `window.panel.close {panel}` hides it.
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
invalid destinations return an error without changing the arrangement.
Lock Workspace prevents moving, detaching, and closing panels; activating a panel remains available.
`window.panel.layout {action}` also accepts the shared, externally tagged action schema.
For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}`
updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
resizes the root divider (`false`/`true` path entries select first/second children).
`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and
`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries.
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
split paths return an error without changing the workspace.
The existing `ui.set` panel visibility, `dockTabs` selection and `dockWidth` controls
remain available after customization. Width keeps its 250–520 point clamp. Named
`dock.hidden_tabs` and intact native-group collapse settings are also preserved. A
legacy `dock` replacement that changes flat group order or heights is rejected after
customization: use `window.panel.layout` to change a custom split tree. A replacement
that changes legacy collapse settings is also rejected when a custom root group mixes
panels from different native groups; use `window.panel.group` to collapse or expand
that group. Validation is atomic across mixed `ui.set` requests.
`window.panel.group {panel, operation}` applies `close`, `collapse`, `expand`, `up`,
or `down` to the panel's entire current group. With `operation:"move"`, provide
`anchor` and optional `zone`/`before` (as for panel.move), or a serialized `placement`,
to transfer all members atomically while preserving their order and selecting the
requested `panel`.
The floating group's header grip also transfers the whole group when dropped on a
dock target. The panel menu and icon rail remain
available in custom workspaces. `window.panel.<id> {show:true}` always reveals, selects
and expands the panel, including when another tab is selected. Workspace snapshots
retain custom collapse settings and Timeline visibility.
+6
View File
@@ -1,5 +1,11 @@
# Roadmap
- UI.5: Panels can detach, join other tab groups, split, resize and redock through shared docking transactions. Custom layouts persist with workspaces, respect workspace locking, and retain collapse flags for intact native groups.
- UI.3: Shared icons, accessible icon buttons and panel tabs come from the pinned `craft-ui` library. Tab overflow stays within narrow panels; document tiling uses bounded shared geometry. Palettes and document commands remain app-owned. Existing strict-Clippy boolean/range warnings are corrected.
- UI.4: Compact toolbars keep colour and mode controls visible while tool rows scroll. Long tool-options rows scroll horizontally and reveal keyboard focus; offscreen geometry and interaction regressions cover all themes and 1×/1.5×/2× display scales.
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-08.
**Parity metrics.** `cargo xtask parity` measures how much of Photoshop's menu tree is *wired to a
+20
View File
@@ -80,6 +80,26 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon
- **macOS:** the traffic lights sit over the integrated title strip (`integrated_titlebar`).
- **Narrow windows** drop controls that are also in a menu before anything overlaps: Discord (Help › Discord), then the theme toggle, then search (Edit › Search), then the workspace switcher narrows (Window › Workspace).
## Compact tool controls
The toolbar scrolls its tool rows when two columns still exceed the available height. Foreground
and background colours stay visible below those rows, together with Pro's Quick Mask and Screen
Mode controls. Long options rows scroll horizontally. Both scrollers bring newly focused
controls into view and retain the existing tool actions and popups.
The offscreen regression fixture uses production panels, fonts and SVG loaders across every
theme, 800×480 and 1280×720 point viewports, and 1×, 1.5× and 2× display scales. To retain its
60 initial/scrolled PNGs while running the compact geometry and interaction checks:
```sh
CRAFT_UI_VISUAL_FIXTURES=/tmp/photocraft-compact cargo test -p photocraft-ui-egui --lib compact_visual_regressions -- --nocapture
```
Keyboard tests clear the configurable `window.togglePanels` canvas shortcut through preferences,
then send normal Tab and Space events through the app's shortcut dispatcher. They exercise
focus traversal and activation with that nondefault binding; default canvas Tab still toggles
panels. These offscreen checks do not establish native window or platform input acceptance.
## Menus
`menu_catalog.rs` holds Photoshop's menu tree (standard command names, order, separators, default shortcuts). Items whose id matches an engine or UI command are live; others render disabled until implemented. Give new commands the catalogue's id (for example `image.imageSize`) and they light up in the right place automatically.
+3 -2
View File
@@ -98,7 +98,7 @@ fn standalone_exception(from: &str, to: &str) -> bool {
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 6;
@@ -345,11 +345,12 @@ mod tests {
#[test]
fn ui_crates_below_l6_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
for dep in ["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("photocraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
}
assert!(check(&[c("photocraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("photocraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
assert!(check(&[c("photocraft-platform", &[("winit", Normal, false)])]).is_empty());
}