Type: text.convertListToText moves the Type tool's caret past the markers

The Type tool's anchor and caret were moved past the inserted markers
only by the menu item in the desktop app. When text.convertListToText
came over MCP or the control channel while the Type tool was editing,
they kept their old byte offsets, and the inserted characters (a bullet
is 3 bytes) could leave an offset inside a character.

The command now ends the Type tool's typing, then moves its anchor and
caret past the characters inserted at or before them. In threaded type,
when the caret's character moved to another frame, the tool edits that
frame, which becomes the selection when it was not selected. The menu
item runs the command without adjusting the selection itself.

Docs: docs/mcp.md.
This commit is contained in:
dotCooCoo
2026-10-26 19:38:12 +07:00
committed by bflatastic
parent 2f445e117f
commit 7a815b0acc
4 changed files with 146 additions and 76 deletions
+86 -1
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to Text",
[],
None,
"{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
"{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them; in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
has_doc,
convert_list_to_text
),
@@ -739,13 +739,93 @@ fn aligned_between(tab: &vectorcraft_doc::TabStop, from: f64, x: f64) -> bool {
tab.align != vectorcraft_doc::TabAlign::Left && tab.position > from + 1e-6 && tab.position < x - 1e-6
}
/// The frames of the story that type object `id` belongs to (its thread in thread order, else `id`
/// alone), each with the length of its text in bytes.
fn story_frames(s: &Session, id: NodeId) -> Vec<(NodeId, usize)> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
let frames = d.text_threads.iter().find(|thread| thread.contains(&id)).cloned().unwrap_or_else(|| vec![id]);
frames
.into_iter()
.filter_map(|f| match d.node(f).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some((f, t.plain_text().len())),
_ => None,
})
.collect()
}
/// After Convert to Text inserted `inserted` (each a frame, the byte it went in at and its length),
/// the Type tool's anchor and caret in frame `id` (`editing`, read before the call, in a story
/// whose frames had the lengths `before`) move past the characters inserted at or before them in
/// the story. In threaded type the caret stays in the frame it was in while its place is still
/// there, and else goes to the frame its character moved to, with the anchor in that frame; that
/// frame becomes the selection when it was not selected.
fn move_editing_selection(
s: &mut Session,
(id, anchor, caret): (NodeId, usize, usize),
before: &[(NodeId, usize)],
inserted: &[(NodeId, usize, usize)],
) -> Result<()> {
// Story positions: the bytes of the frames before a frame, then the byte in it.
let start_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().take_while(|(g, _)| *g != f).map(|(_, n)| n).sum::<usize>();
let len_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().find(|(g, _)| *g == f).map_or(0, |(_, n)| *n);
let inserted: Vec<(usize, usize)> =
inserted.iter().filter(|(f, _, _)| before.iter().any(|(g, _)| g == f)).map(|&(f, at, n)| (start_of(before, f) + at, n)).collect();
if inserted.is_empty() {
return Ok(());
}
let story = |at: usize| {
let at = start_of(before, id) + at.min(len_of(before, id));
at + inserted.iter().filter(|(b, _)| *b <= at).map(|(_, n)| n).sum::<usize>()
};
let (anchor, caret) = (story(anchor), story(caret));
let after = story_frames(s, id);
let fits = |f: NodeId| (start_of(&after, f)..=start_of(&after, f) + len_of(&after, f)).contains(&caret);
let frame = if fits(id) { Some(id) } else { after.iter().map(|(f, _)| *f).find(|f| fits(*f)) };
let Some(frame) = frame else { return Ok(()) };
let (start, len) = (start_of(&after, frame), len_of(&after, frame));
let local = |at: usize| at.saturating_sub(start).min(len);
if frame == id {
s.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)}));
return Ok(());
}
s.set_tool_option("textSelection", &json!({"editing": frame.0, "anchor": local(anchor), "caret": local(caret)}));
// The Type tool ends editing after a command when the frame it edits is not selected, itself
// or through a selected group, so that frame becomes the selection, as a click in it does.
let selected = s.active().is_some_and(|st| {
let mut cur = Some(frame);
while let Some(c) = cur {
if st.selection.contains(c) {
return true;
}
cur = st.doc.parent_of(c);
}
false
});
if !selected {
s.select(|_, sel| sel.set([frame]))?;
}
Ok(())
}
/// Type › Bullets and Numbering › Convert to Text (`text.convertListToText`): the markers of the
/// list paragraphs the call names become characters of the text, and the lists are turned off.
/// While the Type tool edits, its typing ends first and its anchor and caret move past the
/// characters inserted at or before them ([`move_editing_selection`]).
fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.convertListToText";
use vectorcraft_doc::text::ParaList;
let ids = super::typecmd::text_targets(s, p, C)?;
let range = super::typecmd::TextRange::parse(p, C)?;
let editing = match super::textedit::editing_range(s)? {
Some((id, _, _)) => {
let options = s.tool_options();
let at = |k: &str| options.get(k).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
at("anchor").zip(at("caret")).map(|(anchor, caret)| (id, anchor, caret))
}
None => None,
};
let story_before = editing.map(|(id, _, _)| story_frames(s, id)).unwrap_or_default();
let d = &s.doc()?.doc;
let text = |id: NodeId| match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
@@ -897,6 +977,8 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| json!({"id": id.0, "at": at, "length": run.text.len()})))
.collect();
let insertions: Vec<(NodeId, usize, usize)> =
edits.iter().flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| (*id, *at, run.text.len()))).collect();
if !edits.is_empty() {
s.edit("Convert to Text", |d, _| {
for (id, stops, mut inserts) in edits {
@@ -915,6 +997,9 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
if let Some(editing) = editing {
move_editing_selection(s, editing, &story_before, &insertions)?;
}
}
Ok(json!({ "ids": changed, "converted": converted, "inserted": inserted }))
}
+53
View File
@@ -1967,6 +1967,59 @@ mod area_tests {
assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs);
}
/// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel)
/// moves the tool's anchor and caret past the characters it inserts at or before them, so that
/// neither lands inside a bullet. In threaded type, when the text before the caret moves to the
/// frame before, the caret goes with its character into that frame, which becomes the
/// selection.
#[test]
fn convert_to_text_moves_the_type_tools_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("text"),
};
let selection = |s: &Session| {
let o = s.tool_options();
(o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64())
};
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.select_tool("type", Default::default()).unwrap();
// From after the O of One to after the T of Two.
s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5}));
s.execute("text.convertListToText", &json!({})).unwrap();
let text = plain(&s, id);
assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo");
assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T");
assert!(text.is_char_boundary(5) && text.is_char_boundary(13));
let long = "The quick brown fox jumps over the lazy dog. ".repeat(4);
let a = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "area": {"width": 150, "height": 60}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.select_tool("selection", Default::default()).unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.select_tool("type", Default::default()).unwrap();
// The caret after the d of dog, at the start of the second frame.
s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1}));
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
let first = plain(&s, a);
assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}");
let after_d = (first.len() - "og. ".len()) as u64;
assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame");
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
}
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
#[test]
fn create_outlines_makes_a_path_of_the_bullet() {
+5 -74
View File
@@ -7,7 +7,7 @@
use egui::{Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{
Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeId, NodeKind, ParaDirection, ParaStyle, SingleWordJustify,
Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle, SingleWordJustify,
};
use super::character::{Metric, Shared, text_style};
@@ -358,83 +358,13 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value
run_on_paragraphs(app, "text.setFormat", params)
}
/// The frames of the story that type object `id` belongs to (its thread in thread order, else `id`
/// alone), each with the length of its text in bytes.
fn story_frames(app: &VectorcraftApp, id: NodeId) -> Vec<(NodeId, usize)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
let frames = d.text_threads.iter().find(|thread| thread.contains(&id)).cloned().unwrap_or_else(|| vec![id]);
frames
.into_iter()
.filter_map(|f| match d.node(f).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some((f, t.plain_text().len())),
_ => None,
})
.collect()
}
/// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the
/// list paragraphs of the selected type, or of those the Type tool's selection touches, become
/// characters of the text and the lists are turned off (`text.convertListToText`). The Type
/// tool's anchor and caret move past the characters inserted at or before them in the story. In
/// threaded type the caret stays in the frame it was in while its place is still there, and else
/// goes to the frame its character moved to, with the anchor in that frame; that frame becomes the
/// selection when it was not selected.
/// characters of the text and the lists are turned off (`text.convertListToText`, which also moves
/// the Type tool's anchor and caret past the characters inserted at or before them).
pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, String> {
text_style(app).ok_or("select some type first")?;
let editing = super::character::text_editing(app).map(|(id, _, _)| id);
let options = app.session.tool_options();
let offset = |k: &str| options.get(k).and_then(Value::as_u64).map(|v| v as usize);
let (anchor, caret) = (offset("anchor"), offset("caret"));
let before = editing.map(|id| story_frames(app, id)).unwrap_or_default();
let reply = run_on_paragraphs(app, "text.convertListToText", json!({}))?;
let (Some(id), Some(anchor), Some(caret)) = (editing, anchor, caret) else { return Ok(reply) };
// Story positions: the bytes of the frames before a frame, then the byte in it.
let start_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().take_while(|(g, _)| *g != f).map(|(_, n)| n).sum::<usize>();
let len_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().find(|(g, _)| *g == f).map_or(0, |(_, n)| *n);
let inserted: Vec<(usize, usize)> = reply["inserted"]
.as_array()
.into_iter()
.flatten()
.filter_map(|i| {
let frame = NodeId(i["id"].as_u64()?);
before.iter().any(|(g, _)| *g == frame).then_some(())?;
Some((start_of(&before, frame) + usize::try_from(i["at"].as_u64()?).ok()?, usize::try_from(i["length"].as_u64()?).ok()?))
})
.collect();
let story = |at: usize| {
let at = start_of(&before, id) + at.min(len_of(&before, id));
at + inserted.iter().filter(|(b, _)| *b <= at).map(|(_, n)| n).sum::<usize>()
};
let (anchor, caret) = (story(anchor), story(caret));
let after = story_frames(app, id);
let fits = |f: NodeId| (start_of(&after, f)..=start_of(&after, f) + len_of(&after, f)).contains(&caret);
let frame = if fits(id) { Some(id) } else { after.iter().map(|(f, _)| *f).find(|f| fits(*f)) };
let Some(frame) = frame else { return Ok(reply) };
let (start, len) = (start_of(&after, frame), len_of(&after, frame));
let local = |at: usize| at.saturating_sub(start).min(len);
if frame == id {
app.session.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)}));
} else {
app.session.set_tool_option("textSelection", &json!({"editing": frame.0, "anchor": local(anchor), "caret": local(caret)}));
// The Type tool ends editing after a command when the frame it edits is not selected,
// itself or through a selected group, so that frame becomes the selection, as a click in it
// does.
let selected = app.session.active().is_some_and(|st| {
let mut cur = Some(frame);
while let Some(c) = cur {
if st.selection.contains(c) {
return true;
}
cur = st.doc.parent_of(c);
}
false
});
if !selected {
app.run("select.set", json!({"ids": [frame.0]}))?;
}
}
Ok(reply)
run_on_paragraphs(app, "text.convertListToText", json!({}))
}
/// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs
@@ -814,6 +744,7 @@ fn reset_panel(app: &mut VectorcraftApp) {
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::NodeId;
use vectorcraft_engine::Session;
fn alignment_row(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) -> egui::Rect {
+2 -1
View File
@@ -4565,7 +4565,8 @@ Every-line Composer, a paragraph that holds a tab is broken line by line, so a c
can break at other places than its list paragraph did. The change is one undo step, and the reply gives the `ids` of
the type it changed, how many paragraphs were `converted`, and the characters `inserted`, each with the type object's
`id`, the byte `at` which they went in (counted in the text before the change) and their `length` in bytes. While the
Type tool edits, `type.convertListToText` moves its anchor and caret past the characters inserted at or before them.
Type tool edits, `text.convertListToText` ends its typing first and moves its anchor and caret past the characters
inserted at or before them, whether the call comes from the menu, MCP or the control channel.
In threaded type, when the caret's character moved to another frame, the Type tool edits that frame with the caret
next to the same character, and that frame becomes the selection when it was not selected.