mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 20:47:15 -06:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user