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` |