diff --git a/ROADMAP.md b/ROADMAP.md index 8856a825..5b6a69ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd composite), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 6dd86634..d2699837 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -79,8 +79,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.vertical_centered(|ui| { for (id, label, icon) in ICON_PANELS { let open = app.ui.open_panel.as_deref() == Some(*id); - if icons::button(ui, icon, 28.0, open, label).clicked() { - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + let floats = app.ui.floating.iter().any(|(p, _)| p == id); + if icons::button(ui, icon, 28.0, open || floats, label).clicked() { + if floats { + ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id)))); + } else { + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } } ui.add_space(1.0); } @@ -116,24 +121,20 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { open = false; } ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); + // Float: the button beside the close box, or drag the header away from the dock. + let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0)); + let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_text("Float panel"); + float_glyph(ui.painter(), float_r, t.text_dim); + let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y)); + let hr = ui.interact(head, ui.id().with("flyhead"), Sense::drag()); + let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0); + if fr.clicked() || torn { + let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) }; + float_panel(app, &id, at); + return; + } egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(screen.height() - 220.0).show(ui, |ui| match id.as_str() { - "swatches" => panels::swatches::show(app, ui), - "paragraphStyles" => panels::styles::paragraph(app, ui), - "characterStyles" => panels::styles::character(app, ui), - "stroke" => panels::properties::stroke_panel(app, ui), - "character" => panels::properties::character_panel(app, ui), - "paragraph" => panels::properties::paragraph_panel(app, ui), - "textWrap" => panels::properties::wrap_panel(app, ui), - "align" => panels::properties::align_panel(app, ui), - "color" => panels::swatches::color_panel(app, ui), - "gradient" => panels::swatches::gradient_panel(app, ui), - "effects" => panels::properties::effects_panel(app, ui), - "links" => panels::properties::links_panel(app, ui), - "preflight" => panels::properties::preflight_panel(app, ui), - "table" => panels::table::show(app, ui), - _ => panels::properties::info_panel(app, ui), - }); + egui::ScrollArea::vertical().max_height(screen.height() - 220.0).show(ui, |ui| panel_body(app, ui, &id)); }); }); }); @@ -141,3 +142,97 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { app.ui.open_panel = None; } } + +/// The contents of an icon-column panel. +pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { + match id { + "swatches" => panels::swatches::show(app, ui), + "paragraphStyles" => panels::styles::paragraph(app, ui), + "characterStyles" => panels::styles::character(app, ui), + "stroke" => panels::properties::stroke_panel(app, ui), + "character" => panels::properties::character_panel(app, ui), + "paragraph" => panels::properties::paragraph_panel(app, ui), + "textWrap" => panels::properties::wrap_panel(app, ui), + "align" => panels::properties::align_panel(app, ui), + "color" => panels::swatches::color_panel(app, ui), + "gradient" => panels::swatches::gradient_panel(app, ui), + "effects" => panels::properties::effects_panel(app, ui), + "links" => panels::properties::links_panel(app, ui), + "preflight" => panels::properties::preflight_panel(app, ui), + "table" => panels::table::show(app, ui), + _ => panels::properties::info_panel(app, ui), + } +} + +/// Tear panel `id` off the dock into a floating window at `at`. +pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { + if app.ui.open_panel.as_deref() == Some(id) { + app.ui.open_panel = None; + } + app.ui.floating.retain(|(p, _)| p != id); + app.ui.floating.push((id.to_string(), [at.x, at.y])); +} + +/// Put a floating panel back in the dock's icon column. +pub fn dock_panel(app: &mut DesignApp, id: &str) { + app.ui.floating.retain(|(p, _)| p != id); +} + +/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. +pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { + let t = Tokens::get(ctx); + for (id, at) in app.ui.floating.clone() { + let label = ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel"); + let mut open = true; + let mut dock = false; + let r = egui::Window::new(label) + .id(egui::Id::new(("floating_panel", &id))) + .title_bar(false) + .default_pos(egui::pos2(at[0], at[1])) + .default_width(256.0) + .resizable(true) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0))) + .show(ctx, |ui| { + // Header strip (drag it to move the panel): name, Dock, close. + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width().max(200.0), 24.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + ui.painter().text(strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, label, semibold(12.0), t.text_strong); + let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 3.0), vec2(18.0, 18.0)); + if ui.interact(close, ui.id().with("fclose"), Sense::click()).on_hover_text("Close").clicked() { + open = false; + } + ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); + let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0)); + if ui.interact(dock_r, ui.id().with("fdock"), Sense::click()).on_hover_text("Dock panel").clicked() { + dock = true; + } + dock_glyph(ui.painter(), dock_r, t.text_dim); + egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(ctx.content_rect().height() - 160.0).show(ui, |ui| panel_body(app, ui, &id)); + }); + }); + // Remember where it was left. + if let Some(r) = r + && let Some(f) = app.ui.floating.iter_mut().find(|(p, _)| *p == id) + { + f.1 = [r.response.rect.min.x, r.response.rect.min.y]; + } + if !open || dock { + dock_panel(app, &id); + } + } +} + +/// Two overlapping boxes: "float this panel". +fn float_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { + let s = Stroke::new(1.0, c); + p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(3.5, 6.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); + p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(6.5, 3.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); +} + +/// A box with a bar on its right: "back into the dock". +fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { + let b = egui::Rect::from_min_size(r.min + vec2(3.5, 4.5), vec2(11.0, 9.0)); + p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); + p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c794576f..bd55926d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -91,6 +91,8 @@ pub struct UiState { pub dock_tab: String, /// Panel opened from the collapsed icon column (flyout). pub open_panel: Option, + /// Panels torn off the dock into their own floating windows: (panel id, top-left position). + pub floating: Vec<(String, [f32; 2])>, pub dock_expanded: bool, pub units: Unit, pub workspace: String, @@ -135,6 +137,7 @@ impl Default for UiState { tools_double_column: false, dock_tab: "properties".into(), open_panel: None, + floating: Vec::new(), dock_expanded: true, units: Unit::Picas, workspace: "Essentials".into(), @@ -483,6 +486,7 @@ impl DesignApp { canvas::show(self, ui); }); dock::flyout(self, &ctx); + dock::floating(self, &ctx); story_editor::show(self, &ctx); dialogs::show(self, &ctx); about::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4007c82f..12288237 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -52,6 +52,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}", ), + ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), ("window.taskBar", "Contextual Task Bar", None, "{}"), ("help.discord", "Join the ArtCraft Discord…", None, "{} — opens https://discord.gg/artcraft in the browser"), @@ -630,11 +632,27 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); + if !crate::dock::ICON_PANELS.iter().any(|(id, _, _)| *id == panel) { + return Some(Err(format!("{id}: unknown panel `{panel}`"))); + } + if id == "window.floatPanel" { + let at = egui::pos2( + p.get("x").and_then(Value::as_f64).unwrap_or(400.0) as f32, + p.get("y").and_then(Value::as_f64).unwrap_or(160.0) as f32, + ); + crate::dock::float_panel(app, panel, at); + } else { + crate::dock::dock_panel(app, panel); + } + Ok(json!({"floating": app.ui.floating.iter().map(|(p, _)| p.clone()).collect::>()})) + } "window.controlBar" => flag(&mut app.ui.control_bar), "window.taskBar" => flag(&mut app.ui.task_bar), "help.about" => { @@ -1144,6 +1162,20 @@ mod tests { } } + #[test] + fn panels_float_and_dock() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "swatches", "x": 50, "y": 60})).unwrap().unwrap(); + assert_eq!(r["floating"], json!(["swatches"])); + assert_eq!(app.ui.floating[0].1, [50.0, 60.0]); + // Showing a floating panel doesn't also open its flyout. + run_ui(&mut app, "window.panel", &json!({"panel": "swatches"})).unwrap().unwrap(); + assert_eq!(app.ui.open_panel, None); + run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); + assert!(app.ui.floating.is_empty()); + assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err()); + } + #[test] fn quick_apply_lists_styles_then_commands() { let mut s = designcraft_engine::Session::new(); diff --git a/docs/parity.md b/docs/parity.md index 62167404..fe7b3020 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -9,13 +9,13 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 102 | 98 | 4 | 0 | 98% | +| P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 33 | 23 | 41 | 46% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | |---|---| -| Application shell & workspace | 65% | +| Application shell & workspace | 69% | | Documents, pages, spreads | 71% | | Layers | 74% | | Frames, shapes & paths | 76% | @@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Undo, history, saving | 100% | | Accessibility | 20% | -Open P0 items: Dockable panels, Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print). +Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print). ## Rows @@ -44,7 +44,7 @@ Open P0 items: Dockable panels, Preferences, Vector/page formats (PDF/AI/SVG/EPS | Application shell & workspace | Start/Home screen | P0 | D | | Application shell & workspace | Multiple documents in tabs | P0 | D | | Application shell & workspace | New Window for same document | P1 | M | -| Application shell & workspace | Dockable panels | P0 | P | +| Application shell & workspace | Dockable panels | P0 | D | | Application shell & workspace | Workspaces | P1 | P | | Application shell & workspace | Control panel | P0 | D | | Application shell & workspace | Properties panel | P0 | D |