Merge pull request #5134 from dotCooCoo/fix/ai-os23-3797

Fix #3797: documents open in Preview, and Outline is a setting of each document
This commit is contained in:
BFlat
2026-11-02 12:22:04 +07:00
committed by GitHub
13 changed files with 129 additions and 18 deletions
+6 -1
View File
@@ -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,
+4 -4
View File
@@ -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<vectorcraft_doc::NodeId>,
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));
}
+1 -1
View File
@@ -184,7 +184,7 @@ pub(super) fn hit_with<T>(
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),
+60 -2
View File
@@ -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::<crate::UiState>(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##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect x="20" y="20" width="100" height="100" fill="#ff0000"/></svg>"##;
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::<crate::UiState>(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]
+8 -2
View File
@@ -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))
})
+12
View File
@@ -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<String> = 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<egui::Event>) -> Option<egui::Rect> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0));
+5 -1
View File
@@ -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,
+18
View File
@@ -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::<Vec<_>>(), [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());
+1 -1
View File
@@ -17,7 +17,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
fn view_toggle_label(app: &VectorcraftApp, id: &str) -> Option<String> {
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(),
+5 -1
View File
@@ -184,7 +184,11 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
"edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => {
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)
+2 -1
View File
@@ -41,7 +41,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
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(),
+6 -3
View File
@@ -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,
+1 -1
View File
@@ -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` |