Sketch text: live Edit Text from the Text tool, a font list, handles, and whole-text picking (#821)

Live Edit Text from the Text tool, a font list, handles, and picking the whole text.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-13 16:29:37 -04:00
1 parent 69050c81d9
commit ec5d7f9789
17 files changed
+1248 -80

No files matched your search

+526 -25
View File
@@ -1,5 +1,9 @@
//! Edit Text: the shared feature dialog (OK/Cancel, expression fields) for the source of a
//! sketch text's outlines; OK runs `sketch.edit_text`.
//! sketch text's outlines; OK runs `sketch.edit_text`. The canvas follows the fields as they
//! change (live `sketch.edit_text`); the whole edit is one undo step and Cancel puts the text
//! back. The Text tool places a text and opens this dialog on it, so a new text is typed here too.
use std::sync::{Arc, OnceLock};
use serde_json::{Value, json};
use solidcraft_engine::{
Sel,
@@ -31,9 +35,27 @@ pub struct Tx {
/// The saved source: x, y, height (mm) and angle (rad).
original: [f64; 4],
pub(crate) unit: &'static str,
/// Focus the text with all of it selected (typing replaces it).
focus: bool,
/// Filters the font list.
font_filter: String,
/// The checkpoint from before the dialog changed the design (its first live change, or the
/// Text tool placing the text): Cancel goes back to it.
start: Option<u64>,
/// The undo history holds the step from before the dialog: later changes fold into it.
folded: bool,
/// The last parameters the canvas was given (tried once, whether they worked or not).
tried: Option<Value>,
/// The source the live preview made: a design that shows it was changed by the dialog itself.
shown: Option<Source>,
}
/// A text source as the dialog sees it: text, font (empty: built-in), x, y, height and angle.
type Source = (String, String, [f64; 4]);
/// What the Text tool puts down, ready to be typed over.
const PLACEHOLDER: &str = "Text";
/// Resolve an outline or filled letter only when all its boundaries belong to one text.
pub(crate) fn entity_for_selection(app: &SolveApp, selection: &Sel) -> Option<String> {
let sid = app.session.active_sketch?;
@@ -42,6 +64,12 @@ pub(crate) fn entity_for_selection(app: &SolveApp, selection: &Sel) -> Option<St
let ids = match selection {
Sel::SketchCurve { id } => vec![id.clone()],
Sel::Profile { sketch, index } if *sketch == sid => ss.profiles.get(*index)?.curve_ids(),
// A point of a letter's outline stands for its text (a double-click often lands on one).
Sel::SketchPoint { id } => {
let point = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.points.get(i))?;
let link = point.link.as_deref()?;
vec![ss.sketch.curves.iter().find(|curve| curve.link.as_deref() == Some(link))?.id.clone()]
}
_ => return None,
};
let mut link = None;
@@ -56,6 +84,67 @@ pub(crate) fn entity_for_selection(app: &SolveApp, selection: &Sel) -> Option<St
ids.into_iter().next()
}
thread_local! {
/// Shift is held in the viewport: a click picks one letter of a text, not the whole text.
static ONE_LETTER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The viewport's modifiers each frame: Shift picks letters one by one.
pub(crate) fn set_one_letter(on: bool) {
ONE_LETTER.with(|c| c.set(on));
}
pub(crate) fn one_letter() -> bool {
ONE_LETTER.with(std::cell::Cell::get)
}
/// A text is picked whole: the profiles of its letters when profile `index` of `sketch` is
/// bounded by one text alone (none when other geometry shares it). The letters' counters (the
/// inside of an O) are left out; inside one, a letter is a letter again. The picked profile
/// comes first when it is a letter.
pub(crate) fn text_profiles(s: &solidcraft_engine::Session, sketch: u64, index: usize) -> Option<Vec<usize>> {
let state = s.world_state();
let ss = state.sketch(sketch)?;
let text_of = |profile: &solidcraft_engine::sketch::Profile| -> Option<String> {
let mut link: Option<&str> = None;
for id in profile.curve_ids() {
let curve = ss.sketch.curves.iter().find(|curve| curve.id == id)?;
let source = ss.sketch.link(curve.link.as_deref()?)?;
if source.kind != LinkKind::Text || link.is_some_and(|old| old != source.id.as_str()) {
return None;
}
link = Some(source.id.as_str());
}
link.map(str::to_owned)
};
let link = text_of(ss.profiles.get(index)?)?;
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let mine: Vec<usize> = (0..ss.profiles.len()).filter(|&i| ss.profiles.get(i).and_then(text_of).as_deref() == Some(link.as_str())).collect();
// A profile whose outer loop is a hole of another one lies inside it.
let parent = |i: usize| -> Option<usize> {
let outer = sorted(&ss.profiles.get(i)?.outer_curves);
mine.iter().copied().find(|&j| j != i && ss.profiles.get(j).is_some_and(|p| p.hole_curves.iter().any(|hole| sorted(hole) == outer)))
};
let depth = |i: usize| {
let (mut at, mut depth) = (i, 0usize);
while let Some(up) = parent(at) {
at = up;
depth += 1;
if depth > mine.len() {
break;
}
}
depth
};
let mut letters: Vec<usize> = mine.iter().copied().filter(|&i| depth(i) % 2 == 0).collect();
letters.sort_by_key(|&i| i != index);
(!letters.is_empty()).then_some(letters)
}
/// Only a complete selection belonging to one text source can seed the editor.
pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?;
@@ -91,7 +180,7 @@ fn shown(value: f64, unit: &str) -> String {
/// The saved text source behind `link` in the active sketch: its text, its font (empty: the built-in
/// font), and x, y, height and angle.
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<(String, String, [f64; 4])> {
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<Source> {
if app.session.active_sketch != Some(sketch) {
return None;
}
@@ -116,7 +205,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
let (text, font, original) = source(app, sketch, &link)?;
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let values = fields(&original, unit, scale);
Some(Kind::Text(Tx {
let mut tx = Tx {
sketch,
link,
initial_text: text.clone(),
@@ -128,7 +217,15 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
original,
unit,
focus: true,
}))
font_filter: String::new(),
start: None,
folded: false,
tried: None,
shown: None,
};
// The canvas already shows the fields as they open.
tx.tried = edit_params(app, &tx).ok();
Some(Kind::Text(tx))
}
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
@@ -150,6 +247,78 @@ pub(crate) fn open(app: &mut SolveApp, entity: &str) {
}
}
/// The Text tool's click: put a placeholder text down at `at` (sketch coordinates) and open
/// Edit Text on it, its text selected so that typing replaces it. Cancel takes it away again;
/// OK keeps it as one undo step.
pub(crate) fn place(app: &mut SolveApp, at: solidcraft_engine::geom::Vec2, height: f64) {
let Some(checkpoint) = app.session.execute("edit.checkpoint", &json!({})).ok().and_then(|r| r["checkpoint"].as_u64()) else { return };
let placed = app.run("sketch.text", json!({"text": PLACEHOLDER, "at": [at.x, at.y], "height": height}));
let curve = placed.as_ref().ok().and_then(|v| v["curves"].get(0)?.as_str().map(str::to_owned));
match curve.and_then(|curve| kind_for(app, &curve)) {
Some(Kind::Text(mut tx)) => {
tx.start = Some(checkpoint);
tx.folded = true;
app.dialog = Some(Dialog::new(Kind::Text(tx), Vec::new()));
app.tool = None;
}
_ => {
let forget = placed.is_err();
let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint": checkpoint, "forget": forget}));
app.set_status(placed.err().unwrap_or_else(|| "the text could not be placed".into()), true);
}
}
}
/// Run one of the dialog's changes: the undo history keeps one step for the whole dialog (the
/// first change's, which goes back to the design from before it).
fn folded<T>(app: &mut SolveApp, k: &mut Tx, run: impl FnOnce(&mut SolveApp) -> T) -> T {
let mut undo = std::mem::take(&mut app.session.undo);
let out = run(app);
let pushed = std::mem::take(&mut app.session.undo);
if !k.folded && !pushed.is_empty() {
undo.extend(pushed);
k.folded = true;
}
app.session.undo = undo;
out
}
/// Each frame: show the fields on the canvas (a live edit) when they changed. Fields that make
/// no text (an empty one, a half-typed value) leave the last good preview.
pub(crate) fn preview(app: &mut SolveApp, k: &mut Tx) {
let Ok(params) = edit_params(app, k) else { return };
if k.tried.as_ref() == Some(&params) {
return;
}
k.tried = Some(params.clone());
if k.start.is_none() {
let Some(checkpoint) = app.session.execute("edit.checkpoint", &json!({})).ok().and_then(|r| r["checkpoint"].as_u64()) else { return };
k.start = Some(checkpoint);
}
let mut live = params;
live["live"] = json!(true);
if folded(app, k, |app| app.session.execute("sketch.edit_text", &live)).is_ok() {
k.shown = source(app, k.sketch, &k.link);
}
}
/// OK: the fields' final edit (which also brings the features after the sketch up to date), in
/// the dialog's one undo step.
pub(crate) fn apply(app: &mut SolveApp, k: &mut Tx) -> Result<(), String> {
let params = edit_params(app, k)?;
folded(app, k, |app| app.run("sketch.edit_text", params))?;
k.shown = source(app, k.sketch, &k.link);
Ok(())
}
/// The dialog closed: applied, its changes stay; otherwise the design goes back to how it was
/// before them (a placed text goes away).
pub(crate) fn closed(app: &mut SolveApp, k: &mut Tx, applied: bool) {
if let Some(checkpoint) = k.start.take() {
let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint": checkpoint, "forget": applied}));
}
}
/// Is the Edit Text dialog open?
pub(crate) fn editing(app: &SolveApp) -> bool {
matches!(app.dialog.as_ref().map(|d| &d.kind), Some(Kind::Text(_)))
@@ -159,7 +328,12 @@ pub(crate) fn editing(app: &SolveApp) -> bool {
/// text deleted or undone). Other design changes leave it open; when the saved source itself
/// changed (undo, a script), fields the user has not touched follow it.
pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
let Some((text, font, original)) = source(app, k.sketch, &k.link) else { return false };
let Some(current) = source(app, k.sketch, &k.link) else { return false };
// What the live preview made is the dialog's own doing.
if k.shown.as_ref() == Some(&current) {
return true;
}
let (text, font, original) = current;
if original != k.original {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let fresh = fields(&original, unit, scale);
@@ -187,24 +361,30 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
true
}
/// The dialog's rows; true when Enter was pressed in a value field.
/// The dialog's rows; true when Enter was pressed in a value field or in the text (Shift+Enter
/// starts a new line there).
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
let mut enter = false;
crate::dialogs::row_label(ui, "Text");
let width = ui.spacing().text_edit_width;
let response = ui.add(egui::TextEdit::multiline(&mut k.text).id_salt("sketch_text_content").desired_rows(3).desired_width(width));
let id = egui::Id::new("sketch_text_content");
if ui.memory(|m| m.has_focus(id)) {
enter |= ui.input_mut(|i| !i.modifiers.shift && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
let response = ui.add(egui::TextEdit::multiline(&mut k.text).id(id).desired_rows(3).desired_width(width));
crate::scenario::publish_handle("dialog:Text", response.rect.center());
if k.focus {
response.request_focus();
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(k.text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
k.focus = false;
}
ui.end_row();
crate::dialogs::row_label(ui, "Font");
// Empty means the built-in font: its name is the hint.
let hint = solidcraft_engine::sketch::DEFAULT_FONT_FAMILY;
let response = ui.add(egui::TextEdit::singleline(&mut k.font).id_salt("sketch_text_font").hint_text(hint).desired_width(width));
crate::scenario::publish_handle("dialog:Font", response.rect.center());
font_row(ui, k, width);
ui.end_row();
let mut enter = false;
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
crate::dialogs::row_label(ui, label);
enter |= crate::dialogs::field(ui, value);
@@ -213,9 +393,115 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
enter
}
/// The installed font families, listed once in the background (none on the web).
fn families(ctx: &egui::Context) -> Option<Arc<Vec<String>>> {
static FAMILIES: OnceLock<Arc<Vec<String>>> = OnceLock::new();
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if let Some(list) = FAMILIES.get() {
return Some(list.clone());
}
if !STARTED.swap(true, std::sync::atomic::Ordering::SeqCst) {
#[cfg(not(target_arch = "wasm32"))]
{
let ctx = ctx.clone();
let spawned = std::thread::Builder::new().name("font list".into()).spawn(move || {
let _ = FAMILIES.set(Arc::new(solidcraft_engine::sketch::installed_families()));
ctx.request_repaint();
});
if spawned.is_err() {
let _ = FAMILIES.set(Arc::new(Vec::new()));
}
}
#[cfg(target_arch = "wasm32")]
let _ = FAMILIES.set(Arc::new(Vec::new()));
}
ctx.request_repaint_after(std::time::Duration::from_millis(100));
FAMILIES.get().cloned()
}
/// Does the font search name a font file rather than filter the list?
fn is_font_file(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains(['/', '\\']) || [".ttf", ".otf", ".ttc", ".otc"].iter().any(|e| lower.ends_with(e))
}
/// Height of the font list (points).
const FONT_LIST_PX: f32 = 260.0;
/// The font: a list of the installed families (the built-in font first) with a search box; a
/// font file's path typed there can be used too.
fn font_row(ui: &mut egui::Ui, k: &mut Tx, width: f32) {
let builtin = crate::i18n::format("sketch_text.builtin_font", &[("family", solidcraft_engine::sketch::DEFAULT_FONT_FAMILY)]);
let current = if k.font.trim().is_empty() { builtin.clone() } else { k.font.clone() };
let mut chosen = None;
let response = egui::ComboBox::from_id_salt("sketch_text_font")
.width(width)
.height(FONT_LIST_PX + 80.0)
.selected_text(current)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| {
let search = egui::TextEdit::singleline(&mut k.font_filter)
.id_salt("sketch_text_font_search")
.hint_text(crate::i18n::tr("sketch_text.font_search"));
let search = ui.add(search.desired_width(f32::INFINITY));
crate::scenario::publish_handle("dialog:Font search", search.rect.center());
let typed = k.font_filter.trim();
if is_font_file(typed) {
let file = ui.selectable_label(false, crate::i18n::format("sketch_text.use_font_file", &[("path", typed)]));
crate::scenario::publish_handle("dialog:Font file", file.rect.center());
if file.clicked() {
chosen = Some(typed.to_string());
ui.close();
}
return;
}
let filter = typed.to_lowercase();
// The list scrolls below the search box, at one height whatever it holds.
let list = egui::ScrollArea::vertical()
.id_salt("sketch_text_font_list")
.max_height(FONT_LIST_PX)
.min_scrolled_height(FONT_LIST_PX)
.auto_shrink([false, false]);
list.show(ui, |ui| {
if builtin.to_lowercase().contains(&filter) {
let item = ui.selectable_label(k.font.trim().is_empty(), &builtin);
crate::scenario::publish_handle("dialog:Font built-in", item.rect.center());
if item.clicked() {
chosen = Some(String::new());
ui.close();
}
}
match families(ui.ctx()) {
None => {
ui.label(crate::i18n::tr("sketch_text.loading_fonts"));
}
Some(list) => {
for family in list.iter().filter(|f| f.to_lowercase().contains(&filter)) {
if ui.selectable_label(k.font.trim() == family, family).clicked() {
chosen = Some(family.clone());
ui.close();
}
}
}
}
});
})
.response;
crate::scenario::publish_handle("dialog:Font", response.rect.center());
if let Some(font) = chosen {
k.font = font;
k.font_filter.clear();
}
}
/// The command OK runs. Fields take expressions with units and parameters; a bare number is
/// in the design's units (lengths) or degrees (angle).
pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, String> {
Ok(vec![("sketch.edit_text".into(), edit_params(app, k)?)])
}
/// `sketch.edit_text`'s parameters for the fields as they are.
fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
let doc = &app.session.doc;
let mut out = [0.0; 4];
for (i, slot) in out.iter_mut().enumerate() {
@@ -244,7 +530,7 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
if k.font.trim() != k.initial_font.trim() {
params["font"] = json!(k.font.trim());
}
Ok(vec![("sketch.edit_text".into(), params)])
Ok(params)
}
#[cfg(test)]
@@ -368,7 +654,11 @@ mod tests {
for (field, value) in [("dialog:Text", "O"), ("dialog:X", "25"), ("dialog:Y", "12"), ("dialog:Height", "20"), ("dialog:Angle", "90")] {
replace(&mut h, field, value);
}
assert_eq!(h.app.session.doc.to_json(), before);
// The canvas already shows the edit, as one undo step.
let sid = h.app.session.active_sketch.unwrap();
let shown = |h: &Harness| h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone();
assert!(matches!(shown(&h), LinkSource::Text { text, height, .. } if text == "O" && (height - 20.0).abs() < 1e-12));
assert_eq!(h.app.session.undo.len(), undo + 1);
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(h.app.session.undo.len(), undo + 1);
@@ -449,15 +739,31 @@ mod tests {
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).font, "");
// A font that is not there keeps the dialog open with the reason, and the design as it was.
// A font file is typed in the list's search box and chosen from it.
let choose_file = |h: &mut Harness, path: &str| {
h.step(&json!({"click":{"handle":"dialog:Font"}})).unwrap();
h.frames(2);
h.step(&json!({"click":{"handle":"dialog:Font search"}})).unwrap();
h.step(&json!({"text":path})).unwrap();
h.frames(2);
h.step(&json!({"click":{"handle":"dialog:Font file"}})).unwrap();
h.frames(3);
};
// A font that is not there shows nothing new, keeps the dialog open with the reason at OK, and the design
// as it was.
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
replace(&mut h, "dialog:Font", "No Such Family 12345");
let missing = concat!(env!("CARGO_MANIFEST_DIR"), "/no-such-font-12345.ttf");
choose_file(&mut h, missing);
assert_eq!(tx(&h.app).font, missing);
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
ok(&mut h);
assert!(editing(&h.app));
assert!(h.app.dialog.as_ref().unwrap().error.as_ref().is_some_and(|e| e.contains("unknown font")));
assert!(h.app.dialog.as_ref().unwrap().error.as_ref().is_some_and(|e| e.contains("font file")), "{:?}", h.app.dialog.as_ref().unwrap().error);
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
// A font file: one undo step, other outlines, and the dialog shows the font when it is opened again.
replace(&mut h, "dialog:Font", font);
// A font file: other outlines on the canvas at once, one undo step, and the dialog shows the font when it is
// opened again.
choose_file(&mut h, font);
assert!((area(&h) - 17.6385).abs() < 2e-3, "the canvas follows the font");
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(h.app.session.undo.len(), before.1 + 1);
@@ -475,11 +781,13 @@ mod tests {
ok(&mut h);
assert_eq!(font_of(&h).as_deref(), Some(font));
assert!((area(&h) - 2.0 * 17.6385).abs() < 2e-3);
// An emptied field is the built-in font again: two I's of it.
// The built-in font, first in the list: two I's of it.
reopen(&mut h);
h.step(&json!({"click":{"handle":"dialog:Font"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"key":"Backspace"})).unwrap();
h.frames(2);
h.step(&json!({"click":{"handle":"dialog:Font built-in"}})).unwrap();
h.frames(2);
assert_eq!(tx(&h.app).font, "");
ok(&mut h);
assert_eq!(font_of(&h), None);
assert!((area(&h) - 2.0 * 13.5557).abs() < 2e-3);
@@ -563,7 +871,11 @@ mod tests {
h.step(&json!({"widget":"Close draft"})).unwrap();
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(h.app.session.undo.len(), before.1 + 1);
// The pending text was already on the canvas, in the dialog's one undo step.
assert_eq!(h.app.session.undo.len(), before.1);
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap();
assert!(sk.links.iter().any(|l| matches!(&l.source, LinkSource::Text { text, .. } if text == "Pending text")));
}
#[test]
@@ -599,9 +911,198 @@ mod tests {
h.frames(3);
assert!(editing(&h.app));
assert_eq!(tx(&h.app).text, "Kept");
h.app.run("edit.undo", json!({})).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
// Undone: the line, the dialog's change so far, then the text itself.
for _ in 0..3 {
h.app.run("edit.undo", json!({})).unwrap();
}
h.frames(3);
assert!(!editing(&h.app), "the text it edits is gone");
}
/// The text sources of the active sketch.
fn texts(h: &Harness) -> Vec<LinkSource> {
let sid = h.app.session.active_sketch.unwrap();
h.app.session.doc.sketch(sid).unwrap().links.iter().filter(|l| l.kind == LinkKind::Text).map(|l| l.source.clone()).collect()
}
/// A sketch on XY seen from the top, with the Text tool started.
fn text_tool() -> (Harness, usize) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 60.0;
h.frames(3);
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.text"})).unwrap();
(h, undo)
}
#[test]
fn the_text_tool_places_a_text_and_types_it_in_edit_text_live() {
let (mut h, undo) = text_tool();
h.step(&json!({"click":{"sketch":[5,5]}})).unwrap();
h.frames(2);
// A placeholder is down at once, Edit Text is open on it with the text selected, and the tool has ended.
assert!(editing(&h.app));
assert!(h.app.tool.is_none());
let at = solidcraft_engine::geom::Vec2::new(5.0, 5.0);
assert!(matches!(texts(&h).as_slice(), [LinkSource::Text { text, at: a, .. }] if text == PLACEHOLDER && a.dist(at) < 1e-9));
// Typing replaces it, and the canvas follows every key.
h.step(&json!({"text":"Hi"})).unwrap();
h.frames(2);
assert!(matches!(texts(&h).as_slice(), [LinkSource::Text { text, .. }] if text == "Hi"));
h.step(&json!({"text":"!"})).unwrap();
h.frames(2);
assert!(matches!(texts(&h).as_slice(), [LinkSource::Text { text, .. }] if text == "Hi!"));
// Enter in the text is OK: one undo step for placing and typing, and the model is up to date.
h.step(&json!({"key":"Enter"})).unwrap();
h.frames(2);
assert!(!editing(&h.app));
assert!(matches!(texts(&h).as_slice(), [LinkSource::Text { text, .. }] if text == "Hi!"));
assert_eq!(h.app.session.undo.len(), undo + 1);
assert!(!h.app.session.model.is_stale());
h.app.run("edit.undo", json!({})).unwrap();
assert!(texts(&h).is_empty());
}
#[test]
fn cancelling_a_placed_text_takes_it_away_with_its_history() {
let (mut h, undo) = text_tool();
let before = h.app.session.doc.to_json();
h.step(&json!({"click":{"sketch":[0,0]}})).unwrap();
h.step(&json!({"text":"Gone"})).unwrap();
h.frames(2);
assert_eq!(texts(&h).len(), 1);
h.step(&json!({"widget":"Cancel"})).unwrap();
h.frames(2);
assert!(!editing(&h.app));
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn edit_text_shows_changes_live_and_cancel_puts_the_text_back() {
let (mut h, link, _) = setup();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len());
h.app.start("sketch.edit_text");
h.frames(3);
// Opening changes nothing.
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len()), before);
replace(&mut h, "dialog:Text", "OK");
replace(&mut h, "dialog:Height", "20");
h.frames(2);
let sid = h.app.session.active_sketch.unwrap();
let source = |h: &Harness| h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone();
assert!(matches!(source(&h), LinkSource::Text { text, height, .. } if text == "OK" && height == 20.0));
assert_eq!(h.app.session.undo.len(), before.1 + 1, "every change folds into one step");
// The fields stay as typed while the canvas follows them.
assert_eq!(tx(&h.app).text, "OK");
// A value that is not one yet keeps the last good preview.
replace(&mut h, "dialog:Height", "2 +");
h.frames(2);
assert!(matches!(source(&h), LinkSource::Text { height, .. } if height == 20.0));
h.step(&json!({"widget":"Cancel"})).unwrap();
h.frames(2);
assert!(!editing(&h.app));
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len()), before);
}
#[test]
fn shift_enter_starts_a_new_line_in_the_text() {
let (mut h, link, _) = setup();
h.app.start("sketch.edit_text");
h.frames(3);
h.step(&json!({"text":"A"})).unwrap();
h.step(&json!({"key":"Enter","shift":true})).unwrap();
h.step(&json!({"text":"B"})).unwrap();
h.frames(2);
assert!(editing(&h.app));
assert_eq!(tx(&h.app).text, "A\nB");
h.step(&json!({"key":"Enter"})).unwrap();
h.frames(2);
assert!(!editing(&h.app));
let sid = h.app.session.active_sketch.unwrap();
assert!(matches!(&h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source, LinkSource::Text { text, .. } if text == "A\nB"));
}
/// "IO" (10 mm) seen from the top: the profile of the I, and those of the O's ring and its counter.
fn letters() -> (Harness, u64, usize, usize, usize) {
let (mut h, link, _) = setup();
h.app.run("sketch.edit_text", json!({"link":link,"text":"IO"})).unwrap();
h.app.session.selection.clear();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 60.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
let state = h.app.session.world_state();
let profiles = &state.sketch(sid).unwrap().profiles;
assert_eq!(profiles.len(), 3);
let sorted = |v: &[String]| {
let mut v = v.to_vec();
v.sort();
v
};
let ring = profiles.iter().position(|p| !p.hole_curves.is_empty()).unwrap();
let counter = profiles.iter().position(|p| sorted(&p.outer_curves) == sorted(&profiles[ring].hole_curves[0])).unwrap();
let i = (0..3).find(|&i| i != ring && i != counter).unwrap();
(h, sid, i, ring, counter)
}
#[test]
fn a_text_is_picked_whole_without_its_counters() {
let (h, sid, i, ring, counter) = letters();
let mut whole = text_profiles(&h.app.session, sid, i).unwrap();
assert_eq!(whole[0], i, "the picked letter comes first");
whole.sort();
let mut want = vec![i, ring];
want.sort();
assert_eq!(whole, want);
// Inside the O's counter, the text is picked too (its letters).
assert_eq!(text_profiles(&h.app.session, sid, counter).map(|v| v.len()), Some(2));
assert!(text_profiles(&h.app.session, sid, 99).is_none());
}
#[test]
fn clicking_a_letter_selects_the_whole_text_for_an_extrude() {
let (mut h, sid, i, ring, _) = letters();
let at = h.app.session.world_state().sketch(sid).unwrap().profiles[i].centroid;
h.step(&json!({"click":{"sketch":[at.x,at.y]}})).unwrap();
h.frames(2);
let has = |sel: &[Sel], index: usize| sel.iter().any(|s| matches!(s, Sel::Profile { sketch, index: x } if *sketch == sid && *x == index));
assert_eq!(h.app.session.selection.len(), 2, "{:?}", h.app.session.selection);
assert!(has(&h.app.session.selection, i) && has(&h.app.session.selection, ring));
// Extrude picks it whole as well.
h.app.session.selection.clear();
h.app.finish_sketch();
h.frames(3);
h.step(&json!({"start":"solid.extrude"})).unwrap();
h.frames(2);
h.step(&json!({"click":{"world":[at.x,at.y,0.0]}})).unwrap();
h.frames(2);
let picked = h.app.dialog.as_ref().unwrap().inputs[0].items.clone();
assert_eq!(picked.len(), 2, "{picked:?}");
assert!(has(&picked, i) && has(&picked, ring));
// Shift takes one letter out again (and would put it back alone).
h.step(&json!({"click":{"world":[at.x,at.y,0.0]},"shift":true})).unwrap();
h.frames(2);
let picked = h.app.dialog.as_ref().unwrap().inputs[0].items.clone();
assert_eq!(picked.len(), 1, "{picked:?}");
assert!(has(&picked, ring));
}
#[test]
fn shift_clicking_a_letter_selects_that_letter_alone() {
let (mut h, sid, i, ring, _) = letters();
let at = h.app.session.world_state().sketch(sid).unwrap().profiles[i].centroid;
h.step(&json!({"click":{"sketch":[at.x,at.y]},"shift":true})).unwrap();
h.frames(2);
assert_eq!(h.app.session.selection, vec![Sel::Profile { sketch: sid, index: i }]);
// It is shown alone too: the rest of the text is not drawn as selected.
let shown = h.app.session.selection.iter().flat_map(|sel| crate::viewport::grouped_selection(&h.app.session, sel)).collect::<Vec<_>>();
assert!(!shown.contains(&Sel::Profile { sketch: sid, index: ring }));
// A plain click picks the whole text again.
h.step(&json!({"click":{"sketch":[at.x,at.y]}})).unwrap();
h.frames(2);
assert_eq!(h.app.session.selection.len(), 2);
}
}