mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
# Conflicts: # crates/ui-egui/src/dialogs.rs
This commit is contained in:
@@ -165,7 +165,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
|
||||
),
|
||||
@@ -854,16 +854,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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "size_position_tests.rs"]
|
||||
mod size_position_tests;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| 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 `<!-- SUMMARY -->
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user