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:
1 parent
69050c81d9
commit
ec5d7f9789
17 files changed
+1248
-80
No files matched your search
@@ -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<Value> {
|
||||
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<crate::LiveEdit>)> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<LinkSource>(&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());
|
||||
}
|
||||
@@ -103,6 +103,89 @@ pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result<Option<FontData>>
|
||||
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<String> {
|
||||
families_in(&font_dirs())
|
||||
}
|
||||
|
||||
/// The family names of the fonts under `dirs` (see [`installed_families`]).
|
||||
pub fn families_in(dirs: &[PathBuf]) -> Vec<String> {
|
||||
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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<u32> = 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<String> {
|
||||
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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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} を使用"
|
||||
}
|
||||
@@ -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} 사용"
|
||||
}
|
||||
@@ -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(¶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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Drag>> = 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<String> {
|
||||
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<Frame> {
|
||||
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<Screen> {
|
||||
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<Vec2> {
|
||||
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<Pos2>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<Option<(Vec2, String)>> = const { RefCell::new(None) };
|
||||
/// Shift held this frame.
|
||||
static SHIFT: Cell<bool> = 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)]
|
||||
|
||||
@@ -263,7 +263,10 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option<String>)) {
|
||||
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 {
|
||||
|
||||
@@ -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<Sel>, 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<Sel> =
|
||||
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<Sel>, 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<Sel> {
|
||||
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<Sel> {
|
||||
|
||||
Reference in new issue
Block a user