Sketch text: bold and italic, text along a curve, alignment and spacing (#852)
A sketch text gets an optional style: bold and italic faces (a slanted fallback for italic), left/center/right alignment, letter and line spacing, and a path curve of the same sketch for the baseline to follow (reverse takes the other side). Edit Text gets B/I toggles, alignment, spacing fields and a Path chip, all live. The style is saved only when not plain and is noted under format 18.
This commit is contained in:
1 parent
fe3717f2e4
commit
dd7790a43f
21 files changed
+1082
-100
No files matched your search
@@ -246,7 +246,7 @@ pub enum Kind {
|
||||
/// Insert Part from the standard parts library (`dialogs_parts`).
|
||||
Part(crate::dialogs_parts::Pt),
|
||||
/// Edit Text: a sketch text's content and placement (`sketch_text`).
|
||||
Text(crate::sketch_text::Tx),
|
||||
Text(Box<crate::sketch_text::Tx>),
|
||||
TextMove(crate::sketch_text_move::TextMove),
|
||||
/// Sketch-space modify tools: scale and offset, and later fillet, chamfer, the patterns,
|
||||
/// mirror and move (`sketch_modify_ui`).
|
||||
@@ -544,8 +544,10 @@ impl Dialog {
|
||||
inp.label = objects_label(inp.accept);
|
||||
}
|
||||
// Pre-selection: what is selected now becomes the input (first input that takes it); a
|
||||
// face or edge stands for its body where bodies are wanted.
|
||||
for sel in &s.selection {
|
||||
// face or edge stands for its body where bodies are wanted. (Edit Text's selection is the
|
||||
// text it edits, not its path.)
|
||||
let preselection: &[Sel] = if matches!(d.kind, Kind::Text(_)) { &[] } else { &s.selection };
|
||||
for sel in preselection {
|
||||
let as_body = match sel {
|
||||
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }),
|
||||
_ => None,
|
||||
@@ -1497,6 +1499,10 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
|
||||
/// makes it the active input.
|
||||
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
// Edit Text shows its Path input among its own rows.
|
||||
if matches!(d.kind, Kind::Text(_)) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut clear: Option<usize> = None;
|
||||
let mut activate: Option<usize> = None;
|
||||
@@ -1719,6 +1725,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
crate::sketch_text::sync_path(&mut d);
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
if let Kind::TextMove(k) = &d.kind
|
||||
&& !crate::sketch_text_move::valid(app, k)
|
||||
@@ -2458,7 +2465,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
|
||||
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k),
|
||||
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k, &mut d.inputs, &mut d.active),
|
||||
Kind::TextMove(k) => enter |= crate::sketch_text_move::rows(ui, k),
|
||||
Kind::ConfirmDelete { with, fail, .. } => {
|
||||
if !with.is_empty() {
|
||||
|
||||
@@ -329,5 +329,13 @@
|
||||
"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"
|
||||
"sketch_text.use_font_file": "Schriftdatei {path} verwenden",
|
||||
"sketch_text.bold": "Fett",
|
||||
"sketch_text.italic": "Kursiv",
|
||||
"sketch_text.align_left": "Links",
|
||||
"sketch_text.align_center": "Mitte",
|
||||
"sketch_text.align_right": "Rechts",
|
||||
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
|
||||
"sketch_text.path_clear": "Text von der Kurve lösen",
|
||||
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage"
|
||||
}
|
||||
@@ -863,5 +863,18 @@
|
||||
"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}"
|
||||
"sketch_text.use_font_file": "Use the font file {path}",
|
||||
"sketch_text.bold": "Bold",
|
||||
"sketch_text.italic": "Italic",
|
||||
"sketch_text.align_left": "Left",
|
||||
"sketch_text.align_center": "Center",
|
||||
"sketch_text.align_right": "Right",
|
||||
"sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve",
|
||||
"sketch_text.path_clear": "Take the text off its curve",
|
||||
"Style": "Style",
|
||||
"Alignment": "Alignment",
|
||||
"Letter Spacing": "Letter Spacing",
|
||||
"Line Spacing": "Line Spacing",
|
||||
"Path": "Path",
|
||||
"sketch_text.on_path": "The text follows its path: the curve places it"
|
||||
}
|
||||
@@ -863,5 +863,18 @@
|
||||
"sketch_text.builtin_font": "{family}(内蔵)",
|
||||
"sketch_text.font_search": "フォントを検索",
|
||||
"sketch_text.loading_fonts": "インストール済みフォントを検索中…",
|
||||
"sketch_text.use_font_file": "フォント ファイル {path} を使用"
|
||||
"sketch_text.use_font_file": "フォント ファイル {path} を使用",
|
||||
"sketch_text.bold": "太字",
|
||||
"sketch_text.italic": "斜体",
|
||||
"sketch_text.align_left": "左",
|
||||
"sketch_text.align_center": "中央",
|
||||
"sketch_text.align_right": "右",
|
||||
"sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック",
|
||||
"sketch_text.path_clear": "文字を曲線から外す",
|
||||
"Style": "スタイル",
|
||||
"Alignment": "配置",
|
||||
"Letter Spacing": "文字間隔",
|
||||
"Line Spacing": "行間",
|
||||
"Path": "パス",
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります"
|
||||
}
|
||||
@@ -329,5 +329,13 @@
|
||||
"sketch_text.builtin_font": "{family}(내장)",
|
||||
"sketch_text.font_search": "글꼴 검색",
|
||||
"sketch_text.loading_fonts": "설치된 글꼴을 찾는 중…",
|
||||
"sketch_text.use_font_file": "글꼴 파일 {path} 사용"
|
||||
"sketch_text.use_font_file": "글꼴 파일 {path} 사용",
|
||||
"sketch_text.bold": "굵게",
|
||||
"sketch_text.italic": "기울임꼴",
|
||||
"sketch_text.align_left": "왼쪽",
|
||||
"sketch_text.align_center": "가운데",
|
||||
"sketch_text.align_right": "오른쪽",
|
||||
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
|
||||
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
|
||||
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다"
|
||||
}
|
||||
@@ -8,18 +8,27 @@ use serde_json::{Value, json};
|
||||
use solidcraft_engine::{
|
||||
Sel,
|
||||
doc::expr::Kind as ValueKind,
|
||||
sketch::{LinkKind, LinkSource},
|
||||
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) and angle.
|
||||
const LABELS: [&str; 4] = ["X", "Y", "Height", "Angle"];
|
||||
const KINDS: [ValueKind; 4] = [ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle];
|
||||
/// 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;
|
||||
const LINE_SPACING: usize = 5;
|
||||
|
||||
/// The Edit Text dialog's state: the text being edited and its four values as expressions.
|
||||
/// 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,
|
||||
@@ -29,11 +38,16 @@ pub struct Tx {
|
||||
/// 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; 4],
|
||||
values: [String; FIELDS],
|
||||
/// The fields as first shown; an untouched field keeps its exact saved value.
|
||||
initial: [String; 4],
|
||||
/// The saved source: x, y, height (mm) and angle (rad).
|
||||
original: [f64; 4],
|
||||
initial: [String; FIELDS],
|
||||
/// The saved source: x, y, height (mm), angle (rad), letter spacing (mm) and line spacing.
|
||||
original: [f64; 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,
|
||||
@@ -50,8 +64,8 @@ pub struct Tx {
|
||||
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]);
|
||||
/// A text source as the dialog sees it: text, font (empty: built-in), its values and its style.
|
||||
type Source = (String, String, [f64; FIELDS], TextStyle);
|
||||
|
||||
/// What the Text tool puts down, ready to be typed over.
|
||||
const PLACEHOLDER: &str = "Text";
|
||||
@@ -175,7 +189,7 @@ 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 };
|
||||
format!("{text} {unit}")
|
||||
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
|
||||
@@ -186,23 +200,59 @@ fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<Source> {
|
||||
}
|
||||
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 } = &source.source else { return None };
|
||||
Some((text.clone(), font.clone().unwrap_or_default(), [at.x, at.y, *height, *angle]))
|
||||
let LinkSource::Text { text, at, height, angle, font, style } = &source.source else { return None };
|
||||
let values = [at.x, at.y, *height, *angle, style.letter_spacing, style.line_spacing];
|
||||
Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone()))
|
||||
}
|
||||
|
||||
fn fields(original: &[f64; 4], unit: &'static str, scale: f64) -> [String; 4] {
|
||||
fn fields(original: &[f64; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] {
|
||||
std::array::from_fn(|i| {
|
||||
let value = original.get(i).copied().unwrap_or_default();
|
||||
if i == 3 { shown(value.to_degrees(), "deg") } else { shown(value * scale, unit) }
|
||||
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) = source(app, sketch, &link)?;
|
||||
let (text, font, original, style) = source(app, sketch, &link)?;
|
||||
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let values = fields(&original, unit, scale);
|
||||
let mut tx = Tx {
|
||||
@@ -216,6 +266,9 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
|
||||
values,
|
||||
original,
|
||||
unit,
|
||||
initial_style: style.clone(),
|
||||
style,
|
||||
picking: false,
|
||||
focus: true,
|
||||
font_filter: String::new(),
|
||||
start: None,
|
||||
@@ -225,7 +278,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
|
||||
};
|
||||
// The canvas already shows the fields as they open.
|
||||
tx.tried = edit_params(app, &tx).ok();
|
||||
Some(Kind::Text(tx))
|
||||
Some(Kind::Text(Box::new(tx)))
|
||||
}
|
||||
|
||||
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
|
||||
@@ -233,17 +286,19 @@ pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
if id != "sketch.edit_text" {
|
||||
return None;
|
||||
}
|
||||
Some((kind_for(app, &selected_entity(app)?)?, Vec::new()))
|
||||
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) => {
|
||||
app.dialog = Some(Dialog::new(kind, Vec::new()));
|
||||
Some(Kind::Text(tx)) => {
|
||||
app.dialog = Some(dialog(tx));
|
||||
app.tool = None;
|
||||
}
|
||||
None => app.set_status("Choose an outline belonging to sketch text", true),
|
||||
_ => app.set_status("Choose an outline belonging to sketch text", true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +313,7 @@ pub(crate) fn place(app: &mut SolveApp, at: solidcraft_engine::geom::Vec2, heigh
|
||||
Some(Kind::Text(mut tx)) => {
|
||||
tx.start = Some(checkpoint);
|
||||
tx.folded = true;
|
||||
app.dialog = Some(Dialog::new(Kind::Text(tx), Vec::new()));
|
||||
app.dialog = Some(dialog(tx));
|
||||
app.tool = None;
|
||||
}
|
||||
_ => {
|
||||
@@ -333,7 +388,13 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
|
||||
if k.shown.as_ref() == Some(¤t) {
|
||||
return true;
|
||||
}
|
||||
let (text, font, original) = current;
|
||||
let (text, font, original, style) = current;
|
||||
if style != k.initial_style {
|
||||
if k.style == k.initial_style {
|
||||
k.style = style.clone();
|
||||
}
|
||||
k.initial_style = style;
|
||||
}
|
||||
if original != k.original {
|
||||
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let fresh = fields(&original, unit, scale);
|
||||
@@ -362,8 +423,8 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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;
|
||||
@@ -385,14 +446,84 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
|
||||
crate::dialogs::row_label(ui, "Font");
|
||||
font_row(ui, k, width);
|
||||
ui.end_row();
|
||||
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
|
||||
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);
|
||||
enter |= crate::dialogs::field(ui, value);
|
||||
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 = ui.add(chip).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).
|
||||
fn families(ctx: &egui::Context) -> Option<Arc<Vec<String>>> {
|
||||
static FAMILIES: OnceLock<Arc<Vec<String>>> = OnceLock::new();
|
||||
@@ -503,7 +634,7 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
|
||||
/// `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];
|
||||
let mut out = [0.0; FIELDS];
|
||||
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))
|
||||
@@ -520,8 +651,18 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
|
||||
v
|
||||
};
|
||||
}
|
||||
let [x, y, height, angle] = out;
|
||||
let mut params = json!({"link": k.link, "text": k.text, "at": [x, y], "height": height});
|
||||
let [x, y, height, angle, letter_spacing, line_spacing] = out;
|
||||
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": [x, y], "height": 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.
|
||||
if k.values.get(3) != k.initial.get(3) {
|
||||
params["angle"] = json!(angle.to_degrees());
|
||||
@@ -670,6 +811,7 @@ mod tests {
|
||||
height: 20.0,
|
||||
angle: std::f64::consts::FRAC_PI_2,
|
||||
font: None,
|
||||
style: TextStyle::default(),
|
||||
};
|
||||
assert_eq!(sk.link(&link).unwrap().source, expected);
|
||||
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
|
||||
@@ -697,7 +839,7 @@ mod tests {
|
||||
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 }
|
||||
LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::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");
|
||||
@@ -1105,4 +1247,97 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,11 @@ fn frame(app: &SolveApp, link: &str) -> Option<Frame> {
|
||||
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 LinkSource::Text { at, height, angle, style, .. } = &source.source else { return None };
|
||||
// A text on a path follows its curve: it is not placed by hand.
|
||||
if style.path.is_some() {
|
||||
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)) {
|
||||
|
||||
Reference in new issue
Block a user