Files
OneCAD/crates/ui-egui/src/sketch_text.rs
T
b44ada89e5 Preferences: choose the interface font (#1408)
Adds an Interface font setting that leads the proportional family, with the default face and CJK/Hangul fallbacks kept behind it (#1290).

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:40:07 -04:00

1453 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit Text: the shared feature dialog (OK/Cancel, expression fields) for the source of a
//! 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,
doc::expr::Kind as ValueKind,
sketch::{LinkKind, LinkSource, TextAlign, TextStyle},
};
use crate::SolveApp;
use crate::dialogs::{Dialog, Kind};
use crate::selection::SelInput;
/// Field rows after the text: position, height (lengths), angle, letter spacing (a length) and
/// line spacing (a factor).
const LABELS: [&str; FIELDS] = ["X", "Y", "Height", "Angle", "Letter Spacing", "Line Spacing"];
const KINDS: [ValueKind; FIELDS] =
[ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle, ValueKind::Length, ValueKind::Unitless];
const FIELDS: usize = 6;
/// The field shown in degrees, and the one without a unit.
const ANGLE: usize = 3;
/// The fields before this one (X, Y, Height, Angle) can hold an expression of parameters.
const ANGLE_END: usize = 4;
const LINE_SPACING: usize = 5;
/// No input is active: a click on the canvas picks no path until the Path chip is clicked.
const NO_INPUT: usize = usize::MAX;
/// The Edit Text dialog's state: the text being edited and its values as expressions.
#[derive(Clone, Debug)]
pub struct Tx {
sketch: u64,
pub(crate) link: String,
pub(crate) text: String,
initial_text: String,
/// The font as typed: an installed font's family name or a font file's path; empty is the built-in font.
pub(crate) font: String,
initial_font: String,
values: [String; FIELDS],
/// The fields as first shown; an untouched field keeps its exact saved value.
initial: [String; FIELDS],
/// The saved source: x, y, height (mm), angle (rad), letter spacing (mm) and line spacing.
original: [f64; FIELDS],
/// The expressions saved behind x, y, height and angle (a field shows its expression, and
/// keeps it while untouched).
exprs: [Option<String>; FIELDS],
/// Bold, italic, alignment, the path (the Path input's curve) and its direction as chosen.
pub(crate) style: TextStyle,
initial_style: TextStyle,
/// The Path chip was clicked: the next curve clicked on the canvas is the path.
picking: bool,
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), its values, its style and
/// the expressions behind its values.
type Source = (String, String, [f64; FIELDS], TextStyle, [Option<String>; FIELDS]);
/// 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?;
let state = app.session.world_state();
let ss = state.sketch(sid)?;
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;
for id in &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());
}
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()?;
let mut selected = None;
let mut link = None;
for selection in &app.session.selection {
let id = entity_for_selection(app, selection)?;
let curve = sk.curves.iter().find(|curve| curve.id == id)?;
let source = sk.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());
selected = Some(id.clone());
}
selected
}
/// Show every outline affected by the open editor without changing feature selections.
pub(crate) fn highlighted(app: &SolveApp) -> Vec<Sel> {
let Some(Kind::Text(tx)) = app.dialog.as_ref().map(|dialog| &dialog.kind) else { return Vec::new() };
let Ok(sk) = app.session.doc.sketch(tx.sketch) else { return Vec::new() };
sk.curves.iter().filter(|curve| curve.link.as_deref() == Some(tx.link.as_str())).map(|curve| Sel::SketchCurve { id: curve.id.clone() }).collect()
}
/// A value shown in a field: rounded so binary noise does not show, with its unit.
fn shown(value: f64, unit: &str) -> String {
let text = format!("{value:.6}");
let text = text.trim_end_matches('0').trim_end_matches('.');
let text = if text == "-0" { "0" } else { text };
if unit.is_empty() { text.to_string() } else { format!("{text} {unit}") }
}
/// 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<Source> {
if app.session.active_sketch != Some(sketch) {
return None;
}
let sk = app.session.doc.sketch(sketch).ok()?;
let source = sk.link(link).filter(|source| source.kind == LinkKind::Text)?;
let LinkSource::Text { text, at, height, angle, font, style, exprs } = &source.source else { return None };
let values = [at.x, at.y, *height, *angle, style.letter_spacing, style.line_spacing];
let exprs = [exprs.x.clone(), exprs.y.clone(), exprs.height.clone(), exprs.angle.clone(), None, None];
Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone(), exprs))
}
/// The fields as shown: a value, or the expression behind it.
fn fields(original: &[f64; FIELDS], exprs: &[Option<String>; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] {
std::array::from_fn(|i| {
if let Some(Some(e)) = exprs.get(i) {
return e.clone();
}
let value = original.get(i).copied().unwrap_or_default();
match i {
ANGLE => shown(value.to_degrees(), "deg"),
LINE_SPACING => shown(value, ""),
_ => shown(value * scale, unit),
}
})
}
/// The dialog for a text: its Path input holds the curve the text follows; no input is active
/// until the Path chip is clicked.
fn dialog(tx: Box<Tx>) -> Dialog {
let inputs = inputs_for(&tx);
let mut d = Dialog::new(Kind::Text(tx), inputs);
d.active = NO_INPUT;
d
}
fn inputs_for(tx: &Tx) -> Vec<SelInput> {
let mut path = SelInput::new("Path", crate::selection::CURVES, false);
path.items.extend(tx.style.path.iter().map(|id| Sel::SketchCurve { id: id.clone() }));
vec![path]
}
/// Each frame, before the rows: the path is the Path input's curve; a new pick ends picking.
pub(crate) fn sync_path(d: &mut Dialog) {
let Kind::Text(k) = &mut d.kind else { return };
let picked = d.inputs.first().and_then(|i| i.items.first()).and_then(|sel| match sel {
Sel::SketchCurve { id } => Some(id.clone()),
_ => None,
});
if picked != k.style.path {
k.style.path = picked;
k.picking = false;
}
if !k.picking {
d.active = NO_INPUT;
}
}
/// The dialog's kind for the text that `entity` (an outline curve) belongs to.
fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
let sketch = app.session.active_sketch?;
let sk = app.session.doc.sketch(sketch).ok()?;
let link = sk.curves.iter().find(|curve| curve.id == entity).and_then(|curve| curve.link.clone())?;
let (text, font, original, style, exprs) = source(app, sketch, &link)?;
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let values = fields(&original, &exprs, unit, scale);
let mut tx = Tx {
sketch,
link,
initial_text: text.clone(),
text,
initial_font: font.clone(),
font,
initial: values.clone(),
values,
original,
exprs,
unit,
initial_style: style.clone(),
style,
picking: false,
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(Box::new(tx)))
}
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
if id != "sketch.edit_text" {
return None;
}
let Kind::Text(tx) = kind_for(app, &selected_entity(app)?)? else { return None };
let inputs = inputs_for(&tx);
Some((Kind::Text(tx), inputs))
}
/// Open the dialog for the text an outline (picked with the Edit Text tool) belongs to.
pub(crate) fn open(app: &mut SolveApp, entity: &str) {
match kind_for(app, entity) {
Some(Kind::Text(tx)) => {
app.dialog = Some(dialog(tx));
app.tool = None;
}
_ => app.set_status("Choose an outline belonging to sketch text", true),
}
}
/// 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(tx));
app.tool = None;
}
_ => {
let forget = placed.is_err();
let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint": checkpoint, "forget": forget}));
app.set_status(placed.err().unwrap_or_else(|| "the text could not be placed".into()), true);
}
}
}
/// Run one of the dialog's changes: the undo history keeps one step for the whole dialog (the
/// first change's, which goes back to the design from before it).
fn folded<T>(app: &mut SolveApp, k: &mut Tx, run: impl FnOnce(&mut SolveApp) -> T) -> T {
let mut undo = std::mem::take(&mut app.session.undo);
let out = run(app);
let pushed = std::mem::take(&mut app.session.undo);
if !k.folded && !pushed.is_empty() {
undo.extend(pushed);
k.folded = true;
}
app.session.undo = undo;
out
}
/// Each frame: show the fields on the canvas (a live edit) when they changed. Fields that make
/// no text (an empty one, a half-typed value) leave the last good preview.
pub(crate) fn preview(app: &mut SolveApp, k: &mut Tx) {
let Ok(params) = edit_params(app, k) else { return };
if k.tried.as_ref() == Some(&params) {
return;
}
k.tried = Some(params.clone());
if k.start.is_none() {
let Some(checkpoint) = app.session.execute("edit.checkpoint", &json!({})).ok().and_then(|r| r["checkpoint"].as_u64()) else { return };
k.start = Some(checkpoint);
}
let mut live = params;
live["live"] = json!(true);
if folded(app, k, |app| app.session.execute("sketch.edit_text", &live)).is_ok() {
k.shown = source(app, k.sketch, &k.link);
}
}
/// OK: the fields' final edit (which also brings the features after the sketch up to date), in
/// the dialog's one undo step.
pub(crate) fn apply(app: &mut SolveApp, k: &mut Tx) -> Result<(), String> {
let params = edit_params(app, k)?;
folded(app, k, |app| app.run("sketch.edit_text", params))?;
k.shown = source(app, k.sketch, &k.link);
Ok(())
}
/// The dialog closed: applied, its changes stay; otherwise the design goes back to how it was
/// before them (a placed text goes away).
pub(crate) fn closed(app: &mut SolveApp, k: &mut Tx, applied: bool) {
if let Some(checkpoint) = k.start.take() {
let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint": checkpoint, "forget": applied}));
}
}
/// Is the Edit Text dialog open?
pub(crate) fn editing(app: &SolveApp) -> bool {
matches!(app.dialog.as_ref().map(|d| &d.kind), Some(Kind::Text(_)))
}
/// Keep the dialog in step with the design: false once its text is gone (sketch finished, the
/// 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(current) = source(app, k.sketch, &k.link) else { return false };
// What the live preview made is the dialog's own doing.
if k.shown.as_ref() == Some(&current) {
return true;
}
let (text, font, original, style, exprs) = current;
if style != k.initial_style {
if k.style == k.initial_style {
k.style = style.clone();
}
k.initial_style = style;
}
if original != k.original || exprs != k.exprs {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let fresh = fields(&original, &exprs, unit, scale);
for ((value, initial), new) in k.values.iter_mut().zip(k.initial.iter_mut()).zip(fresh) {
if value == initial {
*value = new.clone();
}
*initial = new;
}
k.original = original;
k.exprs = exprs;
k.unit = unit;
}
if text != k.initial_text {
if k.text == k.initial_text {
k.text = text.clone();
}
k.initial_text = text;
}
if font != k.initial_font {
if k.font == k.initial_font {
k.font = font.clone();
}
k.initial_font = font;
}
true
}
/// The dialog's rows; true when Enter was pressed in a value field or in the text (Shift+Enter
/// starts a new line there). `inputs` and `active`: the dialog's Path input.
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) -> bool {
let mut enter = false;
crate::dialogs::row_label(ui, "Text");
let width = ui.spacing().text_edit_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");
font_row(ui, k, width);
ui.end_row();
crate::dialogs::row_label(ui, "Style");
ui.horizontal(|ui| {
let bold = ui.toggle_value(&mut k.style.bold, egui::RichText::new("B").strong()).on_hover_text(crate::i18n::tr("sketch_text.bold"));
crate::scenario::publish_handle("dialog:Bold", bold.rect.center());
let italic = ui.toggle_value(&mut k.style.italic, egui::RichText::new("I").italics()).on_hover_text(crate::i18n::tr("sketch_text.italic"));
crate::scenario::publish_handle("dialog:Italic", italic.rect.center());
});
ui.end_row();
crate::dialogs::row_label(ui, "Alignment");
ui.horizontal(|ui| {
for (align, id, handle) in [
(TextAlign::Left, "sketch_text.align_left", "dialog:Align left"),
(TextAlign::Center, "sketch_text.align_center", "dialog:Align center"),
(TextAlign::Right, "sketch_text.align_right", "dialog:Align right"),
] {
let response = ui.selectable_value(&mut k.style.align, align, crate::i18n::tr(id));
crate::scenario::publish_handle(handle, response.rect.center());
}
});
ui.end_row();
// On a path the curve places the text: X, Y and the angle do not.
let on_path = k.style.path.is_some();
for (i, (label, value)) in LABELS.iter().zip(k.values.iter_mut()).enumerate() {
crate::dialogs::row_label(ui, label);
let placed = on_path && matches!(i, 0 | 1 | ANGLE);
if placed {
let response = ui.add_enabled_ui(false, |ui| crate::dialogs::field(ui, value));
response.response.on_disabled_hover_text(crate::i18n::tr("sketch_text.on_path"));
} else {
enter |= crate::dialogs::field(ui, value);
}
ui.end_row();
}
path_row(ui, k, inputs, active);
enter
}
/// The curve the text follows: a chip that, clicked, takes the next curve clicked on the canvas;
/// × takes the text off it; Reverse runs it the other way along.
fn path_row(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) {
let t = crate::theme::Tokens::get();
crate::dialogs::row_label(ui, "Path");
ui.horizontal(|ui| {
let on = k.style.path.is_some();
let text = if on {
egui::RichText::new(crate::i18n::format("dialog.selected_count", &[("count", "1")])).color(egui::Color32::WHITE)
} else {
egui::RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
};
let stroke = if k.picking { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
let chip = egui::Button::new(text).fill(if on { t.chip } else { egui::Color32::TRANSPARENT }).stroke(stroke).min_size(egui::vec2(0.0, 22.0));
let chip = crate::frame::add_outlined(ui, chip, stroke.width).on_hover_text(crate::i18n::tr("sketch_text.path_hint"));
crate::scenario::publish_handle("dialog:Path", chip.rect.center());
if chip.clicked() {
k.picking = true;
*active = 0;
}
if on {
let clear = ui.small_button("\u{d7}").on_hover_text(crate::i18n::tr("sketch_text.path_clear"));
crate::scenario::publish_handle("dialog:Path clear", clear.rect.center());
if clear.clicked() {
for input in inputs.iter_mut() {
input.items.clear();
}
k.style.path = None;
k.picking = false;
}
}
});
ui.end_row();
if k.style.path.is_some() {
crate::dialogs::row_label(ui, "Reverse");
let reverse = ui.checkbox(&mut k.style.reverse, "");
crate::scenario::publish_handle("dialog:Reverse", reverse.rect.center());
ui.end_row();
}
}
/// The installed font families, listed once in the background (none on the web).
pub(crate) 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. X, Y, Height and Angle go as
/// numbers, or as their expressions when these name parameters (the text then follows them): an
/// untouched field that holds one keeps it.
fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
let doc = &app.session.doc;
let mut out = [0.0; FIELDS];
let mut sent: [Value; FIELDS] = std::array::from_fn(|_| Value::Null);
for (i, slot) in out.iter_mut().enumerate() {
let (Some(value), Some(initial), Some(original), Some(kind), Some(label)) =
(k.values.get(i), k.initial.get(i), k.original.get(i), KINDS.get(i), LABELS.get(i))
else {
return Err("missing value".into());
};
let given = k.exprs.get(i).and_then(Option::as_ref).filter(|_| value == initial);
*slot = if value == initial {
*original
} else {
let typed = doc.with_design_unit(value.trim(), *kind);
let v = doc.eval(&typed, *kind).map_err(|e| format!("{label}: {e}"))?;
if !v.is_finite() {
return Err(format!("{label}: not a finite number"));
}
v
};
let names_parameters = i < ANGLE_END && !solidcraft_engine::doc::expr::references(value).is_empty();
sent[i] = match given {
Some(e) => json!(e),
None if value != initial && names_parameters => json!(value.trim()),
None => json!(if i == ANGLE { slot.to_degrees() } else { *slot }),
};
}
let [_, _, _, _, letter_spacing, line_spacing] = out;
let [sent_x, sent_y, sent_height, sent_angle, ..] = sent;
let align = match k.style.align {
TextAlign::Left => "left",
TextAlign::Center => "center",
TextAlign::Right => "right",
};
let mut params = json!({
"link": k.link, "text": k.text, "at": [sent_x, sent_y], "height": sent_height,
"bold": k.style.bold, "italic": k.style.italic, "align": align,
"letter_spacing": letter_spacing, "line_spacing": line_spacing,
"path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse,
});
// Omitting an unchanged angle keeps its exact saved radians (or its expression).
if k.values.get(ANGLE) != k.initial.get(ANGLE) {
params["angle"] = sent_angle;
}
// An untouched font stays as saved; an emptied one is the built-in font.
if k.font.trim() != k.initial_font.trim() {
params["font"] = json!(k.font.trim());
}
Ok(params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::Harness;
fn setup() -> (Harness, String, 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":"I","at":[0,0],"height":10})).unwrap();
let link = result["link"].as_str().unwrap().to_owned();
let curve = result["curves"][0].as_str().unwrap().to_owned();
h.app.session.selection = vec![Sel::SketchCurve { id: curve.clone() }];
h.frames(3);
(h, link, curve)
}
fn tx(app: &SolveApp) -> &Tx {
match app.dialog.as_ref().map(|d| &d.kind) {
Some(Kind::Text(k)) => k,
_ => panic!("Edit Text is not open"),
}
}
#[test]
fn letter_interior_context_menu_opens_and_highlights_the_whole_text() {
let (mut h, link, _) = setup();
h.app.run("sketch.edit_text", json!({"link":link,"text":"II"})).unwrap();
h.app.session.selection.clear();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 25.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
let at = h.app.session.world_state().sketch(sid).unwrap().profiles[0].centroid;
let [x, y] = h.at(&json!({"sketch":[at.x,at.y]})).unwrap();
h.call("ui.menu", json!({"x":x,"y":y}));
h.frames(3);
assert!(matches!(h.app.session.selection.as_slice(), [Sel::Profile { .. }]));
let item = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport)
.into_iter()
.find(|item| item.id == "sketch.edit_text")
.unwrap();
crate::context_menu::run_item(&mut h.app, &item, egui::Pos2::ZERO);
h.frames(3);
assert_eq!(tx(&h.app).link, link);
let sk = h.app.session.doc.sketch(sid).unwrap();
for curve in sk.curves.iter().filter(|curve| curve.link.as_deref() == Some(&link)) {
assert!(h.app.highlighted().contains(&Sel::SketchCurve { id: curve.id.clone() }));
}
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(highlighted(&h.app).is_empty());
}
#[test]
fn edit_text_tool_accepts_a_letter_interior_without_preselection() {
let (mut h, link, _) = setup();
h.app.session.selection.clear();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 25.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
let at = h.app.session.world_state().sketch(sid).unwrap().profiles[0].centroid;
h.step(&json!({"start":"sketch.edit_text"})).unwrap();
h.step(&json!({"click":{"sketch":[at.x,at.y]}})).unwrap();
assert_eq!(tx(&h.app).link, link);
}
#[test]
fn text_profile_resolution_checks_holes_and_rejects_mixed_sources() {
let (mut h, link, _) = setup();
h.app.run("sketch.edit_text", json!({"link":link,"text":"O"})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
let state = h.app.session.world_state();
let profile = state.sketch(sid).unwrap().profiles.iter().position(|p| !p.hole_curves.is_empty()).unwrap();
let selection = Sel::Profile { sketch: sid, index: profile };
assert!(entity_for_selection(&h.app, &selection).is_some());
h.app.run("sketch.text", json!({"text":"I","at":[20,0],"height":10})).unwrap();
let profiles = h.app.session.world_state().sketch(sid).unwrap().profiles.len();
h.app.session.selection = (0..profiles).map(|index| Sel::Profile { sketch: sid, index }).collect();
assert!(selected_entity(&h.app).is_none());
assert!(entity_for_selection(&h.app, &Sel::Profile { sketch: sid + 1, index: 0 }).is_none());
}
#[test]
fn profile_cut_by_ordinary_geometry_does_not_identify_text() {
let (mut h, _, _) = setup();
h.app.run("sketch.line", json!({"points":[[-10,3],[10,3]]})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
let count = h.app.session.world_state().sketch(sid).unwrap().profiles.len();
assert!(count >= 2);
for index in 0..count {
assert!(entity_for_selection(&h.app, &Sel::Profile { sketch: sid, index }).is_none());
}
}
fn ok(h: &mut Harness) {
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.frames(2);
}
fn replace(h: &mut Harness, handle: &str, value: &str) {
h.step(&json!({"click":{"handle":handle}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":value})).unwrap();
}
#[test]
fn selected_text_menu_edits_source_position_and_size_with_one_undo_and_reopen() {
let (mut h, link, _) = setup();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let item = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport)
.into_iter()
.find(|item| item.id == "sketch.edit_text")
.unwrap();
crate::context_menu::run_item(&mut h.app, &item, egui::Pos2::ZERO);
h.frames(3);
assert!(editing(&h.app));
for (field, value) in [("dialog:Text", "O"), ("dialog:X", "25"), ("dialog:Y", "12"), ("dialog:Height", "20"), ("dialog:Angle", "90")] {
replace(&mut h, field, value);
}
// 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);
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap();
let expected = LinkSource::Text {
text: "O".into(),
at: solidcraft_engine::geom::Vec2::new(25.0, 12.0),
height: 20.0,
angle: std::f64::consts::FRAC_PI_2,
font: None,
style: TextStyle::default(),
exprs: Default::default(),
};
assert_eq!(sk.link(&link).unwrap().source, expected);
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
let changed = h.app.session.doc.to_json();
let reopened = solidcraft_engine::Session::new(solidcraft_engine::doc::Document::from_json(&changed).unwrap());
assert_eq!(reopened.doc.sketch(sid).unwrap().link(&link).unwrap().source, expected);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), changed);
}
#[test]
fn text_editor_converts_document_units_and_preserves_untouched_source_values() {
let (mut h, link, _) = setup();
h.app.run("document.units", json!({"units":"in"})).unwrap();
h.app.run("sketch.edit_text", json!({"link":link,"at":[3.123456789,7.987654321],"height":9.123456789,"angle":17.123456789})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
let original = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone();
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).unit, "in");
replace(&mut h, "dialog:Text", "O");
ok(&mut h);
let LinkSource::Text { at, height, angle, .. } = original else { panic!("text source") };
assert_eq!(
h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source,
LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::default(), exprs: Default::default() }
);
h.app.session.selection = vec![Sel::SketchCurve { id: h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone() }];
h.app.start("sketch.edit_text");
h.frames(3);
let undo = h.app.session.undo.len();
replace(&mut h, "dialog:X", "1.2345");
replace(&mut h, "dialog:Height", "0.25");
ok(&mut h);
let LinkSource::Text { at: updated, height: size, angle: rotation, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source
else {
panic!("text source")
};
assert!((updated.x - 31.3563).abs() < 1e-12);
assert_eq!(updated.y, at.y);
assert!((size - 6.35).abs() < 1e-12);
assert_eq!(rotation, angle);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn the_font_row_sets_keeps_and_clears_the_font_of_a_text() {
// The repository's second font, BIZ UDPGothic: its "I" is 17.6385 mm² at a capital height of 10 mm, the built-in
// font's 13.5557 mm².
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf");
let (mut h, link, _) = setup();
// The dialog closes with the selection; the text's first outline is selected again to open it.
let reopen = |h: &mut Harness| {
let sid = h.app.session.active_sketch.unwrap();
let id = h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone();
h.app.session.selection = vec![Sel::SketchCurve { id }];
h.app.start("sketch.edit_text");
h.frames(3);
};
let area = |h: &Harness| -> f64 {
let sid = h.app.session.active_sketch.unwrap();
h.app.session.world_state().sketch(sid).unwrap().profiles.iter().map(|p| p.area).sum()
};
assert!((area(&h) - 13.5557).abs() < 2e-3);
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).font, "");
// 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());
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("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: 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);
let sid = h.app.session.active_sketch.unwrap();
let font_of = |h: &Harness| match &h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source {
LinkSource::Text { font, .. } => font.clone(),
other => panic!("{other:?}"),
};
assert_eq!(font_of(&h).as_deref(), Some(font));
assert!((area(&h) - 17.6385).abs() < 2e-3);
reopen(&mut h);
assert_eq!(tx(&h.app).font, font);
// Changing only the text keeps the font: two I's of it.
replace(&mut h, "dialog:Text", "II");
ok(&mut h);
assert_eq!(font_of(&h).as_deref(), Some(font));
assert!((area(&h) - 2.0 * 17.6385).abs() < 2e-3);
// 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.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);
}
#[test]
fn invalid_text_values_and_cancel_leave_document_and_history_unchanged() {
let (mut h, _, _) = setup();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
h.app.start("sketch.edit_text");
h.frames(3);
for value in ["NaN", "inf", "-5", "0"] {
replace(&mut h, "dialog:Height", value);
ok(&mut h);
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
}
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(!editing(&h.app));
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
}
#[test]
fn text_editor_rejects_mixed_sources_and_leaves_with_document_or_command() {
let (mut h, _, curve) = setup();
let other = h.app.run("sketch.text", json!({"text":"O","at":[30,0],"height":10})).unwrap();
h.app.session.selection.push(Sel::SketchCurve { id: other["curves"][0].as_str().unwrap().into() });
assert!(selected_entity(&h.app).is_none());
assert!(!crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport).iter().any(|item| item.id == "sketch.edit_text"));
h.app.session.selection = vec![Sel::SketchCurve { id: curve }];
h.app.start("sketch.edit_text");
assert!(editing(&h.app));
h.app.start("sketch.line");
assert!(!editing(&h.app));
h.app.start("sketch.edit_text");
assert!(editing(&h.app));
crate::documents::open_tab(&mut h.app, solidcraft_engine::Session::default()).unwrap();
assert!(!editing(&h.app));
}
#[test]
fn edit_text_without_preselection_picks_a_real_outline() {
let (mut h, link, _) = setup();
h.app.session.selection.clear();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 40.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
let polyline = h.app.session.doc.sketch(sid).unwrap().polyline(0);
let midpoint = (polyline[0] + polyline[1]) * 0.5;
h.step(&json!({"start":"sketch.edit_text"})).unwrap();
assert!(h.app.tool.is_some());
h.step(&json!({"click":{"sketch":[midpoint.x,midpoint.y]}})).unwrap();
assert_eq!(tx(&h.app).link, link);
assert!(h.app.tool.is_none());
h.step(&json!({"widget":"Cancel"})).unwrap();
}
#[test]
fn issue_report_preserves_text_edit_and_owns_keyboard_input() {
let (mut h, _, _) = setup();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Text", "Pending text");
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
h.app.help.menu = true;
h.frames(2);
h.step(&json!({"click":{"handle":"menu:report"}})).unwrap();
h.frames(3);
let token = h.app.shot_token;
crate::issue_report::receive(&mut h.app, token, std::sync::Arc::new(egui::ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
h.frames(3);
let focus = egui::Id::new("issue_report_title");
h.ctx.memory_mut(|memory| memory.request_focus(focus));
h.frames(2);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(focus));
h.step(&json!({"text":"report-only-marker"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(tx(&h.app).text, "Pending text");
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
h.step(&json!({"widget":"Close draft"})).unwrap();
ok(&mut h);
assert!(!editing(&h.app));
// 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")));
}
/// Selects the text `link` again (an edit makes its outlines anew).
fn select_text(h: &mut Harness, link: &str) {
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap();
let id = sk.curves[sk.link_curves(link)[0]].id.clone();
h.app.session.selection = vec![Sel::SketchCurve { id }];
h.frames(3);
}
fn exprs_of(h: &Harness, link: &str) -> solidcraft_engine::sketch::TextExprs {
let sid = h.app.session.active_sketch.unwrap();
let LinkSource::Text { exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source.clone() else { panic!("text source") };
exprs
}
#[test]
fn a_field_that_names_parameters_stays_an_expression_and_is_shown_as_typed() {
let (mut h, link, _) = setup();
h.app.run("parameters.add", json!({"name":"tall","expression":"20 mm"})).unwrap();
h.app.run("parameters.add", json!({"name":"turn","expression":"90 deg"})).unwrap();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:X", "tall + 5 mm");
replace(&mut h, "dialog:Height", "tall / 2");
replace(&mut h, "dialog:Angle", "turn");
ok(&mut h);
assert!(!editing(&h.app), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
let sid = h.app.session.active_sketch.unwrap();
let LinkSource::Text { at, height, angle, exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else {
panic!("text source")
};
assert_eq!(
(exprs.x.as_deref(), exprs.y.as_deref(), exprs.height.as_deref(), exprs.angle.as_deref()),
(Some("tall + 5 mm"), None, Some("tall / 2"), Some("turn"))
);
assert!((at.x - 25.0).abs() < 1e-9 && (height - 10.0).abs() < 1e-9 && (angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
// Opened again, the fields show the expressions; a number typed in one of the others
// leaves them alone.
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).values[0], "tall + 5 mm");
assert_eq!((tx(&h.app).values[2].as_str(), tx(&h.app).values[3].as_str()), ("tall / 2", "turn"));
replace(&mut h, "dialog:Y", "7");
ok(&mut h);
let exprs = exprs_of(&h, &link);
assert_eq!(
(exprs.x.as_deref(), exprs.y.as_deref(), exprs.height.as_deref(), exprs.angle.as_deref()),
(Some("tall + 5 mm"), None, Some("tall / 2"), Some("turn"))
);
// A parameter changes while the saved numbers are not yet worked out again: OK on an
// untouched dialog keeps the expressions, and the numbers follow the parameter.
h.app.run("parameters.change", json!({"name":"tall","expression":"40 mm"})).unwrap();
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
ok(&mut h);
let LinkSource::Text { at, height, exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else {
panic!("text source")
};
assert_eq!((exprs.x.as_deref(), exprs.height.as_deref()), (Some("tall + 5 mm"), Some("tall / 2")));
assert!((at.x - 45.0).abs() < 1e-9 && (at.y - 7.0).abs() < 1e-9 && (height - 20.0).abs() < 1e-9, "{at:?} {height}");
// A number typed over an expression replaces it.
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Height", "6");
ok(&mut h);
let exprs = exprs_of(&h, &link);
assert_eq!((exprs.height, exprs.x.as_deref()), (None, Some("tall + 5 mm")));
// A name that is no parameter is an error in the dialog; nothing changes.
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Height", "nope * 2");
h.frames(2);
ok(&mut h);
assert!(editing(&h.app), "the dialog stays open on a field that cannot be used");
}
#[test]
fn fields_take_expressions_with_units_and_parameters() {
let (mut h, link, _) = setup();
h.app.run("parameters.add", json!({"name":"gap","expression":"4 mm","unit":"mm"})).unwrap();
h.app.run("document.units", json!({"units":"in"})).unwrap();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:X", "10 mm * 2 + 0.5 in");
replace(&mut h, "dialog:Y", "gap * 3");
replace(&mut h, "dialog:Height", "0.5");
replace(&mut h, "dialog:Angle", "45 + 45");
ok(&mut h);
assert!(!editing(&h.app), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
let sid = h.app.session.active_sketch.unwrap();
let LinkSource::Text { at, height, angle, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else {
panic!("text source")
};
assert!((at.x - 32.7).abs() < 1e-9);
assert!((at.y - 12.0).abs() < 1e-9);
assert!((height - 12.7).abs() < 1e-9);
assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
}
#[test]
fn unrelated_design_changes_keep_the_dialog_open() {
let (mut h, _, _) = setup();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Text", "Kept");
h.app.run("sketch.line", json!({"points":[[50,0],[60,0]]})).unwrap();
h.frames(3);
assert!(editing(&h.app));
assert_eq!(tx(&h.app).text, "Kept");
// 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);
}
/// The style of text `link` in the active sketch.
fn style_of(h: &Harness, link: &str) -> TextStyle {
let sid = h.app.session.active_sketch.unwrap();
match &h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source {
LinkSource::Text { style, .. } => style.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn style_alignment_and_spacing_show_live_and_apply_in_one_step() {
let (mut h, link, _) = setup();
let undo = h.app.session.undo.len();
h.app.start("sketch.edit_text");
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Italic"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Align center"}})).unwrap();
replace(&mut h, "dialog:Letter Spacing", "1.5");
replace(&mut h, "dialog:Line Spacing", "1.25");
h.frames(2);
// On the canvas before OK.
let live = style_of(&h, &link);
assert!(live.italic && !live.bold && live.align == TextAlign::Center, "{live:?}");
assert_eq!((live.letter_spacing, live.line_spacing), (1.5, 1.25));
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(style_of(&h, &link), live);
assert_eq!(h.app.session.undo.len(), undo + 1);
// Reopened, the dialog shows them.
let sid = h.app.session.active_sketch.unwrap();
h.app.session.selection = vec![Sel::SketchCurve { id: h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone() }];
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).style, live);
assert_eq!(tx(&h.app).values[4], "1.5 mm");
assert_eq!(tx(&h.app).values[5], "1.25");
}
#[test]
fn bold_in_a_font_without_a_bold_face_is_refused_with_the_reason() {
let (mut h, link, _) = setup();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
h.app.start("sketch.edit_text");
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Bold"}})).unwrap();
h.frames(2);
assert!(!style_of(&h, &link).bold, "the canvas keeps the last text it could make");
ok(&mut h);
assert!(editing(&h.app));
let error = h.app.dialog.as_ref().unwrap().error.clone().unwrap_or_default();
assert!(error.contains("no bold face"), "{error}");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
}
#[test]
fn the_path_chip_takes_the_next_curve_clicked_and_the_text_follows_it() {
let (mut h, link, curve) = setup();
let line = h.app.run("sketch.line", json!({"points":[[-20,-30],[40,-30]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 120.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
h.app.session.selection = vec![Sel::SketchCurve { id: curve }];
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(style_of(&h, &link).path, None, "the selected text is not its own path");
// A click on the canvas before the chip picks nothing.
h.step(&json!({"click":{"sketch":[10,-30]}})).unwrap();
h.frames(2);
assert_eq!(tx(&h.app).style.path, None);
// The chip, then the line: the text stands on it at once.
h.step(&json!({"click":{"handle":"dialog:Path"}})).unwrap();
h.step(&json!({"click":{"sketch":[10,-30]}})).unwrap();
h.frames(3);
assert_eq!(tx(&h.app).style.path.as_deref(), Some(line.as_str()));
assert_eq!(style_of(&h, &link).path.as_deref(), Some(line.as_str()));
let state = h.app.session.world_state();
let sk = &state.sketch(sid).unwrap().sketch;
let low = sk.link_curves(&link).into_iter().flat_map(|ci| sk.polyline(ci)).map(|p| p.y).fold(f64::MAX, f64::min);
assert!((low + 30.0).abs() < 1e-6, "on the line: {low}");
// Reverse, then OK: one undo step.
h.step(&json!({"click":{"handle":"dialog:Reverse"}})).unwrap();
h.frames(2);
assert!(style_of(&h, &link).reverse);
// × takes it off again.
h.step(&json!({"click":{"handle":"dialog:Path clear"}})).unwrap();
h.frames(2);
assert_eq!(style_of(&h, &link).path, None);
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!(style_of(&h, &link), TextStyle::default());
}
}