View: indent and tab markers on the ruler

While you edit text, the horizontal ruler now shows the text area's
span and the indent and tab markers of the paragraph with the caret.

- First-line indent: the top triangle.
- Hanging indent: the bottom triangle. Dragging it moves the left
  indent while the first line stays put.
- Left indent: the box under the hanging triangle. Dragging it moves
  both.
- Tab stops are drawn as left, centre, right and decimal marks.
- A click on the ruler adds a left tab, dragging a tab moves it, and
  dragging a tab down off the ruler removes it.

Every change goes through a command (format.paragraph or the new
format.tabs). Each is one undo step and applies to the selected
paragraphs, and text editing goes on throughout. format.ruler is a
new query that reports the text area's position and the paragraph's
resolved indents and tabs. Rotated and vertical text show no markers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 00:13:17 +03:00
co-authored by Cursor
parent 457406a7a5
commit 8a36a6990e
5 changed files with 393 additions and 4 deletions
+92
View File
@@ -139,6 +139,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
paragraph
),
cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler),
cmd!(
"format.tabs",
"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)",
has_text_or_shapes,
tabs
),
cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor),
cmd!(
"format.direction",
@@ -682,3 +692,85 @@ fn state(s: &mut Session, _p: &Value) -> Result<Value> {
"painter": s.painter.is_some(),
}))
}
/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops,
/// and where its text area sits on the slide.
fn ruler(s: &mut Session, _p: &Value) -> Result<Value> {
let props = current_para(s);
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?;
let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) {
(false, Some(sh)) => {
let ctx = super::text::ctx_for(&st.doc, &st.selection);
let x = match &ctx {
Some(c) => resolve::xfrm(c, sh),
None => sh.xfrm.unwrap_or_default(),
};
let body = match &ctx {
Some(c) => resolve::body(c, sh),
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
};
let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6));
(x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal))
}
_ => (0.0, 0.0, 0.0, true),
};
let tabs: Vec<Value> = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
Ok(json!({
"x": x, "y": y, "width": w, "rotated": rotated,
"marginLeft": props.margin_left.unwrap_or(0.0),
"indent": props.indent.unwrap_or(0.0),
"tabs": tabs,
"defaultTab": props.default_tab.unwrap_or(72.0),
}))
}
const MAX_TABS: usize = 32;
fn tabs(s: &mut Session, p: &Value) -> Result<Value> {
let fin = |v: Option<f64>| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0));
let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") {
"ctr" | "center" => "ctr",
"r" | "right" => "r",
"dec" | "decimal" => "dec",
_ => "l",
};
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 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() {
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 };
if let Some(at) = remove {
stops.retain(|t| !near(t.pos, at));
}
if let Some((from, to)) = mv
&& let Some(t) = stops.iter_mut().find(|t| near(t.pos, from))
{
t.pos = to;
}
if let Some((pos, align)) = add {
stops.retain(|t| !near(t.pos, pos));
stops.push(deckcraft_model::text::TabStop { pos, align });
}
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
stops.dedup_by(|a, b| near(a.pos, b.pos));
stops.truncate(MAX_TABS);
format_para(s, &move |para| {
para.props.tabs = Some(stops.clone());
if let Some(d) = default_tab {
para.props.default_tab = Some(d);
}
})
}
#[cfg(test)]
#[path = "ruler_tests.rs"]
mod ruler_tests;
+59
View File
@@ -0,0 +1,59 @@
//! The ruler's data and tab-stop editing.
use serde_json::json;
use crate::Session;
fn editing() -> (Session, u64) {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.insert", &json!({"text": "a\tb"})).unwrap();
(s, id)
}
#[test]
fn ruler_reports_the_text_area_and_indents() {
let (mut s, _) = editing();
let r = s.execute("format.ruler", &json!({})).unwrap();
assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}");
assert_eq!(r["rotated"], false);
assert_eq!(r["tabs"].as_array().unwrap().len(), 0);
s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap();
let r = s.execute("format.ruler", &json!({})).unwrap();
assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0)));
s.execute("text.exit", &json!({})).unwrap();
assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text");
}
#[test]
fn tabs_add_move_remove_clear_and_undo() {
let (mut s, _) = editing();
let tabs = |s: &mut Session| -> Vec<(f64, String)> {
s.execute("format.ruler", &json!({})).unwrap()["tabs"]
.as_array()
.unwrap()
.iter()
.map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string()))
.collect()
};
s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap();
s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap();
assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted");
s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap();
assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced");
s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap();
s.execute("format.tabs", &json!({"remove": 72.2})).unwrap();
assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(tabs(&mut s).len(), 2);
s.execute("format.tabs", &json!({"clear": true})).unwrap();
assert!(tabs(&mut s).is_empty());
assert!(s.execute("format.tabs", &json!({})).is_err());
assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash");
for i in 0..100 {
s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap();
}
assert!(tabs(&mut s).len() <= 32);
}
+153
View File
@@ -161,6 +161,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
if app.ui.ruler {
ruler(&painter, avail, slide_rect, scale, &t);
ruler_markers(app, ui, &painter, avail, xf, &t);
}
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
@@ -366,6 +367,154 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
}
}
/// What is being dragged on the ruler.
#[derive(Clone, Copy, Debug, PartialEq)]
enum RulerDrag {
/// First-line indent (the top triangle).
First,
/// Hanging indent (the bottom triangle): moves the left indent, the first line stays put.
Hanging,
/// Left indent (the box under the hanging triangle): moves both together.
Left,
/// The tab stop that was at this position (pt from the text area's left).
Tab(f64),
}
/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited,
/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the
/// ruler removes it.
fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) {
if app.session.active().is_none_or(|d| d.selection.text.is_none()) {
return;
}
let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return };
if info["rotated"].as_bool().unwrap_or(true) {
return;
}
let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0);
let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent"));
let tabs: Vec<(f64, String)> = info["tabs"]
.as_array()
.map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect())
.unwrap_or_default();
let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0));
let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x;
let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0;
// The text area's span, outlined on the ruler.
let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top);
p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside);
let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag());
let id = ui.id().with("ruler_drag");
let mut drag: Option<RulerDrag> = ui.data(|d| d.get_temp::<RulerDrag>(id));
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
if resp.drag_started()
&& let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
let near = |q: f64| (sx(q) - at.x).abs() <= 5.0;
let y = at.y - top.min.y;
drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) {
Some(RulerDrag::Tab(*pos))
} else if y < 6.0 && near(ml + ind) {
Some(RulerDrag::First)
} else if (6.0..11.0).contains(&y) && near(ml) {
Some(RulerDrag::Hanging)
} else if y >= 11.0 && near(ml) {
Some(RulerDrag::Left)
} else {
None
};
ui.data_mut(|d| match drag {
Some(v) => {
d.insert_temp(id, v);
}
None => {
d.remove::<RulerDrag>(id);
}
});
}
// Where each marker is drawn: the dragged one follows the pointer.
let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0));
let (mut first, mut left) = (ml + ind, ml);
match live {
Some((RulerDrag::First, q, _)) => first = q,
Some((RulerDrag::Hanging, q, _)) => left = q,
Some((RulerDrag::Left, q, _)) => {
first = q + ind;
left = q;
}
_ => {}
}
let col = t.text_dim;
let tri = |x: f32, y0: f32, down: bool| {
let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) };
p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col)));
};
tri(sx(first), top.min.y + 1.0, true);
tri(sx(left), top.min.y + 6.0, false);
p.rect(
Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)),
CornerRadius::ZERO,
t.card,
Stroke::new(1.0, col),
egui::StrokeKind::Inside,
);
for (pos, align) in &tabs {
let (q, gone) = match live {
Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off),
_ => (*pos, false),
};
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);
}
}
}
if drag.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
// Commit on release; a click on bare ruler adds a left tab.
if resp.drag_stopped() {
if let (Some(d), Some(at)) = (drag, pointer) {
let q = to_q(at.x);
let r = match d {
RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})),
RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})),
RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})),
RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})),
RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})),
};
if let Err(e) = r {
app.set_status(e);
}
}
ui.data_mut(|d| d.remove::<RulerDrag>(id));
} else if resp.clicked()
&& let Some(at) = resp.interact_pointer_pos()
&& at.x >= area.min.x
&& at.x <= area.max.x
{
let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": "l"}}));
}
}
fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
let tol = (4.0 / xf.scale) as f64;
let m = mods(ui);
@@ -697,3 +846,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
}
});
}
#[cfg(test)]
#[path = "ruler_tests.rs"]
mod ruler_tests;
+85
View File
@@ -0,0 +1,85 @@
//! The ruler's indent and tab markers driven with the mouse while editing text.
use deckcraft_engine::Session;
use egui::{Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use serde_json::json;
use crate::{Services, SlideApp};
fn drag(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=8 {
h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn click(h: &mut Harness<'_, SlideApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value {
h.state_mut().session.execute("format.ruler", &json!({})).unwrap()
}
#[test]
fn drag_indents_add_move_and_remove_tabs_on_the_ruler() {
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": "Name\tRole\tTeam"})).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);
let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale);
let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32;
// The horizontal ruler runs along the top of the canvas.
let top = h.state().canvas_rect.unwrap().min.y;
let rx = |q: f32| slide.min.x + (x0 + q) * k;
// First-line indent triangle (top) to 36 pt.
drag(&mut h, Pos2::new(rx(0.0), top + 3.0), Pos2::new(rx(36.0), top + 3.0));
let r = ruler(&mut h);
assert!((r["indent"].as_f64().unwrap() - 36.0).abs() <= 1.0, "{r}");
assert_eq!(r["marginLeft"].as_f64(), Some(0.0));
// Hanging triangle (middle) to 18 pt: the first line stays at 36.
drag(&mut h, Pos2::new(rx(0.0), top + 8.0), Pos2::new(rx(18.0), top + 8.0));
let r = ruler(&mut h);
let (ml, ind) = (r["marginLeft"].as_f64().unwrap(), r["indent"].as_f64().unwrap());
assert!((ml - 18.0).abs() <= 1.0 && (ml + ind - 36.0).abs() <= 1.0, "{r}");
// Click on the ruler: a left tab; drag it along; drag it off to remove it.
click(&mut h, Pos2::new(rx(150.0), top + 12.0));
let r = ruler(&mut h);
assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}");
let pos = r["tabs"][0]["pos"].as_f64().unwrap() as f32;
assert!((pos - 150.0).abs() <= 1.0);
drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(220.0), top + 12.0));
let pos = ruler(&mut h)["tabs"][0]["pos"].as_f64().unwrap() as f32;
assert!((pos - 220.0).abs() <= 1.0, "{pos}");
if let Ok(img) = h.render() {
let _ = img.save("/tmp/ruler.png");
}
assert!(h.state().session.active().unwrap().selection.text.is_some(), "still editing the text");
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());
}
+4 -4
View File
@@ -3,12 +3,12 @@
> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The summary between `<!-- SUMMARY -->
**Weighted breadth parity: 79%** over 191 features.
**Weighted breadth parity: 80%** over 191 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P1 | 65 | 39 | 20 | 6 | 75% |
| P2 | 54 | 13 | 15 | 26 | 38% |
| Area | Parity |
@@ -16,7 +16,7 @@ The summary between `<!-- SUMMARY -->
| Application shell | 77% |
| Files | 71% |
| Slides | 90% |
| Views | 90% |
| Views | 95% |
| Masters & themes | 85% |
| Shapes | 88% |
| Format | 83% |
@@ -86,7 +86,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Views | Notes Page | P1 | D |
| Views | Reading View | P1 | D |
| Views | Zoom, fit to window | P0 | D |
| Views | Rulers, gridlines, guides | P1 | P |
| Views | Rulers, gridlines, guides | P1 | D |
| Views | Smart guides | P1 | D |
| Views | Grayscale / black and white view | P2 | D |
| Views | New window / arrange windows | P2 | M |