diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs index bafcce01..8112b1ce 100644 --- a/apps/vectorcraft/src/native_menu.rs +++ b/apps/vectorcraft/src/native_menu.rs @@ -97,7 +97,10 @@ impl NativeMenu { ) || cmd.starts_with("file.openRecent") || cmd.starts_with("type.recentFont") || cmd.starts_with("view.goto") - || cmd == "view.cornerWidget" + || matches!( + cmd.as_str(), + "view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters" + ) { i.set_text(menus::dynamic_label(app, cmd, "")); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 51be688a..b2560f6a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -267,10 +267,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.view.edges { hover_highlight(app, &painter, &xf); selection_overlay(app, &painter, &xf); + if app.ui.view.text_threads { + thread_overlay(app, &painter, &xf); + } + if app.ui.view.hidden_chars { + hidden_chars_overlay(app, &painter, &xf); + } } let view_info = app.view_info(); - let overlays = app.session.overlays(view_info); - draw_overlays(&painter, &xf, &overlays, &t); + // View → Hide Gradient Annotator hides the Gradient tool's annotator. + if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator { + let overlays = app.session.overlays(view_info); + draw_overlays(&painter, &xf, &overlays, &t); + } if app.ui.view.rulers && app.ui.screen_mode < 3 { rulers(ui, full, &xf, app.hover_doc, &t); @@ -679,6 +688,67 @@ fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: } } +/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story. +fn hidden_chars_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + let clip = p.clip_rect(); + let color = Color32::from_rgb(0x4f, 0x9d, 0xff); + let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0)); + st.doc.walk(|n| { + let NodeKind::Text(tx) = &n.kind else { return }; + if !n.visible || n.geometric_bounds().is_none_or(|b| !xf.rect_to_screen(b).intersects(clip)) { + return; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx); + if lay.on_path { + return; + } + let text = tx.plain_text(); + let to = |q: Point| xf.to_screen(tx.xf * q); + for g in &lay.glyphs { + if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) { + p.circle_filled(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0)), 1.2, color); + } + } + for (i, line) in lay.lines.iter().enumerate() { + let last = i + 1 == lay.lines.len(); + let breaks = text.get(line.end..).is_some_and(|s| s.starts_with('\n')); + let (glyph, at) = if last && line.end >= text.len() { + ("#", true) + } else if breaks { + ("¶", true) + } else { + ("", false) + }; + if at { + p.text(to(Point::new(line.x1 + 1.0, line.baseline)), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color); + } + } + }); +} + +/// Text threads of the selected frames: a line from each frame's out port (bottom right) to the +/// next frame's in port (top left), like Illustrator's thread indicators. +fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + for thread in &st.doc.text_threads { + if !thread.iter().any(|id| st.selection.objects.contains(id)) { + continue; + } + let color = c32(st.doc.layer_color(thread[0])); + let frames: Vec = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect(); + for w in frames.windows(2) { + let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1)), xf.to_screen(Point::new(w[1].x0, w[1].y0))); + p.line_segment([out, inp], Stroke::new(1.0, color)); + for port in [out, inp] { + p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE); + p.rect_stroke(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, color), egui::StrokeKind::Inside); + p.line_segment([port - egui::vec2(2.0, 0.0), port + egui::vec2(2.0, 0.0)], Stroke::new(1.0, color)); + } + } + } +} + fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(st) = app.session.active() else { return }; let tool = app.session.tool_id(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 624b8930..b4e84737 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -102,6 +102,10 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), + ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), + ("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"), ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), @@ -282,6 +286,21 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.snap_to_pixel), + "view.textThreads" => flag(&mut app.ui.view.text_threads), + "type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars), + "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), + "effect.last" => match app.last_effect.clone() { + Some((e, params)) => { + let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone()); + let mut fields = params.as_object().cloned().unwrap_or_default(); + fields.insert("__effect".into(), json!(e)); + fields.insert("__label".into(), json!(label)); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) + } + None => Err("no effect applied yet".into()), + }, id if id.starts_with("type.recentFont") => { let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); match n.checked_sub(1).and_then(|i| app.ui.recent_fonts.get(i)).cloned() { @@ -513,6 +532,13 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "window.workspace.reset" => format!("Reset {}", app.ui.workspace), "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), "view.cornerWidget" => if v.corner_widgets { "Hide Corner Widget" } else { "Show Corner Widget" }.into(), + "view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(), + "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), + "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), + "effect.last" => match &app.last_effect { + Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), + None => label.into(), + }, id if id.starts_with("type.recentFont") => { let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); n.checked_sub(1).and_then(|i| app.ui.recent_fonts.get(i)).cloned().unwrap_or_else(|| "—".into()) @@ -605,7 +631,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id["view.goto".len()..].parse::().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len())) } "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), - "effect.applyLast" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), + "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" => app.session.active().is_some(), _ => true, } @@ -976,7 +1002,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Fill with Placeholder Text", "type.fillPlaceholder"), Sep, - todos("Show Hidden Characters", "Cmd+Alt+I"), + c("Show Hidden Characters", "type.hiddenCharacters"), sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]), ], ), @@ -1086,8 +1112,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]), c("Hide Bounding Box", "view.boundingBox"), c("Show Transparency Grid", "view.transparencyGrid"), - todos("Hide Text Threads", "Cmd+Shift+Y"), - todos("Hide Gradient Annotator", "Cmd+Alt+G"), + c("Hide Text Threads", "view.textThreads"), + c("Hide Gradient Annotator", "view.gradientAnnotator"), c("Hide Corner Widget", "view.cornerWidget"), Sep, sub( @@ -1552,7 +1578,7 @@ fn effect_menu() -> Vec { let cat = vectorcraft_effects::effect_catalog(); let mut out = vec![ c("Apply Last Effect", "effect.applyLast"), - todos("Last Effect", "Cmd+Alt+Shift+E"), + c("Last Effect…", "effect.last"), Sep, c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), Sep, @@ -1602,6 +1628,29 @@ fn effect_menu() -> Vec { mod tests { use super::*; + #[test] + fn last_effect_dialog_and_view_toggles() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + assert!(!enabled(&app, "effect.last")); + app.last_effect = Some(("distort.roughen".into(), json!({"size": 9}))); + assert!(enabled(&app, "effect.last")); + assert_eq!(dynamic_label(&app, "effect.last", "Last Effect…"), "Last Effect: Roughen…"); + app.run("effect.last", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("__effect"), d.f64("size", 0.0)), ("effect", "distort.roughen".to_string(), 9.0)); + for (id, on, off) in [ + ("view.textThreads", "Hide Text Threads", "Show Text Threads"), + ("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"), + ("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"), + ] { + assert_eq!(dynamic_label(&app, id, ""), on); + app.run(id, json!({})).unwrap(); + assert_eq!(dynamic_label(&app, id, ""), off); + } + } + #[test] fn saved_views_store_and_restore_the_view() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index bf000573..6557ee67 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -121,6 +121,12 @@ pub struct ViewFlags { pub corner_widgets: bool, /// View → Snap to Pixel. pub snap_to_pixel: bool, + /// View → Show/Hide Text Threads. + pub text_threads: bool, + /// View → Show/Hide Gradient Annotator. + pub gradient_annotator: bool, + /// Type → Show Hidden Characters. + pub hidden_chars: bool, } impl Default for ViewFlags { @@ -141,6 +147,9 @@ impl Default for ViewFlags { trim_view: false, corner_widgets: true, snap_to_pixel: false, + text_threads: true, + gradient_annotator: true, + hidden_chars: false, } } }