From ec5d7f9789bf1622395895656959e7e78acbcc3a Mon Sep 17 00:00:00 2001 From: phanoob Date: Tue, 13 Oct 2026 22:29:37 +0200 Subject: [PATCH] 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. --- crates/engine/src/cmd/sketch_create.rs | 26 +- .../engine/src/cmd/sketch_text_move_tests.rs | 44 ++ crates/sketch/src/font_tests.rs | 17 + crates/sketch/src/fonts.rs | 83 +++ crates/sketch/src/lib.rs | 2 +- crates/ui-egui/src/dialogs.rs | 19 +- crates/ui-egui/src/issue_report.rs | 8 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/locales/de.json | 6 +- crates/ui-egui/src/locales/en.json | 6 +- crates/ui-egui/src/locales/ja.json | 6 +- crates/ui-egui/src/locales/ko.json | 6 +- crates/ui-egui/src/sketch_text.rs | 551 +++++++++++++++++- crates/ui-egui/src/sketch_text_handles.rs | 463 +++++++++++++++ crates/ui-egui/src/sketch_tools.rs | 40 +- crates/ui-egui/src/tools.rs | 5 +- crates/ui-egui/src/viewport.rs | 45 +- 17 files changed, 1248 insertions(+), 80 deletions(-) create mode 100644 crates/ui-egui/src/sketch_text_handles.rs diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 6aa02462..adacb0b3 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -89,8 +89,9 @@ pub static COMMANDS: &[CommandSpec] = &[ )), CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) .enabled(in_sketch) + .live(edit_text_live) .params(concat!( - "link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)", + "link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font); live?: bool: one frame of an interactive resize or rotation (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)", created_doc!() )), ]; @@ -175,6 +176,25 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; let mut doc = (*s.doc).clone(); let mut sk = doc.sketch(id)?.clone(); + let out = apply_text_edit(&mut sk, p)?; + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok(out) +} + +/// One frame of an interactive resize or rotation: the sketch is solved and shown, the +/// features after it keep their last result until `sketch.drag_end` evaluates the model. +fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option)> { + let cmd = "sketch.edit_text"; + let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; + let (out, report) = super::sketch::edit_solved(s, id, false, |sk, _| Ok((apply_text_edit(sk, p)?, super::sketch::Left::Unsolved)))?; + Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report }))) +} + +/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines +/// regenerated from them. +fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result { + let cmd = "sketch.edit_text"; let l = match (str_(p, "link"), str_(p, "entity")) { (Some(l), _) => l.to_string(), (None, Some(e)) => sk.curves.iter().find(|c| c.id == e).and_then(|c| c.link.clone()).ok_or_else(|| bad(cmd, format!("`{e}` is not text")))?, @@ -189,9 +209,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?; sk.set_link_source(&l, src.clone()); sk.update_link(&l, &geom)?; - let curves = ids_of(&sk, &sk.link_curves(&l)); - *doc.sketch_mut(id)? = sk; - *s.doc_mut() = doc; + let curves = ids_of(sk, &sk.link_curves(&l)); Ok(json!({"link": l, "curves": curves})) } diff --git a/crates/engine/src/cmd/sketch_text_move_tests.rs b/crates/engine/src/cmd/sketch_text_move_tests.rs index becbaf4b..fd24a52f 100644 --- a/crates/engine/src/cmd/sketch_text_move_tests.rs +++ b/crates/engine/src/cmd/sketch_text_move_tests.rs @@ -69,6 +69,50 @@ fn text_drag_moves_all_letters_and_rebuilds_downstream_on_release() { assert!(matches!(sk.link(&link).unwrap().source, LinkSource::Text { .. })); } +#[test] +fn live_text_resize_follows_each_frame_and_rebuilds_downstream_on_release() { + let (mut s, link, _) = setup(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":3})); + run(&mut s, "sketch.edit", json!({"sketch":1})); + let curves = s.doc.sketch(1).unwrap().link_curves(&link).len(); + for (frame, height) in [12.0, 16.0, 20.0].into_iter().enumerate() { + let at = Vec2::new(3.0 - frame as f64, 4.0); + run(&mut s, "sketch.edit_text", json!({"link":link,"at":[at.x,at.y],"height":height,"angle":-10,"live":true})); + assert!(s.model.is_stale(), "a live frame leaves the features after the sketch as they were"); + let sk = s.doc.sketch(1).unwrap(); + let LinkSource::Text { at: now, height: h, angle, text, .. } = &sk.link(&link).unwrap().source else { panic!() }; + assert!(now.dist(at) < 1e-12); + assert_eq!((*h, text.as_str()), (height, "II")); + assert!((*angle + 10_f64.to_radians()).abs() < 1e-12); + // Same letters, same outlines: what references them keeps working. + assert_eq!(sk.link_curves(&link).len(), curves); + let shown = s.world_state(); + assert!(shown.sketch(1).unwrap().profiles.len() >= 2, "the sketch shown follows the frame"); + } + run(&mut s, "sketch.drag_end", json!({})); + assert!(!s.model.is_stale()); + assert!(s.model.result(2).is_some_and(|r| r.error.is_none())); +} + +#[test] +fn live_text_edit_rejects_bad_values_and_leaves_the_design_as_it_was() { + let (mut s, link, _) = setup(); + let before = s.doc.to_json(); + let undo = s.undo.len(); + for params in [ + json!({"link":link,"height":0,"live":true}), + json!({"link":link,"height":-3,"live":true}), + json!({"link":link,"height":2e5,"live":true}), + json!({"link":"no-such-link","height":5,"live":true}), + json!({"height":5,"live":true}), + ] { + assert!(s.execute("sketch.edit_text", ¶ms).is_err(), "{params}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + } +} + #[test] fn text_move_in_a_rotated_component_preserves_font_and_local_coordinates() { let mut s = Session::default(); diff --git a/crates/sketch/src/font_tests.rs b/crates/sketch/src/font_tests.rs index 5991a9e3..09765636 100644 --- a/crates/sketch/src/font_tests.rs +++ b/crates/sketch/src/font_tests.rs @@ -307,3 +307,20 @@ fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() { assert!(json.contains(r#""font":"C:/fonts/Some Font.ttf""#), "{json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), source(Some("C:/fonts/Some Font.ttf"))); } + +#[test] +fn installed_families_are_listed_by_name_once_without_the_built_in_one() { + let folder = Folder::new("families"); + // A second copy of the tiny font, the built-in font and the repository's second font: each family once, + // sorted; the built-in family is not among the installed ones, broken files and other files are skipped. + std::fs::write(folder.0.join("copy.ttf"), tiny_font()).unwrap(); + std::fs::copy(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LiberationSans-Regular.ttf"), folder.0.join("lib.ttf")).unwrap(); + std::fs::copy(web_fonts().join("BIZUDPGothic-Regular.ttf"), folder.0.join("biz.ttf")).unwrap(); + let families = crate::families_in(std::slice::from_ref(&folder.0)); + assert_eq!(families, ["BIZ UDPGothic", "Tiny Block"]); + // Each name finds its font again. + for family in &families { + assert!(find_family_in(std::slice::from_ref(&folder.0), family).unwrap().is_some(), "{family}"); + } + assert!(crate::families_in(&[folder.0.join("missing")]).is_empty()); +} diff --git a/crates/sketch/src/fonts.rs b/crates/sketch/src/fonts.rs index 1214a541..de53524e 100644 --- a/crates/sketch/src/fonts.rs +++ b/crates/sketch/src/fonts.rs @@ -103,6 +103,89 @@ pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result> Ok(None) } +/// The family names of the installed fonts, for choosing one by name: sorted, without repeats +/// and without the built-in family. Only each file's name table is read. +pub fn installed_families() -> Vec { + families_in(&font_dirs()) +} + +/// The family names of the fonts under `dirs` (see [`installed_families`]). +pub fn families_in(dirs: &[PathBuf]) -> Vec { + let mut seen = std::collections::HashSet::from([normalize(DEFAULT_FONT_FAMILY)]); + let mut out = Vec::new(); + for path in font_files(dirs) { + for family in file_families(&path) { + let key = normalize(&family); + // Names starting with a dot are a system's private fonts. + if !key.is_empty() && !family.starts_with('.') && seen.insert(key) { + out.push(family); + } + } + } + out.sort_by_cached_key(|f| f.to_lowercase()); + out +} + +/// Largest name table read when listing families. +const MAX_NAME_TABLE: u32 = 1 << 20; + +/// The family names of each face of a font file, from its name tables alone: the file's table +/// directory says where they are, so a large font costs a few small reads, not its whole size. +fn file_families(path: &Path) -> Vec { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut file) = std::fs::File::open(path) else { return Vec::new() }; + let mut read_at = |offset: u64, len: usize| -> Option> { + file.seek(SeekFrom::Start(offset)).ok()?; + let mut buf = vec![0; len]; + file.read_exact(&mut buf).ok()?; + Some(buf) + }; + let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let Some(head) = read_at(0, 12) else { return Vec::new() }; + let faces: Vec = if head.starts_with(b"ttcf") { + let count = be32(&head, 8).unwrap_or(0).min(64) as usize; + read_at(12, count * 4).map(|b| (0..count).filter_map(|i| be32(&b, i * 4)).collect()).unwrap_or_default() + } else { + vec![0] + }; + let mut out = Vec::new(); + for face in faces { + let Some(dir) = read_at(u64::from(face), 12) else { continue }; + let tables = usize::from(be16(&dir, 4).unwrap_or(0).min(512)); + let Some(records) = read_at(u64::from(face) + 12, tables * 16) else { continue }; + let Some(record) = (0..tables).map(|i| i * 16).find(|&r| records.get(r..r + 4) == Some(b"name")) else { continue }; + let (Some(offset), Some(len)) = (be32(&records, record + 8), be32(&records, record + 12)) else { continue }; + if len == 0 || len > MAX_NAME_TABLE { + continue; + } + let Some(data) = read_at(u64::from(offset), len as usize) else { continue }; + if let Some(family) = ttf_parser::name::Table::parse(&data).and_then(|table| family_name(&table)) { + out.push(family); + } + } + out +} + +/// A face's family name as people know it: the English one when there is one. +fn family_name(table: &ttf_parser::name::Table) -> Option { + use ttf_parser::name_id; + let mut best: Option<(u8, String)> = None; + for name in table.names { + let rank = match name.name_id { + name_id::FAMILY => 2, + name_id::TYPOGRAPHIC_FAMILY => 0, + _ => continue, + } + u8::from(name.language() == ttf_parser::Language::English_UnitedStates); + let Some(text) = name.to_string() else { continue }; + let text = text.trim().to_string(); + if !text.is_empty() && best.as_ref().is_none_or(|(b, _)| rank > *b) { + best = Some((rank, text)); + } + } + best.map(|(_, name)| name) +} + /// The names of one face of a font file (normalised), for matching. struct FaceNames { index: u32, diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 70943b58..f3a1ebf3 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -42,7 +42,7 @@ pub use curves::{ }; pub use decompose::Budget; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; -pub use fonts::{DEFAULT_FONT_FAMILY, FontData, font_data}; +pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, font_data, installed_families}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d34ea5c0..613c8c8f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -804,7 +804,7 @@ impl Dialog { let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel }; let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); let mut picked = - if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] }; + if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::picked_selection(s, &sel) } else { vec![sel.clone()] }; // Tangent chain: an edge brings the edges that continue it smoothly. if chain && let Sel::Edge { body, index, .. } = &sel @@ -2565,7 +2565,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } if ok { - match run_dialog(app, &d) { + // Edit Text applies its own way: its live changes and OK are one undo step. + let run = match &mut d.kind { + Kind::Text(k) => crate::sketch_text::apply(app, k), + _ => run_dialog(app, &d), + }; + match run { Ok(()) => { cancel = true; applied = true; @@ -2575,12 +2580,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } if !keep || cancel { crate::dialogs_motion::closed(app, &mut d, applied); + if let Kind::Text(k) = &mut d.kind { + crate::sketch_text::closed(app, k, applied); + } } // The edited text's outlines are new curves: the old selection would name stale ids. if applied && matches!(d.kind, Kind::Text(_) | Kind::TextMove(_)) { let _ = app.session.execute("select.clear", &json!({})); } if keep && !cancel { + // The canvas follows Edit Text's fields. + if let Kind::Text(k) = &mut d.kind { + crate::sketch_text::preview(app, k); + } app.dialog = Some(d); } else if let Some((_, marker)) = d.editing { // Editing done: put the timeline marker back where it was. @@ -2596,6 +2608,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { pub fn cancel(app: &mut SolveApp) { if let Some(mut d) = app.dialog.take() { crate::dialogs_motion::closed(app, &mut d, false); + if let Kind::Text(k) = &mut d.kind { + crate::sketch_text::closed(app, k, false); + } app.dialog = Some(d); } if let Some(d) = app.dialog.take() diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index ef3b0383..95fc8087 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -789,6 +789,7 @@ mod tests { let mut h = crate::scenario::Harness::new(); h.app.home.open = false; h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + let start = h.app.session.undo.len(); h.app.start("sketch.text"); h.frames(3); h.step(&json!({"click":{"sketch":[0,0]}})).unwrap(); @@ -822,8 +823,11 @@ mod tests { assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); assert!(h.widget("Pending CAD text").is_some()); h.step(&json!({"key":"Enter"})).unwrap(); - assert_ne!(before.0, h.app.session.doc.to_json(), "closing the report must return control to the preserved CAD text"); - assert_eq!(h.app.session.undo.len(), before.1 + 1); + assert!(!crate::sketch_text::editing(&h.app), "closing the report must return control to the preserved CAD text"); + assert_eq!(h.app.session.undo.len(), start + 1, "placing and typing the text is one undo step"); + 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, solidcraft_engine::sketch::LinkSource::Text { text, .. } if text == "Pending CAD text"))); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 35c42156..425ec980 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -112,6 +112,7 @@ pub mod sketch_modify_ui; mod sketch_modify_ui_tests; pub mod sketch_palette; pub(crate) mod sketch_text; +mod sketch_text_handles; mod sketch_text_move; pub mod sketch_tools; pub mod stand_up; diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index 2d6a0656..a9c1e3b6 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -324,5 +324,9 @@ "ui.workspace": "Arbeitsbereich", "ui.x_distance": "X-Abstand", "ui.y_distance": "Y-Abstand", - "ui.zoom_to_the_cursor": "Zum Cursor zoomen" + "ui.zoom_to_the_cursor": "Zum Cursor zoomen", + "sketch_text.builtin_font": "{family} (integriert)", + "sketch_text.font_search": "Schriftarten suchen", + "sketch_text.loading_fonts": "Installierte Schriftarten werden gesucht…", + "sketch_text.use_font_file": "Schriftdatei {path} verwenden" } diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index 2d79190f..5cdd6914 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -858,5 +858,9 @@ "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing", "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).", "cmd.render_backends": "Render Backends", - "cmd.view_navigate": "Navigate View" + "cmd.view_navigate": "Navigate View", + "sketch_text.builtin_font": "{family} (built-in)", + "sketch_text.font_search": "Search fonts", + "sketch_text.loading_fonts": "Looking for installed fonts…", + "sketch_text.use_font_file": "Use the font file {path}" } \ No newline at end of file diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index a563e503..e31737b5 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -858,5 +858,9 @@ "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効", "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。中ボタンをダブルクリックするとホームビューに戻ります(環境設定 › ナビゲーション)。", "cmd.render_backends": "レンダリング バックエンド", - "cmd.view_navigate": "ビューナビゲーション" + "cmd.view_navigate": "ビューナビゲーション", + "sketch_text.builtin_font": "{family}(内蔵)", + "sketch_text.font_search": "フォントを検索", + "sketch_text.loading_fonts": "インストール済みフォントを検索中…", + "sketch_text.use_font_file": "フォント ファイル {path} を使用" } \ No newline at end of file diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index b6d35c34..832c4544 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -324,5 +324,9 @@ "ui.workspace": "작업 공간", "ui.x_distance": "X 거리", "ui.y_distance": "Y 거리", - "ui.zoom_to_the_cursor": "커서 위치로 확대/축소" + "ui.zoom_to_the_cursor": "커서 위치로 확대/축소", + "sketch_text.builtin_font": "{family}(내장)", + "sketch_text.font_search": "글꼴 검색", + "sketch_text.loading_fonts": "설치된 글꼴을 찾는 중…", + "sketch_text.use_font_file": "글꼴 파일 {path} 사용" } diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index 49fc0473..228457bf 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -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, + /// 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, + /// The source the live preview made: a design that shows it was changed by the dialog itself. + shown: Option, } +/// 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 { let sid = app.session.active_sketch?; @@ -42,6 +64,12 @@ pub(crate) fn entity_for_selection(app: &SolveApp, selection: &Sel) -> Option 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 = 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> { + let state = s.world_state(); + let ss = state.sketch(sketch)?; + let text_of = |profile: &solidcraft_engine::sketch::Profile| -> Option { + 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 = (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 { + 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 = 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 { 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 { if app.session.active_sketch != Some(sketch) { return None; } @@ -116,7 +205,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option { 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 { 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(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(¶ms) { + 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(¤t) { + 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>> { + static FAMILIES: OnceLock>> = 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, 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 { 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, 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 { + 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::>(); + 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); + } } diff --git a/crates/ui-egui/src/sketch_text_handles.rs b/crates/ui-egui/src/sketch_text_handles.rs new file mode 100644 index 00000000..09239828 --- /dev/null +++ b/crates/ui-egui/src/sketch_text_handles.rs @@ -0,0 +1,463 @@ +//! Handles on a selected sketch text: a frame around its outlines, a square on each corner +//! that resizes the text (the opposite corner stays where it is) and a round knob above it +//! that turns the text about its centre (Shift: steps of 15°). Each frame of a drag runs +//! `sketch.edit_text` live; the whole gesture is one undo step and Esc cancels it (the drag +//! shares [`crate::viewport::Viewport::text_drag`] with moving a text, so it ends and cancels +//! the same way). +use std::cell::RefCell; + +use egui::{Align2, CursorIcon, FontId, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, vec2}; +use serde_json::json; +use solidcraft_engine::geom::Vec2; +use solidcraft_engine::sketch::{LinkKind, LinkSource}; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// Pixels from a handle's centre that still grab it. +const GRAB_PX: f32 = 8.0; +/// How far above the frame the rotation knob sits (pixels). +const KNOB_PX: f32 = 26.0; +/// Smallest and largest factor one resize may apply, so the text stays a usable size. +const SCALE_RANGE: (f64, f64) = (0.02, 50.0); +/// Rotation steps with Shift held. +const ANGLE_STEP_DEG: f64 = 15.0; + +/// A text's frame: its source and the box around its outlines in the text's own frame (x +/// along the baseline from `at`, y up from it). +#[derive(Clone, Debug, PartialEq)] +struct Frame { + link: String, + at: Vec2, + height: f64, + angle: f64, + min: Vec2, + max: Vec2, +} + +impl Frame { + /// A point of the text's frame in sketch coordinates. + fn to_sketch(&self, local: Vec2) -> Vec2 { + self.at + rotate(local, self.angle) + } + + /// A sketch point in the text's frame. + fn to_local(&self, p: Vec2) -> Vec2 { + rotate(p - self.at, -self.angle) + } + + /// The corners, counter-clockwise from the bottom left (text frame). + fn corners(&self) -> [Vec2; 4] { + [self.min, Vec2::new(self.max.x, self.min.y), self.max, Vec2::new(self.min.x, self.max.y)] + } + + fn center(&self) -> Vec2 { + (self.min + self.max) * 0.5 + } +} + +fn rotate(v: Vec2, angle: f64) -> Vec2 { + let (s, c) = angle.sin_cos(); + Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c) +} + +/// What a drag holds. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grab { + /// A corner (index into [`Frame::corners`]). + Corner(usize), + Rotate, +} + +#[derive(Clone, Debug)] +struct Drag { + grab: Grab, + /// The text as it was when the drag started. + start: Frame, + /// Undo depth at the start: every frame folds into one step. + depth: usize, + /// Where the pointer went down (sketch coordinates). + press: Vec2, +} + +thread_local! { + static DRAG: RefCell> = const { RefCell::new(None) }; +} + +/// The text the handles are for: the one selected text, while nothing else (a tool, a +/// dialog) is going on. +fn target(app: &SolveApp) -> Option { + if let Some(d) = DRAG.with(|d| d.borrow().clone()) { + return Some(d.start.link); + } + if app.tool.is_some() || app.dialog.is_some() { + return None; + } + let entity = crate::sketch_text::selected_entity(app)?; + let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?; + sk.curves.iter().find(|c| c.id == entity)?.link.clone() +} + +/// The frame of text `link` in the active sketch, as shown (solved). +fn frame(app: &SolveApp, link: &str) -> Option { + let sid = app.session.active_sketch?; + let state = app.session.world_state(); + let sk = &state.sketch(sid)?.sketch; + let source = sk.link(link).filter(|l| l.kind == LinkKind::Text)?; + let LinkSource::Text { at, height, angle, .. } = &source.source else { return None }; + let mut f = Frame { link: link.to_owned(), at: *at, height: *height, angle: *angle, min: Vec2::ZERO, max: Vec2::ZERO }; + let mut first = true; + for p in sk.link_curves(link).into_iter().flat_map(|ci| sk.polyline(ci)) { + let q = f.to_local(p); + if !q.is_finite() { + continue; + } + if first { + (f.min, f.max, first) = (q, q, false); + } else { + f.min = Vec2::new(f.min.x.min(q.x), f.min.y.min(q.y)); + f.max = Vec2::new(f.max.x.max(q.x), f.max.y.max(q.y)); + } + } + // A frame needs some width and height to grab (a text of dots or dashes is flat). + let pad = height * 0.05; + if f.max.x - f.min.x < pad { + f.max.x += pad; + f.min.x -= pad; + } + if f.max.y - f.min.y < pad { + f.max.y += pad; + f.min.y -= pad; + } + (!first).then_some(f) +} + +/// The handles on screen: the four corners, then the rotation knob and the point of the frame +/// it hangs from. +struct Screen { + corners: [Pos2; 4], + knob: Pos2, + knob_base: Pos2, +} + +fn screen(app: &SolveApp, proj: &Proj, f: &Frame) -> Option { + let sid = app.session.active_sketch?; + let state = app.session.world_state(); + let plane = state.sketch(sid)?.plane; + let to_screen = |local: Vec2| proj.to_screen(plane.to_world(f.to_sketch(local))); + let [a, b, c, d] = f.corners(); + let corners = [to_screen(a)?, to_screen(b)?, to_screen(c)?, to_screen(d)?]; + let top = Vec2::new((f.min.x + f.max.x) * 0.5, f.max.y); + let knob_base = to_screen(top)?; + // "Up" for the text, as it looks on screen. + let up = to_screen(top + Vec2::new(0.0, f.height.max(1e-3)))? - knob_base; + let up = if up.length() > 1e-3 { up.normalized() } else { vec2(0.0, -1.0) }; + Some(Screen { corners, knob: knob_base + up * KNOB_PX, knob_base }) +} + +/// The handle under a screen position. +fn hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Frame, Grab)> { + let f = frame(app, &target(app)?)?; + let s = screen(app, proj, &f)?; + if s.knob.distance(pos) <= GRAB_PX { + return Some((f, Grab::Rotate)); + } + let corner = (0..4).find(|&i| s.corners.get(i).is_some_and(|c| c.distance(pos) <= GRAB_PX))?; + Some((f, Grab::Corner(corner))) +} + +/// Is a handle under the pointer? (A click there does not change the selection.) +pub(crate) fn over(app: &SolveApp, proj: &Proj, pos: Pos2) -> bool { + hit(app, proj, pos).is_some() +} + +/// Where the pointer is on the active sketch's plane. +fn on_plane(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option { + let state = app.session.world_state(); + let ss = state.sketch(app.session.active_sketch?)?; + let (o, d) = proj.ray(pos); + ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w)) +} + +/// A drag starting on a handle (true: the handles took it). +pub(crate) fn drag_start(app: &mut SolveApp, proj: &Proj, press: Pos2) -> bool { + let Some((start, grab)) = hit(app, proj, press) else { return false }; + let Some(at) = on_plane(app, proj, press) else { return false }; + let Ok(result) = app.session.execute("edit.checkpoint", &json!({})) else { return false }; + let Some(checkpoint) = result["checkpoint"].as_u64() else { return false }; + app.viewport.text_drag = Some((checkpoint, start.link.clone())); + DRAG.with(|d| *d.borrow_mut() = Some(Drag { grab, start, depth: app.session.undo.len(), press: at })); + true +} + +/// Is a handle being dragged? A drag the text move machinery ended (Esc, another command, a +/// document switch) is over here too. +pub(crate) fn dragging(app: &SolveApp) -> bool { + let on = DRAG.with(|d| d.borrow().is_some()); + if on && app.viewport.text_drag.is_none() { + DRAG.with(|d| *d.borrow_mut() = None); + return false; + } + on +} + +/// The text's source after the pointer moved to `to` (sketch coordinates): `at`, `height` +/// and `angle` (radians). +fn follow(d: &Drag, to: Vec2, shift: bool) -> Option<(Vec2, f64, f64)> { + let f = &d.start; + match d.grab { + Grab::Corner(i) => { + let corners = f.corners(); + let anchor = *corners.get((i + 2) % 4)?; + let diagonal = *corners.get(i)? - anchor; + let len2 = diagonal.dot(diagonal); + if len2 <= 1e-18 { + return None; + } + // The pointer's progress along the diagonal: the text keeps its proportions. + let s = ((f.to_local(to) - anchor).dot(diagonal) / len2).clamp(SCALE_RANGE.0, SCALE_RANGE.1); + let pivot = f.to_sketch(anchor); + Some((pivot + (f.at - pivot) * s, f.height * s, f.angle)) + } + Grab::Rotate => { + let center = f.to_sketch(f.center()); + let (a, b) = (d.press - center, to - center); + if a.len() < 1e-9 || b.len() < 1e-9 { + return None; + } + let mut turn = b.y.atan2(b.x) - a.y.atan2(a.x); + if shift { + let step = ANGLE_STEP_DEG.to_radians(); + turn = ((f.angle + turn) / step).round() * step - f.angle; + } + Some((center + rotate(f.at - center, turn), f.height, f.angle + turn)) + } + } +} + +/// Follow the pointer: the text's source changes live; one undo step for the whole drag. +pub(crate) fn drag_to(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) { + let Some(d) = DRAG.with(|d| d.borrow().clone()) else { return }; + let Some(to) = on_plane(app, proj, pos) else { return }; + let Some((at, height, angle)) = follow(&d, to, shift) else { return }; + let mut params = json!({"link": d.start.link, "at": [at.x, at.y], "height": height, "live": true}); + // A resize keeps the exact saved angle. + if d.grab == Grab::Rotate { + params["angle"] = json!(angle.to_degrees()); + } + if app.session.execute("sketch.edit_text", ¶ms).is_ok() { + app.session.undo.truncate(d.depth + 1); + } +} + +/// The drag is over: the model follows the text once, and the gesture is kept. +pub(crate) fn drag_end(app: &mut SolveApp) { + let Some(d) = DRAG.with(|d| d.borrow_mut().take()) else { return }; + let _ = app.session.execute("sketch.drag_end", &json!({})); + app.session.undo.truncate(d.depth + 1); + crate::sketch_text_move::end_drag(app); +} + +/// Draw the frame and its handles (and, while dragging, the value being changed). +pub(crate) fn show(app: &SolveApp, ui: &egui::Ui, painter: &Painter, proj: &Proj, hover: Option) { + let Some(link) = target(app) else { return }; + let Some(f) = frame(app, &link) else { return }; + let Some(s) = screen(app, proj, &f) else { return }; + let tk = Tokens::get(); + let line = Stroke::new(1.0, tk.accent); + let mut outline = s.corners.to_vec(); + outline.push(s.corners[0]); + painter.add(Shape::dashed_line(&outline, line, 5.0, 4.0)); + painter.line_segment([s.knob_base, s.knob], line); + let drag = DRAG.with(|d| d.borrow().as_ref().map(|d| d.grab)); + let under = hover.and_then(|p| hit(app, proj, p)).map(|(_, g)| g); + let lit = |g: Grab| drag == Some(g) || (drag.is_none() && under == Some(g)); + for (i, c) in s.corners.iter().enumerate() { + let r = Rect::from_center_size(*c, vec2(8.0, 8.0)); + painter.rect_filled(r, 1.0, if lit(Grab::Corner(i)) { tk.accent } else { tk.panel }); + painter.rect_stroke(r, 1.0, Stroke::new(1.5, tk.accent), StrokeKind::Middle); + crate::scenario::publish_handle(&format!("text_corner_{i}"), *c); + } + painter.circle(s.knob, 5.0, if lit(Grab::Rotate) { tk.accent } else { tk.panel }, Stroke::new(1.5, tk.accent)); + crate::scenario::publish_handle("text_rotate", s.knob); + match drag.or(under) { + Some(Grab::Rotate) => ui.ctx().set_cursor_icon(if drag.is_some() { CursorIcon::Grabbing } else { CursorIcon::Grab }), + // The corner's diagonal as it looks on screen. + Some(Grab::Corner(i)) => { + let opposite = s.corners[(i + 2) % 4]; + let v = s.corners[i] - opposite; + let down_right = (v.x >= 0.0) == (v.y >= 0.0); + ui.ctx().set_cursor_icon(if down_right { CursorIcon::ResizeNwSe } else { CursorIcon::ResizeNeSw }); + } + None => {} + } + // While dragging, the value that changes, next to the pointer. + if let (Some(grab), Some(p)) = (drag, hover) { + let label = match grab { + Grab::Corner(_) => { + let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units); + format!("{:.2} {unit}", f.height * scale) + } + Grab::Rotate => format!("{:.1}°", f.angle.to_degrees()), + }; + painter.text(p + vec2(14.0, -14.0), Align2::LEFT_BOTTOM, label, FontId::proportional(12.0), tk.accent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame_at(at: Vec2, angle: f64) -> Frame { + Frame { link: "t".into(), at, height: 10.0, angle, min: Vec2::new(0.0, 0.0), max: Vec2::new(20.0, 10.0) } + } + + fn drag(grab: Grab, start: Frame, press: Vec2) -> Drag { + Drag { grab, start, depth: 0, press } + } + + #[test] + fn a_corner_scales_about_the_opposite_corner() { + let f = frame_at(Vec2::new(5.0, 5.0), 0.0); + // The top right corner pulled to twice the box: the bottom left stays at `at`. + let d = drag(Grab::Corner(2), f.clone(), Vec2::new(25.0, 15.0)); + let (at, height, angle) = follow(&d, Vec2::new(45.0, 25.0), false).unwrap(); + assert!(at.dist(Vec2::new(5.0, 5.0)) < 1e-12); + assert!((height - 20.0).abs() < 1e-12); + assert_eq!(angle, 0.0); + // The bottom left pulled in: the top right corner stays put. + let d = drag(Grab::Corner(0), f, Vec2::new(5.0, 5.0)); + let (at, height, _) = follow(&d, Vec2::new(15.0, 10.0), false).unwrap(); + assert!((height - 5.0).abs() < 1e-12); + let after = Frame { at, height, min: Vec2::ZERO, max: Vec2::new(10.0, 5.0), ..frame_at(at, 0.0) }; + assert!(after.to_sketch(after.max).dist(Vec2::new(25.0, 15.0)) < 1e-12); + } + + #[test] + fn a_corner_of_a_turned_text_scales_along_its_own_diagonal() { + let f = frame_at(Vec2::new(0.0, 0.0), std::f64::consts::FRAC_PI_2); + let far = f.to_sketch(Vec2::new(30.0, 15.0)); + let d = drag(Grab::Corner(2), f, Vec2::ZERO); + let (at, height, angle) = follow(&d, far, false).unwrap(); + assert!(at.dist(Vec2::ZERO) < 1e-12); + assert!((height - 15.0).abs() < 1e-12); + assert_eq!(angle, std::f64::consts::FRAC_PI_2); + } + + #[test] + fn a_resize_never_flips_or_vanishes_the_text() { + let f = frame_at(Vec2::ZERO, 0.0); + let d = drag(Grab::Corner(2), f, Vec2::new(20.0, 10.0)); + for to in [Vec2::new(-50.0, -50.0), Vec2::ZERO, Vec2::new(1e9, 1e9)] { + let (_, height, _) = follow(&d, to, false).unwrap(); + assert!(height.is_finite() && height >= 10.0 * SCALE_RANGE.0 && height <= 10.0 * SCALE_RANGE.1); + } + } + + #[test] + fn the_knob_turns_about_the_centre_and_snaps_with_shift() { + let f = frame_at(Vec2::ZERO, 0.0); + let center = f.to_sketch(f.center()); + let d = drag(Grab::Rotate, f, center + Vec2::new(0.0, 10.0)); + // A quarter turn to the left. + let (at, height, angle) = follow(&d, center + Vec2::new(-10.0, 0.0), false).unwrap(); + assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + assert_eq!(height, 10.0); + let turned = Frame { at, angle, ..frame_at(at, angle) }; + assert!(turned.to_sketch(turned.center()).dist(center) < 1e-9, "the centre stays put"); + // 40° snaps to 45° with Shift. + let to = center + rotate(Vec2::new(0.0, 10.0), 40_f64.to_radians()); + let (_, _, snapped) = follow(&d, to, true).unwrap(); + assert!((snapped - 45_f64.to_radians()).abs() < 1e-12); + // At the centre itself there is no direction to follow. + assert!(follow(&d, center, false).is_none()); + } + + use crate::scenario::Harness; + use solidcraft_engine::Sel; + + /// A sketch with "II" (10 mm) seen from the top, the text selected: its handles are up. + fn setup() -> (Harness, String) { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + let result = h.app.run("sketch.text", json!({"text":"II","at":[0,0],"height":10})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 60.0; + h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }]; + h.frames(3); + (h, result["link"].as_str().unwrap().into()) + } + + fn source(h: &Harness, link: &str) -> (Vec2, f64, f64) { + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let LinkSource::Text { at, height, angle, .. } = &sk.link(link).unwrap().source else { panic!("text") }; + (*at, *height, *angle) + } + + #[test] + fn double_clicking_a_letter_opens_its_text_for_editing() { + let (mut h, link) = setup(); + h.app.session.selection.clear(); + h.frames(2); + let sid = h.app.session.active_sketch.unwrap(); + let inside = h.app.session.world_state().sketch(sid).unwrap().profiles[0].centroid; + h.step(&json!({"click":{"sketch":[inside.x,inside.y]},"double":true})).unwrap(); + h.frames(2); + match h.app.dialog.as_ref().map(|d| &d.kind) { + Some(crate::dialogs::Kind::Text(tx)) => assert_eq!(tx.link, link), + other => panic!("Edit Text is not open: {other:?}"), + } + } + + #[test] + fn a_point_of_a_letter_stands_for_its_text() { + let (mut h, link) = setup(); + let sid = h.app.session.active_sketch.unwrap(); + let sk = h.app.session.doc.sketch(sid).unwrap(); + let point = sk.points.iter().find(|p| p.link.as_deref() == Some(link.as_str())).unwrap().id.clone(); + h.app.session.selection = vec![Sel::SketchPoint { id: point }]; + h.frames(2); + assert!(crate::sketch_text::selected_entity(&h.app).is_some()); + assert!(target(&h.app).is_some_and(|l| l == link), "its handles are up too"); + } + + #[test] + fn dragging_a_corner_resizes_the_text_in_one_undo_step() { + let (mut h, link) = setup(); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len()); + let f = frame(&h.app, &link).unwrap(); + // Pull the top right corner out to twice the frame: the bottom left stays put. + let pivot = f.to_sketch(f.min); + let target = pivot + (f.to_sketch(f.max) - pivot) * 2.0; + h.step(&json!({"drag":[{"handle":"text_corner_2"},{"sketch":[target.x,target.y]}],"steps":6})).unwrap(); + h.frames(2); + let (_, height, angle) = source(&h, &link); + assert!((height - 20.0).abs() < 0.5, "height {height}"); + assert_eq!(angle, 0.0); + let after = frame(&h.app, &link).unwrap(); + assert!(after.to_sketch(after.min).dist(pivot) < 0.3, "the opposite corner stays where it was"); + assert!(!h.app.session.model.is_stale(), "the model follows when the drag ends"); + assert!(h.app.viewport.text_drag.is_none()); + assert_eq!(h.app.session.undo.len(), before.1 + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before.0); + } + + #[test] + fn the_rotation_knob_turns_the_text_in_steps_with_shift() { + let (mut h, link) = setup(); + let f = frame(&h.app, &link).unwrap(); + let center = f.to_sketch(f.center()); + // From above the centre to its upper left: about 40°, 45° with Shift. + let to = center + rotate(Vec2::new(0.0, 15.0), 40_f64.to_radians()); + h.step(&json!({"drag":[{"handle":"text_rotate"},{"sketch":[to.x,to.y]}],"steps":6,"shift":true})).unwrap(); + h.frames(2); + let (_, height, angle) = source(&h, &link); + assert!((angle - 45_f64.to_radians()).abs() < 1e-9, "angle {}", angle.to_degrees()); + assert_eq!(height, 10.0); + let after = frame(&h.app, &link).unwrap(); + assert!(after.to_sketch(after.center()).dist(center) < 0.05, "it turns about its centre"); + } +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 6af5ad08..4338e5ae 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -14,8 +14,6 @@ use crate::tools::{Kind, Tool}; use crate::viewport::{Hit, Proj, pick, sketch_point_at}; thread_local! { - /// A placed text waiting for its string (position, text so far). - static TEXT: RefCell> = const { RefCell::new(None) }; /// Shift held this frame. static SHIFT: Cell = const { Cell::new(false) }; /// Glyphs drawn this frame (constraint id, screen centre) and the selected one. @@ -822,8 +820,7 @@ fn run_points(app: &mut SolveApp, t: &Tool) { _ => return, }, ("sketch.text", [at]) => { - let at = at.0; - TEXT.with(|t| *t.borrow_mut() = Some((at, String::new()))); + crate::sketch_text::place(app, at.0, round_nice(app.cam.half_height() * 0.1)); return; } _ => return, @@ -1149,7 +1146,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { painter.text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(10.0), tk.text); } -/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box. +/// Per frame, after the viewport is drawn: constraint glyphs, snap hints and analysis marks. pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift))); glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect))); @@ -1160,39 +1157,6 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } marks(app, painter, proj); iso_lines(app, painter, proj); - text_entry(app, ui.ctx()); -} - -/// The text-entry box for a placed text. -fn text_entry(app: &mut SolveApp, ctx: &egui::Context) { - let Some((at, mut text)) = TEXT.with(|t| t.borrow_mut().take()) else { return }; - let mut done = None; - crate::frame::window(ctx, crate::i18n::tr_label("Text"), crate::frame::Width::Normal) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) - .show(ctx, |ui| { - let r = ui.text_edit_singleline(&mut text); - r.request_focus(); - ui.horizontal(|ui| { - if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - done = Some(true); - } - if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - done = Some(false); - } - }); - }); - match done { - Some(true) => { - if !text.trim().is_empty() { - let h = round_nice(app.cam.half_height() * 0.1); - let _ = app.run("sketch.text", json!({"text": text, "at": xy(at), "height": h})); - } - } - Some(false) => {} - None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))), - } } #[cfg(test)] diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 32891859..f720665f 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -263,7 +263,10 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { let Some(mut tool) = app.tool.take() else { return }; if tool.kind == Kind::Ext { crate::sketch_tools::place_point(app, &mut tool, p); - app.tool = Some(tool); + // A placed text opens Edit Text, which ends the tool. + if !crate::sketch_text::editing(app) { + app.tool = Some(tool); + } return; } let Kind::Draw(n) = tool.kind else { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e2df0674..498e2b17 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2118,6 +2118,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // ---- interaction ---- let add = mods.shift || mods.command || mods.ctrl; + crate::sketch_text::set_one_letter(mods.shift); if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p); @@ -2140,7 +2141,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { .or_else(|| if on_geometry { None } else { crate::dim_view::hit(p) }) .filter(|_| sketching && !off("dimensions")); let on_glyph = crate::sketch_tools::glyph_at(p).filter(|_| sketching && !off("constraints")); - if !crate::sketch3d::click(app, &proj, p) { + if crate::sketch_text_handles::over(app, &proj, p) { + // A text's resize or rotation handle: the text stays selected. + } else if !crate::sketch3d::click(app, &proj, p) { match on_dim.or(on_glyph) { Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), // Clicking again on the same spot selects the next thing under it (not @@ -2157,6 +2160,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } // Double-click a dimension: edit its value in place. let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); + // Double-click a text (an outline or inside a letter; the first click selected it): edit it. + if resp.double_clicked() + && !held + && !dim_edit + && app.tool.is_none() + && app.dialog.is_none() + && let Some(entity) = crate::sketch_text::selected_entity(app) + { + crate::sketch_text::open(app, &entity); + } // Double-click a sketch curve: select the chain joined to it end to end. if resp.double_clicked() && !held @@ -2209,9 +2222,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { .drag_started_by(egui::PointerButton::Primary) .then(|| ui.input(|i| i.pointer.press_origin())) .flatten() - .filter(|a| !crate::sketch3d::drag_start(app, &proj, *a)) + .filter(|a| !crate::sketch_text_handles::drag_start(app, &proj, *a) && !crate::sketch3d::drag_start(app, &proj, *a)) .map(|a| (a, pick(app, &proj, a).into_iter().next())); if let Some((a, hit)) = &grabbed + && !crate::sketch_text_handles::dragging(app) && let Some(q) = on_plane(app, *a) { let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. })); @@ -2223,6 +2237,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { { crate::sketch3d::drag_to(app, &proj, p); } + if crate::sketch_text_handles::dragging(app) + && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) + && let Some(p) = hover + { + crate::sketch_text_handles::drag_to(app, &proj, p, mods.shift); + } if crate::dim_view::dragging() && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) && let Some(q) = hover.and_then(|p| on_plane(app, p)) @@ -2233,6 +2253,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some((a, hit)) = grabbed && !crate::dim_view::dragging() && !crate::sketch3d::dragging() + && !crate::sketch_text_handles::dragging(app) { let active = app.session.active_sketch; match hit { @@ -2308,6 +2329,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } if resp.drag_stopped() { + crate::sketch_text_handles::drag_end(app); if let Some((_, depth, _)) = app.viewport.point_drag.clone() { // Letting go on a point, midpoint or curve adds the matching constraint, then the // whole model follows the drag, all in the drag's undo step. @@ -2331,6 +2353,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let _ = app.session.execute("sketch.drag_end", &json!({})); crate::sketch_text_move::end_drag(app); } + if crate::sketch_text_handles::dragging(app) && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) { + crate::sketch_text_handles::drag_end(app); + } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() && !held @@ -2339,6 +2364,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() && !crate::sketch3d::dragging() + && !crate::sketch_text_handles::dragging(app) { let origin = ui.input(|i| i.pointer.press_origin()); if resp.dragged_by(egui::PointerButton::Primary) @@ -2360,6 +2386,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::dialogs_assembly::origins(app, &painter, &proj); overlays(app, &painter, &proj); crate::dim_view::show(app, ui, &painter, &proj); + crate::sketch_text_handles::show(app, ui, &painter, &proj, hover); crate::sketch3d::show(app, &painter, &proj); crate::inference::show(app, &painter, &proj); crate::drag_snap::show(app, &painter, &proj); @@ -2412,7 +2439,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { pub(crate) fn select(app: &mut SolveApp, sel: Option, add: bool) { match sel { Some(x) => { - let items = grouped_selection(&app.session, &x); + let items = picked_selection(&app.session, &x); if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) { let rest: Vec = app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect(); @@ -2428,6 +2455,18 @@ pub(crate) fn select(app: &mut SolveApp, sel: Option, add: bool) { } } +/// What a click on `sel` picks: its pieces ([`grouped_selection`]), and for a letter of a sketch +/// text the whole text, unless Shift is held (Shift picks the one letter). +pub(crate) fn picked_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec { + if let Sel::Profile { sketch, index } = sel + && !crate::sketch_text::one_letter() + && let Some(letters) = crate::sketch_text::text_profiles(s, *sketch, *index) + { + return letters.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); + } + grouped_selection(s, sel) +} + /// Expand a visible rim or surface into the kernel pieces a command needs. The original /// face pick keeps its point (used by tools such as Hole and construction geometry). pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec {