From fa8d3b8de4f5800b4b9864374dc4f64b1c0a6ca9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:05:22 -0800 Subject: [PATCH] Fix #3797: documents open in Preview, and Outline is a setting of each document View > Outline was one app-wide flag saved in the interface preferences, so after Cmd+Y every later launch drew every opened or new document as outlines, with no fills. The flag is now part of each document's view: a document opens in Preview, each tab shows its own Outline or Preview, and the setting is not saved at launch. An older preferences file that holds the flag still loads, and its value is ignored. The Outline command and ui.set's outline act on the active document and are an error without one. --- crates/ui-egui/src/app/view.rs | 7 +++- crates/ui-egui/src/canvas.rs | 8 ++-- crates/ui-egui/src/canvas/outline.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 62 +++++++++++++++++++++++++++- crates/ui-egui/src/chrome/tabs.rs | 10 ++++- crates/ui-egui/src/chrome/tests.rs | 12 ++++++ crates/ui-egui/src/control.rs | 6 ++- crates/ui-egui/src/control/tests.rs | 18 ++++++++ crates/ui-egui/src/menus/labels.rs | 2 +- crates/ui-egui/src/menus/run.rs | 6 ++- crates/ui-egui/src/menus/state.rs | 3 +- crates/ui-egui/src/state.rs | 9 ++-- docs/control-protocol.md | 2 +- 13 files changed, 129 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 6aac1150e..fd38968d2 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -72,6 +72,11 @@ impl VectorcraftApp { self.session.active_index().and_then(|i| self.views.get(i)) } + /// Whether the active document is in Outline view (View › Outline); false without one. + pub fn outline_view(&self) -> bool { + self.view().is_some_and(|v| v.outline) + } + /// The zero point shown by rulers and position fields: the document's with global rulers, the /// active artboard's with artboard rulers. Command coordinates stay absolute. pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point { @@ -88,7 +93,7 @@ impl VectorcraftApp { pub fn view_info(&self) -> ViewInfo { ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), - outline: self.ui.view.outline, + outline: self.outline_view(), smart_guides: self.ui.view.smart_guides, guides: self.ui.view.guides, snap_to_grid: self.ui.view.snap_to_grid, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4850b9e25..c867918bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -492,7 +492,7 @@ fn cache_key( cy: v.center.y, w, h, - outline: app.ui.view.outline, + outline: app.outline_view(), trim: app.ui.view.trim_view, templates: app.ui.view.templates, guides: app.ui.view.guides, @@ -560,7 +560,7 @@ fn frame_options( mask_view: Option, smooth_images: bool, ) -> vectorcraft_render::RenderOptions { - let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; + let opts = vectorcraft_render::RenderOptions { outline: app.outline_view(), background: None, artboards: false, ..Default::default() }; vectorcraft_render::RenderOptions { proof: vectorcraft_render::proof::active_proof(), overprint_preview: vectorcraft_render::proof::overprint_preview_on(), @@ -669,7 +669,7 @@ fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). let setup = &doc.setup; painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }); - } else if !app.ui.view.outline { + } else if !app.outline_view() { let setup = &doc.setup; let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; @@ -687,7 +687,7 @@ fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, } } } - } else if app.ui.view.outline { + } else if app.outline_view() { for ab in &doc.artboards { painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); } diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index b5d14e151..9d1794ab7 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -184,7 +184,7 @@ pub(super) fn hit_with( let prefs = &app.session.prefs; let opt = vectorcraft_doc::hit::HitOptions { tol: prefs.selection_tolerance / zoom, - outline: app.ui.view.outline, + outline: app.outline_view(), path_only: prefs.object_selection_by_path_only, type_path_only: prefs.type_selection_by_path_only, scope: app.session.active().and_then(|st| st.isolation), diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4b59bdb8f..1d1eb4314 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2588,7 +2588,7 @@ fn pan_fills_the_area_the_last_frame_leaves() { assert!(app.canvas.fill_worker.as_ref().unwrap().pending.is_none(), "the late fill was taken"); // A pan with Outline turned on: the frame shown's options are not the ones asked for, and // no fill is asked for. - app.ui.view.outline = true; + app.view_mut().unwrap().outline = true; app.view_mut().unwrap().center = Point::new(400.0, 150.0); frame(&mut app); assert!(jobs.try_iter().last().is_some_and(|j| j.key.outline), "the outline frame is on the worker"); @@ -3521,7 +3521,7 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { /// another document, or when the device pixels a point changed. #[test] fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { - let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0 }; + let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0, outline: false }; let mut v = view; let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); @@ -3765,6 +3765,64 @@ fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color() assert!(!serde_json::from_value::(saved).unwrap().view.live_paint_gaps); } +/// #3797: View › Outline is a setting of each document's view. A document opened or made after +/// another was put in Outline opens in Preview, where a click inside its fill picks it, and each +/// document keeps its own setting. +#[test] +fn outline_is_per_document_and_new_documents_start_in_preview() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 100})).unwrap(); + app.sync_views(); + let inside = Point::new(70.0, 70.0); + let picks_fill = |app: &VectorcraftApp| super::outline::hit_at(app, inside, 1.0).is_some(); + assert!(picks_fill(&app), "Preview picks the fill"); + app.run("view.outline", json!({})).unwrap(); + assert!(app.outline_view() && app.view_info().outline); + assert!(!picks_fill(&app), "Outline picks only the path"); + // An SVG opened now opens in Preview, its fill drawn and picked. + let svg = + br##""##; + crate::io::open_bytes(&mut app, "b.svg", svg, None).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + assert!(!app.outline_view() && !app.view_info().outline, "the opened document is in Preview"); + assert!(picks_fill(&app)); + assert_eq!(crate::menus::dynamic_label(&app, "view.outline", "Outline"), "Outline"); + assert_eq!(crate::menus::checked(&app, "view.outline", &json!({})), Some(false)); + // A new document opens in Preview too. + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + assert!(!app.outline_view()); + // The first document is still in Outline. + app.session.execute("document.activate", &json!({"index": 0})).unwrap(); + assert!(app.outline_view() && !picks_fill(&app)); + assert_eq!(crate::menus::dynamic_label(&app, "view.outline", "Outline"), "Preview"); + assert_eq!(crate::menus::checked(&app, "view.outline", &json!({})), Some(true)); + // Without a document there is nothing to put in Outline. + while !app.session.documents().is_empty() { + app.session.execute("file.close", &json!({})).unwrap(); + } + app.sync_views(); + assert!(!app.outline_view() && !crate::menus::enabled(&app, "view.outline")); + assert!(app.run("view.outline", json!({})).is_err()); +} + +/// #3797: a preferences file saved while View › Outline was an interface setting still loads, with +/// its other view settings, and its Outline value does not put a document in Outline. +#[test] +fn old_outline_preference_loads_but_does_not_restore_outline() { + let mut saved = serde_json::to_value(crate::UiState::default()).unwrap(); + assert!(saved["view"].get("outline").is_none(), "Outline is not saved with the interface"); + saved["view"]["outline"] = json!(true); + saved["view"]["rulers"] = json!(true); + let ui = serde_json::from_value::(saved).unwrap().sanitized(); + assert!(ui.view.rulers, "the other view settings load"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui = ui; + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + assert!(!app.outline_view(), "a new document is in Preview"); +} + /// #511: a star shows a widget in each of its ten corners with Direct Selection (none with /// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them. #[test] diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 99ca027cb..fe01e91cc 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -12,6 +12,13 @@ pub(super) fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bo format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %")) } +/// The title of open document `i`'s tab, with its own view's zoom and Outline or Preview. +pub(super) fn doc_tab_title(app: &VectorcraftApp, i: usize, d: &vectorcraft_engine::DocState) -> String { + let view = app.views.get(i); + let zoom = view.map_or(1.0, |v| v.zoom); + tab_title(d, zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size) +} + /// Whether a document tab's × comes before its title (macOS) rather than after it (Windows, /// Linux and the web). pub(super) const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos"); @@ -46,8 +53,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { .iter() .enumerate() .map(|(i, d)| { - let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); - let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size); + let title = doc_tab_title(app, i, d); let color = if Some(i) == active { t.text_strong } else { t.text_dim }; (title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color)) }) diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index f32d3a944..842ee1bc1 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -866,6 +866,18 @@ fn the_tab_title_names_the_opacity_mask_while_it_is_edited() { assert!(title(&s).ends_with("(RGB/Preview)")); } +/// #3797: each document's tab names its own Outline or Preview. +#[test] +fn only_the_outlined_documents_tab_reads_outline() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.run("view.outline", json!({})).unwrap(); + let titles: Vec = app.session.documents().iter().enumerate().map(|(i, d)| super::tabs::doc_tab_title(&app, i, d)).collect(); + assert!(titles[0].ends_with("(RGB/Preview)"), "{titles:?}"); + assert!(titles[1].ends_with("(RGB/Outline)"), "{titles:?}"); +} + /// One headless frame of the Control bar; returns the isolation button's rectangle, if it shows. fn isolation_button(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a30075713..c586420a2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -593,6 +593,10 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + // Outline is a setting of the active document's view: without one, nothing is set. + if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() { + return err("no document"); + } let mut r = Ok(Value::Null); if let Some(b) = s("brightness") { r = app.run("window.brightness", json!({"brightness": b})); @@ -613,7 +617,7 @@ fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { if let Some(want) = p.get(k).and_then(Value::as_bool) { let cur = match k { "rulers" => app.ui.view.rulers, - "outline" => app.ui.view.outline, + "outline" => app.outline_view(), "grid" => app.ui.view.grid, "smartGuides" => app.ui.view.smart_guides, _ => app.ui.view.bounding_box, diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 3e27607f3..ded29508a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -22,6 +22,24 @@ fn ui_set_turns_the_image_trace_preview_off_and_on() { } } +/// #3797: `ui.set` `outline` sets the active document's view; without a document it is an error +/// and sets nothing else either. +#[test] +fn ui_set_outline_sets_the_active_documents_view() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + for params in [json!({"outline": false}), json!({"outline": true, "grid": true})] { + let r = input_request(&mut app, "ui.set", params); + assert_eq!(r["ok"], false, "{r}"); + } + assert!(!app.ui.view.grid, "nothing is set"); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = input_request(&mut app, "ui.set", json!({"outline": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.outline_view()); + assert_eq!(app.views.iter().map(|v| v.outline).collect::>(), [false, true], "the other document stays in Preview"); +} + #[test] fn the_image_trace_preview_is_a_command() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index c95a17c46..d9888228c 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -17,7 +17,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { fn view_toggle_label(app: &VectorcraftApp, id: &str) -> Option { let v = &app.ui.view; let text: String = match id { - "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), + "view.outline" => if app.outline_view() { "Preview" } else { "Outline" }.into(), "window.workspace.reset" => format!("Reset {}", app.ui.workspace), "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), "view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index e7f80ecd8..b9e0b6ace 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -184,7 +184,11 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { crate::panel_docking::reveal_panel(app, "separations").and_then(|_| app.session.execute(id, p).map_err(|e| e.to_string())) } - "view.outline" => flag(&mut app.ui.view.outline), + // Outline or Preview is a setting of the active document's view. + "view.outline" => match app.view_mut() { + Some(v) => flag(&mut v.outline), + None => Err("no document".into()), + }, _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ad7f88c5c..42456d742 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -41,7 +41,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), - "view.outline" => v.outline, + "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, "view.snapToPixel" => v.snap_to_pixel, @@ -205,6 +205,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "view.fitArtboard" | "view.fitAll" | "view.actualSize" + | "view.outline" | "view.rotate" => app.session.active().is_some(), "view.rotateToSelection" | crate::addons::arrange::ZOOM => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0a78ee2b0..533500d69 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -21,11 +21,15 @@ pub struct View { /// Size show it. #[serde(default)] pub artboard: usize, + /// View › Outline: the document's art is drawn as outlines. Each document has its own, and a + /// document opens in Preview. + #[serde(default)] + pub outline: bool, } impl Default for View { fn default() -> Self { - Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0, artboard: 0 } + Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0, artboard: 0, outline: false } } } @@ -45,6 +49,7 @@ impl View { fitted: true, rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 }, artboard: 0, + outline: false, }) } } @@ -232,7 +237,6 @@ pub struct ViewFlags { pub guides: bool, pub smart_guides: bool, pub bounding_box: bool, - pub outline: bool, pub pixel_preview: bool, pub snap_to_grid: bool, pub snap_to_point: bool, @@ -271,7 +275,6 @@ impl Default for ViewFlags { guides: true, smart_guides: true, bounding_box: true, - outline: false, pixel_preview: false, snap_to_grid: false, snap_to_point: true, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8f0943230..d17704a53 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -101,7 +101,7 @@ the two can still win that race. | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | -| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | +| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |