W: floating panels — tear off, dock back, persisted (window.floatPanel/dockPanel)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 11:05:13 -04:00
co-authored by Claude Opus 5.5
parent ba1843789d
commit de51043c4c
5 changed files with 156 additions and 24 deletions
+1
View File
@@ -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.
+114 -19
View File
@@ -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);
}
+4
View File
@@ -91,6 +91,8 @@ pub struct UiState {
pub dock_tab: String,
/// Panel opened from the collapsed icon column (flyout).
pub open_panel: Option<String>,
/// 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);
+33 -1
View File
@@ -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<Result<Value,
app.ui.dock_tab = panel;
app.ui.dock_expanded = true;
app.ui.open_panel = None;
} else {
} else if !app.ui.floating.iter().any(|(p, _)| *p == panel) {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel.as_str()) { None } else { Some(panel) };
}
Ok(Value::Null)
}
"window.floatPanel" | "window.dockPanel" => {
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::<Vec<_>>()}))
}
"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();
+4 -4
View File
@@ -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 |