mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
UI.9: preserve PhotoCraft controls in shared UI migration
This commit is contained in:
1 parent
3e000bc1d5
commit
99a3970399
51 files changed
+2818
-323
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
@@ -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.
|
@@ -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 패널 크기 조절
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 调整面板大小
|
||||
|
@@ -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
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
}
|
||||
if let Some(result) = panel_docking::command(self, id, ¶ms) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]) };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user