Browse / github.com/storytold/designcraft / patch
Panels: history, grouped flyouts, and docking against the icon bar
Panels: history, grouped flyouts, and docking against the icon bar ## Summary - History lists every undo state, jumps to one, deletes that state and everything after it, clears the list, and makes a new document from a state. - An icon group opens as one flyout attached beside that group, with a tab per panel. Dragging the header floats it. A later click on the page closes it, and a menu that extends past the flyout stays open. - Dropping a floating panel on the right icon bar docks it flush to the left of that bar, in line with its icon. Each attached panel hugs its content. ## Test plan - [ ] Window > History: jump to a state, delete that state and every
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- CameronGilroy (imported from pull request #1805, open)
- Base
755e4d194c9ff41838e213605404ca7a3906fa67- Size
- +1253 −129 in 15 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7914326e7400dad56880b837
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/edit.rs crates/engine/src/cmd/history.rs crates/engine/src/cmd/mod.rs crates/engine/src/lib.rs crates/ui-egui/src/dock.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panel_docking.rs crates/ui-egui/src/panel_docking_tests.rs crates/ui-egui/src/panels/history.rs crates/ui-egui/src/panels/mod.rs docs/parity-checklist.md docs/ui-parity.md
@@ -4,7 +4,7 @@ - 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (History panel and grouped icon flyouts; placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.@@ -12,6 +12,8 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-25** · A floating panel docks flush to the left of the right icon bar, in line with its icon. No readiness percentage change.+- **2026-10-25** · History panel lists every undo state, jumps to one, deletes later states, clears the list, and makes a new document from a state. Icon groups in a workspace open as one flyout attached beside that group, with a tab per panel, until the header is dragged free. No readiness percentage change. - **2026-10-24** · Explicit PDF layer choices, including all-on, are checked with one actual rewrite per distinct source before editing; reset to file defaults bypasses the check. Native format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change.@@ -6,7 +6,7 @@ use designcraft_doc::{ItemId, Selection, SpreadRef}; use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};-use crate::{HistoryEntry, Result, Session};+use crate::{Result, Session}; fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())@@ -211,29 +211,12 @@ fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { pub fn specs() -> Vec<CommandSpec> { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| {- let st = s.doc_mut()?;- let e = st.history.undo.pop().ok_or_else(|| super::bad("edit.undo", "nothing to undo"))?;- st.history.redo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });- let mut doc = e.doc;- super::rulers::keep_zero_point(&st.doc, &mut doc);- st.doc = doc;- st.selection = e.selection;- st.bump_revision();- // Pending typing overrides belong to the current edit state, not a restored snapshot.- s.typing_format = None;- Ok(json!({"undone": e.label}))+ let label = super::history::undo_once(s)?;+ Ok(json!({"undone": label})) }), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {- let st = s.doc_mut()?;- let e = st.history.redo.pop().ok_or_else(|| super::bad("edit.redo", "nothing to redo"))?;- st.history.undo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });- let mut doc = e.doc;- super::rulers::keep_zero_point(&st.doc, &mut doc);- st.doc = doc;- st.selection = e.selection;- st.bump_revision();- s.typing_format = None;- Ok(json!({"redone": e.label}))+ let label = super::history::redo_once(s)?;+ Ok(json!({"redone": label})) }), cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { let ids = ids_param(p, "ids").unwrap_or_default();new file mode 100644@@ -0,0 +1,471 @@+//! History panel: every undo state, as a list the panel can jump, trim, and branch from.+//!+//! `document.history` stays `{undo, redo}` label arrays. The panel reads `document.history.states`.++use std::sync::Arc;++use designcraft_doc::{Document, Selection};+use serde_json::{Value, json};++use super::{CommandSpec, bad, cmd, has_doc};+use crate::{DocState, HistoryEntry, Result, Session};++pub fn specs() -> Vec<CommandSpec> {+ vec![+ cmd!(+ noundo "edit.history.go",+ "Go to History State",+ [],+ None,+ "{index: number}",+ has_doc,+ |s, p| go(s, p)+ ),+ cmd!(+ noundo "edit.history.delete",+ "Delete History State",+ [],+ None,+ "{index?: number}",+ has_doc,+ |s, p| delete_state(s, p)+ ),+ cmd!(noundo "edit.history.clear", "Clear History", [], None, "{}", has_doc, |s, _| clear(s)),+ cmd!(+ noundo "edit.history.newDocument",+ "New Document",+ [],+ None,+ "{index?: number}",+ has_doc,+ |s, p| new_document(s, p)+ ),+ cmd!(query "document.history.states", "History States", [], None, "{}", has_doc, |s, _| states(s)),+ ]+}++/// One undo step, shared with Edit › Undo. Does not leave data-merge preview.+pub(crate) fn undo_once(s: &mut Session) -> Result<String> {+ let st = s.doc_mut()?;+ let e = st.history.undo.pop().ok_or_else(|| bad("edit.undo", "nothing to undo"))?;+ let label = e.label.clone();+ st.history.redo.push(HistoryEntry { label: label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });+ let mut doc = e.doc;+ super::rulers::keep_zero_point(&st.doc, &mut doc);+ st.doc = doc;+ st.selection = e.selection;+ st.bump_revision();+ // Pending typing overrides belong to the current edit state, not a restored snapshot.+ s.typing_format = None;+ Ok(label)+}++/// One redo step, shared with Edit › Redo.+pub(crate) fn redo_once(s: &mut Session) -> Result<String> {+ let st = s.doc_mut()?;+ let e = st.history.redo.pop().ok_or_else(|| bad("edit.redo", "nothing to redo"))?;+ let label = e.label.clone();+ st.history.undo.push(HistoryEntry { label: label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });+ let mut doc = e.doc;+ super::rulers::keep_zero_point(&st.doc, &mut doc);+ st.doc = doc;+ st.selection = e.selection;+ st.bump_revision();+ s.typing_format = None;+ Ok(label)+}++fn go(s: &mut Session, p: &Value) -> Result<Value> {+ let (current, max) = lengths(s)?;+ let index = required_index("edit.history.go", p)?;+ if index > max {+ return Err(bad("edit.history.go", "history state is out of range"));+ }+ if index < current {+ for _ in index..current {+ undo_once(s)?;+ }+ } else if index > current {+ for _ in current..index {+ redo_once(s)?;+ }+ }+ Ok(json!({"index": index}))+}++fn delete_state(s: &mut Session, p: &Value) -> Result<Value> {+ let (current, max) = lengths(s)?;+ let index = optional_index("edit.history.delete", p, current)?;+ if index == 0 {+ return Err(bad("edit.history.delete", "the first history state stays"));+ }+ if index > max {+ return Err(bad("edit.history.delete", "history state is out of range"));+ }+ let target = index - 1;+ if current > target {+ for _ in target..current {+ undo_once(s)?;+ }+ s.doc_mut()?.history.redo.clear();+ } else if current == target {+ s.doc_mut()?.history.redo.clear();+ } else {+ let keep = target - current;+ let redo = &mut s.doc_mut()?.history.redo;+ let drop_n = redo.len().saturating_sub(keep);+ redo.drain(0..drop_n);+ }+ let index = s.doc()?.history.undo.len();+ Ok(json!({"index": index}))+}++fn clear(s: &mut Session) -> Result<Value> {+ s.stop_data_preview();+ let st = s.doc_mut()?;+ if st.history.undo.is_empty() && st.history.redo.is_empty() {+ return Ok(json!({"cleared": true}));+ }+ if st.history.undo.is_empty() && st.history.origin.is_none() {+ st.history.redo.clear();+ return Ok(json!({"cleared": true}));+ }+ let label = st.history.undo.last().map(|e| e.label.clone()).or_else(|| st.history.origin.clone()).unwrap_or_else(|| "New".to_string());+ st.history.origin = Some(label);+ st.history.undo.clear();+ st.history.redo.clear();+ Ok(json!({"cleared": true}))+}++fn new_document(s: &mut Session, p: &Value) -> Result<Value> {+ let (current, _) = lengths(s)?;+ let index = optional_index("edit.history.newDocument", p, current)?;+ let (doc, selection) = {+ let st = s.doc()?;+ snapshot(st, index).ok_or_else(|| bad("edit.history.newDocument", "history state is out of range"))?+ };+ s.untitled += 1;+ let mut inner = (*doc).clone();+ inner.title = format!("Untitled-{}", s.untitled);+ let mut state = DocState::new(inner, None);+ state.selection = selection;+ state.saved_doc = Arc::new((*state.doc).clone());+ let i = s.add_document(state);+ Ok(json!({"index": i}))+}++fn states(s: &Session) -> Result<Value> {+ let st = s.doc()?;+ let current = st.history.undo.len();+ let (base_label, base_kind) = baseline(st);+ let mut rows = Vec::with_capacity(current.saturating_add(st.history.redo.len()).saturating_add(1));+ rows.push(json!({+ "index": 0,+ "label": base_label,+ "kind": base_kind,+ "current": current == 0,+ "future": false,+ }));+ for (i, entry) in st.history.undo.iter().enumerate() {+ let index = i + 1;+ rows.push(json!({+ "index": index,+ "label": entry.label,+ "kind": "action",+ "current": index == current,+ "future": false,+ }));+ }+ let redo_len = st.history.redo.len();+ for step in 1..=redo_len {+ let Some(entry) = st.history.redo.get(redo_len - step) else { continue };+ let index = current + step;+ rows.push(json!({+ "index": index,+ "label": entry.label,+ "kind": "action",+ "current": false,+ "future": true,+ }));+ }+ Ok(json!({"current": current, "states": rows}))+}++fn lengths(s: &mut Session) -> Result<(usize, usize)> {+ s.stop_data_preview();+ let st = s.doc()?;+ Ok((st.history.undo.len(), st.history.undo.len().saturating_add(st.history.redo.len())))+}++fn baseline(st: &DocState) -> (String, &'static str) {+ if let Some(label) = &st.history.origin {+ return (label.clone(), "action");+ }+ if let Some(name) = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()) {+ return (name.to_string_lossy().into_owned(), "file");+ }+ ("New".to_string(), "new")+}++/// Document and selection at `index` without moving the current state.+fn snapshot(st: &DocState, index: usize) -> Option<(Arc<Document>, Selection)> {+ let current = st.history.undo.len();+ let max = current.saturating_add(st.history.redo.len());+ if index > max {+ return None;+ }+ if index == current {+ return Some((st.doc.clone(), st.selection.clone()));+ }+ if index < current {+ let entry = st.history.undo.get(index)?;+ return Some((entry.doc.clone(), entry.selection.clone()));+ }+ let step = index - current;+ let entry = st.history.redo.get(st.history.redo.len().checked_sub(step)?)?;+ Some((entry.doc.clone(), entry.selection.clone()))+}++fn required_index(cmd: &str, p: &Value) -> Result<usize> {+ match p.get("index") {+ Some(n) => parse_index(cmd, n),+ _ => Err(bad(cmd, "index must be a state number")),+ }+}++fn optional_index(cmd: &str, p: &Value, current: usize) -> Result<usize> {+ match p.get("index") {+ None | Some(Value::Null) => Ok(current),+ Some(n) => parse_index(cmd, n),+ }+}++fn parse_index(cmd: &str, n: &Value) -> Result<usize> {+ let Some(u) = n.as_u64() else {+ return Err(bad(cmd, "index must be a state number"));+ };+ usize::try_from(u).map_err(|_| bad(cmd, "index is out of range"))+}++#[cfg(test)]+mod tests {+ use serde_json::{Value, json};++ use crate::Session;++ fn items(s: &Session) -> usize {+ s.doc().unwrap().doc.spreads[0].items.len()+ }++ fn states(s: &mut Session) -> Value {+ s.execute("document.history.states", &json!({})).unwrap()+ }++ fn labels(v: &Value) -> Vec<String> {+ v["states"].as_array().unwrap().iter().map(|row| row["label"].as_str().unwrap().to_string()).collect()+ }++ fn frame(s: &mut Session) {+ s.execute("frame.create", &json!({"rect": [36.0, 36.0, 136.0, 136.0]})).unwrap();+ }++ #[test]+ fn a_new_document_has_one_state_named_new() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ let v = states(&mut s);+ assert_eq!(v["current"], 0);+ assert_eq!(labels(&v), ["New"]);+ assert_eq!(v["states"][0]["kind"], "new");+ assert_eq!(v["states"][0]["current"], true);+ assert_eq!(v["states"][0]["future"], false);+ assert_eq!(s.execute("document.history", &json!({})).unwrap(), json!({"undo": [], "redo": []}));+ }++ #[test]+ fn two_frames_list_new_then_each_action_and_undo_stays_one_step() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ frame(&mut s);+ let v = states(&mut s);+ assert_eq!(labels(&v), ["New", "Create Frame", "Create Frame"]);+ assert_eq!(v["current"], 2);+ assert_eq!(v["states"][2]["current"], true);+ assert_eq!(items(&s), 2);+ assert_eq!(s.execute("document.history", &json!({})).unwrap(), json!({"undo": ["Create Frame", "Create Frame"], "redo": []}));+ assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Create Frame"}));+ assert_eq!(s.doc().unwrap().history.undo.len(), 1);+ assert_eq!(s.doc().unwrap().history.redo.len(), 1);+ assert_eq!(items(&s), 1);+ }++ #[test]+ fn go_moves_between_states_and_rejects_a_missing_one() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ frame(&mut s);+ s.execute("edit.history.go", &json!({"index": 1})).unwrap();+ let v = states(&mut s);+ assert_eq!(v["current"], 1);+ assert_eq!(items(&s), 1);+ assert_eq!(v["states"][2]["future"], true);+ s.execute("edit.history.go", &json!({"index": 0})).unwrap();+ assert_eq!(states(&mut s)["current"], 0);+ assert_eq!(items(&s), 0);+ let err = s.execute("edit.history.go", &json!({"index": 9})).unwrap_err();+ assert!(err.to_string().contains("out of range"), "{err}");+ assert_eq!(items(&s), 0);+ assert_eq!(s.doc().unwrap().history.undo.len(), 0);+ }++ #[test]+ fn delete_refuses_the_first_state_and_drops_the_current_state_onward() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ frame(&mut s);+ let err = s.execute("edit.history.delete", &json!({"index": 0})).unwrap_err();+ assert!(err.to_string().contains("first history state"), "{err}");+ assert_eq!(items(&s), 2);+ assert_eq!(s.doc().unwrap().history.undo.len(), 2);+ s.execute("edit.history.delete", &json!({"index": 2})).unwrap();+ let v = states(&mut s);+ assert_eq!(v["current"], 1);+ assert_eq!(labels(&v), ["New", "Create Frame"]);+ assert!(s.doc().unwrap().history.redo.is_empty());+ assert_eq!(items(&s), 1);+ }++ #[test]+ fn delete_of_a_future_state_truncates_redo_without_moving() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ frame(&mut s);+ s.execute("edit.history.go", &json!({"index": 0})).unwrap();+ assert_eq!(items(&s), 0);+ s.execute("edit.history.delete", &json!({"index": 2})).unwrap();+ let v = states(&mut s);+ assert_eq!(v["current"], 0);+ assert_eq!(items(&s), 0);+ assert_eq!(s.doc().unwrap().history.redo.len(), 1);+ assert_eq!(labels(&v), ["New", "Create Frame"]);+ assert_eq!(v["states"][1]["future"], true);+ }++ #[test]+ fn clear_keeps_the_document_and_names_the_remaining_state() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ frame(&mut s);+ s.execute("edit.history.clear", &json!({})).unwrap();+ let v = states(&mut s);+ assert_eq!(items(&s), 2);+ assert_eq!(labels(&v), ["Create Frame"]);+ assert_eq!(v["states"][0]["kind"], "action");+ assert_eq!(v["current"], 0);+ assert!(s.doc().unwrap().history.undo.is_empty());+ assert!(s.doc().unwrap().history.redo.is_empty());+ }++ #[test]+ fn clear_of_an_undone_document_with_no_origin_stays_new() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ s.execute("edit.undo", &json!({})).unwrap();+ s.execute("edit.history.clear", &json!({})).unwrap();+ let v = states(&mut s);+ assert_eq!(labels(&v), ["New"]);+ assert_eq!(v["states"][0]["kind"], "new");+ assert!(s.doc().unwrap().history.origin.is_none());+ assert!(s.doc().unwrap().history.redo.is_empty());+ assert_eq!(items(&s), 0);+ }++ #[test]+ fn new_document_copies_one_state_and_leaves_the_source_alone() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ frame(&mut s);+ let untitled = s.untitled;+ let made = s.execute("edit.history.newDocument", &json!({"index": 1})).unwrap();+ assert_eq!(made["index"], 1);+ assert_eq!(s.active_index(), Some(1));+ assert_eq!(s.untitled, untitled + 1);+ let new_doc = &s.documents()[1];+ assert_eq!(new_doc.title(), "Untitled-2");+ assert!(new_doc.is_dirty());+ assert!(new_doc.path.is_none());+ assert!(new_doc.history.undo.is_empty());+ assert!(new_doc.history.redo.is_empty());+ assert!(new_doc.fonts.is_none());+ assert_eq!(new_doc.doc.spreads[0].items.len(), 1);+ let source = &s.documents()[0];+ assert_eq!(source.history.undo.len(), 2);+ assert!(source.history.redo.is_empty());+ assert_eq!(source.doc.spreads[0].items.len(), 2);+ }++ #[test]+ fn a_bad_new_document_index_does_not_take_an_untitled_number() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ let untitled = s.untitled;+ assert!(s.execute("edit.history.newDocument", &json!({"index": 4})).is_err());+ assert_eq!(s.untitled, untitled);+ assert_eq!(s.documents().len(), 1);+ }++ #[test]+ fn the_limit_names_the_dropped_state_and_never_empties_undo() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ s.doc_mut().unwrap().history.limit = 1;+ frame(&mut s);+ frame(&mut s);+ frame(&mut s);+ assert_eq!(s.doc().unwrap().history.undo.len(), 1);+ assert_eq!(s.doc().unwrap().history.origin.as_deref(), Some("Create Frame"));+ let v = states(&mut s);+ assert_eq!(v["states"][0]["label"], "Create Frame");+ assert_eq!(v["states"][0]["kind"], "action");+ assert_ne!(v["states"][0]["label"], "New");+ s.doc_mut().unwrap().history.limit = 0;+ frame(&mut s);+ assert_eq!(s.doc().unwrap().history.undo.len(), 1);+ }++ #[test]+ fn a_saved_path_names_the_first_state_until_history_is_trimmed() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ s.doc_mut().unwrap().path = Some("/tmp/Sample.designcraft".into());+ let v = states(&mut s);+ assert_eq!(v["states"][0]["label"], "Sample.designcraft");+ assert_eq!(v["states"][0]["kind"], "file");+ }++ #[test]+ fn hostile_indexes_are_rejected() {+ let mut s = Session::new();+ s.execute("file.new", &json!({})).unwrap();+ frame(&mut s);+ for (cmd, value) in [+ ("edit.history.go", json!({"index": -1})),+ ("edit.history.go", json!({"index": 1.5})),+ ("edit.history.go", json!({"index": u64::MAX})),+ ("edit.history.go", json!({})),+ ("edit.history.delete", json!({"index": -1})),+ ("edit.history.delete", json!({"index": 1.5})),+ ] {+ assert!(s.execute(cmd, &value).is_err(), "{cmd} {value}");+ }+ assert_eq!(s.doc().unwrap().history.undo.len(), 1);+ assert_eq!(items(&s), 1);+ }+}@@ -17,6 +17,7 @@ mod endnotes; mod export; mod file; pub(crate) mod find;+mod history; pub use edit::copied_objects_text; pub use find::same_find_domain; pub(crate) mod flex;@@ -172,6 +173,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(interchange::specs()); v.extend(export::specs()); v.extend(edit::specs());+ v.extend(history::specs()); v.extend(paste_svg::specs()); v.extend(endnotes::specs()); v.extend(editorial::specs());@@ -72,6 +72,8 @@ pub struct History { pub undo: Vec<HistoryEntry>, pub redo: Vec<HistoryEntry>, pub limit: usize,+ /// Name of the oldest row after the undo limit drops it, or after Clear History.+ pub origin: Option<String>, } #[derive(Clone, Debug)]@@ -747,8 +749,10 @@ impl Session { fn push_undo(st: &mut DocState, e: HistoryEntry) { st.history.undo.push(e); st.history.redo.clear();- if st.history.undo.len() > st.history.limit.max(1) {- st.history.undo.remove(0);+ let cap = st.history.limit.max(1);+ while st.history.undo.len() > cap {+ let dropped = st.history.undo.remove(0);+ st.history.origin = Some(dropped.label); } } @@ -1,5 +1,7 @@ //! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon-//! column whose panels open as flyouts beside the dock.+//! column whose panels open as flyouts beside the dock. A workspace icon group opens as one+//! flyout, with a tab for each panel in the group. The flyout stays attached to that group's+//! icon; dragging its header pulls it off into a floating window. use egui::{Color32, Sense, Stroke, vec2}; @@ -12,6 +14,7 @@ pub const DOCK_TABS: &[(&str, &str, &str)] = /// Panels in the collapsed icon column. pub const ICON_PANELS: &[(&str, &str, &str)] = &[+ ("history", "History", "panel-history"), ("stroke", "Stroke", "panel-stroke"), ("swatches", "Swatches", "panel-swatches"), ("color", "Color", "panel-color"),@@ -129,51 +132,68 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups // (Essentials Classic) show their names. let groups = app.ui.icon_groups.clone();- egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {- if dock_header(ui, &t, "«").clicked() {- app.ui.dock_expanded = true;- }- ui.add_space(4.0);- match &groups {- Some(groups) => {- for (i, group) in groups.iter().enumerate() {- if i > 0 {- let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());- ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));- }- for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) {- let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id);- let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());- if on {- ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active);- } else if response.hovered() {- ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover);+ let icon_rect = egui::Panel::right("icon_column")+ .exact_size(icon_column_width(app))+ .resizable(false)+ .frame(egui::Frame::NONE.fill(t.panel))+ .show(ui, |ui| {+ if dock_header(ui, &t, "«").clicked() {+ app.ui.dock_expanded = true;+ }+ ui.add_space(4.0);+ match &groups {+ Some(groups) => {+ for (i, group) in groups.iter().enumerate() {+ if i > 0 {+ let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());+ ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));+ }+ for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) {+ let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id);+ let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());+ if on {+ ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active);+ } else if response.hovered() {+ ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover);+ }+ icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon);+ crate::rtl::paint(+ ui.painter(),+ r.left_center() + vec2(32.0, 0.0),+ egui::Align2::LEFT_CENTER,+ crate::i18n::tr(&app.ui.language, label),+ semibold(11.5),+ t.text,+ );+ remember_icon(ui, id, r);+ icon_clicked(app, ui, id, &response); }- icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon);- crate::rtl::paint(- ui.painter(),- r.left_center() + vec2(32.0, 0.0),- egui::Align2::LEFT_CENTER,- crate::i18n::tr(&app.ui.language, label),- semibold(11.5),- t.text,- );- icon_clicked(app, ui, id, &response); } }+ None => {+ ui.vertical_centered(|ui| {+ for (id, label, icon) in ICON_PANELS {+ let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id);+ let response =+ icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());+ remember_icon(ui, id, response.rect);+ icon_clicked(app, ui, id, &response);+ ui.add_space(1.0);+ }+ });+ } }- None => {- ui.vertical_centered(|ui| {- for (id, label, icon) in ICON_PANELS {- let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id);- let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());- icon_clicked(app, ui, id, &response);- ui.add_space(1.0);- }- });- }- }- });+ })+ .response+ .rect;+ // The flyout sits against whichever column is furthest left: the named icons, or History beside them.+ let bar = if side_column_width(app) > 0.0 {+ let history = history_column(app, ui, &t);+ if history.min.x < icon_rect.min.x { history } else { icon_rect }+ } else {+ icon_rect+ };+ remember_right_bar(ui.ctx(), bar); } /// Width of the icon column: icons only, or icons with names for a workspace's groups.@@ -186,15 +206,87 @@ fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied() } +/// Panels in `groups` that share a column with `id`, in that column's order.+pub(crate) fn icon_group_ids(groups: &[Vec<String>], id: &str) -> Vec<&'static str> {+ groups+ .iter()+ .find(|group| group.iter().any(|panel| panel == id))+ .map(|group| group.iter().filter_map(|panel| panel_entry(panel).map(|entry| entry.0)).collect())+ .unwrap_or_default()+}++/// Width of the history icon column beside a workspace's named groups. Essentials has no groups+/// and lists History in the main icon column instead.+pub(crate) fn side_column_width(app: &DesignApp) -> f32 {+ if app.ui.icon_groups.as_ref().is_some_and(|groups| !groups.iter().flatten().any(|panel| panel == "history")) { 37.0 } else { 0.0 }+}++/// Raise a floating panel, or open and close its flyout.+fn toggle_open(app: &mut DesignApp, ui: &egui::Ui, id: &str) {+ if app.ui.floating.iter().any(|(panel, _)| panel == id) {+ ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id))));+ } else {+ let open = app.ui.open_panel.as_deref() == Some(id);+ app.ui.open_panel = if open { None } else { Some(id.to_string()) };+ }+}++fn history_column(app: &mut DesignApp, ui: &mut egui::Ui, t: &Tokens) -> egui::Rect {+ egui::Panel::right("history_column")+ .exact_size(37.0)+ .resizable(false)+ .frame(egui::Frame::NONE.fill(t.panel))+ .show(ui, |ui| {+ if dock_header(ui, t, "«").clicked() {+ toggle_open(app, ui, "history");+ }+ ui.add_space(4.0);+ ui.vertical_centered(|ui| {+ let on = app.ui.open_panel.as_deref() == Some("history") || app.ui.floating.iter().any(|(panel, _)| panel == "history");+ let response =+ icons::button(ui, "panel-history", 28.0, on, crate::i18n::tr(&app.ui.language, "History")).interact(Sense::click_and_drag());+ remember_icon(ui, "history", response.rect);+ icon_clicked(app, ui, "history", &response);+ });+ })+ .response+ .rect+}++/// Where an icon was drawn, so its flyout can sit beside that row.+pub(crate) fn remember_icon(ui: &egui::Ui, id: &str, rect: egui::Rect) {+ ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(("icon-row", id)), rect));+}++/// The right icon bar for this frame, so a flyout and a drop can meet its left edge.+pub(crate) fn remember_right_bar(ctx: &egui::Context, rect: egui::Rect) {+ if rect.width() > 1.0 && rect.height() > 1.0 && rect.min.x.is_finite() && rect.min.y.is_finite() {+ ctx.data_mut(|data| data.insert_temp(egui::Id::new("right-icon-bar"), rect));+ }+}++/// Icon bar drawn last frame (during a drag) or earlier this frame (when the flyout opens).+pub(crate) fn right_bar(ctx: &egui::Context) -> Option<egui::Rect> {+ ctx.data(|data| data.get_temp(egui::Id::new("right-icon-bar")))+}++/// The icon bar, plus a gutter on its left. Releasing a floating panel here docks it.+pub(crate) fn beside_bar_zone(bar: egui::Rect) -> egui::Rect {+ egui::Rect::from_min_max(egui::pos2(bar.min.x - 48.0, bar.min.y), bar.max)+}++/// Top of the flyout: the first icon in the group, or this panel's own icon.+fn flyout_y(ctx: &egui::Context, anchor: &str) -> f32 {+ ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", anchor))).map(|rect| rect.min.y))+ .or_else(|| right_bar(ctx).map(|rect| rect.min.y))+ .or_else(|| ctx.memory(|memory| memory.area_rect(egui::Id::new("icon_column")).map(|rect| rect.min.y)))+ .unwrap_or(120.0)+}+ /// A click on a panel's icon: raise it when it floats, else open or close its flyout. fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { if response.clicked() {- if app.ui.floating.iter().any(|(p, _)| p == id) {- ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id))));- } else {- let open = app.ui.open_panel.as_deref() == Some(id);- app.ui.open_panel = if open { None } else { Some(id.to_string()) };- }+ toggle_open(app, ui, id); } crate::panel_docking::legacy_tab(app, ui, id, response); }@@ -208,60 +300,192 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response { response } -/// A panel opened from the icon column, shown as a floating flyout next to the dock.+/// A panel opened from the icon column, attached beside that icon. It is not a free window.+///+/// A group of panels uses the header as its tab strip and lines up with the group's first icon.+/// Dragging the header pulls the panel off the dock. A later click on the canvas closes it;+/// the click that opened it does not, because that id was not yet remembered. pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {- if app.ui.docking.is_some() {+ let remembered = ctx.data(|data| data.get_temp::<String>(egui::Id::new("flyout-open-id")));+ let Some(id) = app.ui.open_panel.clone() else {+ if remembered.is_some() {+ ctx.data_mut(|data| data.remove::<String>(egui::Id::new("flyout-open-id")));+ } return;- }- let Some(id) = app.ui.open_panel.clone() else { return };+ }; let t = Tokens::get(ctx);- let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned();+ let groups = app.ui.icon_groups.clone().unwrap_or_default();+ let members = icon_group_ids(&groups, &id);+ let grouped = members.len() > 1;+ let width = if grouped { 312.0 } else { 268.0 }; let screen = ctx.content_rect();- let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 };- let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0);+ // Flush with the icon bar's left edge. The expanded dock, when it is open, stays to the right of that bar.+ let x = right_bar(ctx).map(|bar| (bar.min.x - width).max(screen.min.x)).unwrap_or_else(|| {+ let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 };+ (screen.max.x - icon_column_width(app) - side_column_width(app) - dock_w - width).max(screen.min.x)+ });+ let anchor = members.first().copied().unwrap_or(id.as_str());+ let y = flyout_y(ctx, anchor);+ let pos = egui::pos2(x, y); let mut open = true;- egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {+ let mut shown = id.clone();+ // Middle keeps the flyout above the page and the dock. Menus, dialogs and floating+ // windows are Foreground, so they stay above it.+ let area = egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| {- ui.set_width(256.0);- // A previous shorter flyout must not constrain the next panel's scroll viewport.+ ui.set_width(width); ui.set_max_height((screen.max.y - pos.y - 12.0).max(80.0));- let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());- ui.painter().rect_filled(strip, 0.0, t.panel_darker);- crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.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 + 4.0), vec2(18.0, 18.0));- if ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked() {+ let stay = if grouped { group_strip(app, ui, &t, width, &members, &mut shown) } else { single_strip(app, ui, &t, width, &shown, pos) };+ if !stay { open = false;+ return; }- 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_ui(|ui| {- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "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);+ if app.ui.open_panel.is_none() { return; }- egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {- scrolled_body(app, ui, &id, screen.height() - 220.0);+ let room = (screen.max.y - pos.y - 48.0).clamp(80.0, 640.0);+ // Each panel hugs its content. History sizes from its rows. The others scroll only+ // once the body is taller than the space left beside the icon.+ let (body_h, margin) =+ if shown == "history" { (history_flyout_body(app, room.min(480.0)), egui::Margin::ZERO) } else { (room, egui::Margin::same(10)) };+ egui::Frame::NONE.inner_margin(margin).show(ui, |ui| {+ scrolled_body(app, ui, &shown, body_h); }); }); }); if !open { app.ui.open_panel = None; }+ let flyout_rect = area.response.rect;+ let click = ctx.input(|input| input.pointer.interact_pos().filter(|_| input.pointer.primary_clicked()));+ let on_canvas = click.is_some_and(|point| app.canvas_rect.is_some_and(|rect| rect.contains(point)) && !flyout_rect.contains(point));+ // After the body, so a menu drawn from this flyout (it can extend past the flyout) counts+ // as open. The click that dismisses that menu does not also dismiss the flyout.+ let popup_open = ctx.any_popup_open();+ if app.ui.open_panel.as_deref() == Some(id.as_str()) && remembered.as_deref() == Some(id.as_str()) && on_canvas && !popup_open {+ app.ui.open_panel = None;+ }+ if let Some(current) = app.ui.open_panel.clone() {+ ctx.data_mut(|data| data.insert_temp(egui::Id::new("flyout-open-id"), current));+ } else {+ ctx.data_mut(|data| data.remove::<String>(egui::Id::new("flyout-open-id")));+ }+}++/// Group header: one tab per panel, then collapse and the panel menu. `false` closes the flyout.+fn group_strip(app: &mut DesignApp, ui: &mut egui::Ui, t: &Tokens, width: f32, members: &[&str], shown: &mut String) -> bool {+ let (strip, _) = ui.allocate_exact_size(vec2(width, 28.0), Sense::hover());+ ui.painter().rect_filled(strip, 0.0, t.tab_strip);+ let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 5.0), vec2(18.0, 18.0));+ let collapse_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 5.0), vec2(18.0, 18.0));+ let tabs_left = strip.min.x + 2.0;+ let avail = (collapse_r.min.x - 2.0 - tabs_left).max(40.0);+ let font = semibold(11.5);+ let lang = app.ui.language.clone();+ let mut widths: Vec<f32> = members+ .iter()+ .map(|panel| {+ let text = crate::i18n::tr(&lang, panel_label(panel));+ let galley = crate::rtl::plain(ui.ctx(), text, font.clone(), t.text);+ galley.size().x + if *panel == shown.as_str() { 28.0 } else { 16.0 }+ })+ .collect();+ if widths.iter().copied().sum::<f32>() > avail {+ let each = avail / widths.len().max(1) as f32;+ widths.fill(each);+ }+ let mut rects = Vec::with_capacity(members.len());+ let mut x = tabs_left;+ let mut torn: Option<&str> = None;+ for (panel, tab_w) in members.iter().copied().zip(widths.iter().copied()) {+ let rect = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(tab_w, strip.height()));+ let response = ui.interact(rect, ui.id().with(("flytab", panel)), Sense::click_and_drag());+ if response.clicked() {+ app.ui.open_panel = Some(panel.to_string());+ *shown = panel.to_string();+ }+ if response.drag_stopped() && response.total_drag_delta().is_some_and(|delta| delta.length() > 24.0) {+ torn = Some(panel);+ }+ rects.push(rect);+ x += tab_w;+ }+ if let Some(panel) = torn {+ let at = ui.ctx().pointer_latest_pos().unwrap_or(strip.min) - vec2(20.0, 10.0);+ float_panel(app, panel, at);+ return true;+ }+ for (panel, rect) in members.iter().copied().zip(rects) {+ let active = panel == shown.as_str();+ if active {+ ui.painter().rect_filled(rect, 0.0, t.panel);+ diamond(ui.painter(), rect.left_center() + vec2(8.0, 0.0), 3.0, t.accent);+ }+ let color = if active { t.text_strong } else { t.text_dim };+ let text_x = rect.min.x + if active { 16.0 } else { 6.0 };+ let label = crate::i18n::tr(&lang, panel_label(panel));+ let galley = crate::rtl::truncated(ui.ctx(), label, font.clone(), color, (rect.max.x - 4.0 - text_x).max(8.0));+ ui.painter().galley(egui::pos2(text_x, rect.center().y - galley.size().y / 2.0), galley, color);+ }+ ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));+ let collapse = ui.interact(collapse_r, ui.id().with("flycollapse"), Sense::click());+ if collapse.hovered() {+ ui.painter().rect_filled(collapse_r, 3.0, t.hover);+ }+ ui.painter().text(collapse_r.center(), egui::Align2::CENTER_CENTER, "»", egui::FontId::proportional(12.0), t.text_dim);+ crate::panel_docking::header_menu(app, ui, menu_r, shown);+ !collapse.clicked()+}++/// One panel: title, menu, float, close. `false` closes the flyout.+fn single_strip(app: &mut DesignApp, ui: &mut egui::Ui, t: &Tokens, width: f32, id: &str, pos: egui::Pos2) -> bool {+ let label = crate::i18n::tr(&app.ui.language, panel_label(id)).to_owned();+ let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());+ ui.painter().rect_filled(strip, 0.0, t.panel_darker);+ crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.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 + 4.0), vec2(18.0, 18.0));+ 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 menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 66.0, strip.min.y + 4.0), vec2(18.0, 18.0));+ crate::panel_docking::header_menu(app, ui, menu_r, id);+ let closed = ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked();+ ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);+ if closed {+ return false;+ }+ let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| {+ crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel"));+ });+ float_glyph(ui.painter(), float_r, t.text_dim);+ let head = egui::Rect::from_min_max(strip.min, egui::pos2(menu_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(|delta| delta.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);+ }+ true+}++/// Height of the History flyout body: one row per state, plus the footer, never past `max_h`.+fn history_flyout_body(app: &DesignApp, max_h: f32) -> f32 {+ let rows = app.session.active().map(|state| state.history.undo.len() + state.history.redo.len() + 1).unwrap_or(1);+ (rows as f32 * 22.0 + 36.0).clamp(72.0, max_h.max(72.0))+}++fn diamond(painter: &egui::Painter, center: egui::Pos2, radius: f32, color: Color32) {+ painter.add(egui::Shape::convex_polygon(+ vec![center + vec2(0.0, -radius), center + vec2(radius, 0.0), center + vec2(0.0, radius), center + vec2(-radius, 0.0)],+ color,+ Stroke::NONE,+ )); } /// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its /// parts itself and keeps its footer at the bottom, so it gets the height instead. fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { if scrolls_itself(id) {- ui.allocate_ui(vec2(ui.available_width(), max_h.clamp(160.0, 560.0)), |ui| panel_body(app, ui, id));+ let min = if id == "history" { 72.0 } else { 160.0 };+ ui.allocate_ui(vec2(ui.available_width(), max_h.clamp(min, 560.0)), |ui| panel_body(app, ui, id)); } else { egui::ScrollArea::vertical().max_height(max_h).show(ui, |ui| panel_body(app, ui, id)); }@@ -269,7 +493,7 @@ fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { /// Panels that lay out their own scrolling parts (the Pages panel: parents, pages, footer). pub fn scrolls_itself(id: &str) -> bool {- id == "pages"+ id == "pages" || id == "history" } /// The contents of an icon-column panel.@@ -285,6 +509,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "character" => panels::properties::character_panel(app, ui), "typography" => panels::typography::show(app, ui), "glyphs" => panels::glyphs::show(app, ui),+ "history" => panels::history::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), "story" => panels::story::show(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui),@@ -336,15 +561,15 @@ pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { } } -/// Put a floating panel back in the dock's icon column.+/// Put a floating panel on the left of the right icon bar, in line with its icon. pub fn dock_panel(app: &mut DesignApp, id: &str) {- if app.ui.docking.is_some() && !float_only(id) {- if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) {- app.status(error);- }+ if float_only(id) {+ app.ui.floating.retain(|(p, _)| p != id); return; }- app.ui.floating.retain(|(p, _)| p != id);+ if let Err(error) = crate::panel_docking::attach_beside(app, id) {+ app.status(error);+ } } /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column.@@ -380,7 +605,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::paint(ui.painter(), 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())+ .interact(close, ui.id().with("fclose"), Sense::click_and_drag()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Close")); })@@ -390,9 +615,12 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { } 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));+ let menu_x = if docks { strip.max.x - 66.0 } else { strip.max.x - 44.0 };+ let menu_r = egui::Rect::from_min_size(egui::pos2(menu_x, strip.min.y + 3.0), vec2(18.0, 18.0));+ crate::panel_docking::header_menu(app, ui, menu_r, &id); if docks && ui- .interact(dock_r, ui.id().with("fdock"), Sense::click())+ .interact(dock_r, ui.id().with("fdock"), Sense::click_and_drag()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel")); })@@ -408,12 +636,18 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { }); }); // Remember where it was left.+ let dropped_on_bar = r.as_ref().is_some_and(|r| {+ r.response.drag_stopped()+ && ctx+ .input(|input| input.pointer.interact_pos())+ .is_some_and(|point| right_bar(ctx).is_some_and(|bar| beside_bar_zone(bar).contains(point)))+ }); 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 {+ if !open || dock || dropped_on_bar { dock_panel(app, &id); } }@@ -392,6 +392,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]),+ ("Clear History", ["Verlauf löschen", "Effacer l'historique", "Borrar historial", "ヒストリーを消去", "清除历史记录"]),+ ("No document", ["Kein Dokument", "Aucun document", "Ningún documento", "ドキュメントなし", "无文档"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), ("Deselect All", ["Auswahl aufheben", "Tout désélectionner", "Deseleccionar todo", "すべての選択を解除", "取消全选"]),@@ -2310,6 +2312,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Split Path", ["Split Path", "Fractionner le tracé", "Split Path", "Split Path", "拆分路径"]), ("Step and Repeat…", ["Step and Repeat…", "Exécuter et répéter…", "Step and Repeat…", "Step and Repeat…", "步进和重复…"]),+ ("Step Backward", ["Schritt zurück", "État précédent", "Paso atrás", "前のステップ", "后退一步"]),+ ("Step Forward", ["Schritt vorwärts", "État suivant", "Paso adelante", "次のステップ", "前进一步"]), ("Stroke Styles", ["Stroke Styles", "Styles de contour", "Stroke Styles", "Stroke Styles", "描边样式"]), ("Structure", ["Structure", "Structure", "Structure", "Structure", "结构"]), ("Style Source", ["Style Source", "Source du style", "Style Source", "Style Source", "样式源"]),@@ -1140,6 +1140,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); }+ "panel-history" => {+ pen.rect(3.0, 3.0, 17.0, 17.0);+ pen.line(&[(6.0, 7.0), (14.0, 7.0)]);+ pen.line(&[(6.0, 10.5), (14.0, 10.5)]);+ pen.line(&[(6.0, 14.0), (11.0, 14.0)]);+ } _ => { pen.rect(4.0, 4.0, 16.0, 16.0); }@@ -893,6 +893,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}", "<", "ui:window.panel|Effects|{\"panel\": \"effects\"}",+ "ui:window.panel|History|{\"panel\": \"history\"}", "ui:window.panel|Info|{\"panel\": \"info\"}", ">Interactive", "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}",@@ -980,14 +981,14 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[ &["gradient"], ]; -/// The Typography preset's available panels in the order observed in InDesign's workspace.-/// CC Libraries has no corresponding DesignCraft panel, and where the Story panel sits in it is-/// not recorded, so these groups leave it out.+/// The Typography preset's panels in the order observed in InDesign's workspace.+/// CC Libraries and Style Packs are Adobe cloud features and have no DesignCraft panel.+/// Story leads the group with Hyperlinks and Effects. pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[ &["pages", "layers", "swatches"], &["glyphs", "textWrap"], &["stroke", "gradient"],- &["hyperlinks", "effects"],+ &["story", "hyperlinks", "effects"], &["paragraph", "paragraphStyles"], &["character", "characterStyles"], ];@@ -5618,8 +5619,17 @@ mod tests { assert!(app.ui.open_panel.is_none()); let groups = app.ui.icon_groups.clone().unwrap(); assert_eq!(groups[0], ["pages", "layers", "swatches"]);+ assert_eq!(groups[3], ["story", "hyperlinks", "effects"]); assert_eq!(groups[4], ["paragraph", "paragraphStyles"]); assert_eq!(groups[5], ["character", "characterStyles"]);+ assert_eq!(crate::dock::icon_group_ids(&groups, "swatches"), vec!["pages", "layers", "swatches"]);+ assert!(crate::dock::icon_group_ids(&groups, "history").is_empty());+ assert_eq!(crate::dock::side_column_width(&app), 37.0);+ assert_eq!(crate::dock::panel_label("history"), "History");+ run_ui(&mut app, "window.panel", &json!({"panel": "history"})).unwrap().unwrap();+ assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("history"), false));+ run_ui(&mut app, "window.panel", &json!({"panel": "history"})).unwrap().unwrap();+ assert!(app.ui.open_panel.is_none()); for panel in ["pages", "paragraphStyles", "characterStyles"] { run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap(); assert_eq!(app.ui.open_panel.as_deref(), Some(panel));@@ -5632,6 +5642,7 @@ mod tests { run_ui(&mut app, "window.workspace", &json!({"name":"Essentials"})).unwrap().unwrap(); assert!(app.ui.icon_groups.is_none()); assert!(app.ui.dock_expanded);+ assert_eq!(crate::dock::side_column_width(&app), 0.0); } #[test]@@ -505,7 +505,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { &mut PanelContent(app), ) };- let output = if layout.root.is_some() {+ let mut output = if layout.root.is_some() { egui::Panel::right("shared-panel-dock") .default_size(310.0) .size_range(220.0..=720.0)@@ -516,10 +516,10 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { } else { draw(ui) };- if let Some(panel) = output.focused {+ if let Some(panel) = output.focused.take() { app.ui.docking_opened = Some(panel); }- if let Some(error) = output.error {+ if let Some(error) = output.error.take() { app.ui.status = error.to_string(); } if ui.input(|input| input.pointer.any_released()) {@@ -528,15 +528,151 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { } ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); }+ let dropped = bar_drop(app, ui, &mut output); for action in output.actions { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } }+ if let Some(panel) = dropped+ && let Err(error) = attach_beside(app, &panel)+ {+ app.ui.status = error;+ } icon_rail(app, ui); true } +/// A floating panel released against the icon bar docks there.+///+/// The title is the drag handle, and it sits at the left of the window. The pointer is often+/// still over the page when the window's right edge meets the bar, so the window rectangle is+/// what has to arrive. Releasing in the gutter just left of the icons docks too. A drop on the+/// expanded stack, to the right of the icons, stays with that stack.+fn bar_drop(app: &DesignApp, ui: &egui::Ui, output: &mut craft_ui::docking::DockOutput<String>) -> Option<String> {+ let bar = crate::dock::right_bar(ui.ctx())?;+ let zone = crate::dock::beside_bar_zone(bar);+ let pointer = ui.input(|input| input.pointer.interact_pos());+ let in_zone = pointer.is_some_and(|point| zone.contains(point));+ let released = ui.input(|input| input.pointer.any_released());+ let dragging = output.group_drag.is_some() || ui.input(|input| input.pointer.primary_down() && input.pointer.is_decidedly_dragging());+ let mut placed = placed_windows(output);+ if let Some((panel, _)) = &output.group_drop+ && !placed.iter().any(|(id, _)| id == panel)+ && let Some(rect) = floating_window(app, panel)+ {+ placed.push((panel.clone(), rect));+ }+ let arrived = placed.iter().find(|(id, rect)| !crate::dock::float_only(id) && window_meets_bar(*rect, bar)).map(|(id, _)| id.clone());+ if app.ui.docking.is_some() && (arrived.is_some() || (in_zone && dragging)) {+ paint_bar_preview(ui, bar, arrived.as_deref().or(output.group_drag.as_deref()));+ }+ if !released {+ return None;+ }+ let panel = arrived.or_else(|| gutter_panel(output, in_zone))?;+ if crate::dock::float_only(&panel) {+ return None;+ }+ output.actions.retain(|action| match action {+ Action::Float { panel: id, .. } | Action::Move { panel: id, .. } | Action::MoveFloating { panel: id, .. } => id != &panel,+ _ => true,+ });+ output.group_drop = None;+ Some(panel)+}++/// Windows this frame actually moved: a torn-off tab (`Float`) or a title drag (`MoveFloating`+/// while a group drag is active). A corner resize also emits `MoveFloating` and must not dock.+fn placed_windows(output: &craft_ui::docking::DockOutput<String>) -> Vec<(String, egui::Rect)> {+ let moving = output.group_drag.is_some() || output.group_drop.is_some();+ let mut placed = Vec::new();+ for action in &output.actions {+ let Some((panel, rect)) = action_window(action, moving) else { continue };+ if let Some(slot) = placed.iter_mut().find(|(id, _)| id == &panel) {+ slot.1 = rect;+ } else {+ placed.push((panel, rect));+ }+ }+ placed+}++fn action_window(action: &Action<String>, moving: bool) -> Option<(String, egui::Rect)> {+ let (panel, rect) = match action {+ Action::Float { panel, rect } => (panel, rect),+ Action::MoveFloating { panel, rect } if moving => (panel, rect),+ _ => return None,+ };+ let [x, y, width, height] = *rect;+ if ![x, y, width, height].into_iter().all(f32::is_finite) || width <= 1.0 || height <= 1.0 {+ return None;+ }+ Some((panel.clone(), egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(width, height))))+}++fn floating_window(app: &DesignApp, panel: &str) -> Option<egui::Rect> {+ let group = app.ui.docking.as_ref()?.floating.iter().find(|group| group.panels.iter().any(|id| id == panel))?;+ let [x, y, width, height] = group.rect;+ if ![x, y, width, height].into_iter().all(f32::is_finite) || width <= 1.0 || height <= 1.0 {+ return None;+ }+ Some(egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(width, height)))+}++/// The right edge is against the icon bar, or overlapping it, without the window sitting+/// entirely on the expanded stack past the icons.+fn window_meets_bar(window: egui::Rect, bar: egui::Rect) -> bool {+ if window.bottom() <= bar.top() || window.top() >= bar.bottom() {+ return false;+ }+ let gap = bar.min.x - window.max.x;+ gap <= 96.0 && gap > -window.width() * 0.7+}++/// Pointer released on the icons or in the gutter beside them.+fn gutter_panel(output: &craft_ui::docking::DockOutput<String>, in_zone: bool) -> Option<String> {+ if !in_zone {+ return None;+ }+ output.group_drop.as_ref().map(|(panel, _)| panel.clone()).or_else(|| {+ output.actions.iter().find_map(|action| match action {+ Action::Float { panel, .. } | Action::Move { panel, .. } => Some(panel.clone()),+ _ => None,+ })+ })+}++fn paint_bar_preview(ui: &egui::Ui, bar: egui::Rect, panel: Option<&str>) {+ let t = crate::theme::Tokens::get(ui.ctx());+ let width = 268.0;+ let top = panel.map(|id| flyout_top(ui.ctx(), id)).unwrap_or(bar.min.y);+ let height = (bar.max.y - top - 12.0).clamp(120.0, 480.0);+ let preview = egui::Rect::from_min_size(egui::pos2((bar.min.x - width).max(0.0), top), egui::vec2(width, height));+ let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("beside-bar-preview")));+ painter.rect(preview, 0.0, t.accent.gamma_multiply(0.18), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);+}++fn flyout_top(ctx: &egui::Context, id: &str) -> f32 {+ ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", id))).map(|rect| rect.min.y))+ .or_else(|| crate::dock::right_bar(ctx).map(|rect| rect.min.y))+ .unwrap_or(120.0)+}++/// Take `panel` out of any floating window or tab group and open it against the icon bar.+pub(crate) fn attach_beside(app: &mut DesignApp, raw: &str) -> Result<(), String> {+ let panel = normalize(raw).ok_or_else(|| "panel must name a registered panel".to_string())?;+ if crate::dock::float_only(panel) {+ return Err("panel does not dock".into());+ }+ if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel.to_string())) {+ change(app, "close", &json!({"panel": panel}))?;+ }+ app.ui.floating.retain(|(id, _)| id != panel);+ app.ui.open_panel = Some(panel.to_string());+ Ok(())+}+ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let column = egui::Panel::right("shared-panel-icons")@@ -553,10 +689,13 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { continue; } + let on = app.ui.open_panel.as_deref() == Some(id); let response =- crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag());+ crate::icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag());+ crate::dock::remember_icon(ui, id, response.rect);+ // A click opens the panel beside this icon. Dragging it still starts a dock gesture. if response.clicked() {- let _ = app.run("window.panel.activate", json!({"panel":id}));+ app.ui.open_panel = if on { None } else { Some(id.to_string()) }; } legacy_tab(app, ui, id, &response); }@@ -564,7 +703,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { }) .response .rect;- let _ = column;+ crate::dock::remember_right_bar(ui.ctx(), column); } pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {@@ -606,9 +745,24 @@ pub(crate) fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &st crate::panels::pages::panel_menu(app, ui); ui.separator(); }+ if panel == "history" {+ crate::panels::history::panel_menu(app, ui);+ ui.separator();+ } panel_menu(app, ui, panel); } +/// The three-line button at `rect`, opening [`three_line_menu`] for `panel`.+pub(crate) fn header_menu(app: &mut DesignApp, ui: &mut egui::Ui, rect: egui::Rect, panel: &str) {+ let t = crate::theme::Tokens::get(ui.ctx());+ let menu = ui.interact(rect, ui.id().with(("header-menu", panel)), egui::Sense::click_and_drag());+ if menu.hovered() {+ ui.painter().rect_filled(rect, 3.0, t.hover);+ }+ crate::icons::paint(ui.painter(), rect, "menu", t.icon);+ egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| three_line_menu(app, ui, panel));+}+ /// The three-line button at the right of a tab strip, for the panel in front. fn three_line_button(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { let t = crate::theme::Tokens::get(ui.ctx());@@ -628,6 +782,14 @@ pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { tab_commands(app, ui, panel); ui.separator(); let language = app.ui.language.clone();+ let floating_now = app.ui.floating.iter().any(|(id, _)| id == panel)+ || app.ui.docking.as_ref().is_some_and(|layout| layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel)));+ if floating_now && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Dock panel"))).clicked() {+ if let Err(error) = attach_beside(app, panel) {+ app.status(error);+ }+ ui.close();+ } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { app.status(error);@@ -668,10 +830,23 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> { } fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) { // The docking surface adds Float Panel and Group with after these, so a tab's menu lists- // what the classic dock's does.+ // what the classic dock's does. A floating tab can also dock against the icon bar. if let Some(panel) = normalize(id) { tab_commands(self.0, ui, panel); ui.separator();+ let floating_now = self.0.ui.floating.iter().any(|(existing, _)| existing == panel)+ || self+ .0+ .ui+ .docking+ .as_ref()+ .is_some_and(|layout| layout.floating.iter().any(|group| group.panels.iter().any(|existing| existing == panel)));+ if floating_now && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&self.0.ui.language, "Dock panel"))).clicked() {+ if let Err(error) = attach_beside(self.0, panel) {+ self.0.status(error);+ }+ ui.close();+ } } } fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 {@@ -787,3 +787,91 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(h.query_by_label("Close Panel").is_none()); assert!(h.query_by_label("Panel Options…").is_none(), "the panel's own commands stay in its panel menu"); }++fn harness_flyout(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> {+ 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 DesignApp| {+ if !ready {+ crate::theme::install_fonts(ui.ctx(), &app.ui.language);+ crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark));+ ready = true;+ return;+ }+ crate::dock::show(app, ui);+ egui::CentralPanel::default().show(ui, |_| {});+ crate::dock::flyout(app, ui.ctx());+ },+ app,+ );+ harness.run_steps(4);+ harness+}++#[test]+fn effects_attaches_beside_the_icon_bar_instead_of_staying_afloat() {+ let mut app = app();+ app.run("window.panel.float", json!({"panel":"effects","x":180.0,"y":160.0,"width":300.0,"height":420.0})).unwrap();+ assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|group| group.panels == ["effects"]));+ attach_beside(&mut app, "effects").unwrap();+ let layout = app.ui.docking.as_ref().unwrap();+ assert!(!layout.contains(&"effects".into()));+ assert!(layout.contains(&"properties".into()), "the expanded dock stays");+ assert_eq!(app.ui.open_panel.as_deref(), Some("effects"));+}++/// Dropping the floating Effects window on the gutter left of the icon bar docks it there,+/// flush with the bar, instead of leaving it over the page.+#[test]+fn dragging_effects_onto_the_right_icon_bar_docks_it_flush() {+ let mut app = app();+ app.run("window.panel.float", json!({"panel":"effects","x":180.0,"y":160.0,"width":300.0,"height":420.0})).unwrap();+ let mut h = harness_flyout(app, vec2(1280.0, 800.0));+ let tab = h.query_all_by_label("Effects").find(|node| node.rect().height() <= 45.0).expect("effects tab").rect();+ let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).expect("icon bar").outer_rect;+ drag(&mut h, tab.center(), egui::pos2(bar.left() - 16.0, bar.center().y));+ h.run_steps(4);+ assert_eq!(h.state().ui.open_panel.as_deref(), Some("effects"), "releasing on the bar docks Effects");+ let layout = h.state().ui.docking.clone().unwrap();+ assert!(!layout.floating.iter().any(|group| group.panels.iter().any(|panel| panel == "effects")));+ assert!(layout.contains(&"properties".into()));+ let flyout = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("panel_flyout"))).expect("attached flyout");+ let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).unwrap().outer_rect;+ assert!((flyout.right() - bar.left()).abs() < 8.0, "flyout {flyout:?} should meet bar {bar:?}");+ assert!(flyout.height() < 180.0, "attached panel hugs its content, got {}", flyout.height());+ let icon = h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", "effects")))).expect("effects icon");+ assert!((icon.top() - flyout.top()).abs() < 12.0, "flyout {flyout:?} should line up with icon {icon:?}");+}++/// The title is at the left of the window. Dropping so the window meets the bar docks it even+/// when that title, and the pointer, are still over the page.+#[test]+fn dropping_effects_against_the_bar_docks_it_when_the_pointer_is_still_over_the_page() {+ let mut app = app();+ app.run("window.panel.float", json!({"panel": "effects", "x": 180.0, "y": 160.0, "width": 300.0, "height": 420.0})).unwrap();+ let mut h = harness_flyout(app, vec2(1280.0, 800.0));+ let tab = h.query_all_by_label("Effects").find(|node| node.rect().height() <= 45.0).expect("effects tab").rect();+ let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).expect("icon bar").outer_rect;+ let group = h+ .state()+ .ui+ .docking+ .as_ref()+ .and_then(|layout| layout.floating.iter().find(|group| group.panels.iter().any(|panel| panel == "effects")))+ .expect("floating effects")+ .rect;+ let grab_x = tab.center().x - group[0];+ let pointer_x = bar.left() - 8.0 + grab_x - group[2];+ assert!(pointer_x < bar.left() - 48.0, "pointer {pointer_x} should be left of the gutter at {}", bar.left() - 48.0);+ drag(&mut h, tab.center(), egui::pos2(pointer_x, bar.center().y));+ h.run_steps(4);+ assert_eq!(h.state().ui.open_panel.as_deref(), Some("effects"), "the window edge docks Effects");+ let layout = h.state().ui.docking.clone().unwrap();+ assert!(!layout.floating.iter().any(|group| group.panels.iter().any(|panel| panel == "effects")));+ let flyout = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("panel_flyout"))).expect("attached flyout");+ let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).unwrap().outer_rect;+ assert!((flyout.right() - bar.left()).abs() < 8.0, "flyout {flyout:?} should meet bar {bar:?}");+ assert!(flyout.height() < 180.0, "attached panel hugs its content, got {}", flyout.height());+ let icon = h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", "effects")))).expect("effects icon");+ assert!((icon.top() - flyout.top()).abs() < 12.0, "flyout {flyout:?} should line up with icon {icon:?}");+}new file mode 100644@@ -0,0 +1,141 @@+//! History panel: one row per undo state, with New Document and Delete in the footer.++use egui::{Color32, Sense, Stroke, vec2};+use serde_json::{Value, json};++use crate::DesignApp;+use crate::theme::Tokens;++/// The list of states. The footer stays put while the list scrolls.+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {+ let t = Tokens::get(ui.ctx());+ let lang = app.ui.language.clone();+ let value = match app.session.execute("document.history.states", &json!({})) {+ Ok(value) => value,+ Err(_) => {+ dim(ui, &t, crate::i18n::tr(&lang, "No document"));+ return;+ }+ };+ let current = value.get("current").and_then(Value::as_u64).unwrap_or(0);+ let Some(states) = value.get("states").and_then(Value::as_array) else {+ dim(ui, &t, crate::i18n::tr(&lang, "No document"));+ return;+ };+ let footer_h = 28.0;+ let list_h = (ui.available_height() - footer_h).max(48.0);+ egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| {+ for state in states {+ let index = state.get("index").and_then(Value::as_u64).unwrap_or(0);+ let raw = state.get("label").and_then(Value::as_str).unwrap_or("");+ let kind = state.get("kind").and_then(Value::as_str).unwrap_or("action");+ let future = state.get("future").and_then(Value::as_bool).unwrap_or(false);+ let selected = state.get("current").and_then(Value::as_bool).unwrap_or(false);+ let owned = raw.to_string();+ let shown = if kind == "file" { owned } else { crate::i18n::tr(&lang, &owned).to_string() };+ let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());+ if selected {+ ui.painter().rect_filled(rect, 2.0, t.row_selected);+ } else if response.hovered() {+ ui.painter().rect_filled(rect, 2.0, t.hover);+ }+ let icon = egui::Rect::from_min_size(rect.min + vec2(4.0, 3.0), vec2(16.0, 16.0));+ let color = if future {+ t.text_dim+ } else if selected {+ t.text_strong+ } else {+ t.text+ };+ crate::icons::paint(ui.painter(), icon, "panel-history", if future { t.text_disabled } else { t.icon });+ crate::rtl::paint(+ ui.painter(),+ rect.left_center() + vec2(24.0, 0.0),+ egui::Align2::LEFT_CENTER,+ &shown,+ egui::FontId::proportional(12.0),+ color,+ );+ if response.clicked()+ && let Err(error) = app.run("edit.history.go", json!({"index": index}))+ {+ app.status_error("History", error);+ }+ }+ });+ let (line, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());+ ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(1.0, t.border));+ ui.horizontal(|ui| {+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ ui.add_space(6.0);+ if footer_button(ui, &t, &lang, "Delete", current > 0, true).clicked()+ && let Err(error) = app.run("edit.history.delete", json!({}))+ {+ app.status_error("History", error);+ }+ if footer_button(ui, &t, &lang, "New Document", true, false).clicked()+ && let Err(error) = app.run("edit.history.newDocument", json!({}))+ {+ app.status_error("History", error);+ }+ });+ });+}++/// History commands at the top of the panel menu.+pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) {+ let lang = app.ui.language.clone();+ let (back, forward, delete, clear, has_doc) = match app.session.active() {+ Some(st) => {+ let undo = !st.history.undo.is_empty();+ let redo = !st.history.redo.is_empty();+ (undo, redo, undo, undo || redo, true)+ }+ None => (false, false, false, false, false),+ };+ let mut command = |ui: &mut egui::Ui, label: &str, on: bool, id: &str| {+ let text = crate::rtl::widget(ui, crate::i18n::tr(&lang, label));+ if ui.add_enabled(on, egui::Button::new(text)).clicked() {+ if let Err(error) = app.run(id, json!({})) {+ app.status_error("History", error);+ }+ ui.close();+ }+ };+ command(ui, "Step Backward", back, "edit.undo");+ command(ui, "Step Forward", forward, "edit.redo");+ command(ui, "Delete", delete, "edit.history.delete");+ command(ui, "Clear History", clear, "edit.history.clear");+ ui.separator();+ command(ui, "New Document", has_doc, "edit.history.newDocument");+}++fn dim(ui: &mut egui::Ui, t: &Tokens, text: &str) {+ ui.label(egui::RichText::new(text).color(t.text_dim));+}++/// Footer icon. `trash` paints the trash can; otherwise a page with a plus.+fn footer_button(ui: &mut egui::Ui, t: &Tokens, lang: &str, tip: &str, enabled: bool, trash: bool) -> egui::Response {+ let (rect, response) = ui.allocate_exact_size(vec2(26.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });+ if enabled && response.hovered() {+ ui.painter().rect_filled(rect, 3.0, t.hover);+ }+ let color = if enabled { t.icon } else { t.text_disabled };+ if trash {+ crate::icons::paint(ui.painter(), rect.shrink(3.0), "trash", color);+ } else {+ page_plus(ui.painter(), rect, color);+ }+ let tip = crate::i18n::tr(lang, tip).to_string();+ response.on_hover_ui(move |ui| {+ crate::rtl::label(ui, tip);+ })+}++fn page_plus(painter: &egui::Painter, rect: egui::Rect, color: Color32) {+ let page = egui::Rect::from_center_size(rect.center() + vec2(-2.0, 0.0), vec2(10.0, 12.0));+ painter.rect_stroke(page, 1.0, Stroke::new(1.0, color), egui::StrokeKind::Middle);+ let center = page.right_bottom() + vec2(-1.0, -1.0);+ painter.line_segment([center + vec2(-3.5, 0.0), center + vec2(3.5, 0.0)], Stroke::new(1.2, color));+ painter.line_segment([center + vec2(0.0, -3.5), center + vec2(0.0, 3.5)], Stroke::new(1.2, color));+}@@ -6,6 +6,7 @@ mod font_menu; pub use font_menu::{FontPick, SAMPLES as FONT_SAMPLES, font_menu_body}; pub mod datamerge; pub mod glyphs;+pub mod history; pub mod interactive; pub mod layers; pub mod library;@@ -16,12 +16,12 @@ of in-scope menu items (75% against the documented menu tree) (see [target-app-p bound. <!-- SUMMARY -->-**Weighted breadth parity: 99%** over 277 features.+**Weighted breadth parity: 99%** over 278 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% |-| P1 | 99 | 99 | 0 | 0 | 100% |+| P1 | 100 | 100 | 0 | 0 | 100% | | P2 | 76 | 72 | 4 | 0 | 97% | | Area | Parity |@@ -323,6 +323,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | View & navigation | Rotate spread view | P2 | D | | View & navigation | Find/zoom to object | P1 | D | | Undo, history, saving | Unlimited undo/redo | P0 | D |+| Undo, history, saving | History panel | P1 | D | | Undo, history, saving | Revert | P0 | D | | Undo, history, saving | Save / Save As / Save a Copy | P0 | D | | Accessibility | Tagged PDF export | P2 | D |@@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (History panel; icon groups open as one tabbed flyout; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app@@ -47,7 +47,7 @@ basics of editing fail for too many users on Windows and Linux. | Quick Apply | ✓ | ✓ | ✓ | | | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |-| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |+| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story, History | ✓ | all present; an icon group opens as one flyout attached beside that group, with a tab per panel, and floats when its header is dragged. Dropping a floating panel on the right icon bar docks it flush to the left of that bar, in line with its icon | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | | Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | | Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |@@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Panels row: a floating panel docks flush to the left of the right icon bar. History panel. An icon group opens as one flyout attached beside that group, with a tab per panel. | | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) |