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