M11.4: text-thread connectors on selected threaded frames + View → Hide/Show Text Threads, Hide/Show Gradient Annotator, Type → Show Hidden Characters (spaces, ¶, #), Effect → Last Effect… (reopens the last effect's dialog)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 08:51:33 -04:00
1 parent dcc5a006a2
commit 55e24d6622
4 files changed
+139 -8

No files matched your search

+4 -1
View File
@@ -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, ""));
}
+72 -2
View File
@@ -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<vectorcraft_geom::Rect> = 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();
+54 -5
View File
@@ -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<R
}
}
"view.snapToPixel" => 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::<usize>().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<Item>)> {
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<Item>)> {
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<Item> {
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<Item> {
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());
+9
View File
@@ -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,
}
}
}