diff --git a/ROADMAP.md b/ROADMAP.md index 389bdc46..6064078b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,18 @@ Newest first. - **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change. - **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change. +- **2026-10-15** · Applying text styles removes matching local formatting so capture sources + inherit later edits; real overrides and named character styles remain. +- **2026-10-15** · Redefine paragraph and character styles from local formatting, including the + Styles panel context menu, global updates and one-step undo. +- **2026-10-15** · Paragraph Styles marks local character overrides and offers Clear Overrides + in the style row menu; named character styles and equal local values are not false overrides. +- **2026-10-15** · Existing paragraph and character styles preview their attribute edits in + Style Options; Cancel restores the document and OK records one edit. +- **2026-10-15** · Paragraph Style Options summarizes captured and pending color, size and + paragraph settings relative to the Based On style. +- **2026-10-15** · Typography uses named panel groups with the Control panel and restores that + arrangement on Reset Workspace, leaving the page clear until a panel is opened. No readiness percentage change. - **2026-10-14** · Threading keeps generated and linked stories, text that refers to a story, local IDs and locked frames intact; a colour chosen at a caret waits for the typing, Swap Fill and Stroke on text keeps each run's colours, control-channel input never reads or writes the system clipboard, and IDML frame preferences given as properties import with bounded column counts (#84). No readiness percentage change. - **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. - **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. @@ -444,6 +456,20 @@ Newest first. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. - **2026-10-15** · Table ▸ Convert Rows ▸ To Header / To Body / To Footer, as in InDesign: the selected rows become header rows when they touch the top of the table or its header rows, footer rows when they touch the bottom or its footer rows, and header or footer rows next to the body go back to body rows. The items are greyed out when the rows can't change (a gap, the last body row, a merged cell across the new boundary); one undo step. No readiness percentage change. - **2026-10-15** · Table of Contents: each included style can set a character style for its page number and for the text between entry and number, and Sort Entries in Alphabetical Order lists its entries alphabetically among their siblings, each with the entries under it (#1069). Both survive Update Table of Contents, renaming or deleting the character style, and IDML (`PageNumberStyle`, `SeparatorStyle`, `Sort`). No readiness percentage change. +- 2026-10-15: Measures in inches show four decimals, as InDesign's fields do (A4 is 8.2677 × 11.6929 in, a 1p0 gutter 0.1667 in); other units keep three. +- 2026-10-15: Ruler menus as InDesign's: right-clicking where the rulers meet sets the units of both; each ruler's menu lists InDesign's units and Hide Rulers, the horizontal one Delete All Guides on Spread. +- 2026-10-15: Properties › Edit Page works as InDesign's: the Page tool on the page in view, and Properties shows that page alone (Page Size: page, preset, orientation, W/H; Margins and Columns; Quick Actions) with Back. It opened Document Setup, which changes every page. +- 2026-10-15: Deleting pages that contain objects asks first, as InDesign does ("The affected page contains objects. Delete the page anyway?", with Don't show again), from the Pages panel, Layout › Pages › Delete Page and Properties. +- 2026-10-15: A page scrolled or jumped to shows its paper while its render is on its way, as in InDesign, instead of the black of its shadow. +- 2026-10-15: Tab, Shift+Tab and Presentation hide the icon column's panel flyout and the classic floating panels too, as InDesign hides floating panels. +- 2026-10-15: Layers panel: an object's row is renamed with a slow double-click (a second click on the selected row), as in InDesign; a quick double-click only selects it. +- **2026-10-15** · New Workspace is InDesign's: Name, and Capture › Panel Locations and Menu Customization (the items hidden with Edit › Menus), both on by default; a workspace saved without panel locations leaves the panels where they are. No readiness percentage change. +- 2026-10-15: Presentation shows one spread at a time, as large as the screen allows; the arrow keys, Page Up/Down, Home, End, a click and Shift-click go through the spreads. +- 2026-10-15: Layout › First Page, Previous Page, Next Page, Last Page, Next Spread and Previous Spread, with InDesign's shortcuts (Shift+Page Up/Down, Shift+Cmd+Page Up/Down, Alt+Page Up/Down). +- **2026-10-15** · A panel tab's context menu (and the end of its panel menu) has InDesign's Close, Close Tab Group, Minimize and Collapse to Icons, before Float Panel and Group with; `window.panel.closeGroup` and `window.collapseToIcons` are commands. No readiness percentage change. +- **2026-10-15** · Window › Styles › Paragraph Styles (or Character Styles) opens InDesign's floating Character Styles | Paragraph Styles group, which stays open while other panels open; it was a flyout that the next panel replaced. No readiness percentage change. +- 2026-10-15: Double-clicking the status bar's preflight count or its light shows the Preflight panel, as in InDesign. +- 2026-10-15: A tool group's flyout closes on Esc and on a click anywhere outside it (a panel, the Fill swatch), as InDesign's; it only closed on a click on the canvas. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 6def1c0b..07763767 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -52,6 +52,10 @@ macro_rules! attr_set { pub fn count(&self) -> usize { 0 $( + self.$field.is_some() as usize )* } + /// Remove local values already supplied by the applied style, keeping real differences. + pub fn retain_differences(&mut self, base: &$props) { + $( if self.$field.as_ref() == Some(&base.$field) { self.$field = None; } )* + } /// Field names that are set (camelCase). pub fn keys(&self) -> Vec<&'static str> { let mut v = Vec::new(); diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index f4482c4f..2437e063 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -462,6 +462,14 @@ impl Document { Ok(ids) } + /// Whether absolute page `abs` holds page items: the ones Delete Pages removes with it + /// (an item belongs to the page its centre is over). + pub fn page_has_items(&self, abs: usize) -> bool { + let Some((si, pi)) = self.page_loc(abs) else { return false }; + let Some(sp) = self.spreads.get(si) else { return false }; + sp.items.iter().any(|it| sp.page_at_x(it.bounds().center().x) == Some(pi)) + } + /// Delete absolute pages (items whose centre is on a deleted page are deleted too). pub fn delete_pages(&mut self, pages: &[usize]) -> Result<()> { if pages.len() >= self.page_count() { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 72ad7918..074e50e2 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,5 +1,203 @@ //! Paragraph / character styles and swatches. +#[cfg(test)] +mod redefine_tests { + use super::*; + + fn sample() -> (Session, Value, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"First header"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect":[36,240,300,400],"content":"text","text":"Second header"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name":"Header","chars":{"size":18,"fill":"[Paper]"}})).unwrap(); + for story in [&a["story"], &b["story"]] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":"Header"})).unwrap(); + } + s.execute("text.select", &json!({"story":a["story"],"anchor":0,"focus":5})).unwrap(); + (s, a["story"].clone(), b["story"].clone()) + } + + #[test] + fn redefine_paragraph_updates_other_stories_and_clears_the_source_overrides() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":20,"fill":"[Black]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter":9})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.redefine", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(s.doc().unwrap().doc.styles.para("Header").unwrap().chars.underline.is_none()); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(attrs["chars"]["size"], 20.0); + assert_eq!(attrs["chars"]["fill"], "[Black]"); + assert_eq!(attrs["para"]["spaceAfter"], 9.0); + assert_eq!(attrs["chars"]["underline"], story == &a); + } + s.execute("style.paragraph.edit", &json!({"name":"Header","chars":{"size":22}})).unwrap(); + s.execute("text.select", &json!({"story":a,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 22.0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + + #[test] + fn redefine_character_does_not_capture_paragraph_font_size() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Underline","chars":{"underline":true}})).unwrap(); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Underline"})).unwrap(); + } + s.execute("type.char", &json!({"fill":"[Black]"})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Underline"})).unwrap(); + let style = s.doc().unwrap().doc.styles.char_style("Underline").unwrap(); + assert!(style.chars.size.is_none()); + assert_eq!(style.chars.underline, Some(true)); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + } + } + + #[test] + fn redefine_rejects_the_wrong_style_and_the_none_entry_atomically() { + let (mut s, _, _) = sample(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("style.paragraph.redefine", &json!({"name":"Other"})).is_err()); + assert!(s.execute("style.character.redefine", &json!({})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn redefine_character_at_a_caret_cleans_the_source_run_but_keeps_other_overrides() { + let (mut s, story, _) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + for (start, end, size) in [(0, 5, 24), (6, 12, 32)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":size})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + s.execute("text.select", &json!({"story":story,"anchor":2,"focus":2})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name":"Emphasis","chars":{"size":30}})).unwrap(); + for (start, end, size) in [(0, 5, 30.0), (6, 12, 32.0)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], size); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } +} + +#[cfg(test)] +mod application_inheritance_tests { + use super::*; + + fn setup(text: &str) -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,300], "content": "text", "text": text})).unwrap(); + (s, f["story"].clone()) + } + fn select(s: &mut Session, story: &Value, start: usize, end: usize) { + s.execute("text.select", &json!({"story": story, "anchor": start, "focus": end})).unwrap(); + } + fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap() + } + + #[test] + fn paragraph_capture_source_follows_later_style_edits_and_undo() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 18, "fill": "[Paper]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 6})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Header", "fromSelection": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + select(&mut s, &story, 6, 6); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Header", "chars": {"size": 20, "fill": "[Black]"}, "para": {"spaceAfter": 12}})).unwrap(); + for pos in [0, 6] { + select(&mut s, &story, pos, pos + 1); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 20.0); + assert_eq!(a["chars"]["fill"], "[Black]"); + assert_eq!(a["para"]["spaceAfter"], 12.0); + } + } + + #[test] + fn applying_styles_keeps_different_overrides_and_named_character_styles() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 14})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 9})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 10}, "para": {"spaceAfter": 6}})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Body"})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 14.0); + assert_eq!(a["para"]["spaceAfter"], 9.0); + assert_eq!(a["characterStyle"], "Emphasis"); + assert_eq!(a["chars"]["underline"], true); + } + + #[test] + fn character_capture_source_remains_sparse_and_follows_style_edits() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "fromSelection": true})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"underline": false}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["underline"], false); + assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").unwrap().chars.size.is_none()); + s.execute("style.paragraph.edit", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "chars": {"size": 20}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["size"], 20.0); + } + + #[test] + fn paragraph_selection_excludes_the_next_paragraph_at_its_start() { + let (mut s, story) = setup("é\nNext"); + select(&mut s, &story, 0, 3); + s.execute("style.paragraph.create", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Header", "clearOverrides": true})).unwrap(); + select(&mut s, &story, 3, 4); + assert_eq!(attrs(&mut s)["paragraphStyle"], designcraft_doc::BASIC_PARAGRAPH); + select(&mut s, &story, 0, 2); + assert_eq!(attrs(&mut s)["paragraphStyle"], "Header"); + } + + #[test] + fn clearing_a_partial_selection_preserves_character_overrides_outside_it() { + let (mut s, story) = setup("First Second"); + select(&mut s, &story, 0, 12); + s.execute("type.char", &json!({"size": 18})).unwrap(); + select(&mut s, &story, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "clearOverrides": true})).unwrap(); + assert_ne!(attrs(&mut s)["chars"]["size"], 18.0); + select(&mut s, &story, 6, 12); + assert_eq!(attrs(&mut s)["chars"]["size"], 18.0); + } +} + use designcraft_color::{Swatch, SwatchValue}; use designcraft_doc::{CharAttrs, CharacterStyle, Effects, Item, ObjectStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; @@ -81,6 +279,24 @@ pub fn specs() -> Vec { ), cmd!("style.paragraph.apply", "Apply Paragraph Style", [], None, "{name, clearOverrides?: bool}", has_text_or_frames, apply_para), cmd!("style.character.apply", "Apply Character Style", [], None, "{name}", has_text_or_frames, apply_char), + cmd!( + "style.paragraph.redefine", + "Redefine Paragraph Style", + [], + None, + "{name?} — incorporate the first selected paragraph/character's local formatting into its paragraph style; retain character styles; one undo step", + has_text_or_frames, + |s, p| redefine(s, p, true) + ), + cmd!( + "style.character.redefine", + "Redefine Character Style", + [], + None, + "{name?} — incorporate the first selected character's local formatting into its character style without capturing paragraph attributes; one undo step", + has_text_or_frames, + |s, p| redefine(s, p, false) + ), cmd!( "style.paragraph.create", "New Paragraph Style…", @@ -449,6 +665,95 @@ fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &st Ok(a) } +fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { + let command = if paragraph { "style.paragraph.redefine" } else { "style.character.redefine" }; + let targets = super::text::format_targets_pub(s); + let target = targets.first().ok_or_else(|| bad(command, "select text using the style to redefine"))?; + let state = s.doc()?; + let story = state.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(command, "no paragraph at the selection"))?; + let cf = super::text::effective_char_format(s, target)?; + let selected = if paragraph { &pf.style } else { &cf.style }; + let name = str_param(p, "name").unwrap_or(selected).to_string(); + if name != *selected || name == if paragraph { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { + return Err(bad(command, "select text using the named style; the no-style entry cannot be redefined")); + } + let (base_para, base_chars) = state.doc.styles.resolve_para_style(&pf.style); + let (resolved_para, paragraph_chars) = state.doc.styles.resolve_para(pf); + let para_changes = base_para.delta(&resolved_para); + let mut current = if paragraph { + paragraph_chars.clone() + } else { + state.doc.styles.resolve_char(¶graph_chars, &designcraft_doc::CharFormat { style: name.clone(), over: CharAttrs::default() }) + }; + let inherited = if paragraph { base_chars } else { current.clone() }; + // A named character style is an intentional layer, not a paragraph override to absorb. + current.apply(&cf.over); + let char_changes = inherited.delta(¤t); + let result = s.edit(|d, _| { + if paragraph { + let style = d.styles_mut().para_mut(&name).ok_or_else(|| bad(command, "paragraph style no longer exists"))?; + style.para.merge(¶_changes); + style.chars.merge(&char_changes); + } else { + let style = + d.styles_mut().character.iter_mut().find(|st| st.name == name).ok_or_else(|| bad(command, "character style no longer exists"))?; + style.chars.merge(&char_changes); + } + let styles = d.styles.clone(); + for target in &targets { + let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; + // At a character-style caret, the sampled format belongs to the containing run + // (the preceding run at a boundary). Normalize that source run too, otherwise its + // old local values would mask the next edit of the redefined style. + let mut selected_range = target.range.clone(); + if !paragraph && selected_range.is_empty() { + let caret = selected_range.start; + let mut start = 0usize; + for run in &story.chars { + let end = start.saturating_add(run.len).min(story.len()); + if caret < end || (caret == end && caret > start) { + selected_range = start..end; + break; + } + start = end; + } + } + let end = if selected_range.is_empty() { selected_range.start } else { selected_range.end.saturating_sub(1) }; + let (first, last) = (story.para_at(selected_range.start), story.para_at(end)); + for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = story.paras.get_mut(index) else { continue }; + if paragraph && pf.style != name { + continue; + } + if paragraph { + let (pp, cp) = styles.resolve_para(pf); + let (base_p, base_c) = styles.resolve_para_style(&name); + pf.para = base_p.delta(&pp); + pf.chars = base_c.delta(&cp); + } + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(story.len()); + let range = if paragraph { range.start..end } else { range.start.max(selected_range.start)..end.min(selected_range.end) }; + story.format_chars(range, |format| { + if !paragraph && format.style != name { + return; + } + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&format.style)); + let resolved = styles.resolve_char(&base, format); + format.over = inherited.delta(&resolved); + }); + } + } + Ok(json!({"name":name})) + }); + if result.is_ok() { + s.typing_format = None; + } + result +} + fn apply_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); @@ -457,19 +762,39 @@ fn apply_para(s: &mut Session, p: &Value) -> Result { } let targets = super::text::format_targets_pub(s); s.edit(|d, _| { + let styles = d.styles.clone(); + let (style_para, style_chars) = styles.resolve_para_style(&name); for t in &targets { let r = &t.range; if let Some(st) = d.text_story_mut(t.story, t.cell) { - st.format_paras(r.clone(), |f| { + // Text selections are half-open: a selection ending at the next paragraph's + // start must not apply a style to that paragraph too. + let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; + let (first, last) = (st.para_at(r.start), st.para_at(end)); + st.format_paras(r.start..end, |f| { f.style = name.clone(); if clear { f.para = ParaAttrs::default(); f.chars = CharAttrs::default(); + } else { + f.para.retain_differences(&style_para); + f.chars.retain_differences(&style_chars); } }); - if clear { - let (a, b) = (st.para_ranges()[st.para_at(r.start)].start, st.para_ranges()[st.para_at(r.end)].end); - st.format_chars(a..b, |f| f.over = CharAttrs::default()); + for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(st.len()); + let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; + st.format_chars(range, |f| { + if clear { + f.over = CharAttrs::default(); + } else { + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&f.style)); + f.over.retain_differences(&inherited); + } + }); } } } @@ -484,9 +809,22 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { } let targets = super::text::format_targets_pub(s); s.edit(|d, _| { + let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - st.format_chars(t.range.clone(), |f| f.style = name.clone()); + for (pi, range) in st.para_ranges().into_iter().enumerate() { + let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(&name)); + st.format_chars(range, |f| { + f.style = name.clone(); + f.over.retain_differences(&inherited); + }); + } } } ok() diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f9ea9438..862a22bb 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1725,10 +1725,19 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; let cp = st.doc.styles.resolve_char(&base, &cf); + let (style_para, style_chars) = st.doc.styles.resolve_para_style(&pf.style); + let mut character_base = base.clone(); + let character_style = st.doc.styles.char_style_attrs(&cf.style); + character_base.apply(&character_style); + let char_delta = character_base.delta(&cp); + let style_keys = character_style.keys(); + let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); // The selected text (its start, on one line), for previews such as the font menu's. let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()}); + "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), + "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, + "selectedText": selected.trim()}); if bool_or(p, "mixed", false) { out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); } diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index 9ad93293..da1676d4 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -258,7 +258,10 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { - format_measure_prec(pt, unit, 3) + // As InDesign shows them: inches to four decimals (a 1p gutter is 0.1667 in, A4 is + // 8.2677 in wide), the other units to three. + let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal) { 4 } else { 3 }; + format_measure_prec(pt, unit, decimals) } pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String { @@ -301,6 +304,17 @@ mod tests { (a - b).abs() < 1e-6 } + /// Inches show four decimals, as InDesign's fields do; other units three. + #[test] + fn inches_show_four_decimals() { + assert_eq!(format_measure(12.0, Unit::Inches), "0.1667 in"); + assert_eq!(format_measure(210.0 * PT_PER_MM, Unit::Inches), "8.2677 in"); + assert_eq!(format_measure(612.0, Unit::Inches), "8.5 in"); + assert_eq!(format_measure(9.0, Unit::Inches), "0.125 in"); + assert_eq!(format_measure(10.0, Unit::Millimeters), "3.528 mm"); + assert_eq!(format_measure(10.0, Unit::Points), "10 pt"); + } + #[test] fn plain_and_units() { assert!(close(p("12"), 12.0)); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a2d6f0fd..cd0e022a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -249,6 +249,36 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// Presentation: the spread in view, centred and as large as `rect` allows. +fn present_fit(app: &mut DesignApp, rect: Rect) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(b) = current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.bounds) else { return }; + if b.width() <= 0.0 || b.height() <= 0.0 { + return; + } + let zoom = (rect.width() as f64 / b.width()).min(rect.height() as f64 / b.height()).clamp(0.05, 40.0); + let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom); + if let Some(v) = app.view_mut() { + v.zoom = zoom; + v.origin = origin; + } +} + +/// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the +/// first and last spread. Parent spreads while they are being edited. +pub fn step_spread(app: &mut DesignApp, delta: isize) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(cur) = current_slot(app, &layout) else { return }; + let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)); + let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return }; + let Some(rect) = app.canvas_rect else { return }; + if let Some(v) = app.view_mut() { + v.origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / v.zoom, b.center().y - rect.height() as f64 / 2.0 / v.zoom); + } +} + pub fn zoom_at(app: &mut DesignApp, screen: Pos2, factor: f64) { let Some(rect) = app.canvas_rect else { return }; let Some(v) = app.view_mut() else { return }; @@ -416,10 +446,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let rect = view_rect(screen, rot); app.canvas_rect = Some(rect); let resp = ui.allocate_rect(full, Sense::click_and_drag()); - if !app.view().is_some_and(|v| v.fitted) { - fit(app, rect, "spread"); + if presenting { + // One spread at a time, as large as the screen allows; a click shows the next one, + // Shift-click the previous one. Tools and selection don't see the input. + present_fit(app, rect); + if resp.clicked() { + // The release that made the click says whether Shift was down. + let shift = ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| matches!(e, egui::Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)) + }); + step_spread(app, if shift { -1 } else { 1 }); + present_fit(app, rect); + } + } else { + if !app.view().is_some_and(|v| v.fitted) { + fit(app, rect, "spread"); + } + handle_input(app, ui, &resp, rect); } - handle_input(app, ui, &resp, rect); if bars { scrollbars(app, ui, outer, screen, rect, &t); } @@ -427,7 +472,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(&v) = app.view() else { return }; let xf = Xf::new(rect, &v); let doc = st.doc.clone(); - let layout = CanvasLayout::new(&doc, st.editing_parents); + let mut layout = CanvasLayout::new(&doc, st.editing_parents); + if presenting && let Some(cur) = current_slot(app, &layout) { + layout.slots = layout.slots.get(cur).copied().into_iter().collect(); + } let painter = ui.painter_at(screen); let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard }; @@ -437,6 +485,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let r = xf.rect(slot.bounds); painter.rect_filled(r.translate(vec2(1.5, 1.5)), 0.0, Color32::BLACK); } + // Paper under the rendered content: a page scrolled or jumped to shows its paper colour, + // not the shadow's black, until its render arrives. + let [r, g, b, _] = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); + let paper = Color32::from_rgb(r, g, b); + for slot in &layout.slots { + for page in doc.spread(slot.spread).map(|sp| sp.pages.as_slice()).unwrap_or_default() { + painter.rect_filled(xf.rect(slot.xf.transform_rect_bbox(page.bounds())), 0.0, paper); + } + } // Rendered content. render_texture(app, ui.ctx(), rect, &xf, &layout, preview); if let (Some(tex), Some(sh)) = (&app.canvas.texture, app.canvas.shown) { @@ -498,6 +555,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_dynamic_spelling(app, ui.ctx(), &painter, &xf, &doc, &layout); } } + if presenting { + return; + } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); if !preview { // Content grabbers: the selected frame's and the hovered one's (Selection tool). @@ -1854,49 +1914,87 @@ fn ruler_rects(full: Rect, rect: Rect) -> (Rect, Rect) { ) } -/// Right-clicking a ruler opens a menu of units for that ruler; right-clicking the corner, one -/// to lock the zero point. +/// Right-clicking a ruler opens its menu, as InDesign's: units for that ruler, Hide Rulers and +/// (the horizontal ruler) Delete All Guides on Spread. Right-clicking the corner where they meet +/// opens Lock Zero Point and the units for both rulers at once. fn ruler_units_menus(app: &mut DesignApp, resp: &egui::Response, full: Rect, rect: Rect, doc: &Document) { let (top, left) = ruler_rects(full, rect); - let at = resp.interact_pointer_pos(); let corner = Rect::from_min_max(full.min, rect.min); - let open = if resp.secondary_clicked() { - Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| corner.contains(p)))) - } else if resp.clicked() { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; + let at = resp.interact_pointer_pos(); + let (h, v) = (doc.settings.horizontal_units, doc.settings.vertical_units); let locked = doc.settings.zero_point_locked; - egui::Popup::context_menu(resp).id(resp.id.with("ruler_corner")).open_memory(open).show(|ui| { - let label = crate::i18n::tr(&app.ui.language, if locked { "Unlock Zero Point" } else { "Lock Zero Point" }); - if ui.button(crate::rtl::widget(ui, label)).clicked() { - let _ = app.run("view.lockZeroPoint", json!({"on": !locked})); - ui.close(); - } - }); - for (ruler, key, cur) in [(top, "horizontalUnits", doc.settings.horizontal_units), (left, "verticalUnits", doc.settings.vertical_units)] { + // (where, which units it sets, the unit checked) + let menus = [(top, RulerMenu::Horizontal, Some(h)), (left, RulerMenu::Vertical, Some(v)), (corner, RulerMenu::Both, (h == v).then_some(h))]; + for (area, which, cur) in menus { let open = if resp.secondary_clicked() { - Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| ruler.contains(p)))) + Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| area.contains(p)))) } else if resp.clicked() { Some(egui::SetOpenCommand::Bool(false)) } else { None }; - egui::Popup::context_menu(resp).id(resp.id.with(("ruler_units", key))).open_memory(open).show(|ui| ruler_units_menu(app, ui, key, cur)); + egui::Popup::context_menu(resp) + .id(resp.id.with(("ruler_units", which as u8))) + .open_memory(open) + .show(|ui| ruler_units_menu(app, ui, which, cur, locked)); } } -/// Every unit, the current one checked; picking one sets `key` (`horizontalUnits` or -/// `verticalUnits`) through `document.preferences`. -fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: designcraft_geom::Unit) { - for u in designcraft_geom::Unit::ALL { - let label = crate::i18n::tr(&app.ui.language, u.label()); - let text = if u == cur { format!("✓ {label}") } else { format!(" {label}") }; - if ui.button(crate::rtl::widget(ui, text)).clicked() { - let _ = app.run("document.preferences", json!({ key: u })); +#[derive(Clone, Copy, PartialEq)] +enum RulerMenu { + Horizontal, + Vertical, + Both, +} + +/// The units a ruler's menu offers, in InDesign's order. +const RULER_UNITS: [designcraft_geom::Unit; 9] = { + use designcraft_geom::Unit::*; + [Points, Picas, Inches, InchesDecimal, Millimeters, Centimeters, Ciceros, Agates, Pixels] +}; + +/// A ruler's menu: the units (the current one checked), then Hide Rulers and, on the horizontal +/// ruler, Delete All Guides on Spread. The corner's menu (`Both`) starts with Lock Zero Point +/// (`locked` = the zero point is locked). Picking a unit sets `horizontalUnits`, +/// `verticalUnits` or both through `document.preferences`. +fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, which: RulerMenu, cur: Option, locked: bool) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + if which == RulerMenu::Both { + let label = tr(if locked { "Unlock Zero Point" } else { "Lock Zero Point" }); + if ui.button(crate::rtl::widget(ui, format!(" {label}"))).clicked() { + let _ = app.run("view.lockZeroPoint", json!({"on": !locked})); ui.close(); } + ui.separator(); + } + // A document in Q or Ha keeps its unit on the list. + let extra = cur.filter(|u| !RULER_UNITS.contains(u)); + for u in RULER_UNITS.into_iter().chain(extra) { + let label = tr(u.label()); + let text = if Some(u) == cur { format!("✓ {label}") } else { format!(" {label}") }; + if ui.button(crate::rtl::widget(ui, text)).clicked() { + let p = match which { + RulerMenu::Horizontal => json!({"horizontalUnits": u}), + RulerMenu::Vertical => json!({"verticalUnits": u}), + RulerMenu::Both => json!({"horizontalUnits": u, "verticalUnits": u}), + }; + let _ = app.run("document.preferences", p); + ui.close(); + } + } + ui.separator(); + let hide = format!(" {}", tr("Hide Rulers")); + if ui + .add(egui::Button::new(crate::rtl::widget(ui, hide)).shortcut_text(crate::menus::shortcut_of(app, "view.rulers").unwrap_or_default())) + .clicked() + { + let _ = app.run("view.rulers", json!({})); + ui.close(); + } + if which == RulerMenu::Horizontal && ui.button(crate::rtl::widget(ui, format!(" {}", tr("Delete All Guides on Spread")))).clicked() { + crate::menus::activate(app, "app.deleteAllGuides", &Value::Null); + ui.close(); } } @@ -2795,6 +2893,39 @@ mod tests { assert_eq!(press(Some(up), 0.0, 1.0), 1); } + /// While a page's render is on its way the page shows paper, not the black of its shadow. + #[test] + fn pages_show_paper_under_their_render() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"pages": 3, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let mut shapes = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + shapes = out.shapes; + } + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // The paper swatch on screen (white, as the colour settings show it). + let [r, g, b, _] = st.doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap().to_rgba8(1.0); + let paper: Vec = shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(s) if s.fill == Color32::from_rgb(r, g, b) => Some(s.rect), + _ => None, + }) + .collect(); + // Each page on its own (a spread's pages may differ in size). + assert_eq!(paper.len(), st.doc.page_count()); + for abs in 0..st.doc.page_count() { + let want = xf.rect(layout.page_rect(&st.doc, abs).unwrap()); + assert!(paper.iter().any(|r| (r.min - want.min).length() < 0.01 && (r.max - want.max).length() < 0.01), "page {abs}"); + } + } + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { @@ -3373,11 +3504,31 @@ mod tests { h.get_by_label(" Inches").click(); h.run_steps(3); assert_eq!(units(&h), (Unit::Inches, Unit::Millimeters)); - // The page and the ruler corner open no units menu. - for p in [rect.center(), rect.min - vec2(RULER / 2.0, RULER / 2.0)] { - right_click(&mut h, p); - assert!(h.query_by_label(" Points").is_none()); - } + // The corner where the rulers meet sets both, as InDesign's: nothing is checked while + // they differ. + let corner = rect.min - vec2(RULER / 2.0, RULER / 2.0); + right_click(&mut h, corner); + assert!(h.query_by_label("✓ Inches").is_none() && h.query_by_label("✓ Millimeters").is_none()); + let _ = h.get_by_label_contains("Hide Rulers"); + let _ = h.get_by_label(" Lock Zero Point"); + assert!(h.query_by_label(" Delete All Guides on Spread").is_none(), "only on the horizontal ruler"); + h.get_by_label(" Points").click(); + h.run_steps(3); + assert_eq!(units(&h), (Unit::Points, Unit::Points)); + // InDesign's list: no Q or Ha. + right_click(&mut h, corner); + h.get_by_label("✓ Points"); + assert!(h.query_by_label(" Q").is_none() && h.query_by_label(" Ha").is_none()); + let _ = h.get_by_label(" Inches Decimal"); + // The horizontal ruler's menu also deletes the spread's guides; Hide Rulers hides them. + right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); + let _ = h.get_by_label(" Delete All Guides on Spread"); + h.get_by_label_contains("Hide Rulers").click(); + h.run_steps(3); + assert!(!h.state().ui.rulers); + // The page opens no units menu. + right_click(&mut h, rect.center()); + assert!(h.query_by_label(" Points").is_none()); } /// The live-corner widget draws on a selected rectangle frame; clicking it draws the diamonds. @@ -3517,14 +3668,14 @@ mod tests { assert_eq!(zero_point(&h), [0.0, 0.0]); // Locked from the corner's context menu, the corner ignores drags and double-clicks. right_click(&mut h, corner); - h.get_by_label("Lock Zero Point").click(); + h.get_by_label(" Lock Zero Point").click(); h.run_steps(3); assert!(h.state().session.doc().unwrap().doc.settings.zero_point_locked); corner_drag(&mut h, corner, dest); assert_eq!(zero_point(&h), [0.0, 0.0]); // Unlocked again from the same menu. right_click(&mut h, corner); - h.get_by_label("Unlock Zero Point").click(); + h.get_by_label(" Unlock Zero Point").click(); h.run_steps(3); corner_drag(&mut h, corner, dest); assert_ne!(zero_point(&h), [0.0, 0.0]); @@ -3603,11 +3754,14 @@ mod tests { } /// A menu row: a button that isn't a toggle, named by its label, then its shortcut if it has - /// one. Toggle buttons with the same name (the Task Bar's icons) don't match. + /// one. Toggle buttons with the same name (the Task Bar's icons) don't match, nor do buttons + /// right of the canvas (the Properties panel's Arrange). fn menu_row<'h>(h: &'h Harness<'_, DesignApp>, label: &'h str) -> Option> { + let right = f64::from(h.state().canvas_rect.map_or(f32::INFINITY, |r| r.max.x)); h.query_by(move |n| { n.role() == egui::accesskit::Role::Button && n.toggled().is_none() + && n.bounding_box().is_none_or(|b| b.x0 < right) && n.label().is_some_and(|l| l == label || l.strip_prefix(label).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.contains(' '))) }) } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 790545ca..0df5361f 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -735,6 +735,16 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Show the Preflight panel. `window.panel` toggles a panel that is already open, so it runs +/// only when Preflight isn't showing. +fn show_preflight(app: &mut DesignApp) { + if app.ui.open_panel.as_deref() != Some("preflight") + && let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) + { + app.status(e); + } +} + pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let resp = egui::Panel::bottom("status_bar") @@ -793,28 +803,36 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); // Preflight: overset text is an error. let errors = panels::preflight_errors(app); - let (r, _) = ui.allocate_exact_size(vec2(9.0, 9.0), Sense::hover()); + let (r, dot) = ui.allocate_exact_size(vec2(9.0, 9.0), Sense::click()); ui.painter().circle_filled( r.center(), 4.0, if errors == 0 { Color32::from_rgb(60, 200, 90) } else { Color32::from_rgb(235, 50, 50) }, ); - crate::rtl::label( - ui, - egui::RichText::new(if errors == 0 { - format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors")) - } else { - format!( - "{} ▾", - crate::i18n::count_label( - &app.ui.language, - if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" }, - errors - ) + let count = if errors == 0 { + format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors")) + } else { + format!( + "{} ▾", + crate::i18n::count_label( + &app.ui.language, + if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" }, + errors ) - }) - .font(small.clone()), - ); + ) + }; + // As in InDesign, the count opens a menu that leads to the Preflight panel. + let menu = crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| { + if ui.button(crate::i18n::tr(&app.ui.language, "Preflight Panel…")).clicked() { + show_preflight(app); + ui.close(); + } + }); + // Double-clicking the light or the count shows the panel straight away (the + // second click has closed the menu the first one opened). + if dot.double_clicked() || menu.response.double_clicked() { + show_preflight(app); + } ui.add_space(12.0); ui.label(egui::RichText::new(&app.ui.status).font(small).color(t.text_dim)); }); @@ -1221,6 +1239,50 @@ mod tests { assert_eq!(field.value().unwrap_or_default(), "", "the query is cleared"); } + /// The status bar's preflight count is a menu; its Preflight Panel entry shows the panel. + #[test] + fn status_bar_error_count_opens_the_preflight_panel() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + h.get_by_label("No errors ▾").click(); + h.run_steps(3); + h.get_by_label("Preflight Panel…").click(); + h.run_steps(3); + // Preflight is not a dock tab: `window.panel` shows it as its own panel. + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + h.get_by_label("Preflight"); + // With the panel already showing, the entry keeps it open. + h.get_by_label("No errors ▾").click(); + h.run_steps(3); + h.get_by_label("Preflight Panel…").click(); + h.run_steps(3); + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + } + + /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. + #[test] + fn double_clicking_the_status_bar_error_count_opens_the_preflight_panel() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open_stepped(app, vec2(1200.0, 700.0), 1.0, 1.0 / 60.0); + let count = h.get_by_label("No errors ▾"); + count.click(); + count.click(); + h.run_steps(3); + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + h.get_by_label("Preflight"); + assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); + // A double-click with the panel already showing keeps it open. + let count = h.get_by_label("No errors ▾"); + count.click(); + count.click(); + h.run_steps(3); + assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + } + /// A very long document name made its tab grow past the window, hiding the close button /// and the tabs after it (#603). #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1142be0c..1cb7395c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -378,6 +378,9 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest }, "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; + // Replacing a modal cancels its transaction just like its Cancel button. + // In particular, an uncommitted style preview must not leak into the new dialog. + crate::dialogs::cancel(app); if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { app.session.reset_find_scope(); } @@ -479,6 +482,30 @@ pub(crate) fn page_index(p: &Value, default: usize, count: usize) -> Result DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", &json!({"pages":2})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 285952f6..d051f6a4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2065,6 +2065,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"), "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), + "deletePages" => crate::i18n::tr(&app.ui.language, "Warning"), "updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"), "menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"), "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"), @@ -2096,7 +2097,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" => 380.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2565,6 +2566,22 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .replace("{}", &d.s("title")); crate::rtl::label(ui, text); } + "deletePages" => { + let several = d.fields.get("pages").and_then(Value::as_array).is_some_and(|p| p.len() > 1); + crate::rtl::label( + ui, + crate::i18n::tr( + &app.ui.language, + if several { + "The affected pages contain objects. Delete the pages anyway?" + } else { + "The affected page contains objects. Delete the page anyway?" + }, + ), + ); + ui.add_space(8.0); + check(ui, &mut d, "dontShowAgain", crate::i18n::tr(&app.ui.language, "Don't show again")); + } "updateLink" => { let text = crate::i18n::tr( &app.ui.language, @@ -2578,10 +2595,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); text_field(ui, &mut d, "name", 220.0); }); - crate::rtl::label( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5), - ); + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Capture")).font(semibold(12.0))); + check(ui, &mut d, "panelLocations", crate::i18n::tr(&app.ui.language, "Panel Locations")); + check(ui, &mut d, "menuCustomization", crate::i18n::tr(&app.ui.language, "Menu Customization")); } "fittingOptions" => { check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); @@ -2768,6 +2785,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { _ => "Cancel", }; ui.horizontal(|ui| { + if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") { + check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + style_options_preview(app, &mut d); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -2922,6 +2943,17 @@ fn confirm_cell_options(app: &mut DesignApp, d: &Dialog) -> Result) -> Result { + let has_items = app.session.active().is_some_and(|st| pages.iter().any(|&abs| st.doc.page_has_items(abs))); + if has_items && !app.ui.skip_delete_pages_warning { + app.ui.dialog = Some(Dialog::new("deletePages", json!({"pages": pages}))); + return Ok(Value::Null); + } + app.run("layout.pages.delete", json!({"pages": pages})) +} + /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was, and Preferences puts back the interface language it /// opened with, since choosing one previews it at once. @@ -2958,6 +2990,10 @@ fn find_change_params(d: &Dialog) -> Value { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && d.fields.contains_key(PREVIEWED) { + // Restore the original before the ordinary command commits one undoable edit. + app.session.cancel_interaction(); + } if d.id != "pdfImport" || !d.b("async") { app.cancel_pdf_import(); } @@ -3363,7 +3399,16 @@ pub fn confirm(app: &mut DesignApp) -> Result { doc["glyphFallback"] = json!(d.b("glyphFallback")); app.run("document.preferences", doc) } - "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "newWorkspace" => app.run( + "window.newWorkspace", + json!({"name": d.s("name"), "panelLocations": d.b("panelLocations"), "menuCustomization": d.b("menuCustomization")}), + ), + "deletePages" => { + if d.b("dontShowAgain") { + app.ui.skip_delete_pages_warning = true; + } + app.run("layout.pages.delete", json!({"pages": d.fields.get("pages").cloned().unwrap_or_else(|| json!([]))})) + } "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. @@ -3544,13 +3589,18 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } -fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { - let name = d.s("name"); - // What the Justification section showed: the style's own values, or a new style's Based On. - let shown = if d.b("new") { d.s("basedOn") } else { name.clone() }; +/// The Justification spacing check of Paragraph Style Options, against what the section showed: +/// the style's own values, or a new style's Based On. +fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { + let shown = if d.b("new") { d.s("basedOn") } else { d.s("name") }; let base = app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default(); - if let Err(e) = check_justification(d, "p.", &base) { + check_justification(d, "p.", &base) +} + +fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { + let name = d.s("name"); + if let Err(e) = check_paragraph_style_justification(app, d) { // The caller shows the reason in the dialog's status line. d.fields.insert("section".into(), json!("justify")); return Err(e); @@ -3608,6 +3658,74 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul Ok(r) } +/// Preview the changed style attributes through the same engine commands as OK. Renaming and +/// export tagging have no visual effect and wait for OK, so the dialog can keep looking up the +/// original style while the preview is visible. The interaction restores its starting document +/// before each update, including after a field is cleared or Preview is switched off. +fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") || d.b("new") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let paragraph = d.id == "paragraphStyleOptions"; + // Values OK would refuse don't reach the page either: the page goes back and the status + // line says why. + if paragraph && let Err(e) = check_paragraph_style_justification(app, d) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + d.fields.insert("status".into(), json!(e)); + return; + } + let mut params = json!({"name": d.s("name"), "chars": {}}); + if paragraph { + params["para"] = json!({}); + } + for (key, value) in &d.fields { + if let Some(attr) = key.strip_prefix("c.") { + params["chars"][attr] = value.clone(); + } else if paragraph && let Some(attr) = key.strip_prefix("p.") { + if let Some((attr, field)) = attr.split_once('.') { + if params["para"][attr].is_null() { + params["para"][attr] = json!({}); + } + if let Some(fields) = params["para"][attr].as_object_mut() { + fields.insert(field.into(), value.clone()); + } + } else { + params["para"][attr] = value.clone(); + } + } + } + if let Some(based) = d.fields.get("basedOn") { + params["basedOn"] = match based.as_str() { + Some(designcraft_doc::NO_PARA_STYLE | designcraft_doc::NO_CHAR_STYLE) => Value::Null, + _ => based.clone(), + }; + } + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(error) = app.session.begin_interaction("Style Options") + { + d.fields.insert("status".into(), json!(error.to_string())); + return; + } + let command = if paragraph { "style.paragraph.edit" } else { "style.character.edit" }; + match app.session.preview(command, params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(error) => d.fields.insert("status".into(), json!(error.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// Paragraph Style Options: sections in a left list (like InDesign), fields on the right. /// Edited values are stored as `p.` / `c.` fields and applied on OK. fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -4069,17 +4187,11 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.end_row(); }); ui.add_space(8.0); - crate::rtl::label( - ui, - egui::RichText::new(format!( - "{} {} {:.1} pt · {}", - cp.font_family, - cp.font_style, - cp.size, - crate::i18n::tr(&app.ui.language, pp.align.label()) - )) - .color(crate::theme::Tokens::get(ui.ctx()).text_dim), - ); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Settings:")).font(semibold(12.0))); + let summary = paragraph_style_summary(&app.ui.language, d, &style, &doc.styles); + egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(220.0).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); + }); } }); }); @@ -4090,6 +4202,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } } +/// Show what the style changes from its parent, including edits waiting for OK. Fully captured +/// styles contain every default, so comparing resolved values keeps the summary readable. +fn paragraph_style_summary(lang: &str, d: &Dialog, style: &designcraft_doc::ParagraphStyle, styles: &designcraft_doc::Styles) -> String { + let based = d.fields.get("basedOn").and_then(Value::as_str).or(style.based_on.as_deref()).unwrap_or(designcraft_doc::NO_PARA_STYLE); + let (base_para, base_chars) = styles.resolve_para_style(based); + let (mut para, mut chars) = (base_para.clone(), base_chars.clone()); + para.apply(&style.para); + chars.apply(&style.chars); + for (key, value) in &d.fields { + if let Some(key) = key.strip_prefix("c.") { + let mut delta = designcraft_doc::CharAttrs::default(); + if delta.set_json(key, value).is_ok() { + chars.apply(&delta); + } + } else if let Some(key) = key.strip_prefix("p.") { + let (key, value) = key.split_once('.').map_or((key, value.clone()), |(attr, field)| (attr, json!({field:value}))); + let mut delta = designcraft_doc::ParaAttrs::default(); + if delta.set_json_over(key, &value, ¶).is_ok() { + para.apply(&delta); + } + } + } + let mut char_delta = base_chars.delta(&chars); + if base_chars.font_family != chars.font_family { + char_delta.font_style = Some(chars.font_style.clone()); + } + let mut parts = vec![crate::i18n::style_name(lang, based).to_string()]; + for value in [serde_json::to_value(base_para.delta(¶)), serde_json::to_value(char_delta)] { + if let Ok(Value::Object(attrs)) = value { + for (key, value) in attrs { + let suffix = if matches!( + key.as_str(), + "size" | "spaceBefore" | "spaceAfter" | "leftIndent" | "rightIndent" | "firstLineIndent" | "lastLineIndent" + ) { + " pt" + } else { + "" + }; + parts.push(format!("{}: {}{suffix}", setting_label(lang, &key), setting_text(&value))); + } + } + } + parts.join(" + ") +} + fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); ui.set_min_width(380.0); @@ -6691,6 +6848,53 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn paragraph_style_settings_show_captured_color_and_pending_spacing_without_changing_the_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"Heading"})).unwrap(); + app.run("text.select", json!({"story":f["story"],"anchor":0,"focus":7})).unwrap(); + app.run("type.char", json!({"fontFamily":"Source Sans 3","fontStyle":"Bold","size":16,"fill":"[Paper]"})).unwrap(); + app.run("style.paragraph.create", json!({"name":"Header","fromSelection":true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let style = doc.styles.para("Header").unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name":"Header"})); + let summary = paragraph_style_summary("", &d, style, &doc.styles); + for value in ["Source Sans 3", "Bold", "16 pt", "[Paper]"] { + assert!(summary.contains(value), "{summary}"); + } + assert!(summary.len() < 250, "default attributes do not swamp the captured settings: {summary}"); + d.fields.insert("c.fill".into(), json!("[Registration]")); + d.fields.insert("p.spaceAfter".into(), json!(4.5)); + let summary = paragraph_style_summary("", &d, style, &doc.styles); + assert!(summary.contains("[Registration]") && !summary.contains("[Paper]"), "{summary}"); + assert!(summary.contains("4.5 pt"), "{summary}"); + assert_eq!(app.session.doc().unwrap().doc, doc); + } + + #[test] + fn paragraph_style_settings_compare_with_the_chosen_parent() { + let mut styles = designcraft_doc::Styles::default(); + styles.paragraph.push(designcraft_doc::ParagraphStyle { + name: "Parent".into(), + chars: designcraft_doc::CharAttrs { size: Some(20.0), ..Default::default() }, + based_on: None, + next_style: None, + para: Default::default(), + shortcut: String::new(), + }); + let style = designcraft_doc::ParagraphStyle { + name: "Child".into(), + based_on: Some("Parent".into()), + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }; + let d = Dialog::new("paragraphStyleOptions", json!({"name":"Child"})); + assert_eq!(paragraph_style_summary("", &d, &style, &styles), "Parent"); + } + #[test] fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -7478,6 +7682,124 @@ mod tests { assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range")); } + #[test] + fn style_options_preview_updates_all_uses_cancels_and_commits_once() { + use egui_kittest::{Harness, kittest::Queryable}; + for paragraph in [true, false] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect":[36,36,300,300],"content":"text","text":"First\nSecond"})).unwrap(); + app.run("text.select", json!({"story":f["story"],"anchor":0,"focus":12})).unwrap(); + let kind = if paragraph { "paragraph" } else { "character" }; + app.run(&format!("style.{kind}.create"), json!({"name":"Tutorial","chars":{"size":10}})).unwrap(); + app.run(&format!("style.{kind}.apply"), json!({"name":"Tutorial"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let journal = app.session.journal.len(); + open_style_options(&mut app, paragraph, "Tutorial"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("style_preview_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Preview").click(); + h.run_steps(3); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("c.size".into(), json!(18)); + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 18.0); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + assert_eq!(app.session.journal.len(), journal); + // Repeated previews replace each other, never accumulate undo entries. + app.ui.dialog.as_mut().unwrap().fields.insert("c.size".into(), json!(20)); + h.run_steps(3); + assert_eq!(h.state_mut().run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 20.0); + h.get_by_label("Preview").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, before); + h.get_by_label("Preview").click(); + h.run_steps(3); + cancel(h.state_mut()); + assert_eq!(h.state().session.doc().unwrap().doc, before); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo); + open_style_options(h.state_mut(), paragraph, "Tutorial"); + let d = h.state_mut().ui.dialog.as_mut().unwrap(); + d.fields.insert("preview".into(), json!(true)); + d.fields.insert("c.size".into(), json!(24)); + h.run_steps(3); + confirm(h.state_mut()).unwrap(); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1); + // Both paragraphs/character runs still use the globally edited definition. + for pos in [0, 6] { + h.state_mut().run("text.select", json!({"story":f["story"],"anchor":pos,"focus":pos+1})).unwrap(); + assert_eq!(h.state_mut().run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 24.0); + } + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc().unwrap().doc, before); + } + } + + #[test] + fn style_preview_refuses_justification_spacing_ok_would_refuse() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut d = + Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::BASIC_PARAGRAPH, "preview": true, "p.wordSpaceDesired": 1.1})); + style_options_preview(&mut app, &mut d); + assert_ne!(app.session.doc().unwrap().doc, before, "a valid value previews"); + // Minimum above Desired: the page goes back and the dialog says why. + d.fields.insert("p.wordSpaceMin".into(), json!(1.5)); + style_options_preview(&mut app, &mut d); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(d.s("status").contains("Word Spacing"), "{:?}", d.fields.get("status")); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + + #[test] + fn invalid_style_preview_leaves_the_starting_document_and_can_be_cancelled() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.character.create", json!({"name":"Emphasis"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut d = Dialog::new("characterStyleOptions", json!({"name":"Emphasis","preview":true,"basedOn":"Emphasis"})); + style_options_preview(&mut app, &mut d); + assert!(d.fields.contains_key("status")); + assert_eq!(app.session.doc().unwrap().doc, before); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + + #[test] + fn style_preview_rejects_conflicting_scalar_and_nested_rule_fields_without_panicking() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut dialog = Dialog::new( + "paragraphStyleOptions", + json!({ + "name": designcraft_doc::BASIC_PARAGRAPH, "preview": true, + "p.ruleAbove": false, "p.ruleAbove.weight": 2 + }), + ); + style_options_preview(&mut app, &mut dialog); + assert!(dialog.fields.contains_key("status")); + assert_eq!(app.session.doc().unwrap().doc, before); + app.ui.dialog = Some(dialog); + cancel(&mut app); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + #[test] fn text_frame_options_set_columns_baseline_and_wrap_as_indesign_does() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 4bdba85f..456ca4a5 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -699,7 +699,7 @@ mod tests { ] { assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); } - assert!(texts.iter().any(|x| x.contains("0.097 in")), "distance in inches: {texts:?}"); + assert!(texts.iter().any(|x| x.contains("0.0972 in")), "distance in inches: {texts:?}"); // Preview: the shadow is on the page while the dialog is open, without an undo step. frame(&mut app, &ctx); assert!(fx(&app).inner_shadow.on); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f6855a2b..eb86d0fa 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,10 +89,48 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), + ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( "Paste Vector Art", ["Vektorgrafik einfügen", "Coller l’illustration vectorielle", "Pegar ilustración vectorial", "ベクトルアートをペースト", "粘贴矢量图稿"], ), + ("Back", ["Zurück", "Retour", "Atrás", "戻る", "返回"]), + ("Margins and Columns", ["Ränder und Spalten", "Marges et colonnes", "Márgenes y columnas", "マージン・段組", "边距和分栏"]), + ("Warning", ["Warnung", "Avertissement", "Advertencia", "警告", "警告"]), + ("Don't show again", ["Nicht mehr anzeigen", "Ne plus afficher", "No volver a mostrar", "今後表示しない", "不再显示"]), + ( + "The affected page contains objects. Delete the page anyway?", + [ + "Die betroffene Seite enthält Objekte. Seite trotzdem löschen?", + "La page concernée contient des objets. Supprimer quand même la page ?", + "La página afectada contiene objetos. ¿Desea eliminarla de todos modos?", + "対象のページにはオブジェクトが含まれています。ページを削除しますか?", + "受影响的页面包含对象。仍要删除该页面吗?", + ], + ), + ( + "The affected pages contain objects. Delete the pages anyway?", + [ + "Die betroffenen Seiten enthalten Objekte. Seiten trotzdem löschen?", + "Les pages concernées contiennent des objets. Supprimer quand même les pages ?", + "Las páginas afectadas contienen objetos. ¿Desea eliminarlas de todos modos?", + "対象のページにはオブジェクトが含まれています。ページを削除しますか?", + "受影响的页面包含对象。仍要删除这些页面吗?", + ], + ), + ("Capture", ["Erfassen", "Capturer", "Capturar", "取り込み", "捕捉"]), + ("Panel Locations", ["Bedienfeldpositionen", "Emplacements des panneaux", "Ubicaciones de paneles", "パネルの位置", "面板位置"]), + ("First Page", ["Erste Seite", "Première page", "Primera página", "最初のページ", "第一页"]), + ("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]), + ("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]), + ("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]), + ( + "Close Tab Group", + ["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"], + ), + ("Minimize", ["Minimieren", "Réduire", "Minimizar", "最小化", "最小化"]), + ("Collapse to Icons", ["Auf Symbole minimieren", "Réduire en icônes", "Contraer a iconos", "アイコンパネルに折りたたむ", "折叠为图标"]), ( "Bulleted & Numbered Lists", [ @@ -733,6 +771,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Break Link to Style", ["Break Link to Style", "Rompre le lien avec le style", "Break Link to Style", "Break Link to Style", "断开与样式的链接"], ), + ("Redefine Style", ["Format neu definieren", "Redéfinir le style", "Redefinir estilo", "スタイルを再定義", "重新定义样式"]), ("Bullets and Numbering", ["Bullets and Numbering", "Puces et numérotation", "Bullets and Numbering", "Bullets and Numbering", "项目符号和编号"]), ("Business Card", ["Business Card", "Carte de visite", "Business Card", "Business Card", "名片"]), ("CMYK", ["CMYK", "CMJN", "CMYK", "CMYK", "CMYK"]), @@ -2779,6 +2818,26 @@ const TABLE: &[(&str, [&str; 5])] = &[ "⌥-click clears overrides", ["⌥-click clears overrides", "⌥-clic efface les remplacements", "⌥-click clears overrides", "⌥-click clears overrides", "⌥-单击清除覆盖"], ), + ( + "Alt-click clears overrides", + [ + "Alt-Klick entfernt Abweichungen", + "Alt-clic efface les remplacements", + "Alt-clic borra las modificaciones", + "Alt を押しながらクリックするとオーバーライドを消去します", + "Alt-单击清除覆盖", + ], + ), + ( + "Apply \"{name}\", Clear Overrides", + [ + "„{name}“ anwenden, Abweichungen löschen", + "Appliquer « {name} », effacer les remplacements", + "Aplicar \"{name}\", borrar modificaciones", + "\"{name}\" を適用、オーバーライドを消去", + "应用\"{name}\",清除覆盖", + ], + ), ("⚠ out of date", ["⚠ out of date", "⚠ obsolète", "⚠ out of date", "⚠ out of date", "⚠ 已过期"]), ("General", ["General", "Général", "General", "General", "常规"]), ( @@ -5042,4 +5101,13 @@ mod tests { // Fixed standard names stay identical in every language. assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4"); } + + #[test] + fn style_row_menu_strings_are_translated_in_every_language() { + for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { + for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] { + assert_ne!(tr(code, key), key, "{code}: {key}"); + } + } + } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 386db946..802e9923 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -46,6 +46,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Corners:", "الزوايا:"), ("Sharp", "حادة"), ("Diffused", "منتشرة"), + ("Inches Decimal", "بوصات عشرية"), + ("Back", "رجوع"), + ("Warning", "تحذير"), + ("Don't show again", "عدم الإظهار مرة أخرى"), + ("The affected page contains objects. Delete the page anyway?", "تحتوي الصفحة المعنية على كائنات. هل تريد حذف الصفحة على أي حال؟"), + ("The affected pages contain objects. Delete the pages anyway?", "تحتوي الصفحات المعنية على كائنات. هل تريد حذف الصفحات على أي حال؟"), + ("Capture", "التقاط"), + ("Panel Locations", "مواضع اللوحات"), + ("Close Tab Group", "إغلاق مجموعة علامات التبويب"), + ("Minimize", "تصغير"), + ("Collapse to Icons", "طي إلى أيقونات"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), @@ -958,6 +969,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bleed and Slug", "النزف ومنطقة المعلومات"), ("Body Rows", "صفوف المتن"), ("Break Link to Style", "فك الارتباط بالنمط"), + ("Redefine Style", "إعادة تعريف النمط"), ("Bullets and Numbering", "التعداد النقطي والرقمي"), ("Business Card", "بطاقة عمل"), ("Buttons act in interactive PDF export.", "تعمل الأزرار عند التصدير إلى PDF تفاعلي."), @@ -1170,6 +1182,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[No Group]", "[بلا مجموعة]"), ("[None] — choose a colour on the ramp.", "[بلا] — اختر لونًا على شريط التدرج."), ("⌥-click clears overrides", "النقر مع ⌥ يمسح التجاوزات"), + ("Alt-click clears overrides", "النقر مع Alt يمسح التجاوزات"), + ("Apply \"{name}\", Clear Overrides", "تطبيق \"{name}\" ومسح التجاوزات"), ("⚠ out of date", "⚠ غير محدّث"), ("Interface", "الواجهة"), ("Advanced Type", "كتابة متقدمة"), @@ -1903,6 +1917,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Picas", "بيكا"), ("Place with Import Options", "وضع مع خيارات الاستيراد"), ("Preflight", "الفحص المسبق"), + ("Preflight Panel…", "لوحة الفحص المسبق…"), ("Previous", "السابق"), ("Previous Page Number", "رقم الصفحة السابقة"), ("Print Booklet", "طباعة كتيّب"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 3aff33c8..e77e72a1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -140,6 +140,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Řádky těla"), ("Bottom", "Dole"), ("Break Link to Style", "Zrušit propojení se stylem"), + ("Redefine Style", "Předefinovat styl"), ("Bullets and Numbering", "Odrážky a číslování"), ("Business Card", "Vizitka"), ("CMYK", "CMYK"), @@ -951,6 +952,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Žádný] — vyberte barvu na spektru."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — pomoc, zpětná vazba a ukázky prací"), ("⌥-click clears overrides", "⌥-kliknutí zruší přepsání"), + ("Alt-click clears overrides", "Alt-kliknutí zruší přepsání"), + ("Apply \"{name}\", Clear Overrides", "Použít „{name}“, zrušit přepsání"), ("⚠ out of date", "⚠ zastaralé"), ("General", "Obecné"), ("Basic Character Formats", "Základní formáty znaků"), @@ -1707,6 +1710,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Symbol odstavce"), ("Picas", "Piky"), ("Preflight", "Předtisková kontrola"), + ("Preflight Panel…", "Panel předtiskové kontroly…"), ("Previous Page Number", "Číslo předchozí stránky"), ("Punctuation Space", "Mezera šířky interpunkce"), ("Quarter Space", "Čtvrtina čtverčíku"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2814f9c1..b96280e0 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Inches Decimal", "Zoll dezimal"), + ("Back", "Zurück"), + ("Warning", "Warnung"), + ("Don't show again", "Nicht mehr anzeigen"), + ("The affected page contains objects. Delete the page anyway?", "Die betroffene Seite enthält Objekte. Seite trotzdem löschen?"), + ("The affected pages contain objects. Delete the pages anyway?", "Die betroffenen Seiten enthalten Objekte. Seiten trotzdem löschen?"), + ("Capture", "Erfassen"), + ("Panel Locations", "Bedienfeldpositionen"), + ("Close Tab Group", "Registerkartengruppe schließen"), + ("Minimize", "Minimieren"), + ("Collapse to Icons", "Auf Symbole minimieren"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), @@ -153,6 +164,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Tabellenkörperzeilen"), ("Bottom", "Unten"), ("Break Link to Style", "Verknüpfung mit Format aufheben"), + ("Redefine Style", "Format neu definieren"), ("Bullets and Numbering", "Aufzählungszeichen und Nummerierung"), ("Business Card", "Visitenkarte"), ("CMYK", "CMYK"), @@ -979,6 +991,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Ohne] – eine Farbe auf dem Farbbalken wählen."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft – Hilfe, Feedback und Arbeiten zeigen"), ("⌥-click clears overrides", "⌥-Klick entfernt Abweichungen"), + ("Alt-click clears overrides", "Alt-Klick entfernt Abweichungen"), + ("Apply \"{name}\", Clear Overrides", "„{name}“ anwenden, Abweichungen löschen"), ("⚠ out of date", "⚠ veraltet"), ("General", "Allgemein"), ("Basic Character Formats", "Grundlegende Zeichenformate"), @@ -1714,6 +1728,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Absatzzeichen"), ("Picas", "Pica"), ("Preflight", "Preflight"), + ("Preflight Panel…", "Preflight-Bedienfeld …"), ("Previous Page Number", "Vorherige Seitenzahl"), ("Punctuation Space", "Interpunktionsleerzeichen"), ("Quarter Space", "Viertelgeviert"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 9ccb87a3..78c5d594 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -64,7 +64,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Figure Space", "Espace chiffre"), ("File/Export", "Fichier/Exporter"), ("Find What", "Rechercher"), - ("First Page", "Première page"), ("Flush Space", "Espace sans alinéa"), ("Forced Line Break", "Saut de ligne forcé"), ("Frame Break", "Saut de bloc"), @@ -75,7 +74,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Graphic", "Graphique"), ("Hair Space", "Espace ultrafine"), ("Hide Guides", "Masquer les repères"), - ("Hide Rulers", "Masquer les règles"), ("High Contrast", "Contraste élevé"), ("High Resolution", "Haute résolution"), ("I, II, III, IV...", "I, II, III, IV..."), @@ -91,7 +89,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), ("Kerning", "Crénage"), - ("Last Page", "Dernière page"), ("Layout/Index", "Page/Index"), ("Layout/Pages", "Page/Pages"), ("Legacy Macintosh RGB", "RVB Macintosh hérité"), @@ -99,7 +96,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD", "Charger la DTD"), ("Load Swatches", "Charger des nuances"), ("Low Resolution", "Basse résolution"), - ("Margins and Columns", "Marges et colonnes"), ("Margins:", "Marges :"), ("Medium Dark", "Moyennement foncé"), ("Medium Light", "Moyennement clair"), @@ -114,7 +110,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Page Reference", "Nouvelle référence de page"), ("New Parent", "Nouveau gabarit"), ("New Stroke Style", "Nouveau style de contour"), - ("Next Page", "Page suivante"), ("Next Page Number", "Numéro de la page suivante"), ("Nonbreaking Hyphen", "Trait d'union insécable"), ("Nonbreaking Space", "Espace insécable"), @@ -146,7 +141,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Picas", "Picas"), ("Place with Import Options", "Importer avec les options d'importation"), ("Preflight", "Contrôle en amont"), - ("Previous Page", "Page précédente"), + ("Preflight Panel…", "Panneau Contrôle en amont…"), ("Previous Page Number", "Numéro de la page précédente"), ("Print Booklet", "Imprimer le cahier"), ("Punctuation Space", "Espace de ponctuation"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 42acffd8..6201003d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -44,6 +44,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Corners:", "Angoli:"), ("Sharp", "Netti"), ("Diffused", "Diffusi"), + ("Inches Decimal", "Pollici decimali"), + ("Back", "Indietro"), + ("Margins and Columns", "Margini e colonne"), + ("Warning", "Avviso"), + ("Don't show again", "Non mostrare più"), + ("The affected page contains objects. Delete the page anyway?", "La pagina interessata contiene oggetti. Eliminarla comunque?"), + ("The affected pages contain objects. Delete the pages anyway?", "Le pagine interessate contengono oggetti. Eliminarle comunque?"), + ("Capture", "Acquisisci"), + ("Panel Locations", "Posizioni pannelli"), + ("Close Tab Group", "Chiudi gruppo schede"), + ("Minimize", "Minimizza"), + ("Collapse to Icons", "Riduci a icone"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), @@ -221,6 +233,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Righe di corpo"), ("Bottom", "Basso"), ("Break Link to Style", "Interrompi collegamento allo stile"), + ("Redefine Style", "Ridefinisci stile"), ("Bullets and Numbering", "Elenchi puntati e numerati"), ("Business Card", "Biglietto da visita"), ("CMYK", "CMYK"), @@ -1060,6 +1073,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Nessuno] — scegli un colore sulla barra dei colori."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — aiuto, feedback e condivisione dei lavori"), ("⌥-click clears overrides", "⌥-clic cancella le modifiche locali"), + ("Alt-click clears overrides", "Alt-clic cancella le modifiche locali"), + ("Apply \"{name}\", Clear Overrides", "Applica \"{name}\", cancella modifiche locali"), ("⚠ out of date", "⚠ non aggiornato"), ("General", "Generale"), ("Basic Character Formats", "Formati carattere di base"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 8bc4536d..5acaa5e3 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -48,6 +48,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Corners:", "角:"), ("Sharp", "シャープ"), ("Diffused", "拡散"), + ("Inches Decimal", "インチ(10 進)"), + ("Back", "戻る"), + ("Warning", "警告"), + ("Don't show again", "今後表示しない"), + ("The affected page contains objects. Delete the page anyway?", "対象のページにはオブジェクトが含まれています。ページを削除しますか?"), + ("The affected pages contain objects. Delete the pages anyway?", "対象のページにはオブジェクトが含まれています。ページを削除しますか?"), + ("Capture", "取り込み"), + ("Panel Locations", "パネルの位置"), + ("Close Tab Group", "タブグループを閉じる"), + ("Minimize", "最小化"), + ("Collapse to Icons", "アイコンパネルに折りたたむ"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), @@ -340,6 +351,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "本体行"), ("Bottom", "下"), ("Break Link to Style", "スタイルへのリンクを解除"), + ("Redefine Style", "スタイルを再定義"), ("Bullets and Numbering", "箇条書き記号と番号付け"), ("Business Card", "名刺"), ("CMYK", "CMYK"), @@ -1177,6 +1189,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[なし] — カラーバーでカラーを選択してください。"), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — サポート・ご意見・作品紹介"), ("⌥-click clears overrides", "⌥ を押しながらクリックするとオーバーライドを消去します"), + ("Alt-click clears overrides", "Alt を押しながらクリックするとオーバーライドを消去します"), + ("Apply \"{name}\", Clear Overrides", "\"{name}\" を適用、オーバーライドを消去"), ("⚠ out of date", "⚠ 要更新"), ("General", "一般"), ("Basic Character Formats", "文字の基本書式"), @@ -1912,6 +1926,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "段落記号"), ("Picas", "パイカ"), ("Preflight", "プリフライト"), + ("Preflight Panel…", "プリフライトパネル…"), ("Previous Page Number", "前のページ番号"), ("Punctuation Space", "句読点幅スペース"), ("Quarter Space", "四分スペース"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 4ea0f599..f63f561d 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Inches Decimal", "ინჩები (ათობითი)"), + ("Back", "უკან"), + ("Warning", "გაფრთხილება"), + ("Don't show again", "აღარ აჩვენო"), + ("The affected page contains objects. Delete the page anyway?", "გვერდი შეიცავს ობიექტებს. მაინც წავშალო გვერდი?"), + ("The affected pages contain objects. Delete the pages anyway?", "გვერდები შეიცავს ობიექტებს. მაინც წავშალო გვერდები?"), + ("Capture", "შენახვა"), + ("Panel Locations", "პანელების მდებარეობა"), + ("Close Tab Group", "ჩანართების ჯგუფის დახურვა"), + ("Minimize", "ჩაკეცვა"), + ("Collapse to Icons", "ხატულებად ჩაკეცვა"), ("Reverse Path", "ტრაექტორიის შებრუნება"), ("Set Anchor", "საყრდენი წერტილის დაყენება"), ("Clear", "გასუფთავება"), @@ -142,6 +153,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "ძირითადი სტრიქონები"), ("Bottom", "ქვემოთ"), ("Break Link to Style", "სტილთან კავშირის გაწყვეტა"), + ("Redefine Style", "სტილის ხელახლა განსაზღვრა"), ("Bullets and Numbering", "ბურთულები და დანომვრა"), ("Business Card", "სავიზიტო ბარათი"), ("CMYK", "CMYK"), @@ -989,6 +1001,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[არცერთი] — აირჩიეთ ფერი სკალაზე."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — დახმარება, გამოხმაურება და ნამუშევრების ჩვენება"), ("⌥-click clears overrides", "⌥-დაწკაპუნება ასუფთავებს გადაფარვებს"), + ("Alt-click clears overrides", "Alt-დაწკაპუნება ასუფთავებს გადაფარვებს"), + ("Apply \"{name}\", Clear Overrides", "„{name}“-ის გამოყენება, გადაფარვების გასუფთავება"), ("⚠ out of date", "⚠ მოძველებულია"), ("General", "ზოგადი"), ("Basic Character Formats", "სიმბოლოს საბაზო ფორმატები"), @@ -1724,6 +1738,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "აბზაცის სიმბოლო"), ("Picas", "პიკები"), ("Preflight", "ბეჭდვისწინა შემოწმება"), + ("Preflight Panel…", "ბეჭდვისწინა შემოწმების პანელი…"), ("Previous Page Number", "წინა გვერდის ნომერი"), ("Punctuation Space", "პუნქტუაციის შორისი"), ("Quarter Space", "მეოთხედი შორისი"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 07f6b062..68d685d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -138,6 +138,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Wiersze treści"), ("Bottom", "Dół"), ("Break Link to Style", "Odłącz od stylu"), + ("Redefine Style", "Przedefiniuj styl"), ("Bullets and Numbering", "Wypunktowanie i numeracja"), ("Business Card", "Wizytówka"), ("CMYK", "CMYK"), @@ -961,6 +962,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Brak] — wybierz kolor na pasku."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — pomoc, opinie i prezentacje prac"), ("⌥-click clears overrides", "⌥-klik usuwa zastąpienia"), + ("Alt-click clears overrides", "Alt-klik usuwa zastąpienia"), + ("Apply \"{name}\", Clear Overrides", "Zastosuj „{name}”, usuń zastąpienia"), ("⚠ out of date", "⚠ nieaktualne"), ("General", "Ogólne"), ("Basic Character Formats", "Podstawowe formaty znaków"), @@ -1679,6 +1682,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Symbol akapitu"), ("Picas", "Pica"), ("Preflight", "Inspekcja wstępna"), + ("Preflight Panel…", "Panel inspekcji wstępnej…"), ("Previous Page Number", "Numer poprzedniej strony"), ("Punctuation Space", "Spacja interpunkcyjna"), ("Quarter Space", "Ćwierćfiret"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 16b12808..75e0af3c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -47,6 +47,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Corners:", "Cantos:"), ("Sharp", "Nítidos"), ("Diffused", "Difusos"), + ("Inches Decimal", "Polegadas decimais"), + ("Back", "Voltar"), + ("Margins and Columns", "Margens e colunas"), + ("Warning", "Aviso"), + ("Don't show again", "Não mostrar novamente"), + ("The affected page contains objects. Delete the page anyway?", "A página afetada contém objetos. Excluir a página mesmo assim?"), + ("The affected pages contain objects. Delete the pages anyway?", "As páginas afetadas contêm objetos. Excluir as páginas mesmo assim?"), + ("Capture", "Capturar"), + ("Panel Locations", "Locais dos painéis"), + ("Close Tab Group", "Fechar grupo de guias"), + ("Minimize", "Minimizar"), + ("Collapse to Icons", "Recolher para ícones"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), @@ -958,6 +970,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bleed and Slug", "Sangria e área de informações"), ("Body Rows", "Linhas do corpo"), ("Break Link to Style", "Romper vínculo com o estilo"), + ("Redefine Style", "Redefinir estilo"), ("Bullets and Numbering", "Marcadores e numeração"), ("Business Card", "Cartão de visita"), ("Buttons act in interactive PDF export.", "Os botões agem na exportação de PDF interativo."), @@ -1185,6 +1198,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[No Group]", "[Sem grupo]"), ("[None] — choose a colour on the ramp.", "[Nenhum] — escolha uma cor na rampa."), ("⌥-click clears overrides", "⌥-clique limpa substituições"), + ("Alt-click clears overrides", "Alt-clique limpa substituições"), + ("Apply \"{name}\", Clear Overrides", "Aplicar \"{name}\", limpar substituições"), ("⚠ out of date", "⚠ desatualizado"), ("Interface", "Interface"), ("Advanced Type", "Tipo avançado"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index bd26ec90..251555ca 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -140,6 +140,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Строки тела"), ("Bottom", "Снизу"), ("Break Link to Style", "Разорвать связь со стилем"), + ("Redefine Style", "Переопределить стиль"), ("Bullets and Numbering", "Маркеры и нумерация"), ("Business Card", "Визитная карточка"), ("CMYK", "CMYK"), @@ -938,6 +939,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Нет] — выберите цвет на шкале."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — помощь, отзывы и обмен работами"), ("⌥-click clears overrides", "⌥-щелчок сбрасывает переопределения"), + ("Alt-click clears overrides", "Alt-щелчок сбрасывает переопределения"), + ("Apply \"{name}\", Clear Overrides", "Применить «{name}», сбросить переопределения"), ("⚠ out of date", "⚠ устарело"), ("General", "Основные"), ("Basic Character Formats", "Основные атрибуты символов"), @@ -1689,6 +1692,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Symbol", "Знак абзаца"), ("Picas", "Пики"), ("Preflight", "Предпечатная проверка"), + ("Preflight Panel…", "Панель предпечатной проверки…"), ("Previous Page Number", "Номер предыдущей страницы"), ("Punctuation Space", "Пунктуационный пробел"), ("Quarter Space", "Четверть шпации"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index c96e2406..6c4cd1ce 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -45,6 +45,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Corners:", "Кути:"), ("Sharp", "Гострі"), ("Diffused", "Розсіяні"), + ("Inches Decimal", "Дюйми (десяткові)"), + ("Back", "Назад"), + ("Margins and Columns", "Поля та колонки"), + ("Warning", "Попередження"), + ("Don't show again", "Більше не показувати"), + ("The affected page contains objects. Delete the page anyway?", "Сторінка містить об’єкти. Усе одно видалити сторінку?"), + ("The affected pages contain objects. Delete the pages anyway?", "Сторінки містять об’єкти. Усе одно видалити сторінки?"), + ("Capture", "Зберегти"), + ("Panel Locations", "Розташування панелей"), + ("Close Tab Group", "Закрити групу вкладок"), + ("Minimize", "Згорнути"), + ("Collapse to Icons", "Згорнути до піктограм"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), @@ -224,6 +236,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Body Rows", "Рядки основного вмісту"), ("Bottom", "Знизу"), ("Break Link to Style", "Розірвати зв’язок зі стилем"), + ("Redefine Style", "Перевизначити стиль"), ("Bullets and Numbering", "Маркери та нумерація"), ("Business Card", "Візитівка"), ("CMYK", "CMYK"), @@ -1057,6 +1070,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[None] — choose a colour on the ramp.", "[Немає] — виберіть колір на шкалі."), ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — допомога, відгуки та обмін роботами"), ("⌥-click clears overrides", "⌥-клацання скидає перевизначення"), + ("Alt-click clears overrides", "Alt-клацання скидає перевизначення"), + ("Apply \"{name}\", Clear Overrides", "Застосувати «{name}», скинути перевизначення"), ("⚠ out of date", "⚠ застаріле"), ("General", "Загальні"), ("Basic Character Formats", "Основне форматування символів"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46ef9b32..a417663e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,6 +119,13 @@ pub struct SavedWorkspace { /// The workspace's own icon column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, + /// Saved with New Workspace › Capture › Panel Locations off: choosing the workspace leaves the + /// bars and panels where they are. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub keep_panels: bool, + /// New Workspace › Capture › Menu Customization: the menu items hidden with Edit › Menus. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden_menu_items: Option>, } /// A missing on-by-default bool stays on. `bool::default` is false. @@ -182,6 +189,9 @@ pub struct UiState { pub screen_mode: ScreenMode, /// The screen mode leaving Presentation returns to: the one it was entered from. pub presentation_return: ScreenMode, + /// The view (by its `views` key) Presentation was entered from; leaving puts it back. + #[serde(skip)] + pub presentation_view: Option<(u64, View)>, pub frame_edges: bool, pub rulers: bool, pub guides: bool, @@ -203,6 +213,8 @@ pub struct UiState { pub flattener_preview: bool, /// Tab / Shift+Tab: 1 = every panel hidden, 2 = all but the Tools panel (0 = shown). pub hidden_panels: u8, + /// "Don't show again" in the warning before deleting pages that contain objects. + pub skip_delete_pages_warning: bool, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, /// View › Proof Colors and View › Proof Setup. @@ -341,6 +353,7 @@ impl Default for UiState { brightness: theme::Brightness::MediumDark, screen_mode: ScreenMode::Normal, presentation_return: ScreenMode::Normal, + presentation_view: None, frame_edges: true, rulers: true, guides: true, @@ -359,6 +372,7 @@ impl Default for UiState { tag_markers: false, flattener_preview: false, hidden_panels: 0, + skip_delete_pages_warning: false, color_settings: None, proof_colors: false, proof_setup: Default::default(), @@ -1553,8 +1567,11 @@ impl DesignApp { ); self.switch_pane(self.focus_pane); } - dock::flyout(self, &ctx); - dock::floating(self, &ctx); + // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. + if !presenting && self.ui.hidden_panels == 0 { + dock::flyout(self, &ctx); + dock::floating(self, &ctx); + } story_editor::show(self, &ctx); dialogs::show(self, &ctx); about::show(self, &ctx); @@ -2670,22 +2687,29 @@ pub(crate) mod test_window { } /// The window at a size in points and a scale, with a new document open. - pub fn open_scaled(mut app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32) -> Harness<'static, Window> { + pub fn open_scaled(app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32) -> Harness<'static, Window> { + open_stepped(app, size, pixels_per_point, 0.25) + } + + /// As `open_scaled`, with `step_dt` seconds between frames (a double-click needs its two + /// clicks closer together than the default quarter second). + pub fn open_stepped(mut app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32, step_dt: f32) -> Harness<'static, Window> { if app.session.active().is_none() { app.run("file.new", serde_json::json!({})).unwrap(); } - let mut h = Harness::builder().with_size(size).with_pixels_per_point(pixels_per_point).with_max_steps(1000).build_ui_state( - |ui, w: &mut Window| { - // The builder runs a frame before the texture size can be raised: skip it. - if !w.ready { - return; - } - let ctx = ui.ctx().clone(); - w.app.logic(&ctx); - w.app.ui(ui); - }, - Window { app, ready: false }, - ); + let mut h = + Harness::builder().with_size(size).with_pixels_per_point(pixels_per_point).with_max_steps(1000).with_step_dt(step_dt).build_ui_state( + |ui, w: &mut Window| { + // The builder runs a frame before the texture size can be raised: skip it. + if !w.ready { + return; + } + let ctx = ui.ctx().clone(); + w.app.logic(&ctx); + w.app.ui(ui); + }, + Window { app, ready: false }, + ); h.input_mut().max_texture_side = Some(8192); h.state_mut().ready = true; h.run_steps(6); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f40a8b92..05462e8a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -223,12 +223,18 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.fastDisplay", "Fast Display", Some("Cmd+Alt+Shift+Z"), "{} — placed graphics as grey boxes, no effects"), ("view.typicalDisplay", "Typical Display", Some("Cmd+Alt+Z"), "{} — low-resolution image proxies"), ("view.highQualityDisplay", "High Quality Display", Some("Cmd+Alt+H"), "{} — full-resolution images"), + ("view.firstPage", "First Page", Some("Cmd+Shift+PageUp"), "{} — scroll to the document's first page (zoom kept)"), + ("view.previousPage", "Previous Page", Some("Shift+PageUp"), "{} — scroll to the page before the one in view"), + ("view.nextPage", "Next Page", Some("Shift+PageDown"), "{} — scroll to the page after the one in view"), + ("view.lastPage", "Last Page", Some("Cmd+Shift+PageDown"), "{} — scroll to the document's last page"), + ("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"), + ("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"), ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"), ( "window.panel", "Show Panel", None, - "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}", + "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), ( @@ -247,6 +253,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), + ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), + ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). @@ -289,7 +297,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{name: Advanced|Book|Digital Publishing|Essentials|Essentials Classic|Interactive for PDF|Printing and Proofing|Typography or a saved workspace}", ), - ("window.newWorkspace", "New Workspace…", None, "{name} — saves the current bars and panel arrangement"), + ( + "window.newWorkspace", + "New Workspace…", + None, + "{name, panelLocations?: bool (default true: the bars and panels), menuCustomization?: bool (default true: the menu items hidden with Edit › Menus)} — saves the workspace", + ), ("window.deleteWorkspace", "Delete Workspace…", None, "{name}"), ("window.resetWorkspace", "Reset Workspace", None, "{} — back to the current workspace as saved (or its defaults)"), ("window.brightness", "Interface Color Theme", None, "{brightness: dark|mediumDark|mediumLight|light|highContrast}"), @@ -399,6 +412,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:layout.createGuides", "cmd:layout.createAlternate", "-", + "ui:view.firstPage", + "ui:view.previousPage", + "ui:view.nextPage", + "ui:view.lastPage", + "ui:view.nextSpread", + "ui:view.previousSpread", "ui:view.goToPage", "-", "cmd:layout.section", @@ -918,6 +937,17 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[ &["gradient"], ]; +/// The Typography preset's available panels in the order observed in InDesign's workspace. +/// CC Libraries and the separate Story panel have no corresponding DesignCraft panel. +pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[ + &["pages", "layers", "swatches"], + &["glyphs", "textWrap"], + &["stroke", "gradient"], + &["hyperlinks", "effects"], + &["paragraph", "paragraphStyles"], + &["character", "characterStyles"], +]; + pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.2)) } @@ -1305,7 +1335,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Option { + let n = app.session.active().map_or(0, |d| d.doc.page_count()); + if n > 0 { + let cur = crate::canvas::current_page(app).unwrap_or(0); + let to = match id { + "view.firstPage" => 0, + "view.previousPage" => cur.saturating_sub(1), + "view.nextPage" => cur.saturating_add(1).min(n - 1), + _ => n - 1, + }; + crate::canvas::go_to_page(app, to); + } + Ok(Value::Null) + } + "view.nextSpread" | "view.previousSpread" => { + crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 }); + Ok(Value::Null) + } "view.goToPage" => { match p.get("page") { Some(Value::Number(n)) => crate::canvas::go_to_page(app, (n.as_u64().unwrap_or(1) as usize).saturating_sub(1)), @@ -1778,9 +1826,15 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), + "window.collapseToIcons" => { + app.ui.dock_expanded = false; + app.ui.open_panel = None; + Ok(Value::Null) + } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newWorkspace", json!({"name": ""}))); + app.ui.dialog = + Some(crate::dialogs::Dialog::new("newWorkspace", json!({"name": "", "panelLocations": true, "menuCustomization": true}))); return Some(Ok(Value::Null)); }; let u = &app.ui; @@ -1797,6 +1851,8 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Option Option Some(CLASSIC_GROUPS), + "Typography" => Some(TYPOGRAPHY_GROUPS), + _ => None, + }; + app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(|p| p.to_string()).collect()).collect()); + app.ui.dock_expanded = groups.is_none(); let dock_tab = match name { - "Typography" => Some("properties"), "Interactive for PDF" | "Digital Publishing" => Some("pages"), _ => None, }; @@ -1861,7 +1927,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("paragraphStyles".into()), "Printing and Proofing" => Some("swatches".into()), "Interactive for PDF" => Some("buttons".into()), "Digital Publishing" => Some("liquid".into()), @@ -1883,8 +1948,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(1), + egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1), + egui::Key::Home => Some(isize::MIN), + egui::Key::End => Some(isize::MAX), + _ => None, + }; + if let Some(step) = step { + ctx.input_mut(|i| i.consume_key(modifiers, key)); + crate::canvas::step_spread(app, step); + continue; + } + } // Command shortcuts. let mut fired: Option<(String, bool)> = None; let all = effective_shortcuts(app); @@ -2990,7 +3080,7 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}"); } app.select_tool(tool); - } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() { + } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { app.select_tool("selection"); } } @@ -3873,6 +3963,57 @@ mod tests { assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app)); } + #[test] + fn tab_and_presentation_hide_floating_panels_too() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp| { + for _ in 0..2 { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + // The icon column's flyout (the classic dock) and a floating panel group (the docking layout). + let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("panel_flyout")); + let group = egui::LayerId::new(egui::Order::Middle, egui::Id::new("designcraft-panel-docking").with(("floating", "preflight"))); + let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer)); + let toggles = [ + ("window.hidePanels", json!({}), json!({})), + ("window.hidePanelsExceptTools", json!({}), json!({})), + ("view.screenMode", json!({"mode": "presentation"}), json!({"mode": "normal"})), + ]; + app.run("window.panel", json!({"panel": "preflight"})).unwrap(); + frame(&mut app); + assert!(app.ui.docking.is_none() && shown(flyout)); + for (id, hide, show) in &toggles { + app.run(id, hide.clone()).unwrap(); + frame(&mut app); + assert!(!shown(flyout), "{id} hides the flyout"); + app.run(id, show.clone()).unwrap(); + frame(&mut app); + assert!(shown(flyout), "{id} again brings it back"); + } + app.run("window.floatPanel", json!({"panel": "preflight", "x": 400, "y": 200})).unwrap(); + frame(&mut app); + assert!(app.ui.docking.is_some() && shown(group)); + for (id, hide, show) in &toggles { + app.run(id, hide.clone()).unwrap(); + frame(&mut app); + assert!(!shown(group), "{id} hides the floating panel"); + app.run(id, show.clone()).unwrap(); + frame(&mut app); + assert!(shown(group), "{id} again brings it back"); + } + } + #[test] fn tab_hides_panels_and_documents_cycle() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -4005,6 +4146,183 @@ mod tests { assert_eq!(items(&app), items0, "the tool saw nothing"); } + #[test] + fn layout_menu_goes_through_pages_and_spreads_with_their_shortcuts() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // The Layout menu lists them, as InDesign's does, before Go to Page. + let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1; + let at = |id: &str| layout.iter().position(|e| e.starts_with(&format!("ui:{id}"))).unwrap(); + assert!(at("view.firstPage") < at("view.previousPage")); + assert!(at("view.previousPage") < at("view.nextPage")); + assert!(at("view.nextPage") < at("view.lastPage")); + assert!(at("view.lastPage") < at("view.nextSpread")); + assert!(at("view.nextSpread") < at("view.previousSpread")); + assert!(at("view.previousSpread") < at("view.goToPage")); + let shift = egui::Modifiers::SHIFT; + let cmd_shift = egui::Modifiers { shift: true, command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + // Pages 1 | 2-3 | 4-5 | 6-7. + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 1, "Next Page"); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 2, "Next Page goes to the right page of the spread"); + frame(&mut app, key(egui::Key::PageUp, shift)); + assert_eq!(page(&app), 1, "Previous Page"); + frame(&mut app, key(egui::Key::PageDown, cmd_shift)); + assert_eq!(page(&app), 6, "Last Page"); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 6, "Next Page stops at the last page"); + frame(&mut app, key(egui::Key::PageUp, cmd_shift)); + assert_eq!(page(&app), 0, "First Page"); + frame(&mut app, key(egui::Key::PageUp, shift)); + assert_eq!(page(&app), 0, "Previous Page stops at the first page"); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 1, "Next Spread"); + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 3, "Next Spread stops at the last spread"); + frame(&mut app, key(egui::Key::PageUp, alt)); + assert_eq!(spread(&app), 2, "Previous Spread"); + // The spread is centred, the zoom kept. + let zoom = app.view().unwrap().zoom; + app.run("view.previousSpread", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let slot = designcraft_tools::layout::CanvasLayout::new(&st.doc, false).slots[1]; + let (v, r) = (*app.view().unwrap(), app.canvas_rect.unwrap()); + let centre = (v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + assert!((centre.0 - slot.bounds.center().x).abs() < 1e-6 && (centre.1 - slot.bounds.center().y).abs() < 1e-6); + assert_eq!(v.zoom, zoom); + } + + #[test] + fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap(); + let id = app.session.active().unwrap().selection.items[0]; + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }, + ] + }; + let click = |pos: egui::Pos2, modifiers: egui::Modifiers| { + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers }, + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + // The spread fills the screen: as wide or as tall as it, centred. + let fills = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let b = designcraft_tools::layout::CanvasLayout::new(&st.doc, false).slots[spread(app)].bounds; + let (v, r) = (*app.view().unwrap(), app.canvas_rect.unwrap()); + let (w, h) = (b.width() * v.zoom, b.height() * v.zoom); + let x0 = (b.x0 - v.origin.x) * v.zoom; + let y0 = (b.y0 - v.origin.y) * v.zoom; + ((w - r.width() as f64).abs() < 0.01 || (h - r.height() as f64).abs() < 0.01) + && w <= r.width() as f64 + 0.01 + && h <= r.height() as f64 + 0.01 + && (x0 * 2.0 + w - r.width() as f64).abs() < 0.01 + && (y0 * 2.0 + h - r.height() as f64).abs() < 0.01 + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // The user's own view (zoomed in, scrolled) comes back after Presentation. + let view = app.view_mut().unwrap(); + view.zoom = 2.0; + view.origin.x += 150.0; + view.origin.y += 300.0; + let before = *app.view().unwrap(); + app.run("view.screenMode", json!({"mode": "presentation"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(app.canvas_rect.unwrap().size(), egui::vec2(1400.0, 900.0), "the whole window"); + assert_eq!(spread(&app), 0); + assert!(fills(&app)); + let bounds = app.session.active().unwrap().doc.item(id).unwrap().bounds(); + frame(&mut app, key(egui::Key::ArrowRight)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 1, "Right Arrow shows the next spread"); + assert!(fills(&app)); + assert_eq!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds, "the arrow doesn't nudge the selection"); + frame(&mut app, key(egui::Key::PageDown)); + assert_eq!(spread(&app), 2, "Page Down"); + frame(&mut app, key(egui::Key::ArrowLeft)); + assert_eq!(spread(&app), 1, "Left Arrow"); + frame(&mut app, key(egui::Key::End)); + assert_eq!(spread(&app), 3, "End"); + frame(&mut app, key(egui::Key::ArrowRight)); + assert_eq!(spread(&app), 3, "the last spread stays"); + frame(&mut app, key(egui::Key::Home)); + assert_eq!(spread(&app), 0, "Home"); + frame(&mut app, key(egui::Key::PageUp)); + assert_eq!(spread(&app), 0, "the first spread stays"); + let c = app.canvas_rect.unwrap().center(); + frame(&mut app, click(c, egui::Modifiers::NONE)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 1, "a click shows the next spread"); + assert!(fills(&app)); + frame(&mut app, click(c, egui::Modifiers::SHIFT)); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0, "Shift-click the previous one"); + assert_eq!(app.session.active().unwrap().selection.items, vec![id], "clicks don't select"); + assert_eq!(app.session.tool_id(), "selection"); + // Out of Presentation, the view is the one it was entered from, and the arrow keys nudge + // again. + app.run("view.screenMode", json!({"mode": "normal"})).unwrap(); + assert_eq!(*app.view().unwrap(), before); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::ArrowRight)); + assert_ne!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds); + } + #[test] fn tool_shortcuts_work_after_drawing_on_the_canvas() { // Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch @@ -4716,8 +5034,10 @@ mod tests { app.run("file.new", json!({})).unwrap(); activate(&mut app, "app.characterPanel", &Value::Null); assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); - assert_eq!(app.ui.open_panel.as_deref(), Some("paragraphStyles")); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); @@ -4743,6 +5063,41 @@ mod tests { assert_eq!(app.ui.workspace, "Essentials"); } + /// New Workspace › Capture (Learn Skills Daily's InDesign course, 11:40): Panel Locations and + /// Menu Customization, both on by default. + #[test] + fn new_workspace_captures_panel_locations_and_menu_customization() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // The dialog offers both, checked. + run_ui(&mut app, "window.newWorkspace", &json!({})).unwrap().unwrap(); + let d = app.ui.dialog.take().unwrap(); + assert_eq!( + (d.id.as_str(), d.fields["panelLocations"].clone(), d.fields["menuCustomization"].clone()), + ("newWorkspace", json!(true), json!(true)) + ); + // Both captured. + app.ui.dock_tab = "swatches".into(); + app.ui.hidden_menu_items = vec!["Type/Glyphs".into()]; + run_ui(&mut app, "window.newWorkspace", &json!({"name": "All"})).unwrap().unwrap(); + // Menus only: choosing it leaves the panels where they are. + app.ui.hidden_menu_items = vec!["Layout/Pages".into()]; + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Menus", "panelLocations": false})).unwrap().unwrap(); + // Panels only: the menus stay as they are. + app.ui.dock_tab = "layers".into(); + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Panels", "menuCustomization": false})).unwrap().unwrap(); + app.ui.dock_tab = "properties".into(); + app.ui.hidden_menu_items.clear(); + run_ui(&mut app, "window.workspace", &json!({"name": "Menus"})).unwrap().unwrap(); + assert_eq!((app.ui.dock_tab.as_str(), app.ui.hidden_menu_items.clone()), ("properties", vec!["Layout/Pages".to_string()])); + run_ui(&mut app, "window.workspace", &json!({"name": "Panels"})).unwrap().unwrap(); + assert_eq!((app.ui.dock_tab.as_str(), app.ui.hidden_menu_items.clone()), ("layers", vec!["Layout/Pages".to_string()])); + run_ui(&mut app, "window.workspace", &json!({"name": "All"})).unwrap().unwrap(); + assert_eq!((app.ui.dock_tab.as_str(), app.ui.hidden_menu_items.clone()), ("swatches", vec!["Type/Glyphs".to_string()])); + // A workspace saved before the options existed restores its panels. + let old: crate::SavedWorkspace = serde_json::from_value(json!({"name": "Old", "dockTab": "pages"})).unwrap(); + assert!(!old.keep_panels && old.hidden_menu_items.is_none()); + } + #[test] fn essentials_classic_is_the_control_panel_and_iconic_groups() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -4764,6 +5119,31 @@ mod tests { assert_eq!(app.ui.icon_groups, Some(groups)); } + #[test] + fn typography_workspace_opens_its_panels_from_named_icon_groups_and_resets() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + run_ui(&mut app, "window.workspace", &json!({"name":"Typography"})).unwrap().unwrap(); + assert!(app.ui.control_bar); + assert!(!app.ui.dock_expanded); + 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[4], ["paragraph", "paragraphStyles"]); + assert_eq!(groups[5], ["character", "characterStyles"]); + 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)); + assert!(!app.ui.dock_expanded); + } + run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap(); + assert_eq!(app.ui.icon_groups, Some(groups)); + assert!(app.ui.open_panel.is_none()); + assert!(!app.ui.dock_expanded); + run_ui(&mut app, "window.workspace", &json!({"name":"Essentials"})).unwrap().unwrap(); + assert!(app.ui.icon_groups.is_none()); + assert!(app.ui.dock_expanded); + } + #[test] fn the_window_menu_is_in_indesigns_order_and_reaches_every_panel() { let window = menu_tree().into_iter().find(|(m, _)| *m == "Window").unwrap().1; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 7fdd471d..6f5d87c4 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -69,6 +69,56 @@ fn legacy(app: &DesignApp) -> Layout { layout } +/// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in +/// an accordion, or not in the layout, is a group of its own. +fn tab_group(layout: &Layout, panel: &str) -> Vec { + if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == panel)) { + return group.panels.clone(); + } + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + _ => {} + } + } + vec![panel.to_string()] +} + +/// Is `panel` an open entry of an accordion (which Minimize closes)? +fn open_in_stack(layout: &Layout, panel: &str) -> bool { + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Stack { entries } => { + if let Some(e) = entries.iter().find(|e| e.panel == panel) { + return e.open; + } + } + Node::Tabs { .. } => {} + } + } + false +} + fn first_root(layout: &Layout, except: &str) -> Option { let root = Layout { root: layout.root.clone(), floating: Vec::new() }; root.panels().into_iter().find(|p| p.as_str() != except).cloned() @@ -116,6 +166,14 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option) -> Result) -> Vec { + let mut out = Vec::new(); + let mut visit: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = visit.pop() { + if out.len() > craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), + Node::Stack { .. } => {} + } + } + out +} + +/// Bring each of `front` back in front of the dock tab group that holds it. +fn restore_front_tabs(layout: &mut Layout, front: &[String]) { + let mut visit: Vec<&mut Node> = layout.root.iter_mut().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Tabs { panels, active } => { + if let Some(i) = panels.iter().position(|p| front.contains(p)) { + *active = i; + } + } + Node::Stack { .. } => {} + } + } +} + +/// Panels InDesign opens together as one floating group from the Window menu, in tab order: +/// Window › Styles › Paragraph Styles (or Character Styles) brings both. +const FLOATING_GROUPS: &[&[&str]] = &[&["characterStyles", "paragraphStyles"]]; + +/// Window › a panel of a [`FLOATING_GROUPS`] group none of whose panels is showing: the group +/// opens floating over the right of the canvas, with that panel in front. None otherwise (the +/// panel opens as before). +fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option> { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize)?; + let group = FLOATING_GROUPS.iter().find(|g| g.contains(&panel))?; + // Panels in the workspace's icon groups open from there. + if app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| group.contains(&p.as_str()))) { + return None; + } + // A panel closed from a place in the dock comes back there. + if group.iter().any(|p| app.ui.docking_hidden.get(*p).is_some_and(|l| l.floating.is_none())) { + return None; + } + let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + if group.iter().any(|p| layout.contains(&p.to_string())) { + return None; + } + let (w, h) = (290.0, 340.0); + let (x, y) = app.canvas_rect.map_or((400.0, 140.0), |r| ((r.right() - w - 24.0).max(r.left()), r.top() + 96.0)); + let before = (app.ui.docking.clone(), app.ui.docking_hidden.clone(), app.ui.floating.clone(), app.ui.open_panel.clone()); + // The dock's tab in front stays in front (opening a panel passes through the dock). + let front = front_tabs(&layout); + let open = |app: &mut DesignApp| -> Result { + let first = group.first().ok_or("empty panel group")?; + change(app, "float", &json!({"panel": first, "x": x, "y": y, "width": w, "height": h}))?; + for p in group.get(1..).unwrap_or_default() { + change(app, "move", &json!({"panel": p, "anchor": first}))?; + } + change(app, "activate", &json!({"panel": panel})) + }; + let result = open(app); + if result.is_ok() + && let Some(layout) = app.ui.docking.as_mut() + { + restore_front_tabs(layout, &front); + } + if result.is_err() { + // All or nothing. + (app.ui.docking, app.ui.docking_hidden, app.ui.floating, app.ui.open_panel) = before; + } + Some(result) +} + fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { @@ -451,6 +601,8 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re /// The docking commands of a panel: its tab's context menu and the end of its panel menu. pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { let Some(panel) = normalize(panel) else { return }; + tab_commands(app, ui, panel, true); + ui.separator(); let language = app.ui.language.clone(); 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})) { @@ -486,6 +638,13 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); }); } + fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) { + // The docking surface adds Float, Group with and Close after these. + if let Some(panel) = normalize(id) { + tab_commands(self.0, ui, panel, false); + ui.separator(); + } + } fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) } @@ -494,6 +653,47 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +/// Close Tab Group: `panel` and the panels tabbed with it, each restorable from Window as one +/// closed with Close. +fn close_group(app: &mut DesignApp, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let group = tab_group(&layout, panel); + for p in &group { + change(app, "close", &json!({"panel": p}))?; + } + Ok(json!({"closed": group})) +} + +/// InDesign's commands on a panel tab (and at the end of its panel menu): Close, Close Tab Group, +/// Minimize (an open accordion entry) and Collapse to Icons. `close` is false where the docking +/// surface adds its own Close. +fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str, close: bool) { + let language = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&language, s).to_owned(); + let run = |app: &mut DesignApp, ui: &mut egui::Ui, id: &str, p: Value| { + if let Err(error) = app.run(id, p) { + app.status(error); + } + ui.close(); + }; + if close && ui.button(crate::rtl::widget(ui, tr("Close"))).clicked() { + run(app, ui, "window.panel.close", json!({"panel": panel})); + } + if ui.button(crate::rtl::widget(ui, tr("Close Tab Group"))).clicked() { + run(app, ui, "window.panel.closeGroup", json!({"panel": panel})); + } + let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let minimizable = open_in_stack(&layout, panel); + if ui.add_enabled(minimizable, egui::Button::new(crate::rtl::widget(ui, tr("Minimize")))).clicked() { + run(app, ui, "window.panel.layout", json!({"action": {"SetStackOpen": {"panel": panel, "open": false}}})); + } + ui.separator(); + if ui.add_enabled(app.ui.dock_expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { + run(app, ui, "window.collapseToIcons", json!({})); + } +} + /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { if app.ui.docking.is_some() { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index d1dec839..1ffb17eb 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -651,3 +651,98 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); } + +/// InDesign's panel tab menu (Learn Skills Daily's InDesign course, 6:20): Close, Close Tab Group, +/// Minimize and Collapse to Icons. Close Tab Group hides the panels tabbed together. +#[test] +fn close_tab_group_hides_the_panels_tabbed_together() { + let mut app = app(); + // The default dock: Properties, Pages and Layers are one tab group. + let r = app.run("window.panel.closeGroup", json!({"panel":"pages"})).unwrap(); + assert_eq!(r["closed"], json!(["properties", "pages", "layers"])); + let layout = app.ui.docking.clone().unwrap(); + assert!(["properties", "pages", "layers"].iter().all(|p| !layout.contains(&p.to_string()))); + // Window brings one back. + app.run("window.panel.activate", json!({"panel":"pages"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"pages".to_string())); + // A floating group goes as a whole; the dock keeps its own. + app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60})).unwrap(); + app.run("window.panel.activate", json!({"panel":"color"})).unwrap(); + app.run("window.panel.move", json!({"panel":"color", "anchor":"swatches"})).unwrap(); + app.run("window.panel.closeGroup", json!({"panel":"color"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(!layout.contains(&"color".to_string()) && !layout.contains(&"swatches".to_string())); + assert!(layout.contains(&"pages".to_string())); + assert!(app.run("window.panel.closeGroup", json!({"panel":"nope"})).is_err()); +} + +#[test] +fn collapse_to_icons_shrinks_the_dock_to_its_icons() { + let mut app = app(); + assert!(app.ui.dock_expanded); + app.run("window.collapseToIcons", json!({})).unwrap(); + assert!(!app.ui.dock_expanded); + // A panel chosen from Window opens the dock again. + app.run("window.panel", json!({"panel":"pages"})).unwrap(); + assert!(app.ui.dock_expanded); +} + +#[test] +fn a_tabs_context_menu_lists_indesigns_tab_commands_and_close_works() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(tab)); + h.run_steps(1); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel"] { + assert!(h.query_by_label(item).is_some(), "{item}"); + } + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(!h.state().ui.docking.as_ref().unwrap().contains(&"layers".to_string())); +} + +/// Window › Styles › Paragraph Styles opens InDesign's floating Character Styles | Paragraph Styles +/// group, which stays open while other panels open (Learn Skills Daily's InDesign course, 7:55). +#[test] +fn paragraph_styles_open_floating_with_character_styles() { + let mut app = app(); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); + assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); + assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); + // The dock still shows Properties in front. + assert!(matches!(&layout.root, Some(craft_ui::docking::Node::Tabs { panels, active }) if panels[*active] == "properties"), "{:?}", layout.root); + // Other panels from Window leave it open. + app.run("window.panel", json!({"panel":"color"})).unwrap(); + app.run("window.panel", json!({"panel":"links"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"])); + // Once showing, Window › Character Styles brings that tab forward. + app.run("window.panel", json!({"panel":"characterStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); + assert_eq!(group.panels[group.active], "characterStyles"); + assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "characterStyles").count(), 1); +} + +/// Style panels the user docked and then closed come back in the dock, not floating. +#[test] +fn paragraph_styles_closed_from_the_dock_reopen_there() { + let mut app = app(); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + for panel in ["characterStyles", "paragraphStyles"] { + app.run("window.panel.move", json!({"panel": panel, "anchor": "properties"})).unwrap(); + } + for panel in ["characterStyles", "paragraphStyles"] { + app.run("window.panel.close", json!({"panel": panel})).unwrap(); + } + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.contains(&"paragraphStyles".to_string())); + assert!(layout.floating.iter().all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0ce122a0..657e9b61 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -276,6 +276,7 @@ fn object_rows( tri_clicked = ui.interact(tri, ui.id().with(("gtri", it.id.0)), Sense::click()).clicked(); } let name = item_label(ls.doc, it); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_sel, &name)); let field = Rect::from_min_max(row.min + vec2(2.0 * COL_W + 28.0 + indent, 3.0), pos2(row.max.x - 28.0, row.max.y - 3.0)); let renaming = match rename_field(ui, field, it.id.0) { // An empty name goes back to the automatic label. @@ -317,13 +318,22 @@ fn object_rows( cmds.push(("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked}))); } else if tri_clicked { ui.data_mut(|d| d.insert_temp(open_id, !open)); - } else if resp.double_clicked() { - // Its own name, or the label it shows without the brackets. - let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() }; - start_rename(ui, it.id.0, ¤t); } else if sq || resp.clicked() { - let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); - cmds.push(("selection.set", json!({"ids": [it.id.0], "add": add}))); + // As InDesign's: a slow double-click (a second click on the selected object's row, + // after the double-click delay) renames it; a quick double-click only selects. + let (now, add) = ui.input(|i| (i.time, i.modifiers.shift || i.modifiers.command)); + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + let last_id = egui::Id::new(("layers_last_click", it.id.0)); + let last: Option = ui.data(|d| d.get_temp(last_id)); + ui.data_mut(|d| d.insert_temp(last_id, now)); + let slow = !sq && !add && !resp.double_clicked() && last.is_some_and(|t| now - t > delay && now - t < SLOW_RENAME_WINDOW); + if slow && (ls.selected == [it.id] || ls.shown == [it.id]) { + // Its own name, or the label it shows without the brackets. + let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() }; + start_rename(ui, it.id.0, ¤t); + } else { + cmds.push(("selection.set", json!({"ids": [it.id.0], "add": add}))); + } } let at = rows.len(); rows.push(Row { @@ -547,13 +557,16 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { }); } +/// Seconds within which a second click on a selected row renames it (after the double-click delay). +const SLOW_RENAME_WINDOW: f64 = 1.5; + /// The object whose row is being renamed. type RenameKey = u64; /// The row, the field's text, and the text it started with. type RenameState = (RenameKey, String, String); const RENAME: &str = "layers_rename"; -/// The name field of the row being renamed (double-click), in `r`: `Some(Some(name))` when Enter +/// The name field of the row being renamed (a layer's double-click, an object's slow one), in `r`: `Some(Some(name))` when Enter /// or a click elsewhere confirms it with a changed text, `Some(None)` when Esc cancels or the text /// is the one it started with, `None` while typing or when this row isn't being renamed. fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> { @@ -737,6 +750,45 @@ mod tests { assert!(!open(&h)); } + /// As InDesign's: a slow double-click on an object's row renames it; a quick one doesn't. + #[test] + fn a_slow_double_click_renames_an_object_and_a_quick_one_does_not() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = + Harness::builder().with_size(egui::vec2(260.0, 400.0)).with_step_dt(0.05).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("layer_open", h.state().session.active().unwrap().active_layer.0)), true)); + h.run_steps(2); + let row = h.get_by_label("").rect().center(); + let click = |h: &mut Harness<'_, DesignApp>| { + h.input_mut().events.push(egui::Event::PointerMoved(row)); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { + pos: row, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + } + }; + let renaming = |h: &Harness<'_, DesignApp>| h.query_by_role(egui::accesskit::Role::TextInput).is_some(); + // A quick double-click selects it and doesn't rename. + click(&mut h); + click(&mut h); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.items, vec![ItemId(id)]); + assert!(!renaming(&h), "a double-click doesn't rename"); + // A second click a moment later renames it. + h.run_steps(12); + click(&mut h); + h.run_steps(2); + assert!(renaming(&h), "a slow double-click renames"); + } + #[test] fn rows_rename_in_place() { use egui_kittest::{Harness, kittest::Queryable}; @@ -746,7 +798,7 @@ mod tests { let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); h.run_steps(2); let rename = |h: &mut Harness<'_, DesignApp>, key: RenameKey, typed: &str| { - // As a double-click starts it (with an emptied field here, so the typing is the name). + // As a slow double-click starts it (with an emptied field here, so the typing is the name). h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new(), String::new()))); h.run_steps(2); h.get_by_role(egui::accesskit::Role::TextInput).type_text(typed); @@ -774,7 +826,7 @@ mod tests { let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); h.run_steps(2); let name = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name.clone(); - // As a double-click on the unnamed row starts it: the field shows the automatic label. + // As a slow double-click on the unnamed row starts it: the field shows the automatic label. let start = |h: &mut Harness<'_, DesignApp>| { h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, "Rectangle".to_string(), "Rectangle".to_string()))); h.run_steps(2); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6e24ed1d..6c3e0e10 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -268,7 +268,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); + let _ = crate::dialogs::delete_pages(app, vec![cur]); } // Create new page; a page dropped on it is duplicated at the end of the document. let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page")); @@ -358,12 +358,12 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu ui.close(); } if item(ui, "Delete Page") { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + let _ = crate::dialogs::delete_pages(app, vec![abs]); ui.close(); } if pages_in_spread > 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); + let _ = crate::dialogs::delete_pages(app, (first..first.saturating_add(pages_in_spread)).collect()); ui.close(); } ui.separator(); @@ -544,6 +544,62 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); } + /// As in InDesign: deleting a page with objects asks first; an empty page goes at once, and + /// "Don't show again" stops the question. + #[test] + fn deleting_a_page_with_objects_asks_first() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(2).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + let count = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.page_count(); + let mut h = menu_harness(app, 2, si); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(count(&h), 5, "nothing deleted yet"); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.fields["pages"].clone()), ("deletePages", json!([2]))); + // Cancel keeps the page. + crate::dialogs::cancel(h.state_mut()); + assert_eq!(count(&h), 5); + // The whole spread: one question for both pages. + h.get_by_label("Delete Spread").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["pages"], json!([1, 2])); + crate::dialogs::cancel(h.state_mut()); + // OK deletes it. + h.get_by_label("Delete Page").click(); + h.run_steps(3); + crate::dialogs::confirm(h.state_mut()).unwrap(); + assert_eq!(count(&h), 4); + assert!(!h.state().ui.skip_delete_pages_warning); + // An empty page goes without a question. + let app = h.state_mut(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 3); + // "Don't show again". + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(1).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 300.0, x + 200.0, 400.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![1]).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dontShowAgain".into(), json!(true)); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.skip_delete_pages_warning); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 2); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(0).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none(), "no more questions"); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 1); + } + #[test] fn the_panel_menu_follows_indesigns_order() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6c33cbe7..7ca1b787 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -84,7 +84,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing = vec2(6.0, 5.0); let info = sel_info(app); let has_text = app.session.active().is_some_and(|s| s.selection.text.is_some()); - selection_band(ui, &selection_title(app, &info)); + if !(info.is_none() && !has_text && app.session.tool_id() == "page") { + selection_band(ui, &selection_title(app, &info)); + } if has_text { text_style_section(app, ui); divider(ui); @@ -104,6 +106,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { quick_actions_text(app, ui); return; } + // The Page tool with no objects selected: the page's own settings, as InDesign's. + if info.is_none() && app.session.tool_id() == "page" { + let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app)); + if let Some(page) = page { + page_sections(app, ui, page); + return; + } + } let Some(i) = info else { document_sections(app, ui); return; @@ -190,6 +200,104 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } +/// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, +/// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and +/// Quick Actions. Back returns to the Selection tool and the document's properties. +fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { + let u = units(app); + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let Some((si, pi)) = d.page_loc(page) else { return }; + let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return }; + let n = page + 1; + let t = Tokens::get(ui.ctx()); + // The band: "Page", and Back at its end. + let band = block(ui, 33.0); + crate::rtl::paint( + ui.painter(), + pos2(band.min.x, band.center().y + 1.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&lang, "Page"), + crate::theme::semibold(11.5), + t.text_strong, + ); + let back = place(ui, Rect::from_min_size(pos2(band.max.x - 58.0, band.center().y - 10.0), vec2(54.0, 20.0)), |ui| { + outline_button(ui, crate::i18n::tr(&lang, "Back"), 54.0).clicked() + }); + rule(ui); + if back { + let _ = app.run("layout.selectPage", json!({"page": null})); + app.select_tool("selection"); + return; + } + section(ui, crate::i18n::tr(&lang, "Page Size"), true); + // Which page. + let b = block(ui, FIELD_H); + let names: Vec = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { + widgets::dropdown_list(ui, names.get(page).map_or("", String::as_str), 93.0, &names, Some(page)) + }); + if let Some(p) = picked { + let _ = app.run("layout.selectPage", json!({"page": p + 1})); + crate::canvas::go_to_page(app, p); + return; + } + ui.add_space(3.0); + // Preset and orientation: this page alone. + let (w, h) = (pg.width, pg.height); + let landscape = w > h; + let b = block(ui, FIELD_H); + let mut size = None; + place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { + let resp = dropdown(ui, crate::i18n::tr(&lang, preset_name(w, h)), 93.0); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(140.0); + for p in PAGE_PRESETS { + if ui.selectable_label(preset_name(w, h) == p.0, crate::i18n::tr(&lang, p.0)).clicked() { + size = Some(if landscape { (p.2, p.1) } else { (p.1, p.2) }); + ui.close(); + } + } + }); + }); + place(ui, sub(b, 126.0, 0.0, 52.0, FIELD_H), |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if icon_toggle(ui, "orient-portrait", !landscape, crate::i18n::tr(&lang, "Portrait")).clicked() && landscape { + size = Some((h, w)); + } + if icon_toggle(ui, "orient-landscape", landscape, crate::i18n::tr(&lang, "Landscape")).clicked() && !landscape { + size = Some((h, w)); + } + }); + ui.add_space(3.0); + let b = block(ui, 2.0 * FIELD_H + 5.0); + for (row, (label, v, key)) in [("W:", w, "width"), ("H:", h, "height")].into_iter().enumerate() { + let y = row as f32 * 26.0; + place(ui, sub(b, 0.0, y, 20.0, FIELD_H), |ui| widgets::caption_w(ui, label, 20.0)); + let nv = place(ui, sub(b, 21.0, y, 72.0, FIELD_H), |ui| { + NumField::measure(&format!("page_{key}"), Some(v), u).width(72.0).spinner().range(1.0, 15552.0).show(ui) + }); + if let Some(nv) = nv { + size = Some(if key == "width" { (nv, h) } else { (w, nv) }); + } + } + if let Some((nw, nh)) = size { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})); + } + ui.add_space(6.0); + let fw = full_width(ui); + if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { + crate::canvas::go_to_page(app, page); + crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null); + } + divider(ui); + section(ui, crate::i18n::tr(&lang, "Quick Actions"), true); + if outline_button(ui, crate::i18n::tr(&lang, "Import File"), fw).clicked() { + let _ = app.run("app.placeDialog", json!({})); + } +} + fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -350,11 +458,13 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } if del { - let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); + let _ = crate::dialogs::delete_pages(app, vec![cur]); } ui.add_space(3.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Edit Page"), fw).clicked() { - crate::dialogs::open_document_setup(app); + // As in InDesign: the Page tool on this page; Properties shows the page's own settings. + app.select_tool("page"); + let _ = app.run("layout.selectPage", json!({"page": cur + 1})); } divider(ui); // Rulers & Grids / Guides: icon toggle rows. @@ -2704,6 +2814,36 @@ mod tests { h } + /// As InDesign's: Edit Page takes the Page tool to the page in view, and Properties sets that + /// page alone; Back returns to the document. + #[test] + fn edit_page_shows_the_page_and_sets_its_size_alone() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 3, "facingPages": false})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + crate::canvas::go_to_page(&mut h.state_mut().app, 1); + h.run_steps(3); + h.get_by_label("Edit Page").click(); + h.run_steps(3); + let st = h.state().app.session.active().unwrap(); + assert_eq!((h.state().app.session.tool_id(), st.selection.page), ("page", Some(1))); + assert!(h.query_by_label("Document Setup").is_none(), "no dialog"); + let _ = h.get_by_label("Page Size"); + let _ = h.get_by_label("Margins and Columns"); + h.get_by_label("Landscape").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + let size = |abs: usize| doc.page(abs).map(|p| (p.width, p.height)).unwrap(); + assert_eq!(size(1), (792.0, 612.0), "the page turns"); + assert_eq!((size(0), size(2)), ((612.0, 792.0), (612.0, 792.0)), "the others don't"); + h.get_by_label("Back").click(); + h.run_steps(3); + let st = h.state().app.session.active().unwrap(); + assert_eq!((h.state().app.session.tool_id(), st.selection.page), ("selection", None)); + let _ = h.get_by_label("Edit Page"); + } + #[test] fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() { let first = "/project/assets/studio-a/photo.svg"; diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 8dd61a4a..f7e6fa80 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -16,7 +16,16 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { }; let attrs = super::text_attrs(app); let current = attrs.as_ref().and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string)); - let overrides = attrs.as_ref().map(|a| a[if para { "paraOverrides" } else { "charOverrides" }].as_u64().unwrap_or(0)).unwrap_or(0); + let overrides = attrs + .as_ref() + .map(|a| { + if para { + a["charOverrides"].as_u64().unwrap_or(0) + a["paraOverrides"].as_u64().unwrap_or(0) + } else { + a["characterStyleOverrides"].as_u64().unwrap_or(0) + } + }) + .unwrap_or(0); crate::rtl::label( ui, egui::RichText::new(match ¤t { @@ -37,12 +46,12 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { if para && n == designcraft_doc::NO_PARA_STYLE { continue; } - style_row(app, ui, para, n, current.as_deref(), &groups, &t); + style_row(app, ui, para, n, current.as_deref(), overrides > 0, &groups, &t); } for g in &groups { egui::CollapsingHeader::new(g.as_str()).id_salt(("style_group", para, g)).default_open(true).show(ui, |ui| { for n in names.iter().filter(|n| n.rsplit_once('/').is_some_and(|(x, _)| x == g)) { - style_row(app, ui, para, n, current.as_deref(), &groups, &t); + style_row(app, ui, para, n, current.as_deref(), overrides > 0, &groups, &t); } }); } @@ -50,7 +59,12 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { ui.horizontal(|ui| { ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "⌥-click clears overrides")).size(10.5).color(t.text_disabled), + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + if cfg!(target_os = "macos") { "⌥-click clears overrides" } else { "Alt-click clears overrides" }, + )) + .size(10.5) + .color(t.text_disabled), )); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button( @@ -93,7 +107,7 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { /// One style in the list: click applies (⌥ clears overrides), double-click edits, /// right-click: edit, apply, break link, move to group. -fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) { +fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, overridden: bool, groups: &[String], t: &Tokens) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if current == Some(n) { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -101,14 +115,10 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.painter().rect_filled(row, 0.0, t.hover); } let shown = n.rsplit('/').next().unwrap_or(n); - crate::rtl::paint( - ui.painter(), - row.min + vec2(8.0, 11.0), - egui::Align2::LEFT_CENTER, - if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }, - egui::FontId::proportional(12.5), - t.text, - ); + let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }; + let row_label = format!("{label}{}", if current == Some(n) && overridden { "+" } else { "" }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, current == Some(n), &row_label)); + crate::rtl::paint(ui.painter(), row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, &row_label, egui::FontId::proportional(12.5), t.text); let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE }; if resp.double_clicked() && editable { crate::dialogs::open_style_options(app, para, n); @@ -129,6 +139,19 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren let _ = app.run(cmd, json!({"name": n})); ui.close(); } + if editable && current == Some(n) && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Redefine Style"))).clicked() { + let command = if para { "style.paragraph.redefine" } else { "style.character.redefine" }; + let _ = app.run(command, json!({"name":n})); + ui.close(); + } + if para + && ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply \"{name}\", Clear Overrides").replace("{name}", label))) + .clicked() + { + let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": true})); + ui.close(); + } if current == Some(n) && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Break Link to Style"))).clicked() { let _ = app.run("style.breakLink", json!({"kind": kind})); ui.close(); @@ -184,6 +207,86 @@ mod tests { use super::*; + #[test] + fn selected_style_context_menu_redefines_its_format() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"Headline"})).unwrap(); + app.run("text.select", json!({"story":frame["story"],"anchor":0,"focus":8})).unwrap(); + app.run("style.paragraph.create", json!({"name":"Header","chars":{"size":18}})).unwrap(); + app.run("style.paragraph.apply", json!({"name":"Header"})).unwrap(); + app.run("type.char", json!({"size":24})).unwrap(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let tokens = Tokens::get(ui.ctx()); + style_row(app, ui, true, "Header", Some("Header"), true, &[], &tokens); + }, + app, + ); + h.run_steps(3); + let at = egui::pos2(30.0, 15.0); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.run_steps(2); + } + h.get_by_label("Redefine Style").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.styles.para("Header").unwrap().chars.size, Some(24.0)); + } + + #[test] + fn paragraph_color_override_is_marked_and_cleared_from_the_row_menu() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36,36,300,200], "content": "text", "text": "Header"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Header", "chars": {"fill": "[Black]"}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Header"})).unwrap(); + app.run("style.character.create", json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); + app.run("style.character.apply", json!({"name": "Emphasis"})).unwrap(); + app.run("type.char", json!({"fill": "[Paper]"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(460.0, 300.0)).build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app); + h.run_steps(3); + let at = h.get_all_by_label("Header+").next_back().unwrap().rect().center(); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.run_steps(2); + } + h.get_by_label("Apply \"Header\", Clear Overrides").click(); + h.run_steps(3); + let app = h.state_mut(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["chars"]["fill"], "[Black]"); + assert_eq!(a["chars"]["underline"], true); + assert_eq!(a["characterStyle"], "Emphasis"); + assert_eq!(a["charOverrides"], 0); + assert_eq!(a["paraOverrides"], 0); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fill"], "[Paper]"); + } + + #[test] + fn named_character_styles_and_redundant_local_values_are_not_paragraph_overrides() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36,36,300,200], "content": "text", "text": "Body text"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 4})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Body", "para": {"spaceAfter": 6}, "chars": {"size": 10}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Body"})).unwrap(); + app.run("style.character.create", json!({"name": "Italic", "chars": {"fontStyle": "Italic"}})).unwrap(); + app.run("style.character.apply", json!({"name": "Italic"})).unwrap(); + app.run("type.char", json!({"size": 10, "fontStyle": "Italic"})).unwrap(); + app.run("type.para", json!({"spaceAfter": 6})).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["paraOverrides"].as_u64(), a["charOverrides"].as_u64()), (Some(0), Some(0))); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state(|ui, app: &mut DesignApp| paragraph(app, ui), app); + h.run_steps(3); + assert!(h.query_by_label("Body+").is_none()); + assert!(h.query_all_by_label("Body").next().is_some()); + } + #[test] fn character_style_plus_captures_selection_for_reuse() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2f941fa1..a04c45a4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -45,6 +45,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(2.0); let current = app.session.tool_id(); let mut chosen: Option<&'static str> = None; + let mut opened_now = false; // A slot is a group (its current tool shown, the others in its flyout) or, in two // columns, one tool of a group InDesign lays out tool by tool. let mut row_items: Vec)>> = Vec::new(); @@ -98,9 +99,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { Stroke::NONE, )); } - if resp.clicked() { + // The release that ends a hold which opened the flyout isn't a click + // on the tool (egui reports releases within 0.8 s as clicks). + let hold_id = egui::Id::new(("flyout_hold", gi)); + let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::(hold_id)).unwrap_or(false); + if resp.clicked() && !ended_hold { chosen = Some(shown.id); } + if !resp.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(hold_id)); + } // Double-click a polygon tool: Polygon Settings. if resp.double_clicked() && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); @@ -112,12 +120,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { { crate::menus::activate(app, cmd, &Value::Null); } - if g.len() > 1 - && (resp.secondary_clicked() - || resp.long_touched() - || (resp.is_pointer_button_down_on() - && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)))) - { + let held = + resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); + if g.len() > 1 && held { + ui.data_mut(|d| d.insert_temp(hold_id, true)); + } + if g.len() > 1 && (resp.secondary_clicked() || resp.long_touched() || held) { + opened_now |= app.ui.flyout != Some(gi); app.ui.flyout = Some(gi); } if app.ui.flyout == Some(gi) { @@ -157,6 +166,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { t.text_dim, ); } + row.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl.label)); if row.clicked() { chosen = Some(tl.id); } @@ -178,8 +188,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(id) = chosen { app.select_tool(id); } - if app.ui.flyout.is_some() && ui.input(|i| i.pointer.any_click()) && chosen.is_none() && !ui.ctx().is_pointer_over_egui() { - app.ui.flyout = None; + // As InDesign's, an open flyout closes on Esc and on a click anywhere outside it (a + // panel, a swatch, the canvas), not only on the canvas. A new press counts, not a + // click: the release that ends the hold which opened it is one. + if let Some(gi) = app.ui.flyout + && chosen.is_none() + && !opened_now + { + let area = ui.ctx().memory(|m| m.area_rect(egui::Id::new(("flyout", gi)))); + let outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| area.is_none_or(|a| !a.contains(p)))); + let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if outside || esc { + app.ui.flyout = None; + } } ui.add_space(8.0); fill_stroke_proxy(app, ui, width, cols); @@ -618,6 +639,63 @@ mod tests { h.get_by_label(label).rect() } + /// As InDesign's: a tool group's flyout closes on Esc and on a click outside it (the fill + /// swatch, a panel), not only on the canvas. + #[test] + fn a_tool_flyout_closes_on_escape_and_on_a_click_elsewhere() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + let open = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>| { + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.state().app.ui.flyout.is_some(), "a right-click opens the flyout"); + let _ = h.get_by_label("Ellipse Frame Tool"); + }; + open(&mut h); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().app.ui.flyout.is_none(), "Esc closes it"); + // A click on a panel closes it. + open(&mut h); + crate::test_window::click_at(&mut h, egui::pos2(1300.0, 700.0)); + assert!(h.state().app.ui.flyout.is_none(), "a click elsewhere closes it"); + // Choosing a tool in it closes it and selects the tool. + open(&mut h); + h.get_by_label("Ellipse Frame Tool").click(); + h.run_steps(3); + assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("ellipseFrame", None)); + } + + /// A flyout opened by holding the group button stays open after the release, and Esc closes + /// it without switching the active tool. + #[test] + fn a_tool_flyout_opened_by_holding_stays_open_and_esc_keeps_the_tool() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = crate::test_window::open_stepped(app, vec2(1400.0, 900.0), 1.0, 1.0 / 60.0); + h.state_mut().app.select_tool("rectangleFrame"); + h.run_steps(2); + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + // Held for half a second: shorter than egui's longest click. + h.run_steps(30); + assert!(h.state().app.ui.flyout.is_some(), "holding opens the flyout"); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().app.ui.flyout.is_some(), "the release keeps it open"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("rectangleFrame", None)); + } + #[test] fn a_short_window_reaches_every_tool() { for double in [false, true] { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8da9779d..fb9876aa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -5,7 +5,7 @@ //! section headers and 1.5 pt section rules. use designcraft_geom::Unit; -use designcraft_geom::units::{format_measure_prec, parse_measure}; +use designcraft_geom::units::{format_measure, parse_measure}; use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::theme::Tokens; @@ -35,7 +35,7 @@ pub enum NumKind<'a> { impl NumKind<'_> { pub fn format(&self, v: f64) -> String { match *self { - NumKind::Measure(u) => format_measure_prec(v, u, 3), + NumKind::Measure(u) => format_measure(v, u), NumKind::Number { suffix, decimals } => { let s = format!("{v:.decimals$}"); let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; @@ -340,6 +340,8 @@ pub fn outline_button(ui: &mut Ui, label: &str, width: f32) -> Response { } p.rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); crate::rtl::paint(p, r.center(), egui::Align2::CENTER_CENTER, label, label_font(), t.text); + // Screen readers (and tests) find it by its label. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); resp } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7454e136..78c94868 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (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 @@ -69,6 +69,45 @@ history (DC-1). ## Remaining effort +Applying a paragraph or character style now removes local values equal to the applied style, +so text used to capture a style follows later edits to that style. Different local formatting +and named character styles stay intact. Paragraph-style application treats selection endpoints +as exclusive; Clear Overrides removes character formatting only inside a nonempty selection. +Regressions cover the capture/edit workflow at 2:16 and 6:02 in +[Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). + +Redefining a character style at a caret also normalizes the sampled source run, so subsequent +style edits reach that text while different local overrides in adjacent runs remain intact. + +The selected style's context menu now offers Redefine Style for paragraph and character styles. +It incorporates the source text's local changes, updates every use, and removes matching source +overrides in one undo step. Named character styles remain separate when redefining a paragraph +style. Commands: `style.paragraph.redefine` and `style.character.redefine` (optional `name`, +which must match the source). This implements the global color, size and spacing updates at +about 5:10–6:00 in [Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). + +The Paragraph Styles panel now includes character formatting in its `+` indicator, on both +the current-style summary and the selected row. The row menu offers Apply "name", Clear +Overrides through the existing command, retaining named character styles. Selection attribute +counts compare effective values with their style definitions, so equal local values and named +character styles alone do not report overrides. This covers the color-override/clear workflow +at 4:25–5:20 in [Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8). + +Style Preview rejects conflicting scalar/nested automation fields without a panic. Replacing a +previewing dialog through the control channel cancels its transaction, restoring the original +document and normal undo recording before the replacement opens. + +Paragraph and Character Style Options now preview edits to existing styles. Preview updates +every use, remains outside undo history, and restores the starting document when disabled or +cancelled. OK applies the final edit once. The color-preview step at about 4:00 in +[Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8) is reproducible +offscreen. New-style creation, renaming and export tagging are committed on OK; they are not +included in the visual preview. + +Paragraph Style Options' General pane shows a Style Settings summary of differences from its +parent, including captured color, size and spacing. Pending edits appear before OK without +changing the document. Fully captured default attributes do not overwhelm the summary. This +covers checking the sampled and redefined settings in the styles tutorial (2:45 and 5:15). | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 |