Text: Tabs dialog and the ruler's tab selector

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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 00:22:16 +03:00
co-authored by Cursor
parent 8a36a6990e
commit 4b2548c99b
7 changed files with 243 additions and 25 deletions
+9 -3
View File
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Vec<deckcraft_model::text::TabStop>> = 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));
}
+3
View File
@@ -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));
}
+58 -19
View File
@@ -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());
+92
View File
@@ -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<String> = 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::<f64>().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::<Vec<_>>().join("|"));
let (ok, cancel) = buttons(ui, "OK");
if ok {
let set: Vec<Value> = 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
}
+3
View File
@@ -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| {
+75
View File
@@ -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");
}
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| 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 `<!-- SUMMARY -->
| 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 |