From 4b2548c99b2ff73830727430e632a80f494e920e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:22:16 +0300 Subject: [PATCH] Text: Tabs dialog and the ruler's tab selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format > Tabs opens from Home > Line Spacing > Tabs…, from the Paragraph dialog, or by double-clicking a tab stop on the ruler. It lists the selected paragraphs' tab stops (inches) with their alignment. Set adds or replaces a stop at a position with Left, Center, Right or Decimal alignment, Clear removes one, Clear All removes them all, and you can change the default tab spacing. OK applies everything in one undo step through format.tabs, which now takes `set`. Cancel changes nothing. The ruler's corner has PowerPoint's tab selector. A click cycles left, center, right and decimal, and a click on the ruler then sets that kind of stop. Presses on the rulers no longer reach the slide, so they don't end text editing. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 12 +++- crates/engine/src/cmd/ruler_tests.rs | 3 + crates/ui-egui/src/canvas.rs | 77 +++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 92 ++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/src/ruler_tests.rs | 75 +++++++++++++++++++++++ docs/parity-checklist.md | 6 +- 7 files changed, 243 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 8581cb5d..0251e6ab 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Tabs…", ["Home", "Paragraph"], None, - "{add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", + "{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", has_text_or_shapes, tabs ), @@ -738,16 +738,22 @@ fn tabs(s: &mut Session, p: &Value) -> Result { let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); let remove = fin(p.get("remove").and_then(Value::as_f64)); + let set: Option> = p.get("set").and_then(Value::as_array).map(|a| { + a.iter() + .take(MAX_TABS * 4) + .filter_map(|t| Some(deckcraft_model::text::TabStop { pos: fin(t.get("pos").and_then(Value::as_f64))?, align: align_of(t).to_string() })) + .collect() + }); let clear = bool_or(p, "clear", false); let default_tab = fin(p.get("defaultTab").and_then(Value::as_f64)).filter(|v| *v >= 1.0); - if add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { + if set.is_none() && add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { return Err(bad("format.tabs", "nothing to change")); } // The stops as the caret's paragraph shows them (inherited ones included), so editing a // placeholder's tab that came from its layout works like editing one set on the paragraph. let base = current_para(s).tabs.unwrap_or_default(); let near = |a: f64, b: f64| (a - b).abs() < 0.5; - let mut stops = if clear { vec![] } else { base }; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; if let Some(at) = remove { stops.retain(|t| !near(t.pos, at)); } diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs index 1d23154e..95c87c6c 100644 --- a/crates/engine/src/cmd/ruler_tests.rs +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -56,4 +56,7 @@ fn tabs_add_move_remove_clear_and_undo() { s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); } assert!(tabs(&mut s).len() <= 32); + s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap(); + assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped"); + assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 073f9f41..f29f62f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -467,25 +467,29 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect if gone { continue; } - let (x, y) = (sx(q), top.max.y - 2.0); - let st = Stroke::new(1.5, t.text); - p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); - match align.as_str() { - "r" => { - p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); - } - "ctr" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - } - "dec" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, t.text); - } - _ => { - p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); - } - } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); if drag.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } @@ -511,7 +515,37 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect && at.x >= area.min.x && at.x <= area.max.x { - let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": "l"}})); + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } } } @@ -519,6 +553,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } let p = xf.to_slide(pos); let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; let primary = ui.input(|i| i.pointer.primary_pressed()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..dd4089c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "export" => "Export", @@ -388,6 +389,12 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { field(ui, d, "Spacing before (pt):", "before", "0"); field(ui, d, "Spacing after (pt):", "after", "0"); field(ui, d, "Line spacing (lines):", "lines", "1.0"); + ui.add_space(4.0); + if ui.button("Tabs…").clicked() { + app.dialog = None; + *d = Dialog::new("tabs"); + return false; + } let (ok, cancel) = buttons(ui, "OK"); if ok { run( @@ -398,6 +405,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -829,3 +837,87 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + let r = app.session.execute("format.ruler", &json!({})).unwrap_or_default(); + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } + } + ok || cancel +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..3df4a781 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -594,6 +594,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { if ui.button("Line Spacing Options…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); egui::Popup::menu(&r).show(|ui| { diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs index fd12d206..7c313563 100644 --- a/crates/ui-egui/src/ruler_tests.rs +++ b/crates/ui-egui/src/ruler_tests.rs @@ -83,3 +83,78 @@ fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); } + +fn editing_harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Item\t9.50"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + let corner = h.state().canvas_rect.unwrap().min + egui::vec2(8.0, 8.0); + assert!(h.query_by_label("Left Tab").is_some()); + for _ in 0..3 { + click(&mut h, corner); + } + assert!(h.query_by_label("Decimal Tab").is_some()); + click(&mut h, Pos2::new(rx(200.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"][0]["align"], "dec", "{r}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler_decimal.png"); + } +} + +#[test] +fn tabs_dialog_sets_clears_and_applies_on_ok() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("tabs")); + h.run_steps(3); + assert!(h.query_by_label_contains("0.50\"").is_some(), "the existing stop is listed"); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("newpos".into(), "2".into()); + d.fields.insert("default".into(), "0.5".into()); + h.run_steps(1); + h.get_by_label("Center").click(); + h.run_steps(2); + h.get_by_label("Set").click(); + h.run_steps(2); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/tabs_dialog.png"); + } + // Nothing changes until OK. + assert_eq!(ruler(&mut h)["tabs"].as_array().unwrap().len(), 1); + h.query_all_by_label("Clear").next().expect("a Clear button per stop").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!((r["tabs"][0]["pos"].as_f64(), r["tabs"][0]["align"].as_str()), (Some(144.0), Some("ctr"))); + assert_eq!(r["defaultTab"].as_f64(), Some(36.0)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ruler(&mut h)["tabs"][0]["pos"].as_f64(), Some(36.0), "one undo step"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a76ca062..50e56824 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 14 | 14 | 26 | 39% | | Area | Parity | |---|---| @@ -21,7 +21,7 @@ The summary between ` | Shapes | 88% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 81% | | Tables | 89% | | Charts | 65% | | SmartArt | 33% | @@ -148,7 +148,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Bullets and numbering | P0 | D | | Text | Picture bullets, custom bullet symbols | P2 | P | | Text | List levels (Tab / Shift+Tab) | P0 | D | -| Text | Tab stops | P2 | P | +| Text | Tab stops | P2 | D | | Text | Columns | P2 | D | | Text | Vertical anchor, margins, wrap | P0 | D | | Text | Autofit (shrink on overflow, resize shape) | P0 | D |