From a6ff24d0c40d68f78728e3fad153f77e7003a604 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:41:42 -0800 Subject: [PATCH 1/2] Tabs: the ruler and Repeat Tab stop at 256 tab stops With 256 stops, a click on an empty part of the Tabs panel's ruler adds no stop and shows "at most 256 tab stops" in the status bar; the stops and the selection stay as they were. Repeat Tab adds copies only up to 256 stops and shows the same message when it stops early. Before, both built a longer list that text.tabs.set refused, so nothing changed and no message showed. --- crates/ui-egui/src/panels/tabs.rs | 32 +++++++++++++++++-------- crates/ui-egui/src/panels/tabs/tests.rs | 29 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index fb12e4fdc0..6a984a1ced 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -686,15 +686,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // A click that lands on a stop's whole point or tick selects that stop and records no // undo step. let added = !stops.iter().any(|s| s.position == position); - if added { - stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); - stops.sort_by(|a, b| a.position.total_cmp(&b.position)); - } - let i = stops.iter().position(|s| s.position == position).unwrap_or(0); - set_pstate::>(ui.ctx(), "tabs-marker-sel", None); - set_pstate(ui.ctx(), "tabs-sel", i); - if added { - apply(app, &stops); + let max = TabStop::MAX_STOPS; + if added && stops.len() >= max { + // A full list takes no more stops: the click leaves the stops and the + // selection as they are and shows the limit. + app.status(format!("at most {max} tab stops")); + } else { + if added { + stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + let i = stops.iter().position(|s| s.position == position).unwrap_or(0); + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); + if added { + apply(app, &stops); + } } } } @@ -729,7 +736,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the // previous stop, or from the paragraph's left indent plus its list level's indent when it is - // the first stop. + // the first stop. The copies stop at the 256-stop limit, which the status bar then shows. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { 0 => (stops[0].position - left_indent).abs(), @@ -738,8 +745,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { .max(1.0); let base = stops[sel].clone(); stops.truncate(sel + 1); + let max = TabStop::MAX_STOPS; let mut pos = base.position + step; while pos <= base.position + step * 20.0 && pos < 2000.0 { + if stops.len() >= max { + app.status(format!("at most {max} tab stops")); + break; + } stops.push(TabStop { position: pos, ..base.clone() }); pos += step; } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 9e96866ab2..97e8c8a00a 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1255,6 +1255,35 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { assert_eq!(current(&app).unwrap().1, before); } +/// With 256 stops, a click on an empty part of the ruler adds none and shows the limit in the +/// status bar. Repeat Tab near the end of the list adds copies only up to 256 stops and shows the +/// limit too. +#[test] +fn the_ruler_and_repeat_tab_stop_at_256_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + // 250 stops 1 pt apart from 0 pt, then six 2 pt apart from 260 pt. + let start: Vec = (0..250).map(f64::from).chain((0..6).map(|i| 260.0 + 2.0 * f64::from(i))).collect(); + let stops: Vec = start.iter().map(|p| json!({"position": p})).collect(); + app.session.execute("text.tabs.set", &json!({ "stops": stops })).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + set_pstate(&ctx, "tabs-sel", 3usize); + drag_on_ruler(&ctx, &mut app, 290.0, 290.0, false); + assert_eq!(positions(&app), start); + assert_eq!(pstate::(&ctx, "tabs-sel"), 3); + assert_eq!(app.ui.status, "at most 256 tab stops"); + // Repeat Tab on the stop at 260 pt (11 pt past the one before it): five copies, not 20. + app.status(""); + set_pstate(&ctx, "tabs-sel", 250usize); + choose(&ctx, &mut app, "Repeat Tab"); + let repeated = positions(&app); + assert_eq!(repeated.len(), 256); + assert_eq!(repeated[249..], [249.0, 260.0, 271.0, 282.0, 293.0, 304.0, 315.0]); + assert_eq!(app.ui.status, "at most 256 tab stops"); +} + /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's /// left indent (18 pt further in for each list level past the first), from the paragraph the /// caret is in while the Type tool edits text. From ee524f06c9aa4be8c1c4473e5a617cad70cc9a08 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:15:05 -0800 Subject: [PATCH 2/2] Tabs: text.tabs.set can shrink a paragraph that holds more than 256 stops text.tabs.set takes a list of more than 256 stops when every paragraph it targets already holds at least that many, so a paragraph loaded with more stops can lose them one at a time (the Tabs panel's Delete Tab). Any other list of more than 256 stops is still refused, counted as supplied before stops at one position merge. Docs: docs/mcp.md. --- crates/engine/src/cmd/tabs.rs | 79 +++++++++++++++++++++++++++++++---- docs/mcp.md | 5 ++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 04a2c5504a..c047b94900 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -38,10 +38,6 @@ pub fn specs() -> Vec { fn parse_stops(p: &Value) -> Result> { const C: &str = "text.tabs.set"; let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; - let max = TabStop::MAX_STOPS; - if arr.len() > max { - return Err(bad(C, format!("at most {max} tab stops"))); - } let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; @@ -90,10 +86,39 @@ fn parse_stops(p: &Value) -> Result> { } fn set(s: &mut Session, p: &Value) -> Result { + // More than the limit is taken only when every targeted paragraph already holds at least as + // many stops, so a paragraph that holds more can still lose them one at a time. The supplied + // list is counted before it is read. + let max = TabStop::MAX_STOPS; + let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len); + if supplied > max && supplied > fewest_held(s, p)? { + return Err(bad("text.tabs.set", format!("at most {max} tab stops"))); + } let stops = parse_stops(p)?; apply(s, p, stops, "Tab Stops") } +/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none). +fn fewest_held(s: &Session, p: &Value) -> Result { + let ids = text_targets(s, p, "text.tabs.set")?; + let range = TextRange::parse(p, "text.tabs.set")?; + let doc = &s.doc()?.doc; + let mut fewest: Option = None; + for id in &ids { + if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) { + let held = match para_span(range, t) { + None => t.para_styles().map(|pa| pa.tabs.len()).min(), + Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(), + }; + fewest = match (fewest, held) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + } + } + Ok(fewest.unwrap_or(0)) +} + fn apply(s: &mut Session, p: &Value, stops: Vec, label: &str) -> Result { let ids = text_targets(s, p, "text.tabs.set")?; let range = TextRange::parse(p, "text.tabs.set")?; @@ -159,14 +184,52 @@ mod tests { let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap(); let many: Vec = (0..257).map(|i| json!({"position": i})).collect(); - assert!(err(Value::from(many.clone())).contains("at most 256 tab stops")); - let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap(); - assert_eq!(ok.len(), 256); + let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string(); + assert!(e.contains("at most 256 tab stops"), "{e}"); + // The supplied list is counted, not the stops left once those at one position merge. + let same: Vec = (0..257).map(|_| json!({"position": 5})).collect(); + assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256)); let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); } + /// A paragraph that already holds more than 256 stops (from a file) can lose them one at a + /// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and + /// a paragraph with fewer stops targeted beside it keeps the limit. + #[test] + fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() { + use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap()); + let held: Vec = + (0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect(); + s.edit("Load", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.edit_paras(None, |pa| pa.tabs = held.clone()); + } + Ok(()) + }) + .unwrap(); + let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::>() }); + assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299)); + assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299); + // The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused, + // and 298 for the first alone is taken. + s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap(); + let mut both = list(298); + assert!(s.execute("text.tabs.set", &both).is_err()); + both["start"] = json!(0); + both["end"] = json!(0); + assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298)); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..50a8a62e0c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7841,8 +7841,9 @@ targeted objects, as before. With a range, the character attributes of `text.set that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or `alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than -8 characters, a position outside 0 to 100000 pt, and more than 256 stops. It sorts the stops by position and keeps the -first of stops at one position. +8 characters, a position outside 0 to 100000 pt, and more than 256 stops unless every paragraph it targets already +holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and +keeps the first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in