mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 12:07:02 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d87ca5cf | ||
|
|
6334d689d2 | ||
|
|
9d571ef644 | ||
|
|
ea3b1159d5 | ||
|
|
23a3b6eba2 |
Generated
+1
@@ -1063,6 +1063,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||||
|
|
||||||
|
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||||
|
|
||||||
|
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||||
|
|
||||||
|
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
||||||
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
||||||
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
||||||
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
|
cmd!(
|
||||||
|
"arrange.reorder",
|
||||||
|
"Reorder Objects",
|
||||||
|
["Arrange"],
|
||||||
|
None,
|
||||||
|
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
|
||||||
|
has_slide,
|
||||||
|
reorder
|
||||||
|
),
|
||||||
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
||||||
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
||||||
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
||||||
@@ -50,52 +58,98 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
|
|
||||||
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
||||||
let ids = targets(s, p)?;
|
let ids = targets(s, p)?;
|
||||||
|
// Already in place (front of its group, say): no change and no undo step.
|
||||||
|
let mut trial = s.doc()?.shapes().to_vec();
|
||||||
|
order_tree(&mut trial, &ids, how, 0);
|
||||||
|
if stacking(&trial) == stacking(s.doc()?.shapes()) {
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
s.edit(|doc, sel| {
|
s.edit(|doc, sel| {
|
||||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
||||||
match how {
|
order_tree(list, &ids, how, 0);
|
||||||
2 => {
|
|
||||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
|
||||||
list.extend(rest);
|
|
||||||
list.append(&mut moved);
|
|
||||||
}
|
|
||||||
-2 => {
|
|
||||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
|
||||||
moved.extend(rest);
|
|
||||||
*list = moved;
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
|
||||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
|
||||||
if a == Some(true) && b == Some(false) {
|
|
||||||
list.swap(i, i + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
for i in 1..list.len() {
|
|
||||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
|
||||||
if a == Some(false) && b == Some(true) {
|
|
||||||
list.swap(i - 1, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
ok()
|
ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every shape id with its depth, in tree order.
|
||||||
|
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
|
||||||
|
let mut out = vec![];
|
||||||
|
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
|
||||||
|
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
|
||||||
|
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
|
||||||
|
if depth > 64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if list.iter().any(|x| ids.contains(&x.id)) {
|
||||||
|
order_list(list, ids, how);
|
||||||
|
}
|
||||||
|
for sh in list.iter_mut() {
|
||||||
|
if let Some(children) = sh.children_mut() {
|
||||||
|
order_tree(children, ids, how, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
|
||||||
|
match how {
|
||||||
|
2 => {
|
||||||
|
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||||
|
list.extend(rest);
|
||||||
|
list.append(&mut moved);
|
||||||
|
}
|
||||||
|
-2 => {
|
||||||
|
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||||
|
moved.extend(rest);
|
||||||
|
*list = moved;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||||
|
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||||
|
if a == Some(true) && b == Some(false) {
|
||||||
|
list.swap(i, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
for i in 1..list.len() {
|
||||||
|
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||||
|
if a == Some(false) && b == Some(true) {
|
||||||
|
list.swap(i - 1, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
|
||||||
|
/// A shape stays in the list that holds it, so a group's child stays in its group.
|
||||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||||
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
||||||
|
let st = s.doc()?;
|
||||||
|
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
|
||||||
|
None => return Err(bad("arrange.reorder", "no such shape")),
|
||||||
|
Some(None) => st.shapes(),
|
||||||
|
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
|
||||||
|
};
|
||||||
|
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||||
|
if from == to.min(siblings.len().saturating_sub(1)) {
|
||||||
|
return Ok(json!({"index": from}));
|
||||||
|
}
|
||||||
s.edit(|doc, sel| {
|
s.edit(|doc, sel| {
|
||||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
|
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||||
|
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||||
let sh = list.remove(i);
|
let sh = list.remove(i);
|
||||||
list.insert(to.min(list.len()), sh);
|
let at = to.min(list.len());
|
||||||
Ok(())
|
list.insert(at, sh);
|
||||||
})?;
|
Ok(json!({"index": at}))
|
||||||
ok()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounds of shapes (rotation included).
|
/// Bounds of shapes (rotation included).
|
||||||
@@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
} else {
|
} else {
|
||||||
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
||||||
};
|
};
|
||||||
edit_shapes(s, p, "arrange.align", |sh| {
|
edit_geometry(s, p, "arrange.align", |sh| {
|
||||||
if let Some(x) = sh.xfrm.as_mut() {
|
if let Some(x) = sh.xfrm.as_mut() {
|
||||||
let b = x.bounds();
|
let b = x.bounds();
|
||||||
let (dx, dy) = match edge.as_str() {
|
let (dx, dy) = match edge.as_str() {
|
||||||
@@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
targets_pos.push((*id, d));
|
targets_pos.push((*id, d));
|
||||||
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
||||||
}
|
}
|
||||||
edit_shapes(s, p, "arrange.distribute", |sh| {
|
edit_geometry(s, p, "arrange.distribute", |sh| {
|
||||||
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
||||||
if horiz {
|
if horiz {
|
||||||
x.x += d;
|
x.x += d;
|
||||||
|
|||||||
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
color
|
color
|
||||||
),
|
),
|
||||||
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
|
||||||
cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case),
|
cmd!(
|
||||||
|
"format.case",
|
||||||
|
"Change Case",
|
||||||
|
["Home", "Font"],
|
||||||
|
Some("Shift+F3"),
|
||||||
|
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
|
||||||
|
has_text_or_shapes,
|
||||||
|
case
|
||||||
|
),
|
||||||
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
|
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
|
||||||
cmd!(
|
cmd!(
|
||||||
"format.spacing",
|
"format.spacing",
|
||||||
@@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let mode = str_param(p, "mode").unwrap_or("sentence").to_string();
|
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
if let Some(t) = st.selection.text.clone() {
|
if let Some(t) = st.selection.text.clone() {
|
||||||
let (a, b) = t.ordered();
|
let (mut a, mut b) = t.ordered();
|
||||||
if a == b {
|
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||||
return ok();
|
let collapsed = a == b;
|
||||||
|
if collapsed {
|
||||||
|
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||||
|
let (wa, wb) = ed::word_at(&body, a);
|
||||||
|
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||||
|
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
(a, b) = (wa, wb);
|
||||||
}
|
}
|
||||||
let m = mode.clone();
|
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||||
super::text::edit_text(s, move |body, _| {
|
super::text::edit_text(s, move |body, t| {
|
||||||
for pi in a.0..=b.0 {
|
for pi in a.0..=b.0 {
|
||||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||||
let n = para.char_len();
|
let n = para.char_len();
|
||||||
@@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
pos += len;
|
pos += len;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if collapsed {
|
||||||
|
t.anchor = a;
|
||||||
|
t.caret = a;
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
return ok();
|
return ok();
|
||||||
}
|
}
|
||||||
|
let mode = if mode == "cycle" {
|
||||||
|
let all: String =
|
||||||
|
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||||
|
"
|
||||||
|
",
|
||||||
|
);
|
||||||
|
ed::next_case(&all).to_string()
|
||||||
|
} else {
|
||||||
|
mode
|
||||||
|
};
|
||||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||||
if let Some(t) = sh.text.as_mut() {
|
if let Some(t) = sh.text.as_mut() {
|
||||||
for para in &mut t.paragraphs {
|
for para in &mut t.paragraphs {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod inspect;
|
|||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod review;
|
pub mod review;
|
||||||
|
pub mod selection;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
pub mod slide;
|
pub mod slide;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
@@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||||||
v.extend(insert::specs());
|
v.extend(insert::specs());
|
||||||
v.extend(shape::specs());
|
v.extend(shape::specs());
|
||||||
v.extend(arrange::specs());
|
v.extend(arrange::specs());
|
||||||
|
v.extend(selection::specs());
|
||||||
v.extend(merge::specs());
|
v.extend(merge::specs());
|
||||||
v.extend(text::specs());
|
v.extend(text::specs());
|
||||||
v.extend(format::specs());
|
v.extend(format::specs());
|
||||||
@@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
|
||||||
|
/// locked targets are left out, and when every target is locked nothing changes.
|
||||||
|
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||||
|
let ids = targets(s, p)?;
|
||||||
|
let st = s.doc()?;
|
||||||
|
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
|
||||||
|
if !ids.is_empty() && free.is_empty() {
|
||||||
|
return Err(bad(cmd, "the object is locked"));
|
||||||
|
}
|
||||||
|
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
||||||
match sel.target {
|
match sel.target {
|
||||||
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
|
||||||
|
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
|
||||||
|
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
|
||||||
|
|
||||||
|
use deckcraft_model::{Shape, ShapeId};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{Result, Session};
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
vec![
|
||||||
|
cmd!(
|
||||||
|
"selection.select",
|
||||||
|
"Select",
|
||||||
|
["Selection Pane"],
|
||||||
|
None,
|
||||||
|
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
|
||||||
|
has_slide,
|
||||||
|
select
|
||||||
|
),
|
||||||
|
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
|
||||||
|
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
|
||||||
|
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
|
||||||
|
/// object from the list shows it again.
|
||||||
|
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
|
||||||
|
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
|
||||||
|
let st = s.doc()?;
|
||||||
|
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
|
||||||
|
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
|
||||||
|
if valid.is_empty() {
|
||||||
|
return Err(bad("selection.select", "no such shape"));
|
||||||
|
}
|
||||||
|
let kind = valid.first().and_then(in_group);
|
||||||
|
valid.retain(|i| in_group(i) == kind);
|
||||||
|
let keep: Vec<ShapeId> = if toggle || add {
|
||||||
|
let current = st.selection.shapes.clone();
|
||||||
|
if current.first().is_some_and(|c| in_group(c) != kind) {
|
||||||
|
// Can't mix objects in groups with top-level ones: nothing changes.
|
||||||
|
return Ok(json!({"selected": current}));
|
||||||
|
}
|
||||||
|
current
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let mut next = keep;
|
||||||
|
for i in &valid {
|
||||||
|
match next.iter().position(|x| x == i) {
|
||||||
|
Some(k) if toggle => {
|
||||||
|
next.remove(k);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
None => next.push(*i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
|
||||||
|
if reveal.is_empty() {
|
||||||
|
s.select(|_, sel| {
|
||||||
|
sel.text = None;
|
||||||
|
sel.cells = None;
|
||||||
|
sel.shapes = next.clone();
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
s.edit(|doc, sel| {
|
||||||
|
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
|
||||||
|
for id in &reveal {
|
||||||
|
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||||
|
sh.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel.text = None;
|
||||||
|
sel.cells = None;
|
||||||
|
sel.shapes = next.clone();
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(json!({"selected": s.doc()?.selection.shapes}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||||
|
fn any_hidden(v: &[Shape]) -> bool {
|
||||||
|
let mut found = false;
|
||||||
|
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
|
||||||
|
found
|
||||||
|
}
|
||||||
|
if !any_hidden(s.doc()?.shapes()) {
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
s.edit(|doc, sel| {
|
||||||
|
fn rec(v: &mut [Shape], depth: usize) {
|
||||||
|
for sh in v {
|
||||||
|
sh.hidden = false;
|
||||||
|
if depth < 64
|
||||||
|
&& let Some(c) = sh.children_mut()
|
||||||
|
{
|
||||||
|
rec(c, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||||
|
if s.doc()?.shapes().iter().all(|x| x.hidden) {
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
s.edit(|doc, sel| {
|
||||||
|
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
|
||||||
|
sh.hidden = true;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "selection_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! Selection pane: reorder, groups, hide/show, lock and undo.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||||
|
fn deck() -> (Session, Vec<u32>) {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||||
|
let mut ids = vec![];
|
||||||
|
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
|
||||||
|
let x = 20.0 + 110.0 * i as f64;
|
||||||
|
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
|
||||||
|
let id = r["id"].as_u64().unwrap() as u32;
|
||||||
|
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||||
|
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
|
||||||
|
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||||
|
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||||
|
ids.extend([h, g]);
|
||||||
|
(s, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tree as text, back to front: `A B C G[D H[E F]]`.
|
||||||
|
fn tree(s: &Session) -> String {
|
||||||
|
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||||
|
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
rec(s.doc().unwrap().shapes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (hidden, locked)
|
||||||
|
fn flags(s: &Session, id: u32) -> (bool, bool) {
|
||||||
|
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
|
||||||
|
(sh.hidden, sh.locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(s: &Session) -> Vec<u32> {
|
||||||
|
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo(s: &mut Session) {
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bx(s: &mut Session, id: u32) -> Value {
|
||||||
|
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bring_forward_and_send_backward_stay_inside_the_group() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
|
||||||
|
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||||
|
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||||
|
// A child moves among its siblings only and stops at the group's front.
|
||||||
|
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||||
|
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||||
|
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
|
||||||
|
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||||
|
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[D H[F E]]");
|
||||||
|
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
|
||||||
|
// Steps that changed nothing left no undo step.
|
||||||
|
undo(&mut s);
|
||||||
|
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||||
|
undo(&mut s);
|
||||||
|
undo(&mut s);
|
||||||
|
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reorder_is_the_drag_and_drop_among_siblings() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
|
||||||
|
// Drag A to the front of the slide (an index past the end clamps).
|
||||||
|
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
|
||||||
|
assert_eq!(tree(&s), "B C G[D H[E F]] A");
|
||||||
|
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "G[D H[E F]] B C A");
|
||||||
|
// A child is dragged within its group: it never leaves it.
|
||||||
|
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||||
|
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
|
||||||
|
assert_eq!(tree(&s), "G[H[F E] D] B C A");
|
||||||
|
// Dropping where it already is changes nothing (no undo step).
|
||||||
|
let n = s.doc().unwrap().history.undo.len();
|
||||||
|
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||||
|
// One undo step per drop.
|
||||||
|
undo(&mut s);
|
||||||
|
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||||
|
undo(&mut s);
|
||||||
|
undo(&mut s);
|
||||||
|
undo(&mut s);
|
||||||
|
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||||
|
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
|
||||||
|
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eye_show_all_and_hide_all() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
|
||||||
|
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
|
||||||
|
assert!(flags(&s, b).0);
|
||||||
|
// Hiding a group hides only the group; its children keep their own flags.
|
||||||
|
s.execute("shape.visible", &json!({"id": g})).unwrap();
|
||||||
|
assert!(flags(&s, g).0 && !flags(&s, d).0);
|
||||||
|
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
|
||||||
|
// Hide All hides the top level only.
|
||||||
|
s.execute("selection.hideAll", &json!({})).unwrap();
|
||||||
|
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
|
||||||
|
// Show All shows everything, children included.
|
||||||
|
s.execute("selection.showAll", &json!({})).unwrap();
|
||||||
|
for id in &ids {
|
||||||
|
assert!(!flags(&s, *id).0, "{id} still hidden");
|
||||||
|
}
|
||||||
|
let n = s.doc().unwrap().history.undo.len();
|
||||||
|
s.execute("selection.showAll", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
|
||||||
|
undo(&mut s);
|
||||||
|
assert!(flags(&s, a).0 && flags(&s, e).0);
|
||||||
|
undo(&mut s);
|
||||||
|
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_cascades_through_groups_and_undoes() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
|
||||||
|
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||||
|
assert!(flags(&s, a).1);
|
||||||
|
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||||
|
assert!(!flags(&s, a).1, "toggles");
|
||||||
|
// Locking a group locks everything in it.
|
||||||
|
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
|
||||||
|
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
|
||||||
|
// A child can be unlocked on its own.
|
||||||
|
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
|
||||||
|
assert!(flags(&s, g).1 && !flags(&s, d).1);
|
||||||
|
// Unlocking the group unlocks everything in it.
|
||||||
|
s.execute("shape.lock", &json!({"id": g})).unwrap();
|
||||||
|
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
|
||||||
|
undo(&mut s);
|
||||||
|
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
|
||||||
|
undo(&mut s);
|
||||||
|
undo(&mut s);
|
||||||
|
assert!(!flags(&s, g).1 && !flags(&s, e).1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let (a, b) = (ids[0], ids[1]);
|
||||||
|
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||||
|
let before = bx(&mut s, a);
|
||||||
|
for (cmd, p) in [
|
||||||
|
("shape.move", json!({"ids": [a], "dx": 10})),
|
||||||
|
("shape.resize", json!({"ids": [a], "w": 300})),
|
||||||
|
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
|
||||||
|
("shape.rotate", json!({"ids": [a], "deg": 45})),
|
||||||
|
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
|
||||||
|
("arrange.align", json!({"ids": [a], "edge": "right"})),
|
||||||
|
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
|
||||||
|
] {
|
||||||
|
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
|
||||||
|
}
|
||||||
|
assert_eq!(bx(&mut s, a), before);
|
||||||
|
// Mixed selections: the unlocked ones move, the locked one stays.
|
||||||
|
let bb = bx(&mut s, b);
|
||||||
|
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
|
||||||
|
assert_eq!(bx(&mut s, a), before);
|
||||||
|
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
|
||||||
|
// Formatting and the name still change.
|
||||||
|
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
|
||||||
|
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
|
||||||
|
// The arrow keys don't nudge it.
|
||||||
|
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||||
|
let _ = s.key("ArrowRight", Mods::default());
|
||||||
|
assert_eq!(bx(&mut s, a), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let a = ids[0];
|
||||||
|
s.prefs.snap_to_grid = false;
|
||||||
|
s.prefs.smart_guides = false;
|
||||||
|
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||||
|
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||||
|
let n = s.doc().unwrap().history.undo.len();
|
||||||
|
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
|
||||||
|
// A: x 20..120, y 50..130.
|
||||||
|
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
|
||||||
|
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
|
||||||
|
assert_eq!(bx(&mut s, a)["x"], 20.0);
|
||||||
|
// No resize handle: pressing on the SE corner and dragging doesn't size it.
|
||||||
|
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
|
||||||
|
assert_eq!(bx(&mut s, a)["w"], 100.0);
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_from_the_pane() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
|
||||||
|
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![b]);
|
||||||
|
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![b, c]);
|
||||||
|
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![c]);
|
||||||
|
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![c, a, b]);
|
||||||
|
// A child inside a group; children across nesting levels go together.
|
||||||
|
s.execute("selection.select", &json!({"id": d})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![d]);
|
||||||
|
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![d, e]);
|
||||||
|
// ...but not with top-level objects: Ctrl+click on one is ignored.
|
||||||
|
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![d, e]);
|
||||||
|
s.execute("selection.select", &json!({"id": a})).unwrap();
|
||||||
|
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![a]);
|
||||||
|
// Picking makes no undo step...
|
||||||
|
let n = s.doc().unwrap().history.undo.len();
|
||||||
|
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||||
|
// ...unless it shows a hidden object again.
|
||||||
|
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
|
||||||
|
s.execute("selection.select", &json!({"id": c})).unwrap();
|
||||||
|
assert_eq!(selected(&s), vec![c]);
|
||||||
|
assert!(!flags(&s, c).0, "picking a hidden object shows it");
|
||||||
|
undo(&mut s);
|
||||||
|
assert!(flags(&s, c).0);
|
||||||
|
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_from_the_pane_undoes() {
|
||||||
|
let (mut s, ids) = deck();
|
||||||
|
let e = ids[4];
|
||||||
|
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
|
||||||
|
undo(&mut s);
|
||||||
|
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
|
||||||
|
}
|
||||||
@@ -76,7 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
||||||
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
||||||
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
||||||
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
|
cmd!(
|
||||||
|
"shape.lock",
|
||||||
|
"Lock",
|
||||||
|
["Selection Pane"],
|
||||||
|
None,
|
||||||
|
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
|
||||||
|
has_selection,
|
||||||
|
lock
|
||||||
|
),
|
||||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||||
cmd!(
|
cmd!(
|
||||||
@@ -113,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||||
edit_shapes(s, p, "shape.move", |sh| {
|
edit_geometry(s, p, "shape.move", |sh| {
|
||||||
if let Some(x) = sh.xfrm.as_mut() {
|
if let Some(x) = sh.xfrm.as_mut() {
|
||||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||||
@@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
||||||
let lock = bool_or(p, "lockAspect", false);
|
let lock = bool_or(p, "lockAspect", false);
|
||||||
edit_shapes(s, p, "shape.resize", |sh| {
|
edit_geometry(s, p, "shape.resize", |sh| {
|
||||||
if let Some(x) = sh.xfrm.as_mut() {
|
if let Some(x) = sh.xfrm.as_mut() {
|
||||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||||
@@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
edit_shapes(s, p, "shape.setBounds", |sh| {
|
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||||
if let Some(x) = sh.xfrm.as_mut() {
|
if let Some(x) = sh.xfrm.as_mut() {
|
||||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||||
@@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||||
edit_shapes(s, p, "shape.rotate", |sh| {
|
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||||
if let Some(x) = sh.xfrm.as_mut() {
|
if let Some(x) = sh.xfrm.as_mut() {
|
||||||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||||||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||||||
@@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||||
edit_shapes(s, p, "shape.flip", |sh| {
|
edit_geometry(s, p, "shape.flip", |sh| {
|
||||||
if let Some(x) = sh.xfrm.as_mut() {
|
if let Some(x) = sh.xfrm.as_mut() {
|
||||||
if v {
|
if v {
|
||||||
x.flip_v = !x.flip_v;
|
x.flip_v = !x.flip_v;
|
||||||
@@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||||
edit_shapes(s, p, "shape.adjust", |sh| {
|
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||||
while adj.len() <= i {
|
while adj.len() <= i {
|
||||||
@@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||||
edit_shapes(s, p, "shape.change", |sh| {
|
edit_geometry(s, p, "shape.change", |sh| {
|
||||||
sh.geom = Geom::preset(info.name);
|
sh.geom = Geom::preset(info.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
|
||||||
|
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
|
||||||
|
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let v = bool_param(p, "locked");
|
let v = bool_param(p, "locked");
|
||||||
edit_shapes(s, p, "shape.lock", |sh| {
|
edit_shapes(s, p, "shape.lock", |sh| {
|
||||||
sh.locked = v.unwrap_or(!sh.locked);
|
let to = v.unwrap_or(!sh.locked);
|
||||||
|
set_locked(sh, to, 0);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
|
||||||
|
sh.locked = to;
|
||||||
|
if depth > 64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(children) = sh.children_mut() {
|
||||||
|
for c in children {
|
||||||
|
set_locked(c, to, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
||||||
@@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||||
edit_shapes(s, p, "picture.crop", |sh| {
|
edit_geometry(s, p, "picture.crop", |sh| {
|
||||||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||||||
for (i, v) in c.iter().enumerate() {
|
for (i, v) in c.iter().enumerate() {
|
||||||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||||||
|
|||||||
+213
-15
@@ -17,7 +17,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||||
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
||||||
|
// The input method's composition: shown in place, an undo step only once committed.
|
||||||
|
CommandSpec {
|
||||||
|
id: "text.compose",
|
||||||
|
label: "Compose Text",
|
||||||
|
menu: &[],
|
||||||
|
shortcut: None,
|
||||||
|
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
|
||||||
|
enabled: has_text,
|
||||||
|
run: compose,
|
||||||
|
journal: false,
|
||||||
|
undoable: false,
|
||||||
|
},
|
||||||
|
cmd!(
|
||||||
|
noundo "text.commit",
|
||||||
|
"Commit Composition",
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
"{text} — the input method's result replaces its composition, as one undo step",
|
||||||
|
has_text,
|
||||||
|
commit
|
||||||
|
),
|
||||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||||
|
cmd!(
|
||||||
|
"text.delete",
|
||||||
|
"Delete Text",
|
||||||
|
[],
|
||||||
|
Some("Backspace"),
|
||||||
|
"{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it",
|
||||||
|
has_text,
|
||||||
|
delete
|
||||||
|
),
|
||||||
|
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word),
|
||||||
@@ -144,6 +175,35 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||||
|
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
|
||||||
|
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
|
||||||
|
/// table cells, which aren't laid out on the slide here.
|
||||||
|
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||||
|
if t.notes || t.cell.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sh = st.shape(t.shape)?;
|
||||||
|
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||||
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||||
|
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||||
|
let mut body = sh.text.clone().unwrap_or_default();
|
||||||
|
if body.paragraphs.is_empty() {
|
||||||
|
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||||
|
}
|
||||||
|
let l = deckcraft_text::layout(
|
||||||
|
&ctx,
|
||||||
|
sh,
|
||||||
|
&body,
|
||||||
|
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||||
|
);
|
||||||
|
// After End on a wrapped line the caret is drawn at that line's end.
|
||||||
|
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||||
|
let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
|
||||||
|
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
|
||||||
|
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||||
|
}
|
||||||
|
|
||||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||||
let a = p.get(key)?.as_array()?;
|
let a = p.get(key)?.as_array()?;
|
||||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||||
@@ -184,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
s.select(|_, sel| {
|
s.select(|_, sel| {
|
||||||
sel.shapes = vec![id];
|
sel.shapes = vec![id];
|
||||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false });
|
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||||
}
|
}
|
||||||
@@ -340,6 +400,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
|
|||||||
ok()
|
ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
|
||||||
|
fn ime_text(s: &str) -> String {
|
||||||
|
s.chars().filter(|c| !c.is_control()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the composition still where it was put (the paragraph holds all of it)?
|
||||||
|
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
|
||||||
|
let Some(t) = &st.selection.text else { return false };
|
||||||
|
let Some(body) = body_of(st, t) else { return false };
|
||||||
|
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
|
||||||
|
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
|
||||||
|
/// goes back to how it was before the composition began.
|
||||||
|
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
|
||||||
|
let n = text.chars().count();
|
||||||
|
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||||
|
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||||
|
(x < y).then_some((x.min(n), y.min(n)))
|
||||||
|
});
|
||||||
|
let current = s.doc()?.composition.clone();
|
||||||
|
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||||
|
let current = match current {
|
||||||
|
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||||
|
s.end_composition();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
c => c,
|
||||||
|
};
|
||||||
|
match current {
|
||||||
|
None if text.is_empty() => Ok(json!({"composing": false})),
|
||||||
|
None => {
|
||||||
|
let st = s.doc()?;
|
||||||
|
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
|
||||||
|
let (start, props) = edit_text(s, |body, t| {
|
||||||
|
let (a, b) = t.ordered();
|
||||||
|
let at = ed::delete(body, a, b);
|
||||||
|
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
|
||||||
|
let c = ed::insert(body, at, &text, props.clone());
|
||||||
|
t.anchor = c;
|
||||||
|
t.caret = c;
|
||||||
|
(at, props)
|
||||||
|
})?;
|
||||||
|
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
|
||||||
|
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||||
|
}
|
||||||
|
Some(c) if text.is_empty() => {
|
||||||
|
let st = s.doc_mut()?;
|
||||||
|
st.doc = c.before;
|
||||||
|
st.selection = c.before_selection;
|
||||||
|
st.composition = None;
|
||||||
|
st.revision += 1;
|
||||||
|
Ok(json!({"composing": false}))
|
||||||
|
}
|
||||||
|
Some(c) => {
|
||||||
|
let (start, len, props) = (c.start, c.len, c.props.clone());
|
||||||
|
edit_text(s, |body, t| {
|
||||||
|
ed::delete(body, start, (start.0, start.1 + len));
|
||||||
|
let e = ed::insert(body, start, &text, props);
|
||||||
|
t.anchor = e;
|
||||||
|
t.caret = e;
|
||||||
|
})?;
|
||||||
|
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
|
||||||
|
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text.commit`: the input method's result replaces the composition (or is typed at the
|
||||||
|
/// insertion point when there is none) and the whole composition becomes one undo step.
|
||||||
|
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
|
||||||
|
if s.doc()?.composition.is_some() || !text.is_empty() {
|
||||||
|
compose(s, &json!({"text": text}))?;
|
||||||
|
}
|
||||||
|
s.end_composition();
|
||||||
|
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
|
||||||
|
Ok(json!({"caret": [caret.0, caret.1]}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||||
|
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||||
|
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||||
|
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||||
|
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||||
|
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||||
|
let Some(l) = layout_for(st, t) else { return vec![] };
|
||||||
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||||
|
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||||
|
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||||
|
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut add = |a: usize, b: usize, thick: bool| {
|
||||||
|
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
|
||||||
|
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add(0, c.len, false);
|
||||||
|
if let Some((a, b)) = c.active {
|
||||||
|
add(a, b.min(c.len), true);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||||
insert_text(s, &text, None)
|
insert_text(s, &text, None)
|
||||||
@@ -349,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
|||||||
let dir = dir.to_string();
|
let dir = dir.to_string();
|
||||||
edit_text(s, move |body, t| {
|
edit_text(s, move |body, t| {
|
||||||
let (a, b) = t.ordered();
|
let (a, b) = t.ordered();
|
||||||
let at = if a != b {
|
let at = if dir == "wordBackward" || dir == "wordForward" {
|
||||||
|
// Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it
|
||||||
|
// (PowerPoint for Windows).
|
||||||
|
ed::delete_word(body, a, dir == "wordForward")
|
||||||
|
} else if a != b {
|
||||||
ed::delete(body, a, b)
|
ed::delete(body, a, b)
|
||||||
} else {
|
} else {
|
||||||
let other = match dir.as_str() {
|
let other = match dir.as_str() {
|
||||||
"forward" => ed::char_move(body, a, true),
|
"forward" => ed::char_move(body, a, true),
|
||||||
"wordBackward" => ed::word_move(body, a, false),
|
|
||||||
"wordForward" => ed::word_move(body, a, true),
|
|
||||||
_ => {
|
_ => {
|
||||||
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
// Backspace at the start of an indented/bulleted paragraph outdents first.
|
||||||
if a.1 == 0
|
if a.1 == 0
|
||||||
@@ -382,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is paragraph `para` right-to-left (its lines run from the right)?
|
||||||
|
fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool {
|
||||||
|
match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) {
|
||||||
|
Some(line) => line.rtl,
|
||||||
|
None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
use deckcraft_text::Affinity;
|
||||||
let to = str_param(p, "to").unwrap_or("right").to_string();
|
let to = str_param(p, "to").unwrap_or("right").to_string();
|
||||||
let extend = bool_or(p, "extend", false);
|
let extend = bool_or(p, "extend", false);
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
@@ -391,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let layout = layout_for(st, &t);
|
let layout = layout_for(st, &t);
|
||||||
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 };
|
||||||
let caret = t.caret;
|
let caret = t.caret;
|
||||||
|
let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream };
|
||||||
|
// Left and right are on screen: in a right-to-left paragraph left goes forward in the text.
|
||||||
|
let rtl = para_rtl(layout.as_ref(), &body, caret.0);
|
||||||
|
let to = match (to.as_str(), rtl) {
|
||||||
|
("left", true) => "right",
|
||||||
|
("right", true) => "left",
|
||||||
|
("wordLeft", true) => "wordRight",
|
||||||
|
("wordRight", true) => "wordLeft",
|
||||||
|
(x, _) => x,
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 };
|
||||||
|
let mut upstream = false;
|
||||||
let new = match to.as_str() {
|
let new = match to.as_str() {
|
||||||
"left" if t.is_range() && !extend => collapse_to(true),
|
"left" if t.is_range() && !extend => collapse_to(true),
|
||||||
"right" if t.is_range() && !extend => collapse_to(false),
|
"right" if t.is_range() && !extend => collapse_to(false),
|
||||||
"left" => ed::char_move(&body, caret, false),
|
"left" => ed::char_move(&body, caret, false),
|
||||||
"right" => ed::char_move(&body, caret, true),
|
"right" => ed::char_move(&body, caret, true),
|
||||||
"wordLeft" => ed::word_move(&body, caret, false),
|
// Ctrl+arrows with a selection go on from its start (left) or end (right).
|
||||||
"wordRight" => ed::word_move(&body, caret, true),
|
"wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false),
|
||||||
|
"wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true),
|
||||||
"paraStart" => (caret.0, 0),
|
"paraStart" => (caret.0, 0),
|
||||||
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
"paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||||
|
"paraUp" => ed::para_up(&body, caret),
|
||||||
|
"paraDown" => ed::para_down(&body, caret),
|
||||||
"start" => (0, 0),
|
"start" => (0, 0),
|
||||||
"end" => ed::end(&body),
|
"end" => ed::end(&body),
|
||||||
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
"up" | "down" | "lineStart" | "lineEnd" => match &layout {
|
||||||
Some(l) => {
|
Some(l) => {
|
||||||
let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0);
|
let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0);
|
||||||
let r = match to.as_str() {
|
let (r, a) = match to.as_str() {
|
||||||
"up" => l.vertical(lp(caret), false, x),
|
"up" => l.vertical_affine(lp(caret), aff, false, x),
|
||||||
"down" => l.vertical(lp(caret), true, x),
|
"down" => l.vertical_affine(lp(caret), aff, true, x),
|
||||||
"lineStart" => l.line_bounds(lp(caret)).0,
|
"lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream),
|
||||||
_ => l.line_bounds(lp(caret)).1,
|
// End stays on its line: after a soft wrap the caret sits at the line's end.
|
||||||
|
_ => l.line_end_caret(lp(caret), aff),
|
||||||
};
|
};
|
||||||
|
upstream = a == Affinity::Upstream;
|
||||||
(r.para, r.ch)
|
(r.para, r.ch)
|
||||||
}
|
}
|
||||||
None => match to.as_str() {
|
None => match to.as_str() {
|
||||||
@@ -427,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
s.select(|_, sel| {
|
s.select(|_, sel| {
|
||||||
if let Some(tt) = sel.text.as_mut() {
|
if let Some(tt) = sel.text.as_mut() {
|
||||||
tt.caret = new;
|
tt.caret = new;
|
||||||
|
tt.line_end = if upstream { Some(new) } else { None };
|
||||||
if !extend {
|
if !extend {
|
||||||
tt.anchor = new;
|
tt.anchor = new;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"caret": [new.0, new.1]}))
|
Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
@@ -479,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||||
let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
let body = body_of(st, &t);
|
||||||
|
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||||
|
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||||
|
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||||
s.select(|_, sel| {
|
s.select(|_, sel| {
|
||||||
if let Some(t) = sel.text.as_mut() {
|
if let Some(t) = sel.text.as_mut() {
|
||||||
t.anchor = (at.0, 0);
|
t.anchor = (at.0, 0);
|
||||||
t.caret = (at.0, n);
|
t.caret = end;
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
ok()
|
ok()
|
||||||
|
|||||||
+119
-11
@@ -20,28 +20,49 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
|
|||||||
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for
|
||||||
|
/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the
|
||||||
|
/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+].
|
||||||
|
pub fn key_token(key: &str) -> String {
|
||||||
|
let k = key.to_ascii_lowercase();
|
||||||
|
let k = k.trim_start_matches("arrow");
|
||||||
|
match k {
|
||||||
|
"period" | ">" => ".",
|
||||||
|
"comma" | "<" => ",",
|
||||||
|
"openbracket" | "{" => "[",
|
||||||
|
"closebracket" | "}" => "]",
|
||||||
|
"equals" => "=",
|
||||||
|
"minus" => "-",
|
||||||
|
"plus" => "+",
|
||||||
|
"slash" => "/",
|
||||||
|
"backslash" => "\\",
|
||||||
|
"semicolon" => ";",
|
||||||
|
"quote" => "'",
|
||||||
|
"backtick" => "`",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
|
/// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`,
|
||||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||||
/// Returns `{handled: bool, …}`.
|
/// Returns `{handled: bool, …}`.
|
||||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||||
|
self.end_composition();
|
||||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||||
let k = key.to_ascii_lowercase();
|
let k = key_token(key);
|
||||||
let k = k.trim_start_matches("arrow");
|
let k = k.as_str();
|
||||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||||
return self.finish_freeform(false);
|
return self.finish_freeform(false);
|
||||||
}
|
}
|
||||||
|
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||||
|
let r = self.execute(id, ¶ms)?;
|
||||||
|
return Ok(json!({"handled": true, "command": id, "result": r}));
|
||||||
|
}
|
||||||
if editing && !mods.cmd {
|
if editing && !mods.cmd {
|
||||||
let r = match k {
|
let r = match k {
|
||||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
|
||||||
"right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))),
|
|
||||||
"up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))),
|
|
||||||
"down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))),
|
|
||||||
"home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))),
|
|
||||||
"end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))),
|
|
||||||
"backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))),
|
|
||||||
"delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))),
|
|
||||||
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
|
"enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))),
|
||||||
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
|
"escape" | "esc" => Some(self.execute("text.exit", &json!({}))),
|
||||||
"tab" => {
|
"tab" => {
|
||||||
@@ -116,6 +137,10 @@ impl Session {
|
|||||||
}
|
}
|
||||||
// Shortcuts from the registry.
|
// Shortcuts from the registry.
|
||||||
let key_name = match k {
|
let key_name = match k {
|
||||||
|
"left" => "Left",
|
||||||
|
"right" => "Right",
|
||||||
|
"up" => "Up",
|
||||||
|
"down" => "Down",
|
||||||
"enter" | "return" => "Enter",
|
"enter" | "return" => "Enter",
|
||||||
"escape" | "esc" => "Escape",
|
"escape" | "esc" => "Escape",
|
||||||
"delete" => "Delete",
|
"delete" => "Delete",
|
||||||
@@ -126,7 +151,7 @@ impl Session {
|
|||||||
"end" => "End",
|
"end" => "End",
|
||||||
"tab" => "Tab",
|
"tab" => "Tab",
|
||||||
"space" | " " => "Space",
|
"space" | " " => "Space",
|
||||||
_ => key,
|
_ => k,
|
||||||
};
|
};
|
||||||
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
|
let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id);
|
||||||
if let Some(id) = found {
|
if let Some(id) = found {
|
||||||
@@ -136,6 +161,50 @@ impl Session {
|
|||||||
Ok(json!({"handled": false}))
|
Ok(json!({"handled": false}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text-editing command for a navigation or deletion key while editing text, by
|
||||||
|
/// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down
|
||||||
|
/// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and
|
||||||
|
/// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection.
|
||||||
|
fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> {
|
||||||
|
let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt };
|
||||||
|
let mac_cmd = self.mac_keys && mods.cmd && !mods.alt;
|
||||||
|
if mods.cmd && !mods.alt && !mods.shift {
|
||||||
|
// Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <.
|
||||||
|
match k {
|
||||||
|
"]" => return Some(("format.grow", json!({}))),
|
||||||
|
"[" => return Some(("format.shrink", json!({}))),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let plain = !mods.cmd && !mods.alt;
|
||||||
|
let to = match k {
|
||||||
|
"left" if word => "wordLeft",
|
||||||
|
"right" if word => "wordRight",
|
||||||
|
"left" if mac_cmd => "lineStart",
|
||||||
|
"right" if mac_cmd => "lineEnd",
|
||||||
|
"left" if plain => "left",
|
||||||
|
"right" if plain => "right",
|
||||||
|
"up" if mac_cmd => "start",
|
||||||
|
"down" if mac_cmd => "end",
|
||||||
|
"up" if word && self.mac_keys => "paraStart",
|
||||||
|
"down" if word && self.mac_keys => "paraEnd",
|
||||||
|
"up" if word => "paraUp",
|
||||||
|
"down" if word => "paraDown",
|
||||||
|
"up" if plain => "up",
|
||||||
|
"down" if plain => "down",
|
||||||
|
"home" if mods.cmd && !mods.alt => "start",
|
||||||
|
"end" if mods.cmd && !mods.alt => "end",
|
||||||
|
"home" if plain => "lineStart",
|
||||||
|
"end" if plain => "lineEnd",
|
||||||
|
"backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))),
|
||||||
|
"delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))),
|
||||||
|
"backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))),
|
||||||
|
"delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(("text.move", json!({"to": to, "extend": mods.shift})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Type text: into the text being edited, or (a shape selected) start editing it.
|
/// Type text: into the text being edited, or (a shape selected) start editing it.
|
||||||
pub fn type_text(&mut self, text: &str) -> Result<Value> {
|
pub fn type_text(&mut self, text: &str) -> Result<Value> {
|
||||||
let st = self.doc()?;
|
let st = self.doc()?;
|
||||||
@@ -151,6 +220,45 @@ impl Session {
|
|||||||
self.execute("text.insert", &json!({"text": text}))
|
self.execute("text.insert", &json!({"text": text}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
|
||||||
|
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
|
||||||
|
/// `active` is the part being converted, in characters of `text`.
|
||||||
|
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
|
||||||
|
if !self.start_typing(text)? {
|
||||||
|
return Ok(json!({"handled": false}));
|
||||||
|
}
|
||||||
|
let mut p = json!({"text": text});
|
||||||
|
if let Some((a, b)) = active {
|
||||||
|
p["active"] = json!([a, b]);
|
||||||
|
}
|
||||||
|
self.execute("text.compose", &p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An input method's result (#101): replaces the composition as one undo step.
|
||||||
|
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
|
||||||
|
if !self.start_typing(text)? {
|
||||||
|
return Ok(json!({"handled": false}));
|
||||||
|
}
|
||||||
|
self.execute("text.commit", &json!({"text": text}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||||
|
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||||
|
/// and nothing being edited).
|
||||||
|
fn start_typing(&mut self, text: &str) -> Result<bool> {
|
||||||
|
let st = self.doc()?;
|
||||||
|
if st.selection.text.is_some() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
|
||||||
|
if st.selection.shapes.len() != 1 || text.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.execute("text.edit", &json!({"id": id}))?;
|
||||||
|
self.execute("text.selectAll", &json!({}))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
||||||
let st = self.doc()?;
|
let st = self.doc()?;
|
||||||
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ pub struct TextSel {
|
|||||||
/// Editing the slide's speaker notes (shape is ignored).
|
/// Editing the slide's speaker notes (shape is ignored).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notes: bool,
|
pub notes: bool,
|
||||||
|
/// The caret position that sits at the end of a wrapped line (after End) rather than at the
|
||||||
|
/// start of the next line. Ignored once the caret moves anywhere else.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub line_end: Option<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextSel {
|
impl TextSel {
|
||||||
@@ -77,6 +81,10 @@ impl TextSel {
|
|||||||
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
|
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
|
||||||
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
|
if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) }
|
||||||
}
|
}
|
||||||
|
/// Is the caret drawn at the end of the line before (upstream), not the start of the next?
|
||||||
|
pub fn at_line_end(&self) -> bool {
|
||||||
|
self.line_end == Some(self.caret)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
|
/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view).
|
||||||
@@ -131,6 +139,24 @@ pub struct Interaction {
|
|||||||
pub selection: Selection,
|
pub selection: Selection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||||
|
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
|
||||||
|
/// before it began makes the whole composition one undo step.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Composition {
|
||||||
|
/// Where the composed text starts: (paragraph, char).
|
||||||
|
pub start: (usize, usize),
|
||||||
|
/// Its length in characters (it never spans paragraphs).
|
||||||
|
pub len: usize,
|
||||||
|
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
|
||||||
|
pub active: Option<(usize, usize)>,
|
||||||
|
/// The formatting the composed text is typed with (as `text.insert` would choose).
|
||||||
|
pub props: Option<deckcraft_model::text::RunProps>,
|
||||||
|
/// The presentation and selection before the composition began.
|
||||||
|
pub before: Arc<Presentation>,
|
||||||
|
pub before_selection: Selection,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DocState {
|
pub struct DocState {
|
||||||
pub doc: Arc<Presentation>,
|
pub doc: Arc<Presentation>,
|
||||||
@@ -145,6 +171,8 @@ pub struct DocState {
|
|||||||
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||||
/// documents); the app mentions them once.
|
/// documents); the app mentions them once.
|
||||||
pub missing_fonts: Vec<String>,
|
pub missing_fonts: Vec<String>,
|
||||||
|
/// An input method's composition in progress ([`Composition`]).
|
||||||
|
pub composition: Option<Composition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
@@ -163,6 +191,7 @@ impl DocState {
|
|||||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
title,
|
title,
|
||||||
missing_fonts: Vec::new(),
|
missing_fonts: Vec::new(),
|
||||||
|
composition: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_dirty(&self) -> bool {
|
pub fn is_dirty(&self) -> bool {
|
||||||
@@ -318,6 +347,10 @@ pub struct Session {
|
|||||||
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
||||||
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
||||||
pub gif_paused: std::collections::HashSet<ShapeId>,
|
pub gif_paused: std::collections::HashSet<ShapeId>,
|
||||||
|
/// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text
|
||||||
|
/// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the
|
||||||
|
/// platform DeckCraft was built for.
|
||||||
|
pub mac_keys: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Session {
|
impl Default for Session {
|
||||||
@@ -345,6 +378,7 @@ impl Session {
|
|||||||
recovery_dir: None,
|
recovery_dir: None,
|
||||||
media_status: Default::default(),
|
media_status: Default::default(),
|
||||||
gif_paused: Default::default(),
|
gif_paused: Default::default(),
|
||||||
|
mac_keys: cfg!(target_os = "macos"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// A session with one new presentation open.
|
/// A session with one new presentation open.
|
||||||
@@ -403,6 +437,10 @@ impl Session {
|
|||||||
|
|
||||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||||
|
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||||
|
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||||
|
self.end_composition();
|
||||||
|
}
|
||||||
if let Err(e) = (spec.enabled)(self) {
|
if let Err(e) = (spec.enabled)(self) {
|
||||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||||
if !named {
|
if !named {
|
||||||
@@ -453,10 +491,24 @@ impl Session {
|
|||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||||
|
/// composition becomes one undo step. Nothing happens without one.
|
||||||
|
pub fn end_composition(&mut self) {
|
||||||
|
let Some(st) = self.active_mut() else { return };
|
||||||
|
let Some(c) = st.composition.take() else { return };
|
||||||
|
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||||
|
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Change only the selection (no undo step).
|
/// Change only the selection (no undo step).
|
||||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||||
let st = self.doc_mut()?;
|
let st = self.doc_mut()?;
|
||||||
let doc = st.doc.clone();
|
let doc = st.doc.clone();
|
||||||
|
// A caret placed anywhere starts downstream; only End puts it back at a line's end.
|
||||||
|
if let Some(t) = st.selection.text.as_mut() {
|
||||||
|
t.line_end = None;
|
||||||
|
}
|
||||||
f(&doc, &mut st.selection);
|
f(&doc, &mut st.selection);
|
||||||
clamp_selection(st);
|
clamp_selection(st);
|
||||||
st.revision += 1;
|
st.revision += 1;
|
||||||
@@ -501,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_keys;
|
||||||
|
|||||||
@@ -606,3 +606,171 @@ fn shapes_off_the_slide_can_be_selected_and_moved() {
|
|||||||
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn title_text(s: &Session) -> String {
|
||||||
|
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||||
|
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
|
||||||
|
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
|
||||||
|
/// committed text is one undo step, and nothing on the way is an undo step of its own.
|
||||||
|
#[test]
|
||||||
|
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
|
||||||
|
let mut s = session();
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
s.execute("text.insert", &json!({"text": "A "})).unwrap();
|
||||||
|
let undo0 = s.doc().unwrap().history.undo.len();
|
||||||
|
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||||
|
s.compose_text(pre, None).unwrap();
|
||||||
|
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
|
||||||
|
let c = s.doc().unwrap().composition.clone().unwrap();
|
||||||
|
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
|
||||||
|
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
|
||||||
|
}
|
||||||
|
s.commit_text("한글").unwrap();
|
||||||
|
assert_eq!(title_text(&s), "A 한글");
|
||||||
|
assert!(s.doc().unwrap().composition.is_none());
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "A ");
|
||||||
|
s.execute("edit.redo", &json!({})).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "A 한글");
|
||||||
|
|
||||||
|
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
|
||||||
|
// then a new preedit.
|
||||||
|
s.compose_text("ㄴ", None).unwrap();
|
||||||
|
s.compose_text("", None).unwrap();
|
||||||
|
s.commit_text("ㄴ").unwrap();
|
||||||
|
s.compose_text("ㅁ", None).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
|
||||||
|
s.commit_text("마").unwrap();
|
||||||
|
assert_eq!(title_text(&s), "A 한글ㄴ마");
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "A 한글ㄴ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
|
||||||
|
/// the text and the selection it replaced are as they were, with no undo step.
|
||||||
|
#[test]
|
||||||
|
fn ime_cancel_restores_the_text_and_selection() {
|
||||||
|
let mut s = session();
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||||
|
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
|
||||||
|
let undo0 = s.doc().unwrap().history.undo.len();
|
||||||
|
let rev = s.doc().unwrap().revision;
|
||||||
|
s.compose_text("せかい", Some((0, 3))).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
|
||||||
|
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
|
||||||
|
s.compose_text("世界", Some((0, 9))).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
|
||||||
|
s.compose_text("", None).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "Hello world");
|
||||||
|
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||||
|
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||||
|
assert!(s.doc().unwrap().revision > rev, "redrawn");
|
||||||
|
// A commit of nothing without a composition is nothing.
|
||||||
|
s.commit_text("").unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||||
|
// A commit without a preedit (some input methods send only the result) is one step.
|
||||||
|
s.commit_text("世界").unwrap();
|
||||||
|
assert_eq!(title_text(&s), "Hello 世界");
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #101: anything else settles the composition first: its text stays and is its own undo step.
|
||||||
|
#[test]
|
||||||
|
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
|
||||||
|
let mut s = session();
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
let undo0 = s.doc().unwrap().history.undo.len();
|
||||||
|
s.compose_text("안", None).unwrap();
|
||||||
|
s.key("Right", Mods::default()).unwrap();
|
||||||
|
assert!(s.doc().unwrap().composition.is_none());
|
||||||
|
assert_eq!(title_text(&s), "안");
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||||
|
|
||||||
|
s.compose_text("녕", None).unwrap();
|
||||||
|
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||||
|
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
|
||||||
|
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||||
|
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
|
||||||
|
assert_eq!(title_text(&s), "안녕");
|
||||||
|
|
||||||
|
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
s.compose_text("하", None).unwrap();
|
||||||
|
s.execute("text.get", &json!({"id": title})).unwrap();
|
||||||
|
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
|
||||||
|
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "안녕하!");
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "안녕하");
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(title_text(&s), "안녕");
|
||||||
|
|
||||||
|
// Text changed under the composition (here behind its back): a new one starts, no panic.
|
||||||
|
s.compose_text("ㄱ", None).unwrap();
|
||||||
|
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
|
||||||
|
s.compose_text("가", None).unwrap();
|
||||||
|
assert!(title_text(&s).ends_with('가'));
|
||||||
|
// Control characters (an Enter sent as text) never split the paragraph.
|
||||||
|
s.commit_text("가\n\r\u{b}").unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
|
||||||
|
/// does; with nothing selected there is nowhere to compose.
|
||||||
|
#[test]
|
||||||
|
fn ime_on_a_selected_shape_replaces_its_text() {
|
||||||
|
let mut s = session();
|
||||||
|
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||||
|
let id = r["id"].as_u64().unwrap();
|
||||||
|
s.select(|_, sel| {
|
||||||
|
sel.text = None;
|
||||||
|
sel.shapes = vec![ShapeId(id as u32)];
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
|
||||||
|
s.compose_text("新", None).unwrap();
|
||||||
|
s.commit_text("新しい").unwrap();
|
||||||
|
let v = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||||
|
assert_eq!(v["text"], "新しい");
|
||||||
|
s.select(|_, sel| {
|
||||||
|
sel.text = None;
|
||||||
|
sel.shapes.clear();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
|
||||||
|
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #101: the composition is underlined in place, under the composed characters, with a thicker
|
||||||
|
/// line under the part being converted.
|
||||||
|
#[test]
|
||||||
|
fn ime_composition_underlines_follow_the_text() {
|
||||||
|
let mut s = session();
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||||
|
s.compose_text("にほんご", Some((2, 4))).unwrap();
|
||||||
|
let u = cmd::text::composition_underlines(s.doc().unwrap());
|
||||||
|
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
|
||||||
|
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
|
||||||
|
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
|
||||||
|
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
|
||||||
|
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
|
||||||
|
for (a, b, _) in &u {
|
||||||
|
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
|
||||||
|
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
|
||||||
|
}
|
||||||
|
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
|
||||||
|
s.commit_text("日本語").unwrap();
|
||||||
|
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
//! Text-editing keys driven through `Session::key`, as the keyboard, control channel and MCP
|
||||||
|
//! send them. Every expectation is PowerPoint for Windows' observed behaviour unless it says Mac.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::Session;
|
||||||
|
use crate::tools::Mods;
|
||||||
|
|
||||||
|
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
|
||||||
|
const CTRL: Mods = Mods { shift: false, alt: false, cmd: true };
|
||||||
|
const CTRL_SHIFT: Mods = Mods { shift: true, alt: false, cmd: true };
|
||||||
|
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false };
|
||||||
|
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
|
||||||
|
|
||||||
|
type Pos = (usize, usize);
|
||||||
|
|
||||||
|
/// A session editing a new text box holding `text` (Windows keys), caret at `at`.
|
||||||
|
fn editing(text: &str, at: Pos) -> (Session, u32) {
|
||||||
|
editing_in(text, at, [60.0, 60.0, 600.0, 300.0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editing_in(text: &str, at: Pos, rect: [f64; 4]) -> (Session, u32) {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
s.mac_keys = false;
|
||||||
|
s.prefs.autocorrect = false;
|
||||||
|
s.prefs.smart_quotes = false;
|
||||||
|
s.execute("insert.textBox", &json!({"rect": rect, "text": "x"})).unwrap();
|
||||||
|
let id = s.doc().unwrap().selection.shapes[0].0;
|
||||||
|
s.execute("text.set", &json!({"id": id, "text": text})).unwrap();
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [at.0, at.1]})).unwrap();
|
||||||
|
(s, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sel(s: &Session) -> (Pos, Pos) {
|
||||||
|
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||||
|
(t.anchor, t.caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caret(s: &Session) -> Pos {
|
||||||
|
sel(s).1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(s: &mut Session, key: &str, mods: Mods) -> Value {
|
||||||
|
let r = s.key(key, mods).unwrap();
|
||||||
|
assert_eq!(r["handled"], true, "{key} {mods:?} not handled");
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(s: &mut Session, id: u32) -> String {
|
||||||
|
s.execute("text.get", &json!({"id": id})).unwrap()["text"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Press `key` until the caret stops moving; the carets visited (in the first paragraph).
|
||||||
|
fn walk(s: &mut Session, key: &str, mods: Mods) -> Vec<usize> {
|
||||||
|
let mut out = vec![caret(s).1];
|
||||||
|
for _ in 0..200 {
|
||||||
|
let before = caret(s);
|
||||||
|
press(s, key, mods);
|
||||||
|
if caret(s) == before {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(caret(s).1);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_arrows_stop_where_powerpoint_does() {
|
||||||
|
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||||
|
let stops = vec![0, 5, 7, 12, 15, 18, 21, 25, 33, 38, 43, 44, 45, 46, 48, 49, 50, 52, 53, 54, 55, 56, 60, 61, 64, 66, 67, 68, 70, 73, 74];
|
||||||
|
let (mut s, _) = editing(t, (0, 0));
|
||||||
|
assert_eq!(walk(&mut s, "ArrowRight", CTRL), stops);
|
||||||
|
let mut back = stops.clone();
|
||||||
|
back.reverse();
|
||||||
|
assert_eq!(walk(&mut s, "ArrowLeft", CTRL), back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_arrows_over_cjk_rtl_emoji_and_marks() {
|
||||||
|
let (mut s, _) = editing("日本語のテキスト abc 한국어", (0, 0));
|
||||||
|
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 15, 16]);
|
||||||
|
// Arabic and Hebrew words inside a left-to-right paragraph: in logical order.
|
||||||
|
let (mut s, _) = editing("Hello مرحبا بالعالم world שלום end", (0, 0));
|
||||||
|
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 6, 12, 20, 26, 31, 34]);
|
||||||
|
// An emoji ZWJ sequence and a combining mark are never split.
|
||||||
|
let (mut s, _) = editing("e\u{301}te 👨\u{200d}👩\u{200d}👧 x", (0, 0));
|
||||||
|
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 5, 11, 12]);
|
||||||
|
assert_eq!(walk(&mut s, "Left", CTRL), vec![12, 11, 5, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_arrows_cross_paragraphs_line_breaks_and_tabs() {
|
||||||
|
let (mut s, id) = editing("One two\nThree four\n\nFive", (0, 0));
|
||||||
|
let mut seen = vec![caret(&s)];
|
||||||
|
for _ in 0..9 {
|
||||||
|
press(&mut s, "Right", CTRL);
|
||||||
|
seen.push(caret(&s));
|
||||||
|
}
|
||||||
|
assert_eq!(seen, vec![(0, 0), (0, 4), (0, 7), (1, 0), (1, 6), (1, 10), (2, 0), (3, 0), (3, 4), (3, 4)]);
|
||||||
|
// A line break and a tab are stops of their own.
|
||||||
|
s.execute("text.set", &json!({"id": id, "text": "x"})).unwrap();
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||||
|
s.execute("text.selectAll", &json!({})).unwrap();
|
||||||
|
s.execute("text.insert", &json!({"text": "Five\u{b}six seven\tTab x"})).unwrap();
|
||||||
|
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||||
|
assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 4, 5, 9, 14, 15, 21, 22]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_shift_arrows_extend_by_words() {
|
||||||
|
let (mut s, _) = editing("Hello world foo, bar\nnext para", (0, 2));
|
||||||
|
let mut carets = vec![];
|
||||||
|
for _ in 0..6 {
|
||||||
|
press(&mut s, "Right", CTRL_SHIFT);
|
||||||
|
carets.push(sel(&s));
|
||||||
|
}
|
||||||
|
let a = (0, 2);
|
||||||
|
assert_eq!(carets.iter().map(|x| x.1).collect::<Vec<_>>(), vec![(0, 6), (0, 13), (0, 16), (0, 18), (0, 21), (1, 0)]);
|
||||||
|
assert!(carets.iter().all(|x| x.0 == a));
|
||||||
|
let mut back = vec![];
|
||||||
|
for _ in 0..7 {
|
||||||
|
press(&mut s, "Left", CTRL_SHIFT);
|
||||||
|
back.push(caret(&s));
|
||||||
|
}
|
||||||
|
assert_eq!(back, vec![(0, 21), (0, 18), (0, 16), (0, 13), (0, 6), (0, 0), (0, 0)]);
|
||||||
|
assert_eq!(sel(&s).0, a);
|
||||||
|
// From a caret inside a word going left: the word's start, then on.
|
||||||
|
let (mut s, _) = editing("Hello world foo, bar\nnext para", (1, 2));
|
||||||
|
press(&mut s, "Left", CTRL_SHIFT);
|
||||||
|
press(&mut s, "Left", CTRL_SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((1, 2), (0, 21)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_arrows_leave_a_selection_from_its_ends() {
|
||||||
|
for (anchor, caret_at) in [((0, 2), (0, 7)), ((0, 7), (0, 2))] {
|
||||||
|
let (mut s, _) = editing("Hello world foo, bar", anchor);
|
||||||
|
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||||
|
press(&mut s, "Right", CTRL);
|
||||||
|
assert_eq!(sel(&s), ((0, 13), (0, 13)));
|
||||||
|
s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap();
|
||||||
|
press(&mut s, "Left", CTRL);
|
||||||
|
assert_eq!(sel(&s), ((0, 0), (0, 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_backspace_and_delete() {
|
||||||
|
let (mut s, id) = editing("Hello world foo, bar\nnext para", (0, 13));
|
||||||
|
press(&mut s, "Backspace", CTRL);
|
||||||
|
assert_eq!(text(&mut s, id), "Hello foo, bar\nnext para");
|
||||||
|
assert_eq!(caret(&s), (0, 6));
|
||||||
|
press(&mut s, "Delete", CTRL);
|
||||||
|
assert_eq!(text(&mut s, id), "Hello , bar\nnext para".replace(" ,", ","));
|
||||||
|
assert_eq!(caret(&s), (0, 5));
|
||||||
|
// At a paragraph's ends they join paragraphs.
|
||||||
|
s.execute("text.move", &json!({"to": "paraEnd"})).unwrap();
|
||||||
|
press(&mut s, "Delete", CTRL);
|
||||||
|
assert_eq!(text(&mut s, id), "Hello, barnext para");
|
||||||
|
// A selection is not deleted: the word goes from its start (PowerPoint for Windows).
|
||||||
|
let (mut s, id) = editing("Hello world", (0, 2));
|
||||||
|
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 7]})).unwrap();
|
||||||
|
press(&mut s, "Backspace", CTRL);
|
||||||
|
assert_eq!((text(&mut s, id), sel(&s)), ("llo world".into(), ((0, 0), (0, 0))));
|
||||||
|
let (mut s, id) = editing("Hello world", (0, 7));
|
||||||
|
s.execute("text.select", &json!({"anchor": [0, 7], "caret": [0, 2]})).unwrap();
|
||||||
|
press(&mut s, "Delete", CTRL);
|
||||||
|
assert_eq!((text(&mut s, id), sel(&s)), ("He world".into(), ((0, 2), (0, 2))));
|
||||||
|
// It is one undo step.
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(text(&mut s, id), "Hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_up_down_move_by_paragraph() {
|
||||||
|
let t = "First paragraph that is long enough to wrap in this box.\nSecond\n\nFourth para";
|
||||||
|
let (mut s, _) = editing_in(t, (0, 5), [60.0, 60.0, 200.0, 300.0]);
|
||||||
|
let mut seen = vec![];
|
||||||
|
for _ in 0..5 {
|
||||||
|
press(&mut s, "Down", CTRL);
|
||||||
|
seen.push(caret(&s));
|
||||||
|
}
|
||||||
|
assert_eq!(seen, vec![(1, 0), (2, 0), (3, 0), (3, 11), (3, 11)]);
|
||||||
|
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||||
|
press(&mut s, "Left", NONE);
|
||||||
|
let mut seen = vec![];
|
||||||
|
for _ in 0..5 {
|
||||||
|
press(&mut s, "Up", CTRL);
|
||||||
|
seen.push(caret(&s));
|
||||||
|
}
|
||||||
|
assert_eq!(seen, vec![(3, 0), (2, 0), (1, 0), (0, 0), (0, 0)]);
|
||||||
|
// With Shift they extend; back past the anchor the selection flips.
|
||||||
|
s.execute("text.edit", &json!({"at": [0, 5]})).unwrap();
|
||||||
|
press(&mut s, "Down", CTRL_SHIFT);
|
||||||
|
press(&mut s, "Down", CTRL_SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((0, 5), (2, 0)));
|
||||||
|
press(&mut s, "Up", CTRL_SHIFT);
|
||||||
|
press(&mut s, "Up", CTRL_SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((0, 5), (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_end_and_ctrl_home_end() {
|
||||||
|
let t = "First paragraph that is long enough to wrap across several lines in this narrow box.\nSecond";
|
||||||
|
let (mut s, _) = editing_in(t, (0, 0), [60.0, 60.0, 160.0, 300.0]);
|
||||||
|
let st = s.doc().unwrap();
|
||||||
|
let ts = st.selection.text.clone().unwrap();
|
||||||
|
let layout = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||||
|
let lines: Vec<(usize, usize)> = layout.lines.iter().filter(|l| l.para == 0).map(|l| (l.start, l.end)).collect();
|
||||||
|
assert!(lines.len() >= 3, "{lines:?}");
|
||||||
|
let (l1, l2) = (lines[1], lines[2]);
|
||||||
|
// End goes past a wrapped line's trailing space, to where the next line starts, as in
|
||||||
|
// PowerPoint, and the caret stays drawn on the line it ended.
|
||||||
|
let next = lines[2].0;
|
||||||
|
s.execute("text.edit", &json!({"at": [0, l1.0 + 2]})).unwrap();
|
||||||
|
press(&mut s, "Home", NONE);
|
||||||
|
assert_eq!(caret(&s), (0, l1.0));
|
||||||
|
press(&mut s, "End", NONE);
|
||||||
|
assert_eq!(caret(&s), (0, next));
|
||||||
|
assert!(s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||||
|
let drawn = |s: &Session| {
|
||||||
|
let st = s.doc().unwrap();
|
||||||
|
let ts = st.selection.text.clone().unwrap();
|
||||||
|
let l = crate::cmd::text::layout_for(st, &ts).unwrap();
|
||||||
|
let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
|
||||||
|
l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff).unwrap()
|
||||||
|
};
|
||||||
|
let line1_top = layout.lines.iter().filter(|l| l.para == 0).nth(1).unwrap().top;
|
||||||
|
assert_eq!(drawn(&s).1, line1_top);
|
||||||
|
// End again stays on the line; Home goes back to its start, not the next line's.
|
||||||
|
press(&mut s, "End", NONE);
|
||||||
|
assert_eq!(caret(&s), (0, next));
|
||||||
|
press(&mut s, "Home", SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((0, next), (0, l1.0)));
|
||||||
|
press(&mut s, "End", SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((0, next), (0, next)));
|
||||||
|
assert_eq!(drawn(&s).1, line1_top);
|
||||||
|
// Down from a line's end lands on the next line, not two lines down.
|
||||||
|
press(&mut s, "Down", NONE);
|
||||||
|
let c = caret(&s);
|
||||||
|
assert!(c.1 > l2.0 && c.1 <= l2.1, "{c:?} not on line {l2:?}");
|
||||||
|
// The caret is drawn at the end of the line it belongs to.
|
||||||
|
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
|
||||||
|
let ts = s.doc().unwrap().selection.text.clone().unwrap();
|
||||||
|
assert!(ts.at_line_end());
|
||||||
|
// Ctrl+Home / Ctrl+End, with Shift extending.
|
||||||
|
press(&mut s, "End", CTRL);
|
||||||
|
assert_eq!(caret(&s), (1, 6));
|
||||||
|
press(&mut s, "Home", CTRL_SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((1, 6), (0, 0)));
|
||||||
|
press(&mut s, "Home", CTRL);
|
||||||
|
press(&mut s, "End", CTRL_SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((0, 0), (1, 6)));
|
||||||
|
// A caret placed anywhere else is drawn at the start of its line again.
|
||||||
|
s.execute("text.select", &json!({"anchor": [0, next], "caret": [0, next]})).unwrap();
|
||||||
|
assert!(!s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end());
|
||||||
|
assert!(drawn(&s).1 > line1_top);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn right_to_left_paragraphs_swap_left_and_right() {
|
||||||
|
let (mut s, _) = editing("مرحبا بالعالم hello world", (0, 0));
|
||||||
|
s.execute("format.rtl", &json!({"on": true})).unwrap();
|
||||||
|
// Ctrl+Left goes forward in a right-to-left paragraph, Ctrl+Right back.
|
||||||
|
assert_eq!(walk(&mut s, "Left", CTRL), vec![0, 6, 14, 20, 25]);
|
||||||
|
assert_eq!(walk(&mut s, "Right", CTRL), vec![25, 20, 14, 6, 0]);
|
||||||
|
press(&mut s, "Left", NONE);
|
||||||
|
assert_eq!(caret(&s), (0, 1));
|
||||||
|
press(&mut s, "Left", CTRL_SHIFT);
|
||||||
|
assert_eq!(sel(&s), ((0, 1), (0, 6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mac_keys() {
|
||||||
|
let (mut s, id) = editing("Hello world foo\nnext", (0, 8));
|
||||||
|
s.mac_keys = true;
|
||||||
|
press(&mut s, "Right", ALT);
|
||||||
|
assert_eq!(caret(&s), (0, 12));
|
||||||
|
press(&mut s, "Left", ALT);
|
||||||
|
assert_eq!(caret(&s), (0, 6));
|
||||||
|
press(&mut s, "Right", CTRL);
|
||||||
|
assert_eq!(caret(&s), (0, 15));
|
||||||
|
press(&mut s, "Left", CTRL);
|
||||||
|
assert_eq!(caret(&s), (0, 0));
|
||||||
|
press(&mut s, "Down", CTRL);
|
||||||
|
assert_eq!(caret(&s), (1, 4));
|
||||||
|
press(&mut s, "Up", CTRL);
|
||||||
|
assert_eq!(caret(&s), (0, 0));
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [0, 11]})).unwrap();
|
||||||
|
press(&mut s, "Backspace", ALT);
|
||||||
|
assert_eq!(text(&mut s, id), "Hello foo\nnext");
|
||||||
|
// Ctrl+arrows aren't word moves on the Mac, nor Alt+arrows on Windows.
|
||||||
|
s.mac_keys = false;
|
||||||
|
let before = caret(&s);
|
||||||
|
let r = s.key("Right", ALT).unwrap();
|
||||||
|
assert_eq!((r["handled"].as_bool(), caret(&s)), (Some(false), before));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn font_size_keys() {
|
||||||
|
let (mut s, _) = editing("hello wOrld. foo bar", (0, 0));
|
||||||
|
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||||
|
let size = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||||
|
let base = size(&mut s).unwrap_or(18.0);
|
||||||
|
// egui names the keys `Period`, `Comma`, `CloseBracket`, `OpenBracket`.
|
||||||
|
press(&mut s, "Period", CTRL_SHIFT);
|
||||||
|
let up = size(&mut s).unwrap();
|
||||||
|
assert!(up > base);
|
||||||
|
press(&mut s, "Comma", CTRL_SHIFT);
|
||||||
|
assert_eq!(size(&mut s), Some(base));
|
||||||
|
press(&mut s, "CloseBracket", CTRL);
|
||||||
|
assert_eq!(size(&mut s), Some(up));
|
||||||
|
press(&mut s, "OpenBracket", CTRL);
|
||||||
|
assert_eq!(size(&mut s), Some(base));
|
||||||
|
press(&mut s, ">", CTRL_SHIFT);
|
||||||
|
assert_eq!(size(&mut s), Some(up));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_italic_underline_and_clear_on_a_caret_in_a_word() {
|
||||||
|
let (mut s, id) = editing("hello wOrld. foo bar", (0, 8));
|
||||||
|
let r = press(&mut s, "b", CTRL);
|
||||||
|
assert_eq!(r["command"], "format.bold");
|
||||||
|
let body = |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||||
|
let runs: Vec<(String, Option<bool>)> = body(&s).paragraphs[0].runs.iter().map(|r| (r.text.clone(), r.props.bold)).collect();
|
||||||
|
assert_eq!(runs.iter().find(|r| r.1 == Some(true)).map(|r| r.0.as_str()), Some("wOrld"));
|
||||||
|
// At a word boundary nothing visible changes.
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [0, 5]})).unwrap();
|
||||||
|
press(&mut s, "i", CTRL);
|
||||||
|
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.italic != Some(true)));
|
||||||
|
// Ctrl+Space clears the word's formatting.
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||||
|
press(&mut s, "Space", CTRL);
|
||||||
|
assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.bold != Some(true)));
|
||||||
|
press(&mut s, "u", CTRL);
|
||||||
|
let under: String = body(&s).paragraphs[0].runs.iter().filter(|r| r.props.underline.is_some()).map(|r| r.text.clone()).collect();
|
||||||
|
assert_eq!(under, "wOrld");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alignment_keys() {
|
||||||
|
let (mut s, _) = editing("one\ntwo", (0, 1));
|
||||||
|
for (k, want) in [("e", "ctr"), ("r", "r"), ("j", "just"), ("l", "l")] {
|
||||||
|
press(&mut s, k, CTRL);
|
||||||
|
let st = s.execute("format.state", &json!({})).unwrap();
|
||||||
|
assert_eq!(st["align"], want, "Ctrl+{k}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_f3_cycles_case() {
|
||||||
|
let (mut s, id) = editing("hello world", (0, 0));
|
||||||
|
s.execute("text.selectAll", &json!({})).unwrap();
|
||||||
|
let mut seen = vec![];
|
||||||
|
for _ in 0..3 {
|
||||||
|
press(&mut s, "F3", SHIFT);
|
||||||
|
seen.push(text(&mut s, id));
|
||||||
|
}
|
||||||
|
assert_eq!(seen, vec!["Hello World", "HELLO WORLD", "hello world"]);
|
||||||
|
s.execute("text.set", &json!({"id": id, "text": "hello wOrld. foo"})).unwrap();
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap();
|
||||||
|
press(&mut s, "F3", SHIFT);
|
||||||
|
assert_eq!(text(&mut s, id), "hello WORLD. foo");
|
||||||
|
assert_eq!(sel(&s), ((0, 6), (0, 6)));
|
||||||
|
press(&mut s, "F3", SHIFT);
|
||||||
|
assert_eq!(text(&mut s, id), "hello world. foo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_enter_and_word_and_paragraph_selection() {
|
||||||
|
let (mut s, id) = editing("Hello world, foo bar\nnext", (0, 7));
|
||||||
|
s.execute("text.selectWord", &json!({})).unwrap();
|
||||||
|
assert_eq!(sel(&s), ((0, 6), (0, 11)));
|
||||||
|
s.execute("text.selectWord", &json!({"at": [0, 12]})).unwrap();
|
||||||
|
assert_eq!(sel(&s), ((0, 11), (0, 12)));
|
||||||
|
// Triple-click: the paragraph and its mark.
|
||||||
|
s.execute("text.selectParagraph", &json!({"at": [0, 3]})).unwrap();
|
||||||
|
assert_eq!(sel(&s), ((0, 0), (1, 0)));
|
||||||
|
s.execute("text.selectParagraph", &json!({"at": [1, 1]})).unwrap();
|
||||||
|
assert_eq!(sel(&s), ((1, 0), (1, 4)));
|
||||||
|
s.execute("text.edit", &json!({"id": id, "at": [1, 2]})).unwrap();
|
||||||
|
press(&mut s, "Enter", SHIFT);
|
||||||
|
let b = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||||
|
assert_eq!(b.paragraphs.len(), 2);
|
||||||
|
assert_eq!(b.paragraphs[1].text(), "ne\u{b}xt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_never_panic_on_any_text() {
|
||||||
|
let texts = ["", " ", "\u{b}", "\t\t", "👨\u{200d}👩\u{200d}👧", "e\u{301}", "日本", "مرحبا", "a\n\nb", " x \n "];
|
||||||
|
let keys = ["Left", "Right", "Up", "Down", "Home", "End", "Backspace", "Delete"];
|
||||||
|
for t in texts {
|
||||||
|
for k in keys {
|
||||||
|
for m in [CTRL, CTRL_SHIFT, NONE, SHIFT, ALT] {
|
||||||
|
for at in [(0, 0), (0, 1), (5, 5)] {
|
||||||
|
let (mut s, _) = editing(t, at);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = s.key(k, m).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,10 @@ impl Session {
|
|||||||
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
|
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
|
||||||
for id in st.selection.shapes.iter().rev() {
|
for id in st.selection.shapes.iter().rev() {
|
||||||
let Some(sh) = st.shape(*id) else { continue };
|
let Some(sh) = st.shape(*id) else { continue };
|
||||||
|
// A locked object shows no live handles: it can't be sized, rotated or reshaped.
|
||||||
|
if sh.locked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||||
if sh.is_line() {
|
if sh.is_line() {
|
||||||
let a = x.affine();
|
let a = x.affine();
|
||||||
@@ -247,8 +251,9 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Shapes, topmost first.
|
// Shapes, topmost first.
|
||||||
|
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||||
for sh in st.shapes().iter().rev() {
|
for sh in st.shapes().iter().rev() {
|
||||||
if sh.hidden || sh.locked {
|
if sh.hidden {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(h) = hit_shape(st, sh, p, tol) {
|
if let Some(h) = hit_shape(st, sh, p, tol) {
|
||||||
@@ -265,6 +270,10 @@ impl Session {
|
|||||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||||
return Ok(Value::Null);
|
return Ok(Value::Null);
|
||||||
}
|
}
|
||||||
|
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||||
|
if !matches!(ev.kind, PointerKind::Move) {
|
||||||
|
self.end_composition();
|
||||||
|
}
|
||||||
match ev.kind {
|
match ev.kind {
|
||||||
PointerKind::Move => {
|
PointerKind::Move => {
|
||||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||||
@@ -419,7 +428,9 @@ impl Session {
|
|||||||
} else {
|
} else {
|
||||||
vec![id]
|
vec![id]
|
||||||
};
|
};
|
||||||
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
// Locked objects stay put when the selection is dragged.
|
||||||
|
let origs: Vec<(ShapeId, Xfrm)> =
|
||||||
|
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||||
// placeholders take the caret straight away like PowerPoint.
|
// placeholders take the caret straight away like PowerPoint.
|
||||||
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
||||||
@@ -429,7 +440,8 @@ impl Session {
|
|||||||
sel.text = None;
|
sel.text = None;
|
||||||
sel.cells = None;
|
sel.cells = None;
|
||||||
})?;
|
})?;
|
||||||
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
|
let duplicate = mods.alt && origs.len() == ids.len();
|
||||||
|
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
|
||||||
// Format Painter applies on click.
|
// Format Painter applies on click.
|
||||||
if self.painter.is_some() && !toggle {
|
if self.painter.is_some() && !toggle {
|
||||||
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
||||||
@@ -460,7 +472,7 @@ impl Session {
|
|||||||
match drag {
|
match drag {
|
||||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||||
let mut d = p - start;
|
let mut d = p - start;
|
||||||
if !moved && d.hypot() < tol.max(1.0) {
|
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
|
||||||
return Ok(Value::Null);
|
return Ok(Value::Null);
|
||||||
}
|
}
|
||||||
if !moved {
|
if !moved {
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ deckcraft-color = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
unicode-segmentation = { workspace = true }
|
||||||
|
|||||||
+248
-45
@@ -2,6 +2,7 @@
|
|||||||
//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
|
//! with `\n` splits paragraphs; formatting a range splits runs at its ends.
|
||||||
|
|
||||||
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
|
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
|
||||||
|
use crate::words;
|
||||||
|
|
||||||
/// (paragraph, char offset in the paragraph).
|
/// (paragraph, char offset in the paragraph).
|
||||||
pub type Pos = (usize, usize);
|
pub type Pos = (usize, usize);
|
||||||
@@ -275,65 +276,109 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
|||||||
caret
|
caret
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_word(c: char) -> bool {
|
fn para_text(body: &TextBody, pi: usize) -> String {
|
||||||
c.is_alphanumeric() || c == '_' || c == '\''
|
body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Word boundaries around `pos` (double-click selection).
|
/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its
|
||||||
|
/// trailing spaces, as PowerPoint selects it.
|
||||||
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
|
pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) {
|
||||||
let (pi, ch) = clamp(body, pos);
|
let (pi, ch) = clamp(body, pos);
|
||||||
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
|
let (s, e) = words::word_at(¶_text(body, pi), ch);
|
||||||
if t.is_empty() {
|
((pi, s), (pi, e))
|
||||||
return ((pi, 0), (pi, 0));
|
|
||||||
}
|
|
||||||
let at = ch.min(t.len().saturating_sub(1));
|
|
||||||
let w = t.get(at).copied().is_some_and(is_word);
|
|
||||||
let mut s = at;
|
|
||||||
while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) {
|
|
||||||
s -= 1;
|
|
||||||
}
|
|
||||||
let mut e = at;
|
|
||||||
while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) {
|
|
||||||
e += 1;
|
|
||||||
}
|
|
||||||
// Include trailing spaces after a word.
|
|
||||||
while w && e < t.len() && t.get(e) == Some(&' ') {
|
|
||||||
e += 1;
|
|
||||||
}
|
|
||||||
((pi, s), (pi, e.max(s)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Next/previous word start (Option/Ctrl + arrows).
|
/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of
|
||||||
|
/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the
|
||||||
|
/// end of the previous one.
|
||||||
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||||
let (pi, ch) = clamp(body, pos);
|
let (pi, ch) = clamp(body, pos);
|
||||||
let t: Vec<char> = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default();
|
let t = para_text(body, pi);
|
||||||
if forward {
|
if forward {
|
||||||
if ch >= t.len() {
|
match words::next_stop(&t, ch) {
|
||||||
return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) };
|
Some(s) => (pi, s),
|
||||||
|
None if pi + 1 < body.paragraphs.len() => (pi + 1, 0),
|
||||||
|
None => (pi, ch),
|
||||||
}
|
}
|
||||||
let mut i = ch;
|
|
||||||
while i < t.len() && t.get(i).copied().is_some_and(is_word) {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
while i < t.len() && !t.get(i).copied().is_some_and(is_word) {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
(pi, i)
|
|
||||||
} else {
|
} else {
|
||||||
if ch == 0 {
|
match words::prev_stop(&t, ch) {
|
||||||
return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) };
|
Some(s) => (pi, s),
|
||||||
|
None if pi > 0 => (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)),
|
||||||
|
None => (pi, 0),
|
||||||
}
|
}
|
||||||
let mut i = ch;
|
|
||||||
while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) {
|
|
||||||
i -= 1;
|
|
||||||
}
|
|
||||||
while i > 0 && t.get(i - 1).copied().is_some_and(is_word) {
|
|
||||||
i -= 1;
|
|
||||||
}
|
|
||||||
(pi, i)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ctrl+Up: the start of the paragraph, or of the previous one when already there.
|
||||||
|
pub fn para_up(body: &TextBody, pos: Pos) -> Pos {
|
||||||
|
let (pi, ch) = clamp(body, pos);
|
||||||
|
if ch > 0 { (pi, 0) } else { (pi.saturating_sub(1), 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl+Down: the start of the next paragraph, or the end of the last one.
|
||||||
|
pub fn para_down(body: &TextBody, pos: Pos) -> Pos {
|
||||||
|
let (pi, _) = clamp(body, pos);
|
||||||
|
if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in
|
||||||
|
/// PowerPoint; returns the caret.
|
||||||
|
///
|
||||||
|
/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the
|
||||||
|
/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a
|
||||||
|
/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then
|
||||||
|
/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or
|
||||||
|
/// at the end of the paragraph after a word go, and sentence punctuation left against a word
|
||||||
|
/// gets a space. At a paragraph's end it joins the next paragraph.
|
||||||
|
pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos {
|
||||||
|
if body.paragraphs.is_empty() {
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
let (pi, ch) = clamp(body, pos);
|
||||||
|
let t = para_text(body, pi);
|
||||||
|
if !forward {
|
||||||
|
let to = match words::prev_stop(&t, ch) {
|
||||||
|
Some(s) => (pi, s),
|
||||||
|
None => char_move(body, (pi, ch), false),
|
||||||
|
};
|
||||||
|
return delete(body, to, (pi, ch));
|
||||||
|
}
|
||||||
|
let Some(e) = words::delete_forward_end(&t, ch) else {
|
||||||
|
let to = char_move(body, (pi, ch), true);
|
||||||
|
return delete(body, (pi, ch), to);
|
||||||
|
};
|
||||||
|
let at = delete(body, (pi, ch), (pi, e));
|
||||||
|
smart_space(body, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret.
|
||||||
|
fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
|
||||||
|
let (pi, s) = at;
|
||||||
|
let chars: Vec<char> = para_text(body, pi).chars().collect();
|
||||||
|
let after = chars.get(s).copied();
|
||||||
|
let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
||||||
|
if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') {
|
||||||
|
let mut w = s;
|
||||||
|
while w > 0 && chars.get(w - 1) == Some(&' ') {
|
||||||
|
w -= 1;
|
||||||
|
}
|
||||||
|
if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) {
|
||||||
|
return delete(body, (pi, w), (pi, s));
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
if let (Some(b), Some(a)) = (before, after)
|
||||||
|
&& words::is_sentence_punct(b)
|
||||||
|
&& a.is_alphanumeric()
|
||||||
|
{
|
||||||
|
let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric();
|
||||||
|
if !digits {
|
||||||
|
return insert(body, at, " ", None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
at
|
||||||
|
}
|
||||||
|
|
||||||
/// One character left/right across paragraph boundaries.
|
/// One character left/right across paragraph boundaries.
|
||||||
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||||
let (pi, ch) = clamp(body, pos);
|
let (pi, ch) = clamp(body, pos);
|
||||||
@@ -361,6 +406,18 @@ pub fn end(body: &TextBody) -> Pos {
|
|||||||
(pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0))
|
(pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each
|
||||||
|
/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first.
|
||||||
|
pub fn next_case(s: &str) -> &'static str {
|
||||||
|
if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) {
|
||||||
|
"title"
|
||||||
|
} else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) {
|
||||||
|
"lower"
|
||||||
|
} else {
|
||||||
|
"upper"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||||
pub fn change_case(s: &str, mode: &str) -> String {
|
pub fn change_case(s: &str, mode: &str) -> String {
|
||||||
match mode {
|
match mode {
|
||||||
@@ -456,7 +513,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn words_and_moves() {
|
fn words_and_moves() {
|
||||||
let b = body("Hello brave world");
|
let b = body("Hello brave world");
|
||||||
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 12)));
|
assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11)));
|
||||||
assert_eq!(word_move(&b, (0, 0), true), (0, 6));
|
assert_eq!(word_move(&b, (0, 0), true), (0, 6));
|
||||||
assert_eq!(word_move(&b, (0, 8), false), (0, 6));
|
assert_eq!(word_move(&b, (0, 8), false), (0, 6));
|
||||||
assert_eq!(char_move(&b, (0, 17), true), (0, 17));
|
assert_eq!(char_move(&b, (0, 17), true), (0, 17));
|
||||||
@@ -472,4 +529,150 @@ mod tests {
|
|||||||
assert_eq!(b.paragraphs[0].char_len(), 3);
|
assert_eq!(b.paragraphs[0].char_len(), 3);
|
||||||
assert_eq!(b.paragraphs.len(), 1);
|
assert_eq!(b.paragraphs.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results).
|
||||||
|
fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) {
|
||||||
|
let mut b = body(text);
|
||||||
|
let c = delete_word(&mut b, at, forward);
|
||||||
|
(b.text(), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_backspace_like_powerpoint() {
|
||||||
|
let t = "ab cd...ef gh";
|
||||||
|
let want = [
|
||||||
|
("ab cd...ef gh", 0),
|
||||||
|
("b cd...ef gh", 0),
|
||||||
|
(" cd...ef gh", 0),
|
||||||
|
(" cd...ef gh", 0),
|
||||||
|
(" cd...ef gh", 0),
|
||||||
|
("cd...ef gh", 0),
|
||||||
|
("ab d...ef gh", 5),
|
||||||
|
("ab ...ef gh", 5),
|
||||||
|
("ab cd..ef gh", 7),
|
||||||
|
("ab cd.ef gh", 7),
|
||||||
|
("ab cdef gh", 7),
|
||||||
|
("ab cd...f gh", 10),
|
||||||
|
("ab cd... gh", 10),
|
||||||
|
("ab cd...gh", 10),
|
||||||
|
("ab cd...ef h", 13),
|
||||||
|
("ab cd...ef ", 13),
|
||||||
|
];
|
||||||
|
for (c, (txt, caret)) in want.iter().enumerate() {
|
||||||
|
assert_eq!(del_word(t, (0, c), false), (txt.to_string(), (0, *caret)), "caret {c}");
|
||||||
|
}
|
||||||
|
let w = "Hello world foo, bar\nnext para";
|
||||||
|
assert_eq!(del_word(w, (0, 12), false), ("Hello foo, bar\nnext para".into(), (0, 6)));
|
||||||
|
assert_eq!(del_word(w, (0, 17), false), ("Hello world foo bar\nnext para".into(), (0, 16)));
|
||||||
|
// At a paragraph's start it joins the paragraph before.
|
||||||
|
assert_eq!(del_word(w, (1, 0), false), ("Hello world foo, barnext para".into(), (0, 21)));
|
||||||
|
assert_eq!(del_word("ab\n cd", (1, 2), false), ("ab\ncd".into(), (1, 0)));
|
||||||
|
// A line break is a word of its own.
|
||||||
|
let mut b = body("ab");
|
||||||
|
insert(&mut b, (0, 2), "\u{b}cd", None);
|
||||||
|
assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2));
|
||||||
|
assert_eq!(b.text(), "abcd");
|
||||||
|
let mut b = body("ab");
|
||||||
|
insert(&mut b, (0, 2), "\u{b}cd", None);
|
||||||
|
assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2));
|
||||||
|
assert_eq!(b.text(), "abcd");
|
||||||
|
assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0)));
|
||||||
|
// Emoji sequences and combining marks are never split.
|
||||||
|
assert_eq!(del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false), ("a ".into(), (0, 2)));
|
||||||
|
assert_eq!(del_word("ce\u{301}te x", (0, 5), false), (" x".into(), (0, 0)));
|
||||||
|
assert_eq!(del_word("ce\u{301}te x", (0, 6), false), ("x".into(), (0, 0)));
|
||||||
|
// Each CJK character is a word.
|
||||||
|
assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_delete_like_powerpoint() {
|
||||||
|
let t = "ab cd...ef gh";
|
||||||
|
let want = [
|
||||||
|
("cd...ef gh", 0),
|
||||||
|
("a cd...ef gh", 1),
|
||||||
|
("ab...ef gh", 2),
|
||||||
|
("ab cd...ef gh", 3),
|
||||||
|
("ab cd...ef gh", 4),
|
||||||
|
("ab...ef gh", 2),
|
||||||
|
("ab c...ef gh", 6),
|
||||||
|
("ab cdef gh", 7),
|
||||||
|
("ab cd. ef gh", 9),
|
||||||
|
("ab cd.. ef gh", 10),
|
||||||
|
("ab cd... gh", 11),
|
||||||
|
("ab cd...e gh", 11),
|
||||||
|
("ab cd...ef", 12),
|
||||||
|
("ab cd...ef", 12),
|
||||||
|
("ab cd...ef g", 14),
|
||||||
|
("ab cd...ef gh", 15),
|
||||||
|
];
|
||||||
|
for (c, (txt, caret)) in want.iter().enumerate() {
|
||||||
|
assert_eq!(del_word(t, (0, c), true), (txt.to_string(), (0, *caret)), "caret {c}");
|
||||||
|
}
|
||||||
|
let w = "Hello world foo, bar\nnext para";
|
||||||
|
let want = [
|
||||||
|
(0, "world foo, bar\nnext para", 0),
|
||||||
|
(2, "He world foo, bar\nnext para", 2),
|
||||||
|
(5, "Hello foo, bar\nnext para", 5),
|
||||||
|
(6, "Hello foo, bar\nnext para", 6),
|
||||||
|
(8, "Hello wo foo, bar\nnext para", 8),
|
||||||
|
(11, "Hello world, bar\nnext para", 11),
|
||||||
|
(12, "Hello world foo, bar\nnext para", 12),
|
||||||
|
(16, "Hello world foobar\nnext para", 16),
|
||||||
|
(17, "Hello world foo,\nnext para", 17),
|
||||||
|
(18, "Hello world foo, \nnext para", 18),
|
||||||
|
(21, "Hello world foo, barnext para", 21),
|
||||||
|
];
|
||||||
|
for (c, txt, caret) in want {
|
||||||
|
assert_eq!(del_word(w, (0, c), true), (txt.to_string(), (0, caret)), "caret {c}");
|
||||||
|
}
|
||||||
|
assert_eq!(del_word(w, (1, 0), true), ("Hello world foo, bar\npara".into(), (1, 0)));
|
||||||
|
assert_eq!(del_word(w, (1, 2), true), ("Hello world foo, bar\nne para".into(), (1, 2)));
|
||||||
|
assert_eq!(del_word("ab \ncd", (0, 2), true), ("ab\ncd".into(), (0, 2)));
|
||||||
|
assert_eq!(del_word("ab \ncd", (0, 3), true), ("ab\ncd".into(), (0, 2)));
|
||||||
|
assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2)));
|
||||||
|
assert_eq!(del_word("ab cd(x) ef", (0, 3), true), ("ab (x) ef".into(), (0, 3)));
|
||||||
|
assert_eq!(del_word("ab cd, ef", (0, 3), true), ("ab, ef".into(), (0, 2)));
|
||||||
|
assert_eq!(del_word("ab. (cd) ef", (0, 5), true), ("ab. () ef".into(), (0, 5)));
|
||||||
|
assert_eq!(del_word("ab \"cd\" ef", (0, 4), true), ("ab \"\" ef".into(), (0, 4)));
|
||||||
|
assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4)));
|
||||||
|
// Right-to-left text deletes in logical order like any other.
|
||||||
|
assert_eq!(del_word("مرحبا بالعالم hello", (0, 6), true), ("مرحبا hello".into(), (0, 6)));
|
||||||
|
assert_eq!(del_word("مرحبا بالعالم hello", (0, 8), false), ("مرحبا لعالم hello".into(), (0, 6)));
|
||||||
|
// Numbers keep their separators: no space is pushed into "3.14".
|
||||||
|
assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2)));
|
||||||
|
// Hostile positions clamp.
|
||||||
|
assert_eq!(del_word("ab", (9, 9), true).0, "ab");
|
||||||
|
assert_eq!(del_word("", (0, 0), false).0, "");
|
||||||
|
let mut empty = TextBody::default();
|
||||||
|
assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paragraph_moves() {
|
||||||
|
let b = body("one two\nthree\n\nfour");
|
||||||
|
assert_eq!(para_down(&b, (0, 2)), (1, 0));
|
||||||
|
assert_eq!(para_down(&b, (1, 0)), (2, 0));
|
||||||
|
assert_eq!(para_down(&b, (3, 1)), (3, 4));
|
||||||
|
assert_eq!(para_up(&b, (3, 2)), (3, 0));
|
||||||
|
assert_eq!(para_up(&b, (3, 0)), (2, 0));
|
||||||
|
assert_eq!(para_up(&b, (0, 0)), (0, 0));
|
||||||
|
assert_eq!(word_move(&b, (0, 4), true), (0, 7));
|
||||||
|
assert_eq!(word_move(&b, (0, 7), true), (1, 0));
|
||||||
|
assert_eq!(word_move(&b, (1, 0), false), (0, 7));
|
||||||
|
assert_eq!(word_move(&b, (2, 0), true), (3, 0));
|
||||||
|
assert_eq!(word_move(&b, (3, 4), true), (3, 4));
|
||||||
|
assert_eq!(word_move(&b, (0, 0), false), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_cycle() {
|
||||||
|
assert_eq!(next_case("hello world"), "title");
|
||||||
|
assert_eq!(next_case("Hello World"), "upper");
|
||||||
|
assert_eq!(next_case("HELLO WORLD"), "lower");
|
||||||
|
assert_eq!(next_case("Hello world"), "upper");
|
||||||
|
assert_eq!(next_case("hELLO wORLD"), "upper");
|
||||||
|
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||||
|
assert_eq!(next_case("123"), "upper");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod style;
|
|||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
pub mod words;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -443,6 +444,46 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> {
|
|||||||
rec(shapes, id, 0)
|
rec(shapes, id, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The list holding `id` among its siblings: the tree's top level, or a group's children.
|
||||||
|
pub fn sibling_list_mut(shapes: &mut Vec<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
|
||||||
|
fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
|
||||||
|
if depth > 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if shapes.iter().any(|s| s.id == id) {
|
||||||
|
return Some(shapes);
|
||||||
|
}
|
||||||
|
for s in shapes.iter_mut() {
|
||||||
|
if let ShapeKind::Group { children, .. } = &mut s.kind
|
||||||
|
&& let Some(f) = rec(children, id, depth + 1)
|
||||||
|
{
|
||||||
|
return Some(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
rec(shapes, id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent.
|
||||||
|
pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option<Option<ShapeId>> {
|
||||||
|
fn rec(shapes: &[Shape], parent: Option<ShapeId>, id: ShapeId, depth: usize) -> Option<Option<ShapeId>> {
|
||||||
|
if depth > 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for s in shapes {
|
||||||
|
if s.id == id {
|
||||||
|
return Some(parent);
|
||||||
|
}
|
||||||
|
if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) {
|
||||||
|
return Some(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
rec(shapes, None, id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Background of a slide, layout or master.
|
/// Background of a slide, layout or master.
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||||
|
|||||||
@@ -210,3 +210,23 @@ fn a_layout_colour_map_override_applies_to_its_slides() {
|
|||||||
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
||||||
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sibling_lists_and_parents_reach_into_groups() {
|
||||||
|
let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() };
|
||||||
|
let group = |id: u32, children: Vec<Shape>| Shape {
|
||||||
|
id: ShapeId(id),
|
||||||
|
kind: ShapeKind::Group { children, child: Default::default() },
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)];
|
||||||
|
assert_eq!(parent_of(&tree, ShapeId(1)), Some(None));
|
||||||
|
assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2))));
|
||||||
|
assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4))));
|
||||||
|
assert_eq!(parent_of(&tree, ShapeId(9)), None);
|
||||||
|
let ids = |v: &Vec<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
|
||||||
|
assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6]));
|
||||||
|
assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4]));
|
||||||
|
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
|
||||||
|
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
//! Word boundaries in a paragraph, as PowerPoint's Ctrl+arrow, Ctrl+Backspace/Delete and
|
||||||
|
//! double-click see them (observed black-box on PowerPoint for Windows, see
|
||||||
|
//! `docs/ui-parity.md`, Text editing keys).
|
||||||
|
//!
|
||||||
|
//! A paragraph splits into *tokens*. Each token is a run of one class of grapheme clusters plus
|
||||||
|
//! the spaces that follow it:
|
||||||
|
//!
|
||||||
|
//! - **word**: letters, digits and combining marks. An apostrophe (`'` or `’`) joins a word when a
|
||||||
|
//! letter or digit follows it (`it's`, `'tis`, but `dogs'` ends before it); a hyphen joins when
|
||||||
|
//! a letter or digit comes before it (`a-b`, `1-2`, `a-`); `.`, `,` and `:` join between digits
|
||||||
|
//! (`3.14`, `1,000`, `3:45`, but `e.g.` is five tokens).
|
||||||
|
//! - **punctuation**: everything else that is not a symbol; a run of it is one token (`...`,
|
||||||
|
//! `?!`, `),`, `。。`). `_`, `@`, `/`, `#`, quotes and brackets are punctuation.
|
||||||
|
//! - **symbol**: emoji and pictographs (a run of them is one token, `👍👍`), `©`, `®`, `™`.
|
||||||
|
//! - **ideograph**: each CJK ideograph, kana or Hangul syllable is a token of its own.
|
||||||
|
//! - **tab** and **line break**: each is a token of its own, and spaces after one form their own
|
||||||
|
//! token instead of joining it.
|
||||||
|
//! - **space**: U+0020, no-break space, ideographic space and the other fixed-width spaces. Spaces
|
||||||
|
//! join the token before them; spaces at the start of a paragraph (or after a tab or line break)
|
||||||
|
//! are a token of their own.
|
||||||
|
//!
|
||||||
|
//! Positions are character (Unicode scalar) offsets in the paragraph text, where a line break is
|
||||||
|
//! one `\u{b}` character. A token never splits a grapheme cluster, so the caret never lands
|
||||||
|
//! inside an emoji sequence or between a letter and its combining marks.
|
||||||
|
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Class {
|
||||||
|
Space,
|
||||||
|
Tab,
|
||||||
|
Break,
|
||||||
|
Word,
|
||||||
|
Ideo,
|
||||||
|
Punct,
|
||||||
|
Symbol,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One grapheme cluster: its first character offset and class.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct Cluster {
|
||||||
|
start: usize,
|
||||||
|
class: Class,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A space for word movement: absorbed into the token before it.
|
||||||
|
pub fn is_space(c: char) -> bool {
|
||||||
|
matches!(c, ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CJK ideographs, kana and Hangul: every one is a word of its own.
|
||||||
|
fn is_ideo(c: char) -> bool {
|
||||||
|
matches!(c,
|
||||||
|
'\u{1100}'..='\u{11ff}'
|
||||||
|
| '\u{2e80}'..='\u{2fdf}'
|
||||||
|
| '\u{3005}'..='\u{3007}'
|
||||||
|
| '\u{3021}'..='\u{3029}'
|
||||||
|
| '\u{3031}'..='\u{3035}'
|
||||||
|
| '\u{3038}'..='\u{303c}'
|
||||||
|
| '\u{3040}'..='\u{309f}'
|
||||||
|
| '\u{30a0}'..='\u{30ff}'
|
||||||
|
| '\u{3100}'..='\u{318f}'
|
||||||
|
| '\u{31a0}'..='\u{31ff}'
|
||||||
|
| '\u{3400}'..='\u{4dbf}'
|
||||||
|
| '\u{4e00}'..='\u{9fff}'
|
||||||
|
| '\u{a960}'..='\u{a97f}'
|
||||||
|
| '\u{ac00}'..='\u{d7ff}'
|
||||||
|
| '\u{f900}'..='\u{faff}'
|
||||||
|
| '\u{ff66}'..='\u{ffdc}'
|
||||||
|
| '\u{20000}'..='\u{3134f}')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emoji, pictographs and the common letter-like symbols.
|
||||||
|
fn is_symbol(c: char) -> bool {
|
||||||
|
matches!(c,
|
||||||
|
'\u{a9}' | '\u{ae}' | '\u{2122}'
|
||||||
|
| '\u{2190}'..='\u{21ff}'
|
||||||
|
| '\u{2300}'..='\u{23ff}'
|
||||||
|
| '\u{25a0}'..='\u{27bf}'
|
||||||
|
| '\u{2b00}'..='\u{2bff}'
|
||||||
|
| '\u{1f000}'..='\u{1faff}')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_class(c: char) -> Class {
|
||||||
|
match c {
|
||||||
|
'\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break,
|
||||||
|
'\t' => Class::Tab,
|
||||||
|
c if is_space(c) => Class::Space,
|
||||||
|
c if is_ideo(c) => Class::Ideo,
|
||||||
|
c if c.is_alphanumeric() => Class::Word,
|
||||||
|
c if is_symbol(c) => Class::Symbol,
|
||||||
|
// A combining mark with no base (or any other mark) stays with letters.
|
||||||
|
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word,
|
||||||
|
_ => Class::Punct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clusters(text: &str) -> Vec<Cluster> {
|
||||||
|
let mut out: Vec<Cluster> = Vec::new();
|
||||||
|
let mut firsts: Vec<char> = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
for g in text.graphemes(true) {
|
||||||
|
let n = g.chars().count();
|
||||||
|
let first = g.chars().next().unwrap_or(' ');
|
||||||
|
// "\r\n" is one grapheme; any other cluster takes the class of its first character.
|
||||||
|
out.push(Cluster { start: pos, class: base_class(first) });
|
||||||
|
firsts.push(first);
|
||||||
|
pos += n;
|
||||||
|
}
|
||||||
|
// Context: apostrophes, hyphens and number separators that belong to a word.
|
||||||
|
let classes: Vec<Class> = out.iter().map(|c| c.class).collect();
|
||||||
|
for (i, cl) in out.iter_mut().enumerate() {
|
||||||
|
let Some(&c) = firsts.get(i) else { continue };
|
||||||
|
let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied();
|
||||||
|
let next = firsts.get(i + 1).copied();
|
||||||
|
let joins = match c {
|
||||||
|
'\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric),
|
||||||
|
'-' => prev.is_some_and(char::is_alphanumeric),
|
||||||
|
'.' | ',' | ':' => prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if joins && classes.get(i) == Some(&Class::Punct) {
|
||||||
|
cl.class = Class::Word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does a token start at cluster `cur` (after `prev`)?
|
||||||
|
fn starts_token(prev: Class, cur: Class) -> bool {
|
||||||
|
if cur == Class::Space {
|
||||||
|
// Spaces join the token before them, except after a tab or a line break.
|
||||||
|
return matches!(prev, Class::Tab | Class::Break);
|
||||||
|
}
|
||||||
|
prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The word stops of a paragraph's text in ascending order: 0, the start of every token, and the
|
||||||
|
/// end of the text. Ctrl+Right goes to the next one.
|
||||||
|
pub fn stops(text: &str) -> Vec<usize> {
|
||||||
|
let cs = clusters(text);
|
||||||
|
let mut out = vec![0];
|
||||||
|
for w in cs.windows(2) {
|
||||||
|
if let [a, b] = w
|
||||||
|
&& starts_token(a.class, b.class)
|
||||||
|
{
|
||||||
|
out.push(b.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n = text.chars().count();
|
||||||
|
if out.last() != Some(&n) {
|
||||||
|
out.push(n);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stops Ctrl+Left uses. PowerPoint's backward scan differs from the forward one in two
|
||||||
|
/// observed details: a no-break or other non-ASCII space is a stop of its own (unless an ASCII
|
||||||
|
/// space follows it), and a symbol followed by punctuation (`👍?`) is one token.
|
||||||
|
pub fn back_stops(text: &str) -> Vec<usize> {
|
||||||
|
let cs = clusters(text);
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut out = vec![0];
|
||||||
|
for w in cs.windows(2) {
|
||||||
|
if let [a, b] = w {
|
||||||
|
let symbol_punct = matches!((a.class, b.class), (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol));
|
||||||
|
if starts_token(a.class, b.class) && !symbol_punct {
|
||||||
|
out.push(b.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, c) in chars.iter().enumerate() {
|
||||||
|
if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 {
|
||||||
|
out.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_unstable();
|
||||||
|
out.dedup();
|
||||||
|
let n = chars.len();
|
||||||
|
if out.last() != Some(&n) {
|
||||||
|
out.push(n);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next stop after `ch` (Ctrl+Right), or `None` at the end of the text.
|
||||||
|
pub fn next_stop(text: &str, ch: usize) -> Option<usize> {
|
||||||
|
stops(text).into_iter().find(|s| *s > ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous stop before `ch` (Ctrl+Left), or `None` at the start.
|
||||||
|
pub fn prev_stop(text: &str, ch: usize) -> Option<usize> {
|
||||||
|
back_stops(text).into_iter().rev().find(|s| *s < ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the character at `i` a space (for word purposes)?
|
||||||
|
fn space_at(chars: &[char], i: usize) -> bool {
|
||||||
|
chars.get(i).is_some_and(|c| is_space(*c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end of the token's text (before its trailing spaces) for the token containing `ch`.
|
||||||
|
fn content_end(text: &str, ch: usize) -> usize {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let next = next_stop(text, ch).unwrap_or(chars.len());
|
||||||
|
let mut e = ch;
|
||||||
|
while e < next && !space_at(&chars, e) {
|
||||||
|
e += 1;
|
||||||
|
}
|
||||||
|
// A token made only of spaces (leading spaces) ends at the next stop.
|
||||||
|
if e == ch && space_at(&chars, ch) { next } else { e }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The word a double-click at `ch` selects: the token under the caret without its trailing
|
||||||
|
/// spaces; on a space just after a token, that token; in a run of spaces, the spaces.
|
||||||
|
pub fn word_at(text: &str, ch: usize) -> (usize, usize) {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
if n == 0 {
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
let ch = ch.min(n);
|
||||||
|
let on_space = ch >= n || space_at(&chars, ch);
|
||||||
|
let after_token = ch > 0 && !space_at(&chars, ch - 1);
|
||||||
|
let at = if on_space && after_token { ch - 1 } else { ch.min(n - 1) };
|
||||||
|
if space_at(&chars, at) {
|
||||||
|
let mut s = at;
|
||||||
|
while s > 0 && space_at(&chars, s - 1) {
|
||||||
|
s -= 1;
|
||||||
|
}
|
||||||
|
let mut e = at;
|
||||||
|
while e < n && space_at(&chars, e) {
|
||||||
|
e += 1;
|
||||||
|
}
|
||||||
|
return (s, e);
|
||||||
|
}
|
||||||
|
let s = stops(text).into_iter().rev().find(|s| *s <= at).unwrap_or(0);
|
||||||
|
(s, content_end(text, s).max(at + 1).min(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Ctrl+Delete removes from `ch` (before smart spacing): inside a token, the rest of its
|
||||||
|
/// text; on spaces right after a token, those spaces and the next token's text; anywhere else up
|
||||||
|
/// to the next stop. `None` at the end of the text.
|
||||||
|
pub fn delete_forward_end(text: &str, ch: usize) -> Option<usize> {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
if ch >= n {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let st = stops(text);
|
||||||
|
let at_stop = st.contains(&ch);
|
||||||
|
if !at_stop && !space_at(&chars, ch) {
|
||||||
|
return Some(content_end(text, ch));
|
||||||
|
}
|
||||||
|
let prev_is_text = ch > 0 && chars.get(ch - 1).is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break));
|
||||||
|
if space_at(&chars, ch) && prev_is_text {
|
||||||
|
let mut j = ch;
|
||||||
|
while j < n && space_at(&chars, j) {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j >= n {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
return Some(content_end(text, j));
|
||||||
|
}
|
||||||
|
st.into_iter().find(|s| *s > ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closing punctuation that a smart delete pulls its word's spaces up to (`ab cd, ef` → Ctrl+Delete
|
||||||
|
/// on `cd` gives `ab, ef`).
|
||||||
|
pub fn is_closing_punct(c: char) -> bool {
|
||||||
|
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '%' | '\u{2026}' | '\u{201d}' | '\u{2019}' | '\u{bb}')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Punctuation that a smart delete keeps a space after when a word follows (`cd...ef gh` →
|
||||||
|
/// Ctrl+Delete on `ef` gives `cd... gh`).
|
||||||
|
pub fn is_sentence_punct(c: char) -> bool {
|
||||||
|
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Stops drawn as `|` in the text, for readable assertions.
|
||||||
|
fn marked(text: &str, st: &[usize]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in text.chars().enumerate() {
|
||||||
|
if st.contains(&i) {
|
||||||
|
out.push('|');
|
||||||
|
}
|
||||||
|
out.push(match c {
|
||||||
|
'\u{b}' => '↵',
|
||||||
|
'\t' => '→',
|
||||||
|
c => c,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if st.contains(&text.chars().count()) {
|
||||||
|
out.push('|');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
fn fwd(text: &str) -> String {
|
||||||
|
marked(text, &stops(text))
|
||||||
|
}
|
||||||
|
fn back(text: &str) -> String {
|
||||||
|
marked(text, &back_stops(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every expected string below is a stop trace observed in PowerPoint for Windows.
|
||||||
|
#[test]
|
||||||
|
fn punctuation_and_spaces() {
|
||||||
|
let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end.";
|
||||||
|
let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|";
|
||||||
|
assert_eq!(fwd(t), want);
|
||||||
|
assert_eq!(back(t), want);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyphens_apostrophes_numbers() {
|
||||||
|
let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end";
|
||||||
|
assert_eq!(
|
||||||
|
fwd(t),
|
||||||
|
"|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|"
|
||||||
|
);
|
||||||
|
let t = "it’s x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 —x x— a's 'tis o'clock ‘q’ end";
|
||||||
|
assert_eq!(fwd(t), "|it’s |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |—|x |x|— |a's |'tis |o'clock |‘|q|’ |end|");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_emoji_symbols() {
|
||||||
|
let t = "café e\u{301}te 👍🏽 x 👨\u{200d}👩\u{200d}👧 y ½ ®z «q» “w” ¿no? ok—em dash–en x";
|
||||||
|
let want = "|café |e\u{301}te |👍🏽 |x |👨\u{200d}👩\u{200d}👧 |y |½ |®|z |«|q|» |“|w|” |¿|no|? |ok|—|em |dash|–|en |x|";
|
||||||
|
assert_eq!(fwd(t), want);
|
||||||
|
assert_eq!(back(t), want);
|
||||||
|
// A run of emoji is one token; emoji then punctuation is two going right, one going left.
|
||||||
|
assert_eq!(fwd("👍👍 x 👍? x👍y"), "|👍👍 |x |👍|? |x|👍|y|");
|
||||||
|
assert_eq!(back("👍👍 x 👍? x👍y"), "|👍👍 |x |👍? |x|👍|y|");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cjk_each_character() {
|
||||||
|
let t = "日本語のテキストです。カタカナ漢字 abc 中文字符,标点。한국어 문장 a。。b";
|
||||||
|
assert_eq!(fwd(t), "|日|本|語|の|テ|キ|ス|ト|で|す|。|カ|タ|カ|ナ|漢|字 |abc |中|文|字|符|,|标|点|。|한|국|어 |문|장 |a|。。|b|");
|
||||||
|
assert_eq!(back(t), fwd(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtl_words_in_logical_order() {
|
||||||
|
let t = "Hello مرحبا بالعالم world שלום עולם end";
|
||||||
|
assert_eq!(fwd(t), "|Hello |مرحبا |بالعالم |world |שלום |עולם |end|");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tabs_breaks_and_other_spaces() {
|
||||||
|
assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |");
|
||||||
|
assert_eq!(fwd("Five\u{b}six seven\tTab x"), "|Five|↵|six |seven|→|Tab |x|");
|
||||||
|
assert_eq!(fwd("a\t b x \u{b} y q\u{b}\u{b}z"), "|a|→| |b |x |↵| |y |q|↵|↵|z|");
|
||||||
|
let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end";
|
||||||
|
assert_eq!(fwd(t), "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
|
||||||
|
assert_eq!(back(t), "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_and_prev() {
|
||||||
|
let t = " lead x mid ";
|
||||||
|
assert_eq!(next_stop(t, 1), Some(2));
|
||||||
|
assert_eq!(next_stop(t, 9), Some(11));
|
||||||
|
assert_eq!(next_stop(t, 16), None);
|
||||||
|
assert_eq!(prev_stop(t, 1), Some(0));
|
||||||
|
assert_eq!(prev_stop(t, 9), Some(7));
|
||||||
|
assert_eq!(prev_stop(t, 15), Some(11));
|
||||||
|
assert_eq!(prev_stop(t, 0), None);
|
||||||
|
assert_eq!(next_stop("", 0), None);
|
||||||
|
assert_eq!(prev_stop("", 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_click_word() {
|
||||||
|
let t = "Hello world, foo bar";
|
||||||
|
assert_eq!(word_at(t, 7), (6, 11));
|
||||||
|
assert_eq!(word_at(t, 15), (14, 17));
|
||||||
|
assert_eq!(word_at(t, 11), (11, 12));
|
||||||
|
assert_eq!(word_at(t, 12), (11, 12));
|
||||||
|
assert_eq!(word_at(t, 13), (12, 14));
|
||||||
|
assert_eq!(word_at(t, 21), (18, 21));
|
||||||
|
assert_eq!(word_at("日本語のテキスト abc", 5), (5, 6));
|
||||||
|
assert_eq!(word_at("", 3), (0, 0));
|
||||||
|
assert_eq!(word_at("x👨\u{200d}👩\u{200d}👧y", 2), (1, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_forward_extent() {
|
||||||
|
let t = "ab cd...ef gh";
|
||||||
|
let ends: Vec<Option<usize>> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect();
|
||||||
|
// Before smart spacing: PowerPoint's results after it are tested in `edit`.
|
||||||
|
assert_eq!(
|
||||||
|
ends,
|
||||||
|
vec![
|
||||||
|
Some(5),
|
||||||
|
Some(2),
|
||||||
|
Some(7),
|
||||||
|
Some(5),
|
||||||
|
Some(5),
|
||||||
|
Some(7),
|
||||||
|
Some(7),
|
||||||
|
Some(10),
|
||||||
|
Some(10),
|
||||||
|
Some(10),
|
||||||
|
Some(13),
|
||||||
|
Some(12),
|
||||||
|
Some(15),
|
||||||
|
Some(15),
|
||||||
|
Some(15),
|
||||||
|
None
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(delete_forward_end("ab\t cd", 2), Some(3));
|
||||||
|
assert_eq!(delete_forward_end("ab ", 2), Some(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,18 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking
|
||||||
|
/// attributes; it reads an object as locked only when the whole set for its kind is there, so a
|
||||||
|
/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock.
|
||||||
|
fn is_locked(l: &El) -> bool {
|
||||||
|
let set: &[&str] = match l.local() {
|
||||||
|
"grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"],
|
||||||
|
"graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"],
|
||||||
|
_ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"],
|
||||||
|
};
|
||||||
|
set.iter().all(|a| l.bool(a) == Some(true))
|
||||||
|
}
|
||||||
|
|
||||||
/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…).
|
/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…).
|
||||||
fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
||||||
let c = nv.and_then(|n| n.child("cNvPr"));
|
let c = nv.and_then(|n| n.child("cNvPr"));
|
||||||
@@ -122,10 +134,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape {
|
|||||||
match e.local() {
|
match e.local() {
|
||||||
"cNvSpPr" => {
|
"cNvSpPr" => {
|
||||||
s.text_box = e.bool("txBox").unwrap_or(false);
|
s.text_box = e.bool("txBox").unwrap_or(false);
|
||||||
s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
s.locked = e.child("spLocks").is_some_and(is_locked);
|
||||||
}
|
}
|
||||||
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
|
"cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => {
|
||||||
s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true));
|
s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked);
|
||||||
}
|
}
|
||||||
"nvPr" => {
|
"nvPr" => {
|
||||||
if let Some(ph) = e.child("ph") {
|
if let Some(ph) = e.child("ph") {
|
||||||
|
|||||||
@@ -177,8 +177,24 @@ fn nv_pr(w: &mut W, s: &Shape) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and
|
||||||
|
/// reads back as locked): see `read::shapes::is_locked`.
|
||||||
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
|
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
|
||||||
let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked);
|
let a = extra.t("noGrp", s.ph.is_some() || s.locked);
|
||||||
|
let a = match tag {
|
||||||
|
"a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked),
|
||||||
|
_ => {
|
||||||
|
let a = a
|
||||||
|
.t("noRot", s.locked)
|
||||||
|
.t("noMove", s.locked)
|
||||||
|
.t("noResize", s.locked)
|
||||||
|
.t("noEditPoints", s.locked)
|
||||||
|
.t("noAdjustHandles", s.locked)
|
||||||
|
.t("noChangeArrowheads", s.locked)
|
||||||
|
.t("noChangeShapeType", s.locked);
|
||||||
|
if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a }
|
||||||
|
}
|
||||||
|
};
|
||||||
if a.v.is_empty() {
|
if a.v.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -458,7 +474,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
|
|||||||
w.open0("p:nvGraphicFramePr");
|
w.open0("p:nvGraphicFramePr");
|
||||||
cnvpr(w, x, o, id, s);
|
cnvpr(w, x, o, id, s);
|
||||||
w.open0("p:cNvGraphicFramePr");
|
w.open0("p:cNvGraphicFramePr");
|
||||||
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked));
|
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked));
|
||||||
w.close("p:cNvGraphicFramePr");
|
w.close("p:cNvGraphicFramePr");
|
||||||
nv_pr(w, s);
|
nv_pr(w, s);
|
||||||
w.close("p:nvGraphicFramePr");
|
w.close("p:nvGraphicFramePr");
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked.
|
||||||
|
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
|
||||||
|
use deckcraft_geom::Xfrm;
|
||||||
|
use deckcraft_model::style::PictureFill;
|
||||||
|
use deckcraft_model::table::Table;
|
||||||
|
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults};
|
||||||
|
|
||||||
|
fn png() -> Vec<u8> {
|
||||||
|
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]));
|
||||||
|
let mut out = Cursor::new(vec![]);
|
||||||
|
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||||||
|
out.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape {
|
||||||
|
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a
|
||||||
|
/// locked group (one child unlocked, one hidden), and a locked picture, connector and table.
|
||||||
|
fn deck() -> Presentation {
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let img = p.add_media("image1.png", "image/png", png());
|
||||||
|
let mut v = vec![];
|
||||||
|
let mut a = shape(&mut p, "Locked", 0.0);
|
||||||
|
a.locked = true;
|
||||||
|
v.push(a);
|
||||||
|
v.push(shape(&mut p, "Free", 100.0));
|
||||||
|
let mut t = shape(&mut p, "Hidden text", 200.0);
|
||||||
|
t.text_box = true;
|
||||||
|
t.hidden = true;
|
||||||
|
t.locked = true;
|
||||||
|
v.push(t);
|
||||||
|
let mut c1 = shape(&mut p, "Child locked", 300.0);
|
||||||
|
c1.locked = true;
|
||||||
|
let c2 = shape(&mut p, "Child free", 400.0);
|
||||||
|
let mut c3 = shape(&mut p, "Child hidden", 500.0);
|
||||||
|
c3.hidden = true;
|
||||||
|
let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0);
|
||||||
|
let mut g = Shape {
|
||||||
|
id: ShapeId(p.alloc_id()),
|
||||||
|
name: "Group".into(),
|
||||||
|
xfrm: Some(gx),
|
||||||
|
kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx },
|
||||||
|
locked: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
g.hidden = false;
|
||||||
|
v.push(g);
|
||||||
|
let mut pic = shape(&mut p, "Pic", 0.0);
|
||||||
|
pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } };
|
||||||
|
pic.locked = true;
|
||||||
|
v.push(pic);
|
||||||
|
let mut cx = shape(&mut p, "Conn", 100.0);
|
||||||
|
cx.kind = ShapeKind::Connector { start: None, end: None };
|
||||||
|
cx.geom = deckcraft_model::Geom::preset("straightConnector1");
|
||||||
|
cx.locked = true;
|
||||||
|
v.push(cx);
|
||||||
|
let mut tb = shape(&mut p, "Tbl", 200.0);
|
||||||
|
tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0));
|
||||||
|
tb.locked = true;
|
||||||
|
v.push(tb);
|
||||||
|
if let Some(s) = p.slides.first_mut() {
|
||||||
|
std::sync::Arc::make_mut(s).shapes = v;
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape {
|
||||||
|
let mut found = None;
|
||||||
|
deckcraft_model::walk(&s.shapes, &mut |x, _| {
|
||||||
|
if found.is_none() && x.name == name {
|
||||||
|
found = Some(x);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
found.unwrap_or_else(|| panic!("no shape {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entries(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||||
|
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||||
|
(0..z.len())
|
||||||
|
.map(|i| {
|
||||||
|
let mut f = z.by_index(i).expect("entry");
|
||||||
|
let mut d = vec![];
|
||||||
|
f.read_to_end(&mut d).expect("read");
|
||||||
|
(f.name().to_string(), d)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rezip(es: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||||
|
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
let o = zip::write::SimpleFileOptions::default();
|
||||||
|
for (n, d) in es {
|
||||||
|
z.start_file(n.as_str(), o).expect("start");
|
||||||
|
z.write_all(d).expect("write");
|
||||||
|
}
|
||||||
|
z.finish().expect("finish").into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slide1_xml(bytes: &[u8]) -> String {
|
||||||
|
let es = entries(bytes);
|
||||||
|
let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||||
|
String::from_utf8(d.clone()).expect("utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The element following `name="…"` up to the end of the object's non-visual properties.
|
||||||
|
fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||||
|
let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}"));
|
||||||
|
let rest = &xml[i..];
|
||||||
|
let j = rest.find("<p:nvPr").unwrap_or(rest.len());
|
||||||
|
&rest[..j]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_and_hidden_round_trip() {
|
||||||
|
let p = deck();
|
||||||
|
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||||
|
let s = &q.slides[0];
|
||||||
|
for (name, locked, hidden) in [
|
||||||
|
("Locked", true, false),
|
||||||
|
("Free", false, false),
|
||||||
|
("Hidden text", true, true),
|
||||||
|
("Group", true, false),
|
||||||
|
("Child locked", true, false),
|
||||||
|
("Child free", false, false),
|
||||||
|
("Child hidden", false, true),
|
||||||
|
("Pic", true, false),
|
||||||
|
("Conn", true, false),
|
||||||
|
("Tbl", true, false),
|
||||||
|
] {
|
||||||
|
let sh = find(s, name);
|
||||||
|
assert_eq!((sh.locked, sh.hidden), (locked, hidden), "{name}");
|
||||||
|
}
|
||||||
|
// The stacking order of the slide and of the group survives too.
|
||||||
|
let names: Vec<&str> = s.shapes.iter().map(|x| x.name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]);
|
||||||
|
let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect();
|
||||||
|
assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_writes_powerpoints_full_attribute_set() {
|
||||||
|
let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export"));
|
||||||
|
let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#;
|
||||||
|
assert!(nv_of(&xml, "Locked").contains(&format!("<a:spLocks {sp}/>")), "{}", nv_of(&xml, "Locked"));
|
||||||
|
assert!(nv_of(&xml, "Hidden text").contains(&format!("<a:spLocks {sp}/>")));
|
||||||
|
assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#));
|
||||||
|
assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free"));
|
||||||
|
assert!(nv_of(&xml, "Group").contains(r#"<a:grpSpLocks noGrp="1" noUngrp="1" noRot="1" noMove="1" noResize="1"/>"#));
|
||||||
|
assert!(nv_of(&xml, "Conn").contains(&format!("<a:cxnSpLocks {sp}/>")), "{}", nv_of(&xml, "Conn"));
|
||||||
|
assert!(nv_of(&xml, "Pic").contains(&format!(r#"<a:picLocks noChangeAspect="1" {sp} noCrop="1"/>"#)), "{}", nv_of(&xml, "Pic"));
|
||||||
|
assert!(nv_of(&xml, "Tbl").contains(r#"<a:graphicFrameLocks noGrp="1" noDrilldown="1" noMove="1" noResize="1"/>"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's
|
||||||
|
/// partial lock), or the set without `noGrp`, isn't locked.
|
||||||
|
#[test]
|
||||||
|
fn partial_locks_are_not_lock() {
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let a = shape(&mut p, "A", 0.0);
|
||||||
|
let b = shape(&mut p, "B", 100.0);
|
||||||
|
let c = shape(&mut p, "C", 200.0);
|
||||||
|
let mut d = shape(&mut p, "D", 300.0);
|
||||||
|
d.locked = true;
|
||||||
|
if let Some(s) = p.slides.first_mut() {
|
||||||
|
std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d];
|
||||||
|
}
|
||||||
|
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||||
|
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||||
|
let xml = String::from_utf8(slide.1.clone()).expect("utf8");
|
||||||
|
let swap = |xml: String, name: &str, locks: &str| {
|
||||||
|
let i = xml.find(&format!("name=\"{name}\"")).expect("name");
|
||||||
|
let j = i + xml[i..].find("<p:cNvSpPr").expect("cNvSpPr");
|
||||||
|
let k = j + xml[j..].find("<p:nvPr").expect("nvPr");
|
||||||
|
format!("{}<p:cNvSpPr>{locks}</p:cNvSpPr>{}", &xml[..j], &xml[k..])
|
||||||
|
};
|
||||||
|
let xml = swap(xml, "A", r#"<a:spLocks noMove="1" noResize="1"/>"#);
|
||||||
|
let xml = swap(
|
||||||
|
xml,
|
||||||
|
"B",
|
||||||
|
r#"<a:spLocks noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1"/>"#,
|
||||||
|
);
|
||||||
|
let xml =
|
||||||
|
swap(xml, "C", r#"<a:spLocks noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeShapeType="1"/>"#);
|
||||||
|
slide.1 = xml.into_bytes();
|
||||||
|
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||||
|
let s = &q.slides[0];
|
||||||
|
assert!(!find(s, "A").locked);
|
||||||
|
assert!(!find(s, "B").locked);
|
||||||
|
assert!(!find(s, "C").locked);
|
||||||
|
assert!(find(s, "D").locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked.
|
||||||
|
#[test]
|
||||||
|
fn empty_locks_element_is_unlocked() {
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let a = shape(&mut p, "A", 0.0);
|
||||||
|
if let Some(s) = p.slides.first_mut() {
|
||||||
|
std::sync::Arc::make_mut(s).shapes = vec![a];
|
||||||
|
}
|
||||||
|
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||||
|
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
|
||||||
|
let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("<p:cNvSpPr/>", "<p:cNvSpPr><a:spLocks/></p:cNvSpPr>", 1);
|
||||||
|
slide.1 = xml.into_bytes();
|
||||||
|
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||||
|
assert!(!find(&q.slides[0], "A").locked);
|
||||||
|
}
|
||||||
+79
-6
@@ -1831,26 +1831,99 @@ impl TextLayout {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
/// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
|
||||||
|
/// visual span, a little below each line's baseline (PowerPoint underlines the composition
|
||||||
|
/// in place). A composition that wraps gets one underline per line.
|
||||||
|
pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
|
||||||
|
let (s, e) = if a <= b { (a, b) } else { (b, a) };
|
||||||
|
let mut out = vec![];
|
||||||
|
if s == e {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for l in &self.lines {
|
||||||
|
let lp = Pos { para: l.para, ch: l.start };
|
||||||
|
let le = Pos { para: l.para, ch: l.end };
|
||||||
|
if le <= s || lp >= e {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let from = if s > lp { s.ch } else { l.start };
|
||||||
|
let to = if e < le { e.ch } else { l.end };
|
||||||
|
// Under the baseline, in the descent, never below the line.
|
||||||
|
let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
|
||||||
|
for (x0, x1) in l.spans(from, to) {
|
||||||
|
if x1 > x0 {
|
||||||
|
out.push((x0, x1, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
||||||
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
||||||
let Some(li) = self.line_of(pos) else { return pos };
|
self.vertical_affine(pos, Affinity::Downstream, down, x).0
|
||||||
let target = if down { li + 1 } else { li.wrapping_sub(1) };
|
}
|
||||||
let Some(l) = self.lines.get(target) else { return pos };
|
/// The line a caret is on. An upstream caret at the start of a soft-wrapped line (where End
|
||||||
|
/// leaves it, after the previous line's trailing spaces) belongs to the line before.
|
||||||
|
pub fn line_index(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
|
||||||
|
if affinity == Affinity::Upstream && pos.ch > 0 {
|
||||||
|
for (i, w) in self.lines.windows(2).enumerate() {
|
||||||
|
if let [a, b] = w
|
||||||
|
&& b.para == pos.para
|
||||||
|
&& a.para == pos.para
|
||||||
|
&& b.start == pos.ch
|
||||||
|
{
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.line_of_affine(pos, affinity)
|
||||||
|
}
|
||||||
|
/// Up/Down from a caret with its affinity: the position on the line above/below nearest `x`.
|
||||||
|
pub fn vertical_affine(&self, pos: Pos, affinity: Affinity, down: bool, x: f64) -> (Pos, Affinity) {
|
||||||
|
let Some(li) = self.line_index(pos, affinity) else { return (pos, affinity) };
|
||||||
|
let target = if down { li.checked_add(1) } else { li.checked_sub(1) };
|
||||||
|
let Some(l) = target.and_then(|t| self.lines.get(t)) else { return (pos, affinity) };
|
||||||
let p = Point::new(x, (l.top + l.bottom) / 2.0);
|
let p = Point::new(x, (l.top + l.bottom) / 2.0);
|
||||||
let mut h = self.hit(p);
|
let mut h = self.hit(p);
|
||||||
if h.para != l.para {
|
if h.para != l.para {
|
||||||
h.para = l.para;
|
h.para = l.para;
|
||||||
h.ch = h.ch.clamp(l.start, l.end);
|
|
||||||
}
|
}
|
||||||
h
|
h.ch = h.ch.clamp(l.start, l.end);
|
||||||
|
(h, Affinity::Downstream)
|
||||||
}
|
}
|
||||||
/// Start/end of the visual line containing `pos` (logical: Home/End).
|
/// Start/end of the visual line containing `pos` (logical: Home/End).
|
||||||
pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
|
pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
|
||||||
match self.line_of(pos).and_then(|i| self.lines.get(i)) {
|
self.line_bounds_affine(pos, Affinity::Downstream)
|
||||||
|
}
|
||||||
|
/// Start/end of the visual line a caret with this affinity is on.
|
||||||
|
pub fn line_bounds_affine(&self, pos: Pos, affinity: Affinity) -> (Pos, Pos) {
|
||||||
|
match self.line_index(pos, affinity).and_then(|i| self.lines.get(i)) {
|
||||||
Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }),
|
Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }),
|
||||||
None => (pos, pos),
|
None => (pos, pos),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Where End puts the caret: after a soft wrap, past the line's trailing spaces at the start
|
||||||
|
/// of the next line with upstream affinity (so it is still drawn on this line), else the end
|
||||||
|
/// of the paragraph.
|
||||||
|
pub fn line_end_caret(&self, pos: Pos, affinity: Affinity) -> (Pos, Affinity) {
|
||||||
|
let Some(li) = self.line_index(pos, affinity) else { return (pos, Affinity::Downstream) };
|
||||||
|
let Some(l) = self.lines.get(li) else { return (pos, Affinity::Downstream) };
|
||||||
|
match self.lines.get(li + 1) {
|
||||||
|
Some(n) if n.para == l.para && n.start > l.start => (Pos { para: l.para, ch: n.start }, Affinity::Upstream),
|
||||||
|
_ => (Pos { para: l.para, ch: l.end }, Affinity::Downstream),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// The caret to draw (x, top, bottom): an upstream caret after a soft wrap is drawn at the end
|
||||||
|
/// of the line it belongs to.
|
||||||
|
pub fn caret_drawn(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
|
||||||
|
let li = self.line_index(pos, affinity)?;
|
||||||
|
let l = self.lines.get(li)?;
|
||||||
|
if pos.ch > l.end {
|
||||||
|
let x = l.caret_x.last().copied()?;
|
||||||
|
return Some((x, l.top, l.bottom));
|
||||||
|
}
|
||||||
|
self.caret_at(pos, affinity)
|
||||||
|
}
|
||||||
/// Bounding box of all glyphs/lines (shape-local).
|
/// Bounding box of all glyphs/lines (shape-local).
|
||||||
pub fn bounds(&self) -> Rect {
|
pub fn bounds(&self) -> Rect {
|
||||||
let mut r: Option<Rect> = None;
|
let mut r: Option<Rect> = None;
|
||||||
|
|||||||
@@ -151,6 +151,36 @@ fn caret_and_hit_roundtrip() {
|
|||||||
assert_eq!(r.len(), 1);
|
assert_eq!(r.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #101: the text an input method is composing is underlined in place, under the baseline of each
|
||||||
|
/// line it occupies, and only over the composed characters.
|
||||||
|
#[test]
|
||||||
|
fn composition_is_underlined_per_line() {
|
||||||
|
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
|
||||||
|
let l = lay(&p, &sh, 600.0, 100.0);
|
||||||
|
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
|
||||||
|
let u = l.composition_underlines(a, b);
|
||||||
|
assert_eq!(u.len(), 1, "{u:?}");
|
||||||
|
let (x0, x1, y) = u[0];
|
||||||
|
let (cx0, ..) = l.caret(a).unwrap();
|
||||||
|
let (cx1, ..) = l.caret(b).unwrap();
|
||||||
|
assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}");
|
||||||
|
let line = &l.lines[0];
|
||||||
|
assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom);
|
||||||
|
// Reversed ends give the same; an empty composition, nothing.
|
||||||
|
assert_eq!(l.composition_underlines(b, a), u);
|
||||||
|
assert!(l.composition_underlines(a, a).is_empty());
|
||||||
|
|
||||||
|
// Wrapped: one underline per line.
|
||||||
|
let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0);
|
||||||
|
let l = lay(&p, &sh, 120.0, 400.0);
|
||||||
|
assert!(l.lines.len() >= 2);
|
||||||
|
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
|
||||||
|
assert!(u.len() >= 2, "{u:?}");
|
||||||
|
assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}");
|
||||||
|
// Positions past the text never panic.
|
||||||
|
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alignment_center_and_right() {
|
fn alignment_center_and_right() {
|
||||||
let (p, mut sh) = setup("Hi", 400.0, 100.0);
|
let (p, mut sh) = setup("Hi", 400.0, 100.0);
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
}
|
}
|
||||||
pointer(app, ui, &resp, xf);
|
pointer(app, ui, &resp, xf);
|
||||||
overlays(app, ui, &painter, xf, &t);
|
overlays(app, ui, &painter, xf, &t);
|
||||||
|
ime_output(app, ui, xf);
|
||||||
media_bar(app, ui, xf);
|
media_bar(app, ui, xf);
|
||||||
gif_buttons(app, ui, xf, &gifs);
|
gif_buttons(app, ui, xf, &gifs);
|
||||||
context_menu(app, &resp);
|
context_menu(app, &resp);
|
||||||
@@ -185,6 +186,47 @@ fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_rend
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
|
||||||
|
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
|
||||||
|
/// nothing to type into.
|
||||||
|
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||||
|
let st = app.session.active()?;
|
||||||
|
if let Some(ts) = &st.selection.text {
|
||||||
|
if ts.notes {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||||
|
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
|
||||||
|
}
|
||||||
|
let sh = st.shape(ts.shape)?;
|
||||||
|
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
|
||||||
|
}
|
||||||
|
if st.selection.shapes.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sh = st.shape(*st.selection.shapes.first()?)?;
|
||||||
|
if sh.is_line() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
||||||
|
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
||||||
|
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||||
|
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
||||||
|
let ctx = ui.ctx();
|
||||||
|
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(r) = ime_area(app, xf) else { return };
|
||||||
|
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
|
||||||
|
let r = to_global * r;
|
||||||
|
ctx.output_mut(|o| {
|
||||||
|
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||||
let Some(d) = app.session.active() else { return };
|
let Some(d) = app.session.active() else { return };
|
||||||
@@ -419,6 +461,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
|||||||
} else {
|
} else {
|
||||||
poly(painter, corners, sel_stroke);
|
poly(painter, corners, sel_stroke);
|
||||||
}
|
}
|
||||||
|
if sh.locked && !editing_this {
|
||||||
|
// Locked: greyed handles that don't respond, no rotate or adjust handles.
|
||||||
|
for h in box_handles(&x).0 {
|
||||||
|
painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if st.selection.shapes.len() == 1 || !editing_this {
|
if st.selection.shapes.len() == 1 || !editing_this {
|
||||||
let (hs, rot) = box_handles(&x);
|
let (hs, rot) = box_handles(&x);
|
||||||
let rot_s = xf.to_screen(rot);
|
let rot_s = xf.to_screen(rot);
|
||||||
@@ -453,12 +502,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
|||||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
||||||
}
|
}
|
||||||
|
// An input method's composition: underlined in place, thicker under the part being converted.
|
||||||
|
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||||
|
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
||||||
|
}
|
||||||
if !ts.is_range() {
|
if !ts.is_range() {
|
||||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||||
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
|
// An empty placeholder shows the caret where typing begins too.
|
||||||
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
|
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||||
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
|
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,6 +647,11 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
|||||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
|
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
|
||||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
|
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
|
||||||
});
|
});
|
||||||
|
let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked));
|
||||||
|
if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() {
|
||||||
|
let _ = app.run("shape.lock", json!({"locked": !locked}));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
if ui.button("Link…").clicked() {
|
if ui.button("Link…").clicked() {
|
||||||
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
||||||
ui.close();
|
ui.close();
|
||||||
|
|||||||
@@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
|||||||
let Some(t) = s("text") else { return err("missing `text`") };
|
let Some(t) = s("text") else { return err("missing `text`") };
|
||||||
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
|
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
|
||||||
}
|
}
|
||||||
|
"ui.ime" => {
|
||||||
|
// An input method's event, through egui like a real one: it reaches the slide's text
|
||||||
|
// or a focused text field (#101).
|
||||||
|
let ev = if let Some(t) = s("commit") {
|
||||||
|
egui::ImeEvent::Commit(t.into())
|
||||||
|
} else if let Some(t) = s("preedit") {
|
||||||
|
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||||
|
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||||
|
(x <= y).then_some(x..y)
|
||||||
|
});
|
||||||
|
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
|
||||||
|
} else {
|
||||||
|
return err("missing `preedit` or `commit`");
|
||||||
|
};
|
||||||
|
app.synthetic.push(egui::Event::Ime(ev));
|
||||||
|
ok(json!({"queued": true}))
|
||||||
|
}
|
||||||
"ui.tool.select" => {
|
"ui.tool.select" => {
|
||||||
let Some(name) = s("tool") else { return err("missing `tool`") };
|
let Some(name) = s("tool") else { return err("missing `tool`") };
|
||||||
match tool_kind(name, s("preset")) {
|
match tool_kind(name, s("preset")) {
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ icons! {
|
|||||||
Info => "info",
|
Info => "info",
|
||||||
Warning => "warning",
|
Warning => "warning",
|
||||||
Lock => "lock",
|
Lock => "lock",
|
||||||
|
/// Open padlock (Selection pane: an object that isn't locked).
|
||||||
|
Unlock => "unlock",
|
||||||
Eye => "eye",
|
Eye => "eye",
|
||||||
EyeOff => "eye-off",
|
EyeOff => "eye-off",
|
||||||
Close => "close",
|
Close => "close",
|
||||||
@@ -2262,22 +2264,30 @@ fn draw(p: &Pen<'_>, icon: Icon) {
|
|||||||
p.seg((12.0, 9.0), (12.0, 14.0), D);
|
p.seg((12.0, 9.0), (12.0, 14.0), D);
|
||||||
p.dot((12.0, 17.2), 1.2, D);
|
p.dot((12.0, 17.2), 1.2, D);
|
||||||
}
|
}
|
||||||
|
// Selection pane toggles: monochrome outlines in the ink colour.
|
||||||
I::Lock => {
|
I::Lock => {
|
||||||
let mut sh = vec![(7.5, 10.5), (7.5, 8.0)];
|
let mut sh = vec![(7.5, 10.5), (7.5, 8.0)];
|
||||||
sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0));
|
sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0));
|
||||||
sh.push((16.5, 10.5));
|
sh.push((16.5, 10.5));
|
||||||
p.line(&sh, K);
|
p.line(&sh, K);
|
||||||
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K);
|
p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K);
|
||||||
p.dot((12.0, 15.0), 1.5, D);
|
p.seg((12.0, 14.5), (12.0, 17.5), K);
|
||||||
p.seg((12.0, 15.5), (12.0, 18.3), D);
|
}
|
||||||
|
I::Unlock => {
|
||||||
|
// The shackle swung open: its left leg out of the body, raised.
|
||||||
|
let mut sh = vec![(12.0, 10.5), (12.0, 6.0)];
|
||||||
|
sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0));
|
||||||
|
sh.push((21.0, 8.5));
|
||||||
|
p.line(&sh, K);
|
||||||
|
p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K);
|
||||||
|
p.seg((9.75, 14.5), (9.75, 17.5), K);
|
||||||
}
|
}
|
||||||
I::Eye | I::EyeOff => {
|
I::Eye | I::EyeOff => {
|
||||||
let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16);
|
let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16);
|
||||||
e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1));
|
e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1));
|
||||||
e.pop();
|
e.pop();
|
||||||
p.shape(&e, N, K);
|
p.shape(&e, N, K);
|
||||||
p.circle((12.0, 12.0), 3.4, B, N);
|
p.circle((12.0, 12.0), 3.2, N, K);
|
||||||
p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W });
|
|
||||||
if icon == I::EyeOff {
|
if icon == I::EyeOff {
|
||||||
p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1);
|
p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
|
||||||
|
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
|
||||||
|
//! real glyphs rather than boxes.
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
||||||
|
use egui::{Event, FontId, ImeEvent};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{Services, SlideApp};
|
||||||
|
use deckcraft_engine::Session;
|
||||||
|
|
||||||
|
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SlideApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
SlideApp::new(session, Services::default()),
|
||||||
|
);
|
||||||
|
// The app keeps requesting repaints, so step frames instead of run().
|
||||||
|
h.run_steps(3);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preedit(text: &str) -> Event {
|
||||||
|
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_text(app: &SlideApp) -> String {
|
||||||
|
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
|
||||||
|
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undo_len(app: &SlideApp) -> usize {
|
||||||
|
app.session.active().unwrap().history.undo.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
|
||||||
|
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
|
||||||
|
/// is on while the slide's text is edited, with its window at the insertion point.
|
||||||
|
#[test]
|
||||||
|
fn korean_composes_in_a_slide_text_box() {
|
||||||
|
let mut h = harness(Session::with_new());
|
||||||
|
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
|
||||||
|
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||||
|
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
|
||||||
|
let slide = h.state().slide_rect.unwrap();
|
||||||
|
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
|
||||||
|
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
|
||||||
|
let caret0 = ime.rect;
|
||||||
|
|
||||||
|
let undo0 = undo_len(h.state());
|
||||||
|
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||||
|
h.event(preedit(pre));
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
|
||||||
|
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
|
||||||
|
let st = h.state().session.active().unwrap();
|
||||||
|
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
|
||||||
|
}
|
||||||
|
h.run_steps(1);
|
||||||
|
let ime = h.output().platform_output.ime.unwrap();
|
||||||
|
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
|
||||||
|
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(title_text(h.state()), "한글");
|
||||||
|
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||||
|
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
|
||||||
|
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
|
||||||
|
|
||||||
|
// Escape cancels a composition: an empty preedit, and the text is as it was.
|
||||||
|
h.event(preedit("ㅁ"));
|
||||||
|
h.event(preedit(""));
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(title_text(h.state()), "한글");
|
||||||
|
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||||
|
|
||||||
|
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
|
||||||
|
/// composition the input method left open.
|
||||||
|
#[test]
|
||||||
|
fn japanese_conversion_shows_the_active_part() {
|
||||||
|
let mut h = harness(Session::with_new());
|
||||||
|
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||||
|
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||||
|
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
|
||||||
|
h.run_steps(2);
|
||||||
|
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
|
||||||
|
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
|
||||||
|
let undo0 = undo_len(h.state());
|
||||||
|
h.key_press(egui::Key::End);
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(title_text(h.state()), "日本ご");
|
||||||
|
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||||
|
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
|
||||||
|
/// the input method is on for it.
|
||||||
|
#[test]
|
||||||
|
fn composing_on_a_selected_shape_starts_editing_it() {
|
||||||
|
let mut h = harness(Session::with_new());
|
||||||
|
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||||
|
let id = r["id"].as_u64().unwrap();
|
||||||
|
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.state().session.active().unwrap().selection.text.is_none());
|
||||||
|
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
|
||||||
|
h.event(preedit("新"));
|
||||||
|
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
|
||||||
|
h.run_steps(2);
|
||||||
|
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
|
||||||
|
assert_eq!(v["text"], "新しい");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
|
||||||
|
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
|
||||||
|
#[test]
|
||||||
|
fn korean_composes_in_the_notes_pane() {
|
||||||
|
let mut h = harness(Session::with_new());
|
||||||
|
let id = egui::Id::new("notes_edit");
|
||||||
|
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.ctx.memory(|m| m.has_focus(id)));
|
||||||
|
let undo0 = undo_len(h.state());
|
||||||
|
for pre in ["ㄴ", "노", "놑", "노트"] {
|
||||||
|
h.event(preedit(pre));
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
|
||||||
|
}
|
||||||
|
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||||
|
let slide = h.state().slide_rect.unwrap();
|
||||||
|
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||||
|
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(h.state().notes_buf.2, "노트");
|
||||||
|
// Focus leaves the field (Escape): the notes are committed as one step.
|
||||||
|
h.key_press(egui::Key::Escape);
|
||||||
|
h.run_steps(2);
|
||||||
|
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
|
||||||
|
assert_eq!(notes, "노트");
|
||||||
|
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||||
|
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
|
||||||
|
|
||||||
|
// Composing, then going to another slide: the notes stay with the slide they were typed for.
|
||||||
|
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||||
|
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||||
|
h.run_steps(2);
|
||||||
|
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||||
|
h.event(preedit("ㅇ"));
|
||||||
|
h.event(Event::Ime(ImeEvent::Commit("요".into())));
|
||||||
|
h.run_steps(1);
|
||||||
|
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
let d = h.state().session.active().unwrap();
|
||||||
|
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
|
||||||
|
assert_eq!(d.doc.slides[1].notes_text(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
|
||||||
|
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
|
||||||
|
fn system_has(s: &str) -> bool {
|
||||||
|
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
|
||||||
|
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
|
||||||
|
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
|
||||||
|
#[test]
|
||||||
|
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
|
||||||
|
let h = harness(Session::with_new());
|
||||||
|
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
|
||||||
|
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
let korean = "한국어 발표 자료";
|
||||||
|
let japanese = "日本語のスライド";
|
||||||
|
let chinese = "简体中文演示文稿";
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
|
||||||
|
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
|
||||||
|
s.execute("section.add", &json!({"name": chinese})).unwrap();
|
||||||
|
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||||
|
let mut h = harness(s);
|
||||||
|
h.state_mut().run("view.outline", json!({})).unwrap();
|
||||||
|
h.run_steps(3);
|
||||||
|
assert!(h.state().session.active().unwrap().title().contains(korean));
|
||||||
|
let mut checked = 0;
|
||||||
|
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
|
||||||
|
if !system_has(text) {
|
||||||
|
eprintln!("skipped {text:?}: no font on this machine draws it");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
|
||||||
|
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
|
||||||
|
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
|
||||||
|
}
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
if cfg!(windows) || cfg!(target_os = "macos") {
|
||||||
|
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
|
||||||
|
}
|
||||||
|
// Text typed later (a dialog field, a new comment) is looked at too.
|
||||||
|
let typed = "ไทย";
|
||||||
|
h.event(Event::Text(typed.into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
if system_has(typed) {
|
||||||
|
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-1
@@ -16,6 +16,7 @@ pub mod media;
|
|||||||
pub mod menus;
|
pub mod menus;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod ribbon;
|
pub mod ribbon;
|
||||||
|
pub mod selpane;
|
||||||
pub mod show;
|
pub mod show;
|
||||||
pub mod sorter;
|
pub mod sorter;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
@@ -23,6 +24,7 @@ pub mod textures;
|
|||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod thumbs;
|
pub mod thumbs;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
pub mod uifonts;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
@@ -214,8 +216,12 @@ pub struct SlideApp {
|
|||||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||||
pub quit_confirmed: bool,
|
pub quit_confirmed: bool,
|
||||||
pub(crate) notes_buf: (u64, usize, String),
|
pub(crate) notes_buf: (u64, usize, String),
|
||||||
|
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||||
|
pub ui_fonts: uifonts::UiFonts,
|
||||||
/// Thumbnail pane drag (slide index, current drop index).
|
/// Thumbnail pane drag (slide index, current drop index).
|
||||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||||
|
/// Selection pane: folded groups, renaming, drag and drop.
|
||||||
|
pub selpane: selpane::State,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SlideApp {
|
impl SlideApp {
|
||||||
@@ -253,7 +259,9 @@ impl SlideApp {
|
|||||||
last_recovery: 0.0,
|
last_recovery: 0.0,
|
||||||
quit_confirmed: false,
|
quit_confirmed: false,
|
||||||
notes_buf: (0, usize::MAX, String::new()),
|
notes_buf: (0, usize::MAX, String::new()),
|
||||||
|
ui_fonts: uifonts::UiFonts::default(),
|
||||||
thumb_drag: None,
|
thumb_drag: None,
|
||||||
|
selpane: selpane::State::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +553,7 @@ impl SlideApp {
|
|||||||
ctx.set_zoom_factor(scale);
|
ctx.set_zoom_factor(scale);
|
||||||
}
|
}
|
||||||
if !self.styled {
|
if !self.styled {
|
||||||
theme::install_fonts(ctx);
|
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
|
||||||
self.styled = true;
|
self.styled = true;
|
||||||
self.restyle = true;
|
self.restyle = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -568,6 +576,9 @@ impl SlideApp {
|
|||||||
if !self.synthetic.is_empty() {
|
if !self.synthetic.is_empty() {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
if self.fonts_ready {
|
||||||
|
self.note_ui_text(ctx);
|
||||||
|
}
|
||||||
self.collect_screenshots(ctx);
|
self.collect_screenshots(ctx);
|
||||||
self.issue_screenshots(ctx);
|
self.issue_screenshots(ctx);
|
||||||
// Closing the window with unsaved changes asks first.
|
// Closing the window with unsaved changes asks first.
|
||||||
@@ -599,6 +610,52 @@ impl SlideApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
|
||||||
|
/// presentations' names and text, recent files, and what is typed or composed this frame.
|
||||||
|
fn note_ui_text(&mut self, ctx: &egui::Context) {
|
||||||
|
let typed: String = ctx.input(|i| {
|
||||||
|
i.events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
|
||||||
|
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
self.ui_fonts.note(ctx, &typed);
|
||||||
|
for d in self.session.documents() {
|
||||||
|
self.ui_fonts.note(ctx, &d.title());
|
||||||
|
}
|
||||||
|
for r in &self.ui.recent {
|
||||||
|
self.ui_fonts.note(ctx, r);
|
||||||
|
}
|
||||||
|
if let Some(st) = self.session.active() {
|
||||||
|
self.ui_fonts.note_doc(ctx, st);
|
||||||
|
}
|
||||||
|
self.ui_fonts.apply(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
|
||||||
|
/// replaced on each update; the commit replaces it as one undo step.
|
||||||
|
fn ime_event(&mut self, e: &egui::ImeEvent) {
|
||||||
|
let r = match e {
|
||||||
|
egui::ImeEvent::Preedit { text, active_range_chars } => {
|
||||||
|
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
|
||||||
|
}
|
||||||
|
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
|
||||||
|
#[allow(deprecated)]
|
||||||
|
egui::ImeEvent::Disabled => {
|
||||||
|
self.session.end_composition();
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
_ => Ok(Value::Null),
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
|
self.set_status(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Inject synthetic events (one pointer event per frame).
|
/// Inject synthetic events (one pointer event per frame).
|
||||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||||
if self.synthetic.is_empty() {
|
if self.synthetic.is_empty() {
|
||||||
@@ -646,6 +703,7 @@ impl SlideApp {
|
|||||||
self.set_status(e.to_string());
|
self.set_status(e.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
egui::Event::Ime(ime) => self.ime_event(&ime),
|
||||||
egui::Event::Copy => {
|
egui::Event::Copy => {
|
||||||
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
||||||
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
||||||
@@ -889,6 +947,9 @@ fn js_now() -> f64 {
|
|||||||
js_sys::Date::now()
|
js_sys::Date::now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ime_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
@@ -975,6 +1036,89 @@ mod tests {
|
|||||||
assert_eq!(harness.state().session.documents().len(), 2);
|
assert_eq!(harness.state().session.documents().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real
|
||||||
|
/// egui key events (Windows keys).
|
||||||
|
#[test]
|
||||||
|
fn ctrl_word_keys_edit_text() {
|
||||||
|
use egui::{Key, Modifiers};
|
||||||
|
let mut session = Session::with_new();
|
||||||
|
session.mac_keys = false;
|
||||||
|
session.prefs.autocorrect = false;
|
||||||
|
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SlideApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
SlideApp::new(session, Services::default()),
|
||||||
|
);
|
||||||
|
harness.run_steps(2);
|
||||||
|
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||||
|
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
harness.event(egui::Event::Text("Hello, world. Foo bar".into()));
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(title_text(harness.state()), "Hello, world. Foo bar");
|
||||||
|
let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap();
|
||||||
|
let ctrl = Modifiers::COMMAND;
|
||||||
|
let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||||
|
|
||||||
|
// Ctrl+Left stops at every word and punctuation run, as in PowerPoint.
|
||||||
|
let mut stops = vec![];
|
||||||
|
for _ in 0..6 {
|
||||||
|
harness.key_press_modifiers(ctrl, Key::ArrowLeft);
|
||||||
|
harness.run_steps(1);
|
||||||
|
stops.push(caret(&harness).caret.1);
|
||||||
|
}
|
||||||
|
assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]);
|
||||||
|
harness.key_press_modifiers(ctrl, Key::ArrowRight);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(caret(&harness).caret, (0, 5));
|
||||||
|
|
||||||
|
// Ctrl+Shift+Right selects to the next stops.
|
||||||
|
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
|
||||||
|
harness.key_press_modifiers(ctrl_shift, Key::ArrowRight);
|
||||||
|
harness.run_steps(1);
|
||||||
|
let t = caret(&harness);
|
||||||
|
assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12)));
|
||||||
|
|
||||||
|
// Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one
|
||||||
|
// before the caret.
|
||||||
|
harness.key_press_modifiers(ctrl, Key::Delete);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(title_text(harness.state()), "Helloworld. Foo bar");
|
||||||
|
harness.key_press(Key::End);
|
||||||
|
harness.key_press_modifiers(ctrl, Key::Backspace);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(title_text(harness.state()), "Helloworld. Foo ");
|
||||||
|
|
||||||
|
// Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends.
|
||||||
|
harness.key_press(Key::Enter);
|
||||||
|
harness.event(egui::Event::Text("Next".into()));
|
||||||
|
harness.key_press_modifiers(ctrl, Key::ArrowUp);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(caret(&harness).caret, (1, 0));
|
||||||
|
harness.key_press_modifiers(ctrl, Key::ArrowUp);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(caret(&harness).caret, (0, 0));
|
||||||
|
harness.key_press_modifiers(ctrl, Key::ArrowDown);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert_eq!(caret(&harness).caret, (1, 0));
|
||||||
|
harness.key_press_modifiers(ctrl_shift, Key::Home);
|
||||||
|
harness.run_steps(1);
|
||||||
|
let t = caret(&harness);
|
||||||
|
assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0)));
|
||||||
|
|
||||||
|
// Ctrl+Shift+> (egui reports the Period key) grows the selected text's font.
|
||||||
|
let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64();
|
||||||
|
let before = size(&mut harness);
|
||||||
|
harness.key_press_modifiers(ctrl_shift, Key::Period);
|
||||||
|
harness.run_steps(1);
|
||||||
|
let after = size(&mut harness).unwrap();
|
||||||
|
assert!(after > before.unwrap_or(0.0), "{before:?} → {after}");
|
||||||
|
harness.key_press_modifiers(ctrl, Key::OpenBracket);
|
||||||
|
harness.run_steps(1);
|
||||||
|
assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn home_needs_an_open_presentation() {
|
fn home_needs_an_open_presentation() {
|
||||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
"format" => format_shape(app, ui),
|
"format" => format_shape(app, ui),
|
||||||
"background" => background(app, ui),
|
"background" => background(app, ui),
|
||||||
"animation" => animation(app, ui),
|
"animation" => animation(app, ui),
|
||||||
"selection" => selection(app, ui),
|
"selection" => crate::selpane::show(app, ui),
|
||||||
"comments" => comments(app, ui),
|
"comments" => comments(app, ui),
|
||||||
"designer" => designer(app, ui),
|
"designer" => designer(app, ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -549,72 +549,6 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
let _ = Stroke::NONE;
|
let _ = Stroke::NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selection(app: &mut SlideApp, ui: &mut Ui) {
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let Some(st) = app.session.active() else { return };
|
|
||||||
let shapes: Vec<(u32, String, bool, &'static str)> =
|
|
||||||
st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect();
|
|
||||||
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.button("Show All").clicked() {
|
|
||||||
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
|
|
||||||
run(app, "shape.visible", json!({"ids": ids, "visible": true}));
|
|
||||||
}
|
|
||||||
if ui.button("Hide All").clicked() {
|
|
||||||
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
|
|
||||||
run(app, "shape.visible", json!({"ids": ids, "visible": false}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.separator();
|
|
||||||
let n = shapes.len();
|
|
||||||
for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() {
|
|
||||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
|
|
||||||
let sel = selected.contains(&id);
|
|
||||||
if sel || resp.hovered() {
|
|
||||||
ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover });
|
|
||||||
}
|
|
||||||
ui.painter().text(
|
|
||||||
pos2(r.min.x + 8.0, r.center().y),
|
|
||||||
Align2::LEFT_CENTER,
|
|
||||||
&name,
|
|
||||||
theme::font(12.5),
|
|
||||||
if hidden { t.text_faint } else { t.text },
|
|
||||||
);
|
|
||||||
ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint);
|
|
||||||
let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
|
||||||
icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false);
|
|
||||||
let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
|
|
||||||
if eye_resp.clicked() {
|
|
||||||
run(app, "shape.visible", json!({"id": id, "visible": hidden}));
|
|
||||||
} else if resp.clicked() {
|
|
||||||
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
|
||||||
run(app, "edit.select", json!({"ids": [id], "toggle": add}));
|
|
||||||
}
|
|
||||||
if resp.double_clicked() {
|
|
||||||
let mut d = crate::dialogs::Dialog::new("renameShape");
|
|
||||||
d.params = json!({"id": id, "name": name});
|
|
||||||
app.dialog = Some(d);
|
|
||||||
}
|
|
||||||
resp.context_menu(|ui| {
|
|
||||||
if ui.button("Bring Forward").clicked() {
|
|
||||||
run(app, "arrange.bringForward", json!({"ids": [id]}));
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
if ui.button("Send Backward").clicked() {
|
|
||||||
run(app, "arrange.sendBackward", json!({"ids": [id]}));
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
if ui.button("Rename…").clicked() {
|
|
||||||
let mut d = crate::dialogs::Dialog::new("renameShape");
|
|
||||||
d.params = json!({"id": id, "name": name});
|
|
||||||
app.dialog = Some(d);
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let _ = (k, n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn comments(app: &mut SlideApp, ui: &mut Ui) {
|
fn comments(app: &mut SlideApp, ui: &mut Ui) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
if ui.button("+ New Comment").clicked() {
|
if ui.button("+ New Comment").clicked() {
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
//! Selection pane: every object on the slide, front-most first, groups expanded with their
|
||||||
|
//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among
|
||||||
|
//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move
|
||||||
|
//! through the list. Everything it changes goes through engine commands.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use deckcraft_model::Shape;
|
||||||
|
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::SlideApp;
|
||||||
|
use crate::icons::{self, Icon};
|
||||||
|
use crate::theme::{self, Tokens};
|
||||||
|
|
||||||
|
pub const ROW_H: f32 = 24.0;
|
||||||
|
const INDENT: f32 = 14.0;
|
||||||
|
|
||||||
|
/// Pane-only state (nothing here is part of the document).
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct State {
|
||||||
|
/// Groups folded shut.
|
||||||
|
pub collapsed: HashSet<u32>,
|
||||||
|
/// Object being renamed and the text typed so far.
|
||||||
|
pub renaming: Option<(u32, String)>,
|
||||||
|
rename_started: bool,
|
||||||
|
/// Last row clicked without Shift (the start of a Shift+click run).
|
||||||
|
pub anchor: Option<u32>,
|
||||||
|
/// Row being dragged and the drop it would make: (index among siblings, line y).
|
||||||
|
pub drag: Option<(u32, Option<(usize, f32)>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of the list.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Row {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub depth: usize,
|
||||||
|
pub group: bool,
|
||||||
|
pub hidden: bool,
|
||||||
|
/// Inside a hidden group: drawn dimmed.
|
||||||
|
pub dimmed: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub parent: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows as listed: front-most first, children under their group unless it's folded.
|
||||||
|
pub fn rows(shapes: &[Shape], collapsed: &HashSet<u32>) -> Vec<Row> {
|
||||||
|
fn rec(v: &[Shape], depth: usize, parent: Option<u32>, dim: bool, collapsed: &HashSet<u32>, out: &mut Vec<Row>) {
|
||||||
|
if depth > 64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for sh in v.iter().rev() {
|
||||||
|
out.push(Row {
|
||||||
|
id: sh.id.0,
|
||||||
|
name: sh.name.clone(),
|
||||||
|
depth,
|
||||||
|
group: sh.is_group(),
|
||||||
|
hidden: sh.hidden,
|
||||||
|
dimmed: dim,
|
||||||
|
locked: sh.locked,
|
||||||
|
parent,
|
||||||
|
});
|
||||||
|
if sh.is_group() && !collapsed.contains(&sh.id.0) {
|
||||||
|
rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = vec![];
|
||||||
|
rec(shapes, 0, None, false, collapsed, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's
|
||||||
|
/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index
|
||||||
|
/// among the siblings in the file's order (0 = back) and the y of the insertion line.
|
||||||
|
pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> {
|
||||||
|
let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect();
|
||||||
|
let first = others.first()?;
|
||||||
|
let mut gaps: Vec<f32> = vec![first.1.top()];
|
||||||
|
gaps.extend(others.iter().map(|(_, r)| r.bottom()));
|
||||||
|
let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?;
|
||||||
|
// `g` siblings sit above the line; the dragged object goes behind them.
|
||||||
|
Some((others.len() - g, *gy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) {
|
||||||
|
let _ = app.run(id, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each row is a widget with an id of its own (keyboard focus sits on the selected row).
|
||||||
|
fn row_id(id: u32) -> Id {
|
||||||
|
Id::new(("selection_pane_row", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
|
||||||
|
let t = Tokens::get(ui.ctx());
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||||
|
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label));
|
||||||
|
if enabled && resp.hovered() {
|
||||||
|
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||||
|
}
|
||||||
|
ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||||
|
icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled);
|
||||||
|
resp.on_hover_text(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||||
|
let t = Tokens::get(ui.ctx());
|
||||||
|
let Some(st) = app.session.active() else { return };
|
||||||
|
let rows = rows(st.shapes(), &app.selpane.collapsed);
|
||||||
|
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
|
||||||
|
let has_sel = !selected.is_empty();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() {
|
||||||
|
run(app, "selection.showAll", json!({}));
|
||||||
|
}
|
||||||
|
if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() {
|
||||||
|
run(app, "selection.hideAll", json!({}));
|
||||||
|
}
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() {
|
||||||
|
run(app, "arrange.sendBackward", json!({}));
|
||||||
|
}
|
||||||
|
if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() {
|
||||||
|
run(app, "arrange.bringForward", json!({}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
// The list has the keyboard while one of its rows has focus.
|
||||||
|
let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id));
|
||||||
|
if let Some(f) = focused_row {
|
||||||
|
ui.memory_mut(|m| {
|
||||||
|
m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut rects: Vec<Rect> = Vec::with_capacity(rows.len());
|
||||||
|
let width = ui.available_width();
|
||||||
|
for (k, row) in rows.iter().enumerate() {
|
||||||
|
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||||
|
let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag());
|
||||||
|
rects.push(r);
|
||||||
|
let sel = selected.contains(&row.id);
|
||||||
|
resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name));
|
||||||
|
let painter = ui.painter_at(r);
|
||||||
|
if sel {
|
||||||
|
painter.rect_filled(r, CornerRadius::same(3), t.selected);
|
||||||
|
painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside);
|
||||||
|
} else if resp.hovered() {
|
||||||
|
painter.rect_filled(r, CornerRadius::same(3), t.hover);
|
||||||
|
}
|
||||||
|
let x0 = r.min.x + 4.0 + INDENT * row.depth as f32;
|
||||||
|
// Group chevron.
|
||||||
|
if row.group {
|
||||||
|
let open = !app.selpane.collapsed.contains(&row.id);
|
||||||
|
let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0));
|
||||||
|
let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click());
|
||||||
|
let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name);
|
||||||
|
cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
|
||||||
|
icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false);
|
||||||
|
if cresp.clicked() {
|
||||||
|
if open {
|
||||||
|
app.selpane.collapsed.insert(row.id);
|
||||||
|
} else {
|
||||||
|
app.selpane.collapsed.remove(&row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name_x = x0 + 16.0;
|
||||||
|
let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0));
|
||||||
|
let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0));
|
||||||
|
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0));
|
||||||
|
let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone());
|
||||||
|
if let Some(mut buf) = renaming {
|
||||||
|
let edit_id = ui.id().with(("rename", row.id));
|
||||||
|
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0)));
|
||||||
|
if !app.selpane.rename_started {
|
||||||
|
out.request_focus();
|
||||||
|
if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) {
|
||||||
|
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()));
|
||||||
|
s.cursor.set_char_range(Some(all));
|
||||||
|
s.store(ui.ctx(), edit_id);
|
||||||
|
}
|
||||||
|
app.selpane.rename_started = true;
|
||||||
|
}
|
||||||
|
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||||
|
if out.lost_focus() || enter || esc {
|
||||||
|
app.selpane.renaming = None;
|
||||||
|
app.selpane.rename_started = false;
|
||||||
|
if !esc && buf.trim() != row.name && !buf.trim().is_empty() {
|
||||||
|
run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()}));
|
||||||
|
}
|
||||||
|
ui.memory_mut(|m| m.request_focus(row_id(row.id)));
|
||||||
|
} else if let Some((_, s)) = app.selpane.renaming.as_mut() {
|
||||||
|
*s = buf;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let color = if row.dimmed { t.text_faint } else { t.text };
|
||||||
|
painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color);
|
||||||
|
}
|
||||||
|
// Eye and padlock.
|
||||||
|
let ink = if row.dimmed { t.text_faint } else { t.text_dim };
|
||||||
|
icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false);
|
||||||
|
icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false);
|
||||||
|
let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click());
|
||||||
|
let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name);
|
||||||
|
eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label));
|
||||||
|
let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click());
|
||||||
|
let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name);
|
||||||
|
lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label));
|
||||||
|
if eye_resp.clicked() {
|
||||||
|
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
|
||||||
|
} else if lock_resp.clicked() {
|
||||||
|
run(app, "shape.lock", json!({"id": row.id}));
|
||||||
|
} else if resp.double_clicked() {
|
||||||
|
app.selpane.renaming = Some((row.id, row.name.clone()));
|
||||||
|
app.selpane.rename_started = false;
|
||||||
|
} else if resp.clicked() {
|
||||||
|
let m = ui.input(|i| i.modifiers);
|
||||||
|
click(app, &rows, k, m.command || m.ctrl, m.shift);
|
||||||
|
ui.memory_mut(|mem| mem.request_focus(row_id(row.id)));
|
||||||
|
}
|
||||||
|
if resp.drag_started() {
|
||||||
|
if !sel {
|
||||||
|
run(app, "selection.select", json!({"id": row.id}));
|
||||||
|
}
|
||||||
|
app.selpane.drag = Some((row.id, None));
|
||||||
|
}
|
||||||
|
context_menu(app, &resp, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging: the insertion line among the dragged row's siblings.
|
||||||
|
if let Some((dragged, _)) = app.selpane.drag {
|
||||||
|
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||||
|
let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down());
|
||||||
|
let target = pointer.and_then(|p| {
|
||||||
|
let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?;
|
||||||
|
let blocks = sibling_blocks(&rows, &rects, parent);
|
||||||
|
drop_target(dragged, &blocks, p.y)
|
||||||
|
});
|
||||||
|
app.selpane.drag = Some((dragged, target));
|
||||||
|
if let Some((_, y)) = target {
|
||||||
|
let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width);
|
||||||
|
let p = ui.painter();
|
||||||
|
let s = Stroke::new(2.0, t.accent);
|
||||||
|
p.line_segment([pos2(x0, y), pos2(x1, y)], s);
|
||||||
|
p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s);
|
||||||
|
p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s);
|
||||||
|
}
|
||||||
|
if released {
|
||||||
|
app.selpane.drag = None;
|
||||||
|
if let Some((index, _)) = target {
|
||||||
|
run(app, "arrange.reorder", json!({"id": dragged, "index": index}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys while the list has focus.
|
||||||
|
if let Some(f) = focused_row
|
||||||
|
&& app.selpane.renaming.is_none()
|
||||||
|
{
|
||||||
|
keys(app, ui, &rows, &selected, f);
|
||||||
|
}
|
||||||
|
// Clicking the empty part of the list doesn't change anything; keep the pane's height.
|
||||||
|
let rest = ui.available_size();
|
||||||
|
if rest.y > 0.0 {
|
||||||
|
ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom.
|
||||||
|
fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option<u32>) -> Vec<(u32, Rect)> {
|
||||||
|
let mut out: Vec<(u32, Rect)> = vec![];
|
||||||
|
let depth_of = |id: Option<u32>| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0);
|
||||||
|
let depth = depth_of(parent);
|
||||||
|
let mut current: Option<(u32, Rect)> = None;
|
||||||
|
for (row, rect) in rows.iter().zip(rects) {
|
||||||
|
if row.parent == parent {
|
||||||
|
if let Some(c) = current.take() {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
current = Some((row.id, *rect));
|
||||||
|
} else if row.depth > depth
|
||||||
|
&& let Some((_, r)) = current.as_mut()
|
||||||
|
{
|
||||||
|
*r = r.union(*rect);
|
||||||
|
} else if let Some(c) = current.take() {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(c) = current {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) {
|
||||||
|
let Some(row) = rows.get(k) else { return };
|
||||||
|
if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) {
|
||||||
|
// A run of rows of the same kind (top level, or inside groups) between the anchor and here.
|
||||||
|
let (lo, hi) = if a <= k { (a, k) } else { (k, a) };
|
||||||
|
let top = row.parent.is_none();
|
||||||
|
let ids: Vec<u32> = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect();
|
||||||
|
run(app, "selection.select", json!({"ids": ids, "add": ctrl}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run(app, "selection.select", json!({"id": row.id, "toggle": ctrl}));
|
||||||
|
app.selpane.anchor = Some(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) {
|
||||||
|
let (up, down, home, end, f2, esc) = ui.input(|i| {
|
||||||
|
(
|
||||||
|
i.key_pressed(egui::Key::ArrowUp),
|
||||||
|
i.key_pressed(egui::Key::ArrowDown),
|
||||||
|
i.key_pressed(egui::Key::Home),
|
||||||
|
i.key_pressed(egui::Key::End),
|
||||||
|
i.key_pressed(egui::Key::F2),
|
||||||
|
i.key_pressed(egui::Key::Escape),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id));
|
||||||
|
let to = if up {
|
||||||
|
Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0))
|
||||||
|
} else if down {
|
||||||
|
Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0))
|
||||||
|
} else if home {
|
||||||
|
Some(0)
|
||||||
|
} else if end {
|
||||||
|
Some(rows.len().saturating_sub(1))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(k) = to
|
||||||
|
&& let Some(r) = rows.get(k)
|
||||||
|
&& Some(k) != cur
|
||||||
|
{
|
||||||
|
run(app, "selection.select", json!({"id": r.id}));
|
||||||
|
app.selpane.anchor = Some(r.id);
|
||||||
|
ui.memory_mut(|m| m.request_focus(row_id(r.id)));
|
||||||
|
}
|
||||||
|
if f2
|
||||||
|
&& let [one] = selected
|
||||||
|
&& let Some(r) = rows.iter().find(|r| r.id == *one)
|
||||||
|
{
|
||||||
|
app.selpane.renaming = Some((r.id, r.name.clone()));
|
||||||
|
app.selpane.rename_started = false;
|
||||||
|
}
|
||||||
|
if esc {
|
||||||
|
ui.memory_mut(|m| m.surrender_focus(row_id(focused)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) {
|
||||||
|
resp.context_menu(|ui| {
|
||||||
|
for (label, cmd) in [
|
||||||
|
("Bring to Front", "arrange.bringToFront"),
|
||||||
|
("Bring Forward", "arrange.bringForward"),
|
||||||
|
("Send Backward", "arrange.sendBackward"),
|
||||||
|
("Send to Back", "arrange.sendToBack"),
|
||||||
|
] {
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
run(app, cmd, json!({"ids": [row.id]}));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Rename").clicked() {
|
||||||
|
app.selpane.renaming = Some((row.id, row.name.clone()));
|
||||||
|
app.selpane.rename_started = false;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() {
|
||||||
|
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() {
|
||||||
|
run(app, "shape.lock", json!({"id": row.id}));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn r(id: u32, top: f32) -> (u32, Rect) {
|
||||||
|
(id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_target_picks_the_nearest_gap() {
|
||||||
|
// Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1.
|
||||||
|
let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)];
|
||||||
|
// Above everything: in front of all three others.
|
||||||
|
assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0)));
|
||||||
|
// Between 4 and 3: one sibling above → index 2.
|
||||||
|
assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0)));
|
||||||
|
// Below the last other row: the back.
|
||||||
|
assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0)));
|
||||||
|
// Nothing else to drop among.
|
||||||
|
assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "selpane_tests.rs"]
|
||||||
|
mod ui_tests;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye,
|
||||||
|
//! the padlock, renaming and the keyboard.
|
||||||
|
|
||||||
|
use deckcraft_engine::Session;
|
||||||
|
use egui::{Modifiers, Pos2};
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{Services, SlideApp};
|
||||||
|
|
||||||
|
/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open.
|
||||||
|
fn harness() -> (Harness<'static, SlideApp>, Vec<u32>) {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||||
|
let mut ids = vec![];
|
||||||
|
for (i, n) in ["A", "B", "C", "D", "E"].iter().enumerate() {
|
||||||
|
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 50, 100, 80]})).unwrap();
|
||||||
|
let id = r["id"].as_u64().unwrap() as u32;
|
||||||
|
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
let g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||||
|
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||||
|
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||||
|
ids.push(g);
|
||||||
|
let mut app = SlideApp::new(s, Services::default());
|
||||||
|
app.ui.pane = Some("selection".into());
|
||||||
|
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, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(h: &Harness<'_, SlideApp>) -> String {
|
||||||
|
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||||
|
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
rec(h.state().session.active().unwrap().shapes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(h: &Harness<'_, SlideApp>) -> Vec<String> {
|
||||||
|
let st = h.state().session.active().unwrap();
|
||||||
|
st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect {
|
||||||
|
h.get_by_label(name).rect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape {
|
||||||
|
let st = h.state().session.active().unwrap();
|
||||||
|
let mut found = None;
|
||||||
|
deckcraft_model::walk(st.shapes(), &mut |s, _| {
|
||||||
|
if found.is_none() && s.name == name {
|
||||||
|
found = Some(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
found.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_front_most_first_with_groups_expanded() {
|
||||||
|
let (h, _) = harness();
|
||||||
|
let ys: Vec<f32> = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect();
|
||||||
|
// G is in front, then its children front-most first (E above D), then C, B, A.
|
||||||
|
let order = ["G", "E", "D", "C", "B", "A"];
|
||||||
|
let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect();
|
||||||
|
by_y.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||||
|
assert_eq!(by_y.iter().map(|x| x.0).collect::<Vec<_>>(), order);
|
||||||
|
// Children are indented under the group (the name's row starts at the same x; the group
|
||||||
|
// has a fold button).
|
||||||
|
assert!(h.query_by_label("Collapse G").is_some());
|
||||||
|
assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn click_ctrl_click_and_shift_click_select() {
|
||||||
|
let (mut h, _) = harness();
|
||||||
|
h.get_by_label("A").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["A"]);
|
||||||
|
h.get_by_label("C").click_modifiers(Modifiers::COMMAND);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["A", "C"]);
|
||||||
|
h.get_by_label("A").click_modifiers(Modifiers::COMMAND);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["C"]);
|
||||||
|
// Shift+click: the run from the last click (C) down to A.
|
||||||
|
h.get_by_label("C").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("A").click_modifiers(Modifiers::SHIFT);
|
||||||
|
h.run_steps(2);
|
||||||
|
let mut sel = selected(&h);
|
||||||
|
sel.sort();
|
||||||
|
assert_eq!(sel, ["A", "B", "C"]);
|
||||||
|
// A child of the group.
|
||||||
|
h.get_by_label("D").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["D"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eye_and_padlock_toggle_and_undo() {
|
||||||
|
let (mut h, _) = harness();
|
||||||
|
h.get_by_label("Hide B").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(shape(&h, "B").hidden);
|
||||||
|
assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it");
|
||||||
|
h.get_by_label("Lock G").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children");
|
||||||
|
h.get_by_label("Unlock D").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(!shape(&h, "D").locked && shape(&h, "G").locked);
|
||||||
|
h.get_by_label("Hide All").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden));
|
||||||
|
h.get_by_label("Show All").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden));
|
||||||
|
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(shape(&h, "A").hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drag a row with the mouse: press, move in steps, release.
|
||||||
|
fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) {
|
||||||
|
h.hover_at(from);
|
||||||
|
h.run_steps(1);
|
||||||
|
h.drag_at(from);
|
||||||
|
h.run_steps(1);
|
||||||
|
for i in 1..=6 {
|
||||||
|
let t = i as f32 / 6.0;
|
||||||
|
h.hover_at(from + (to - from) * t);
|
||||||
|
h.run_steps(1);
|
||||||
|
}
|
||||||
|
h.drop_at(to);
|
||||||
|
h.run_steps(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drag_and_drop_restacks_among_siblings() {
|
||||||
|
let (mut h, _) = harness();
|
||||||
|
assert_eq!(names(&h), "A B C G[D E]");
|
||||||
|
// A (bottom row) dragged above G (top row): A goes to the front.
|
||||||
|
let (a, g) = (row(&h, "A"), row(&h, "G"));
|
||||||
|
drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0));
|
||||||
|
assert_eq!(names(&h), "B C G[D E] A");
|
||||||
|
assert_eq!(selected(&h), ["A"], "the dragged object is selected");
|
||||||
|
// C dragged to the bottom of the list: the back.
|
||||||
|
let (c, b) = (row(&h, "C"), row(&h, "B"));
|
||||||
|
drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0));
|
||||||
|
assert_eq!(names(&h), "C B G[D E] A");
|
||||||
|
// D (a child) dragged far above its group stays in the group, at its front.
|
||||||
|
let (d, a) = (row(&h, "D"), row(&h, "A"));
|
||||||
|
drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0));
|
||||||
|
assert_eq!(names(&h), "C B G[E D] A");
|
||||||
|
// B dropped over the group lands among the top-level objects (here just in front of the
|
||||||
|
// group), never inside it.
|
||||||
|
let (b, g) = (row(&h, "B"), row(&h, "G"));
|
||||||
|
drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0));
|
||||||
|
assert_eq!(names(&h), "C G[E D] B A");
|
||||||
|
// Each drop is one undo step.
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(names(&h), "A B C G[D E]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bring_forward_and_send_backward_buttons() {
|
||||||
|
let (mut h, _) = harness();
|
||||||
|
h.get_by_label("A").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("Bring Forward").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(names(&h), "B A C G[D E]");
|
||||||
|
h.get_by_label("D").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("Bring Forward").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("Bring Forward").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group");
|
||||||
|
h.get_by_label("Send Backward").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(names(&h), "B A C G[D E]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_click_renames_in_place() {
|
||||||
|
let (mut h, _) = harness();
|
||||||
|
let b = row(&h, "B").center();
|
||||||
|
h.hover_at(b);
|
||||||
|
h.run_steps(1);
|
||||||
|
for _ in 0..2 {
|
||||||
|
for pressed in [true, false] {
|
||||||
|
h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||||
|
h.run_steps(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming");
|
||||||
|
// The whole name is selected: typing replaces it.
|
||||||
|
h.event(egui::Event::Text("Bravo".into()));
|
||||||
|
h.run_steps(1);
|
||||||
|
h.key_press(egui::Key::Enter);
|
||||||
|
h.run_steps(3);
|
||||||
|
assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id);
|
||||||
|
assert!(h.state().selpane.renaming.is_none());
|
||||||
|
// Escape cancels.
|
||||||
|
h.get_by_label("Bravo").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.key_press(egui::Key::F2);
|
||||||
|
h.run_steps(3);
|
||||||
|
assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object");
|
||||||
|
h.event(egui::Event::Text("Nope".into()));
|
||||||
|
h.run_steps(1);
|
||||||
|
h.key_press(egui::Key::Escape);
|
||||||
|
h.run_steps(3);
|
||||||
|
assert!(h.query_by_label("Bravo").is_some());
|
||||||
|
assert!(h.query_by_label("Nope").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_keys_move_through_the_list_and_collapse_hides_children() {
|
||||||
|
let (mut h, _) = harness();
|
||||||
|
h.get_by_label("G").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
h.key_press(egui::Key::ArrowDown);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["E"]);
|
||||||
|
h.key_press(egui::Key::ArrowDown);
|
||||||
|
h.run_steps(2);
|
||||||
|
h.key_press(egui::Key::ArrowDown);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["C"]);
|
||||||
|
// The arrows moved through the list: C itself didn't move on the slide.
|
||||||
|
assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0));
|
||||||
|
h.key_press(egui::Key::ArrowUp);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(selected(&h), ["D"]);
|
||||||
|
h.get_by_label("Collapse G").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some());
|
||||||
|
h.get_by_label("Expand G").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.query_by_label("D").is_some());
|
||||||
|
}
|
||||||
@@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
ui.painter()
|
ui.painter()
|
||||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
|
||||||
app.notes_buf = (uid, idx, current.clone());
|
|
||||||
}
|
|
||||||
let id = egui::Id::new("notes_edit");
|
let id = egui::Id::new("notes_edit");
|
||||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||||
if !focused && app.notes_buf.2 != current {
|
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||||
|
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||||
|
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||||
|
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||||
|
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||||
|
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||||
|
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||||
|
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||||
app.notes_buf.2 = current.clone();
|
app.notes_buf.2 = current.clone();
|
||||||
}
|
}
|
||||||
let mut buf = app.notes_buf.2.clone();
|
let mut buf = app.notes_buf.2.clone();
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
|||||||
|
|
||||||
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
|
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
|
||||||
pub fn install_fonts(ctx: &egui::Context) {
|
pub fn install_fonts(ctx: &egui::Context) {
|
||||||
|
install_fonts_with(ctx, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
|
||||||
|
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
|
||||||
|
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
|
||||||
|
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||||
|
ctx.set_fonts(font_definitions(extra));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interface font definitions; see [`install_fonts_with`].
|
||||||
|
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
|
||||||
let mut defs = FontDefinitions::default();
|
let mut defs = FontDefinitions::default();
|
||||||
let mut names = vec![];
|
let mut names = vec![];
|
||||||
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
|
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
|
||||||
@@ -178,7 +190,34 @@ pub fn install_fonts(ctx: &egui::Context) {
|
|||||||
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
|
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
|
||||||
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
||||||
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
||||||
ctx.set_fonts(defs);
|
add_fallback_faces(&mut defs, extra);
|
||||||
|
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
|
||||||
|
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
|
||||||
|
// unhinted, they draw.
|
||||||
|
for (name, d) in defs.font_data.iter_mut() {
|
||||||
|
if name.contains("BIZ UDPGothic") {
|
||||||
|
let mut data = (**d).clone();
|
||||||
|
data.tweak.hinting = Some(false);
|
||||||
|
*d = std::sync::Arc::new(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `faces` after every other font of each interface family (the last fallbacks).
|
||||||
|
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||||
|
for f in faces {
|
||||||
|
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
|
||||||
|
if defs.font_data.contains_key(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut data = FontData::from_owned(f.data().to_vec());
|
||||||
|
data.index = f.index();
|
||||||
|
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
|
||||||
|
for list in defs.families.values_mut() {
|
||||||
|
list.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bold(size: f32) -> egui::FontId {
|
pub fn bold(size: f32) -> egui::FontId {
|
||||||
@@ -192,3 +231,38 @@ pub fn font(size: f32) -> egui::FontId {
|
|||||||
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
|
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
|
||||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
|
||||||
|
/// has ink (a glyph image).
|
||||||
|
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
ctx.set_fonts(super::font_definitions(&[]));
|
||||||
|
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||||
|
o.textures_delta.clear();
|
||||||
|
let mut out = vec![];
|
||||||
|
ctx.fonts_mut(|f| {
|
||||||
|
for size in [11.0, 12.0, 13.0, 16.0] {
|
||||||
|
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
|
||||||
|
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
|
||||||
|
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
|
||||||
|
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut o = ctx.run_ui(Default::default(), |_| {});
|
||||||
|
o.textures_delta.clear();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||||
|
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||||
|
// so notes and the outline showed gaps where they were.
|
||||||
|
for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") {
|
||||||
|
assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
|
||||||
|
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
|
||||||
|
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
|
||||||
|
//! character on screen that they lack, a face that has it is borrowed from the font database (the
|
||||||
|
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
|
||||||
|
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
|
||||||
|
//! there are no installed fonts, only the embedded faces are used.
|
||||||
|
//!
|
||||||
|
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
|
||||||
|
//! the composition and caption to every text the interface draws from the presentation.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use deckcraft_fonts::{FontDb, FontFace};
|
||||||
|
|
||||||
|
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
|
||||||
|
const MAX_FACE_BYTES: usize = 48 << 20;
|
||||||
|
|
||||||
|
/// Faces borrowed for the interface, and the characters already looked at.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct UiFonts {
|
||||||
|
/// Faces added after the interface fonts, in the order they were found.
|
||||||
|
pub faces: Vec<Arc<FontFace>>,
|
||||||
|
/// Characters already looked at (drawn, given a face, or found in no face).
|
||||||
|
seen: HashSet<char>,
|
||||||
|
/// The presentation last looked through: (uid, revision).
|
||||||
|
doc: Option<(u64, u64)>,
|
||||||
|
/// Faces were added since the fonts were last installed.
|
||||||
|
changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
|
||||||
|
fn wanted(c: char) -> bool {
|
||||||
|
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
|
||||||
|
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
|
||||||
|
/// very large faces are skipped (egui draws neither well).
|
||||||
|
pub fn faces_for_text(
|
||||||
|
text: &str,
|
||||||
|
have: &[Arc<FontFace>],
|
||||||
|
seen: &mut HashSet<char>,
|
||||||
|
mut covered: impl FnMut(char) -> bool,
|
||||||
|
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
|
||||||
|
) -> Vec<Arc<FontFace>> {
|
||||||
|
let mut out: Vec<Arc<FontFace>> = Vec::new();
|
||||||
|
for c in text.chars().filter(|c| wanted(*c)) {
|
||||||
|
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(f) = lookup(c) else { continue };
|
||||||
|
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiFonts {
|
||||||
|
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
|
||||||
|
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
|
||||||
|
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font = egui::FontId::proportional(13.0);
|
||||||
|
let new = ctx.fonts_mut(|f| {
|
||||||
|
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||||
|
});
|
||||||
|
if !new.is_empty() {
|
||||||
|
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
|
self.faces.extend(new);
|
||||||
|
self.changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look through the text of a presentation the interface shows (once per revision): its
|
||||||
|
/// title, slide text and names, notes, comments, sections and layout names.
|
||||||
|
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
|
||||||
|
if self.doc == Some((st.uid, st.revision)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.doc = Some((st.uid, st.revision));
|
||||||
|
let text = ui_text(st);
|
||||||
|
self.note(ctx, &text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the interface fonts again when faces were added. True if they were.
|
||||||
|
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
|
||||||
|
if !std::mem::take(&mut self.changed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
crate::theme::install_fonts_with(ctx, &self.faces);
|
||||||
|
ctx.request_repaint();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of a presentation that the interface can show (not the slides' pictures of it).
|
||||||
|
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
|
||||||
|
let mut out = st.title();
|
||||||
|
let mut add = |s: &str| {
|
||||||
|
if s.chars().any(wanted) {
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
|
||||||
|
for p in &b.paragraphs {
|
||||||
|
for r in &p.runs {
|
||||||
|
add(&r.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let doc = &st.doc;
|
||||||
|
add(&doc.props.title);
|
||||||
|
for sec in &doc.sections {
|
||||||
|
add(&sec.name);
|
||||||
|
}
|
||||||
|
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
|
||||||
|
deckcraft_model::walk(list, &mut |sh, _| {
|
||||||
|
add(&sh.name);
|
||||||
|
add(&sh.title);
|
||||||
|
add(&sh.descr);
|
||||||
|
if let Some(t) = &sh.text {
|
||||||
|
body(t, add);
|
||||||
|
}
|
||||||
|
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||||
|
for row in &tb.rows {
|
||||||
|
for cell in &row.cells {
|
||||||
|
body(&cell.text, add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for s in &doc.slides {
|
||||||
|
add(&s.name);
|
||||||
|
shapes(&s.shapes, &mut add);
|
||||||
|
body(&s.notes, &mut add);
|
||||||
|
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
|
||||||
|
while let Some(c) = stack.pop() {
|
||||||
|
add(&c.author);
|
||||||
|
add(&c.text);
|
||||||
|
stack.extend(c.replies.iter());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in &doc.masters {
|
||||||
|
add(&m.name);
|
||||||
|
for l in &m.layouts {
|
||||||
|
add(&l.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn face(family: &str) -> Arc<FontFace> {
|
||||||
|
FontDb::global().face(family, "Regular")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One face for a whole string, looked up once per character, never for characters the
|
||||||
|
/// interface already draws, and never twice.
|
||||||
|
#[test]
|
||||||
|
fn one_face_per_script_and_each_character_once() {
|
||||||
|
let inter = face("Inter");
|
||||||
|
let looked = std::cell::Cell::new(0);
|
||||||
|
let lookup = |_: char| {
|
||||||
|
looked.set(looked.get() + 1);
|
||||||
|
Some(inter.clone())
|
||||||
|
};
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
|
||||||
|
assert_eq!(got.len(), 1, "one face, not one per character");
|
||||||
|
// Inter doesn't cover Hangul, so every character was looked up once.
|
||||||
|
assert_eq!(looked.get(), 5);
|
||||||
|
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
|
||||||
|
assert_eq!(looked.get(), 5);
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
|
||||||
|
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
|
||||||
|
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_text_of_a_presentation() {
|
||||||
|
let mut s = deckcraft_engine::Session::with_new();
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
|
||||||
|
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
|
||||||
|
let t = ui_text(s.doc().unwrap());
|
||||||
|
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
|
||||||
|
assert!(t.starts_with(&s.doc().unwrap().title()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
|||||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
||||||
|
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
|
||||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
||||||
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
||||||
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
|
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
# Where DeckCraft falls short of PowerPoint
|
# Where DeckCraft falls short of PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||||
@@ -225,7 +225,11 @@ Detail in [localization-parity.md](localization-parity.md).
|
|||||||
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
|
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
|
||||||
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
|
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
|
||||||
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
|
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
|
||||||
- **No IME composition in text editing** (CJK input). 3–6 h.
|
- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
|
||||||
|
(Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
|
||||||
|
outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
|
||||||
|
Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h.
|
||||||
|
- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h.
|
||||||
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
|
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
|
||||||
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
|
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
|
||||||
|
|
||||||
@@ -251,6 +255,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hardware parity with PowerPoint
|
# Hardware parity with PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||||
|
|
||||||
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
|
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
|
||||||
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
|
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
|
||||||
@@ -46,7 +46,7 @@ because it mostly follows from hardware use.
|
|||||||
| Pinch to zoom, two-finger gestures | yes | no |
|
| Pinch to zoom, two-finger gestures | yes | no |
|
||||||
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
|
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
|
||||||
| Touch screens (Windows) | yes | basic pointer only |
|
| Touch screens (Windows) | yes | basic pointer only |
|
||||||
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
|
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
|
||||||
| Dictation | yes (macOS) | no |
|
| Dictation | yes (macOS) | no |
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
@@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software.
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-12 | minor | IME input methods (#101) |
|
||||||
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Localization parity with PowerPoint
|
# Localization parity with PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
|
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
|
||||||
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
|
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
|
||||||
@@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review.
|
|||||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
|
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
|
||||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
||||||
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
|
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
|
||||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
|
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
|
||||||
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
|
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
|
||||||
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
|
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
|
||||||
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
|
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
|
||||||
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||||
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
||||||
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||||
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
|
| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
|
||||||
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
|
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
|
||||||
|
|
||||||
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
|
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
|
||||||
@@ -44,7 +44,8 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
|
|||||||
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
|
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
|
||||||
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
|
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
|
||||||
the system (#15), plural and number formatting, and an `xtask` check that every key has a
|
the system (#15), plural and number formatting, and an `xtask` check that every key has a
|
||||||
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
|
translation. IME composition landed with #101 (slide text, notes and text fields; one undo step
|
||||||
|
per composition); the hours that included it are kept until the next re-measure.
|
||||||
|
|
||||||
## Proofing languages
|
## Proofing languages
|
||||||
|
|
||||||
@@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
|
||||||
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
||||||
|
|||||||
+36
-1
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
|
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||||
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
|
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
|
||||||
@@ -29,7 +31,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
|
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
|
||||||
| Format Background pane | yes | yes |
|
| Format Background pane | yes | yes |
|
||||||
| Selection Pane | yes | yes |
|
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
|
||||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||||
| Comments pane (threads) | yes | yes |
|
| Comments pane (threads) | yes | yes |
|
||||||
| Design Ideas (Designer) | yes (cloud) | no |
|
| Design Ideas (Designer) | yes (cloud) | no |
|
||||||
@@ -64,6 +66,35 @@ group/ungroup, bold/italic/underline, alignment, font size, list levels, Format
|
|||||||
start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
|
start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
|
||||||
precision without snapping, F6 pane cycling, ribbon KeyTips (Windows).
|
precision without snapping, F6 pane cycling, ribbon KeyTips (Windows).
|
||||||
|
|
||||||
|
### Text editing keys
|
||||||
|
|
||||||
|
Observed black-box in PowerPoint for Windows (Microsoft 365, October 2026) on synthetic text, and
|
||||||
|
pinned by tests in `crates/model/src/words.rs`, `crates/model/src/edit.rs` and
|
||||||
|
`crates/engine/src/tests_keys.rs` (#268). Windows and Linux use Ctrl; the Mac uses Option for words
|
||||||
|
and Cmd for line and text ends (`Session::mac_keys`).
|
||||||
|
|
||||||
|
| Keys | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Ctrl+Left / Right | Previous / next word stop. A stop is the start of every word, punctuation run, symbol run, tab, line break and CJK character, plus the paragraph's end; spaces belong to the word before them. `it's`, `baz-qux`, `a-`, `3.14`, `1,000`, `3:45` are one word; `e.g.`, `a_b`, `x@y.com`, `dogs'` are not. Every CJK ideograph, kana and Hangul syllable is a word. Combining marks and emoji sequences never split. With a selection: Right goes on from its end, Left from its start |
|
||||||
|
| Ctrl+Shift+Left / Right | Moves the selection's moving end by the same stops |
|
||||||
|
| Ctrl+Up / Down | Start of the paragraph (then of the previous one) / start of the next paragraph (end of the text in the last one); Shift extends |
|
||||||
|
| Ctrl+Backspace | Deletes back to the previous stop (word plus its trailing spaces); at a paragraph's start joins it to the previous paragraph |
|
||||||
|
| Ctrl+Delete | Inside a word: the rest of it. On spaces after a word: the spaces and the next word. At a word's start: the word and its spaces. Then smart spacing: spaces left before closing punctuation (`ab cd, ef` → `ab, ef`) or at the paragraph's end after a word go; sentence punctuation left touching a word gets a space (`cd...ef gh` → `cd... gh`). At a paragraph's end joins the next paragraph |
|
||||||
|
| Ctrl+Backspace / Delete with a selection | Leave the selection and delete the word part before / after its start |
|
||||||
|
| Right-to-left paragraph | Left and Right (with or without Ctrl) swap: Left goes forward in the text. Arabic or Hebrew words in a left-to-right paragraph move in logical order |
|
||||||
|
| Home / End | Start / end of the visual line. End on a wrapped line goes past its trailing space and the caret stays drawn at that line's end; End again stays; Home from there returns to the same line's start |
|
||||||
|
| Ctrl+Home / End | Start / end of the text; Shift extends |
|
||||||
|
| Ctrl+Shift+> / <, Ctrl+] / [ | Next / previous size in the font size list (18 → 20 → 24) |
|
||||||
|
| Ctrl+B / I / U, Ctrl+Space | With a caret strictly inside a word, the whole word (without trailing spaces); at a word's edge, only what is typed next |
|
||||||
|
| Ctrl+E / L / R / J | Center / left / right / justify the paragraphs in the selection |
|
||||||
|
| Shift+F3 | Cycles lowercase → Capitalize Each Word → UPPERCASE → lowercase; other mixes go to UPPERCASE first. A caret in a word changes that word and moves to its start |
|
||||||
|
| Shift+Enter | Line break |
|
||||||
|
| Double-click / triple-click | The word without its trailing spaces (a CJK character on its own) / the paragraph including its paragraph mark |
|
||||||
|
|
||||||
|
Quirks kept on purpose because PowerPoint has them: Ctrl+Left (not Ctrl+Right) also stops at
|
||||||
|
each no-break or ideographic space, and treats an emoji followed by punctuation as one word. Tab, Shift+Tab and
|
||||||
|
Alt+Shift+arrows in text are handled elsewhere (#78, #93).
|
||||||
|
|
||||||
## Visual fidelity
|
## Visual fidelity
|
||||||
|
|
||||||
Colours, spacing, ribbon card and tab geometry follow the observations in
|
Colours, spacing, ribbon card and tab geometry follow the observations in
|
||||||
@@ -89,4 +120,8 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
|||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||||
|
|
||||||
|
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||||
|
|
||||||
|
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
|
||||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||||
|
|||||||
Reference in New Issue
Block a user