Merge PR #567 from NikitkaRa: View: indent and tab markers on the ruler

# Conflicts:
#	crates/engine/src/cmd/format.rs
#	crates/ui-egui/src/canvas.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:26:32 -04:00
5 changed files with 391 additions and 1 deletions
+92
View File
@@ -159,6 +159,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",
@@ -798,3 +808,85 @@ fn font_rescan(_s: &mut Session, _p: &Value) -> Result<Value> {
let faces = 0usize;
Ok(json!({"faces": faces, "families": db.families().len()}))
}
/// 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
@@ -221,6 +221,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);
@@ -427,6 +428,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) {
if crate::eyedropper::canvas(app, ui, resp, xf) {
return;
@@ -960,3 +1109,7 @@ mod tests {
assert_near(mid, slide_point(&h, c), "zoom out");
}
}
#[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());
}
+2 -1
View File
@@ -23,6 +23,7 @@ The summary between `<!-- SUMMARY -->
| Files | 71% |
| Slides | 90% |
| Views | 93% |
| Views | 95% |
| Masters & themes | 85% |
| Views | 90% |
| Masters & themes | 88% |
@@ -97,7 +98,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 | P |