diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index bc2752ac..a7119059 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -165,7 +165,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 ), @@ -854,16 +854,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 a928ce5a..a68281b5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -528,25 +528,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); } @@ -572,7 +576,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); + } } } @@ -583,6 +617,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 b5a7d697..1152c2e5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -45,6 +45,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "export" => "Export", @@ -1500,6 +1501,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( @@ -1510,6 +1517,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"); @@ -2358,6 +2366,90 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { )); } +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 +} + #[cfg(test)] #[path = "size_position_tests.rs"] mod size_position_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3d624d53..883e850a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -745,6 +745,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 44a2e4e5..90463770 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,6 +16,7 @@ The summary between ` | P2 | 54 | 14 | 15 | 25 | 40% | | P1 | 65 | 39 | 21 | 5 | 76% | | P2 | 54 | 14 | 17 | 23 | 42% | +| P2 | 54 | 14 | 14 | 26 | 39% | | Area | Parity | |---|---| @@ -33,6 +34,8 @@ The summary between ` | Arrange | 100% | | Text | 80% | | Tables | 95% | +| Text | 81% | +| Tables | 89% | | Charts | 65% | | SmartArt | 33% | | Pictures | 52% | @@ -160,7 +163,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 |