mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-09 20:10:12 -06:00
Merge branch 'stk-1105' into stack/s3
This commit is contained in:
@@ -11,6 +11,8 @@ fn text_of(k: &EntityKind) -> Option<String> {
|
||||
EntityKind::Text(t) => Some(t.value.clone()),
|
||||
EntityKind::AttDef(a) => Some(a.text.value.clone()),
|
||||
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
|
||||
// A multileader's text (one with a block instead has none, #1081).
|
||||
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),
|
||||
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
|
||||
_ => None,
|
||||
}
|
||||
@@ -25,11 +27,16 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
|
||||
EntityKind::Text(t) => t.value = text.to_string(),
|
||||
EntityKind::AttDef(a) => a.text.value = text.to_string(),
|
||||
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
|
||||
EntityKind::MLeader(m) if m.text.is_some() => {
|
||||
if let Some(t) = &mut m.text {
|
||||
t.contents = text.replace('\n', "\\P");
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
d.text = if text == "<>" { String::new() } else { text.to_string() };
|
||||
d.block = None;
|
||||
}
|
||||
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
|
||||
_ => return Err(other("Select a text, mtext, multileader, attribute definition or dimension.")),
|
||||
}
|
||||
set_kind(s, h, k)
|
||||
}
|
||||
@@ -53,12 +60,18 @@ pub(super) struct TextEditM {
|
||||
single: bool,
|
||||
/// Asking for the edit mode.
|
||||
mode: bool,
|
||||
/// The MTEXT being edited, for the in-place editor (#842).
|
||||
mtext: Option<cadcraft_doc::MText>,
|
||||
}
|
||||
|
||||
impl Interactive for TextEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TEXTEDIT"
|
||||
}
|
||||
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
self.h.as_ref().and(self.mtext.clone())
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.mode {
|
||||
let current = if self.single { "Single" } else { "Multiple" };
|
||||
@@ -83,7 +96,19 @@ impl Interactive for TextEditM {
|
||||
match (&self.h, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
|
||||
let kind = curves::entity(s, h)?.kind;
|
||||
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?;
|
||||
self.mtext = match kind {
|
||||
EntityKind::MText(m) => Some(m),
|
||||
// A multileader's text opens in the same editor (#1081).
|
||||
EntityKind::MLeader(m) => m.text,
|
||||
_ => None,
|
||||
};
|
||||
// The editor would cut a text this long short, and saving would shorten it.
|
||||
if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) {
|
||||
self.mtext = None;
|
||||
return Err(other("This text is too long to edit; it is left as it is."));
|
||||
}
|
||||
self.h = Some((h, old));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -93,10 +118,12 @@ impl Interactive for TextEditM {
|
||||
set_text(s, h, &t)?;
|
||||
self.edits.push((h, before));
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(Some(_), Input::Enter) => {
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Done),
|
||||
|
||||
@@ -402,6 +402,17 @@ fn apply_kind_properties(
|
||||
t.rotation = x.to_radians();
|
||||
}
|
||||
}
|
||||
// A multileader's text: its contents (#1081) and height.
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
if let Some(v) = str_param(params, "text") {
|
||||
t.contents = v.replace('\n', "\\P");
|
||||
}
|
||||
if let Some(x) = num("height").filter(|x| *x > 0.0) {
|
||||
t.height = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
if let Some(x) = num("width").filter(|x| *x >= 0.0) {
|
||||
pl.const_width = x;
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct CharFmt {
|
||||
pub font: Option<String>,
|
||||
/// Absolute character height set inline (`\H`); `None`: the text's.
|
||||
pub height: Option<f64>,
|
||||
/// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written
|
||||
/// back as it was so the part keeps scaling with the text's height.
|
||||
pub height_rel: Option<f64>,
|
||||
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
|
||||
pub color: Option<MTextColor>,
|
||||
}
|
||||
@@ -99,6 +102,23 @@ fn arg_end(chars: &[char], from: usize) -> usize {
|
||||
impl Formatted {
|
||||
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
|
||||
pub fn from_contents(contents: &str) -> Formatted {
|
||||
Self::from_contents_with_height(contents, None)
|
||||
}
|
||||
|
||||
/// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no
|
||||
/// inline height before it scales that one instead of being dropped.
|
||||
pub fn from_contents_with_height(contents: &str, height: Option<f64>) -> Formatted {
|
||||
Self::parse(contents, height).0
|
||||
}
|
||||
|
||||
/// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters the rest would
|
||||
/// be cut off, and saving would shorten the text.
|
||||
pub fn fits(contents: &str) -> bool {
|
||||
Self::parse(contents, None).1
|
||||
}
|
||||
|
||||
/// The parse, and whether it took in all of `contents`.
|
||||
fn parse(contents: &str, height: Option<f64>) -> (Formatted, bool) {
|
||||
let mut f = Formatted::default();
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut stack: Vec<CharFmt> = Vec::new();
|
||||
@@ -108,7 +128,7 @@ impl Formatted {
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if f.fmt.len() >= MAX_CHARS {
|
||||
break;
|
||||
return (f, false);
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
@@ -151,7 +171,7 @@ impl Formatted {
|
||||
i = end + 1;
|
||||
match k {
|
||||
'f' | 'F' => apply_font(&mut cur, &a),
|
||||
'H' => cur.height = parse_height(&a, cur.height),
|
||||
'H' => apply_height(&mut cur, &a, height),
|
||||
'C' => {
|
||||
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
|
||||
}
|
||||
@@ -187,7 +207,7 @@ impl Formatted {
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
(f, true)
|
||||
}
|
||||
|
||||
fn push_char(&mut self, c: char, fmt: &CharFmt) {
|
||||
@@ -445,7 +465,9 @@ impl Formatted {
|
||||
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
|
||||
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
|
||||
}
|
||||
if let Some(h) = f.height {
|
||||
if let Some(m) = f.height_rel {
|
||||
s.push_str(&format!("\\H{}x;", trim(m)));
|
||||
} else if let Some(h) = f.height {
|
||||
s.push_str(&format!("\\H{};", trim(h)));
|
||||
}
|
||||
match f.color {
|
||||
@@ -462,12 +484,32 @@ fn trim(v: f64) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
|
||||
/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no
|
||||
/// inline height before it, or a relative one) stays relative; over an inline absolute height it
|
||||
/// becomes absolute.
|
||||
fn apply_height(cur: &mut CharFmt, a: &str, base: Option<f64>) {
|
||||
let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h);
|
||||
let a = a.trim();
|
||||
match a.strip_suffix(['x', 'X']) {
|
||||
// A relative height needs the current one; without it the code is dropped.
|
||||
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
|
||||
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
|
||||
let Some(r) = a.strip_suffix(['x', 'X']) else {
|
||||
if let Some(h) = a.parse::<f64>().ok().and_then(valid) {
|
||||
cur.height = Some(h);
|
||||
cur.height_rel = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(m) = r.trim().parse::<f64>().ok().and_then(valid) else { return };
|
||||
match (cur.height, cur.height_rel) {
|
||||
(_, Some(r0)) => {
|
||||
if let Some(m) = valid(r0 * m) {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
(Some(h), None) => cur.height = valid(h * m).or(cur.height),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +535,22 @@ fn apply_font(cur: &mut CharFmt, a: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_relative_height_is_written_back_relative() {
|
||||
let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0));
|
||||
assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height");
|
||||
let out = f.to_contents();
|
||||
assert!(out.contains("\\H2x;big"), "{out}");
|
||||
assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}");
|
||||
// Opened again for a taller text, the part scales with it.
|
||||
assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0));
|
||||
// Relative over relative multiplies; over an absolute one it is absolute.
|
||||
let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents();
|
||||
assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}");
|
||||
// With no height known it is still kept.
|
||||
assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_formats_and_atoms() {
|
||||
for src in [
|
||||
|
||||
@@ -86,6 +86,7 @@ mod minsert;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
mod mleader_text_edit;
|
||||
mod mline;
|
||||
mod mocoro;
|
||||
mod modify_curves;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and
|
||||
//! hands it to the in-place editor, and the Properties palette's Contents field changes it.
|
||||
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::json;
|
||||
|
||||
fn text_of(s: &Session) -> String {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None })
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textedit_and_properties_change_a_multileaders_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
|
||||
// TEXTEDIT on the text: the current contents are the default, and the in-place editor gets
|
||||
// the multileader's MTEXT.
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Enter new text");
|
||||
assert_eq!(p.default.as_deref(), Some("example text"));
|
||||
assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string()));
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "edited\\Psecond line");
|
||||
s.cancel();
|
||||
|
||||
// The Properties palette's Contents field.
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!(text_of(&s), "from properties");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_too_long_for_the_editor_is_left_as_it_is() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS));
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap();
|
||||
assert_eq!(text_of(&s), long);
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("This text is too long to edit; it is left as it is."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
s.cancel();
|
||||
assert_eq!(text_of(&s), long, "the text is not shortened");
|
||||
}
|
||||
@@ -26,12 +26,16 @@ pub(super) fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOption
|
||||
}
|
||||
|
||||
/// The model space tiles on screen (none without tiling): each tile's rectangle in `full`.
|
||||
/// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu
|
||||
/// offers Edit Text for); false when there is none.
|
||||
/// Start TEXTEDIT on the text, mtext, attribute definition or multileader text at `p` (the objects
|
||||
/// its shortcut menu offers Edit Text for, and multileaders, #1081); false when there is none.
|
||||
pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
let is_text = cadcraft_engine::cmd::curves::pick_at(&app.session, p).and_then(|h| {
|
||||
app.session.doc().ok()?.entity(h).map(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_)))
|
||||
app.session.doc().ok()?.entity(h).map(|e| match &e.kind {
|
||||
EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) => true,
|
||||
EntityKind::MLeader(m) => m.text.is_some(),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
if is_text != Some(true) {
|
||||
return false;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! the text's location and width with a formatting toolbar (font, height, bold, italic,
|
||||
//! underline, overline, colour, alignment, lists, tabs) and a ruler for the paragraph's indents
|
||||
//! and tab stops. The text and its formats live in the engine's
|
||||
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits.
|
||||
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits. TEXTEDIT
|
||||
//! (and so a double-click) on an existing MTEXT opens it here with its contents (#842).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -72,6 +73,9 @@ struct State {
|
||||
sel: (usize, usize),
|
||||
/// A caret position to set on the text box in the next frame (after a toolbar insertion).
|
||||
caret: Option<usize>,
|
||||
/// The text box has had keyboard focus: it takes it once when the editor opens, so typing
|
||||
/// goes into the text, not to the command line.
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
@@ -91,7 +95,8 @@ fn color32(c: MTextColor) -> Color32 {
|
||||
Color32::from_rgb(rgb.0, rgb.1, rgb.2)
|
||||
}
|
||||
|
||||
/// Show the editor while MTEXT collects its text; clears its state when it isn't running.
|
||||
/// Show the editor while MTEXT, or TEXTEDIT on an MTEXT, collects its text; clears its state
|
||||
/// when neither is running.
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let mtext = app.session.editing_mtext();
|
||||
let asking = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
|
||||
@@ -102,14 +107,17 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get();
|
||||
let (rect, scale) = app.canvas.xf.map_or((ctx.content_rect(), 20.0), |x| (x.rect, x.scale.max(1e-6)));
|
||||
let mut st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).unwrap_or_else(|| {
|
||||
let mut fmt = Formatted::default();
|
||||
// An existing MTEXT (TEXTEDIT) starts with its contents and their formats.
|
||||
let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents_with_height(&mt.contents, Some(mt.height)) };
|
||||
if let Some(f) = app.session.doc().ok().and_then(|d| d.text_style(&mt.style).map(|s| s.font.clone())) {
|
||||
// A family name, not a font file (`txt.shx`, `arial.ttf`).
|
||||
if !f.is_empty() && !f.contains('.') {
|
||||
fmt.base_font = f;
|
||||
}
|
||||
}
|
||||
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }
|
||||
// An existing text opens with the caret at its end.
|
||||
let caret = (!fmt.is_empty()).then(|| fmt.len());
|
||||
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret, focused: false }
|
||||
});
|
||||
let base_h = if mt.height > 0.0 { mt.height } else { 0.2 };
|
||||
let px = ((base_h * scale) as f32).clamp(MIN_PX, MAX_PX);
|
||||
@@ -158,8 +166,12 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
Done::Ok => {
|
||||
ctx.data_mut(|d| d.remove::<State>(state_id()));
|
||||
// The editor holds the whole contents: one input, then Enter ends MTEXT's text entry.
|
||||
// Emptied while editing an existing MTEXT: it keeps its text (Enter), as TEXTEDIT
|
||||
// keeps it for an empty answer.
|
||||
if !st.fmt.is_empty() {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents()));
|
||||
} else if app.session.running.as_ref().is_some_and(|r| r.id == "textedit") {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Enter);
|
||||
}
|
||||
if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Enter);
|
||||
@@ -248,7 +260,10 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
|
||||
let mut h = cur.height.unwrap_or(base_h);
|
||||
if height_field(ui, &mut h, base_h) {
|
||||
let none = (h - base_h).abs() < 1e-9;
|
||||
change(st, |c| c.height = (!none).then_some(h));
|
||||
change(st, |c| {
|
||||
c.height = (!none).then_some(h);
|
||||
c.height_rel = None;
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
for (flag, label, tip) in [
|
||||
@@ -507,9 +522,8 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) {
|
||||
/// MTEXT editor area with the formatted preview; restores a pending caret position before laying text out with `base_h`, `px`, `width` and the theme tokens.
|
||||
fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) {
|
||||
let id = edit_id();
|
||||
if let Some(at) = st.caret.take()
|
||||
&& let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), id)
|
||||
{
|
||||
if let Some(at) = st.caret.take() {
|
||||
let mut s = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at))));
|
||||
s.store(ui.ctx(), id);
|
||||
}
|
||||
@@ -568,9 +582,13 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32,
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
out.response.response.clone().accessible_name(crate::tl!("Text"));
|
||||
if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() {
|
||||
// On opening, and while the text is empty, the editor takes the focus; a field of the toolbar
|
||||
// the user clicked keeps it.
|
||||
let free = ui.memory(|m| m.focused().is_none());
|
||||
if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty() && free)) {
|
||||
out.response.request_focus();
|
||||
}
|
||||
st.focused = true;
|
||||
if out.response.changed() {
|
||||
let typing = st.typing.clone();
|
||||
st.fmt.set_text(&buf, &typing);
|
||||
@@ -603,7 +621,8 @@ mod tests {
|
||||
/// Clicking Diameter inserts U+2300 into both the formatted text and `to_contents()`, selects it, and leaves no unnamed widget.
|
||||
#[test]
|
||||
fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() {
|
||||
let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
|
||||
let state =
|
||||
State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None, focused: false };
|
||||
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).with_context_setup(install_diameter_glyph).build_ui_state(
|
||||
|ui, state| {
|
||||
toolbar(ui, state, 2.5);
|
||||
@@ -620,10 +639,32 @@ mod tests {
|
||||
assert!(harness.unnamed_widgets().is_empty());
|
||||
}
|
||||
|
||||
/// With the text empty, a click on the height field keeps the focus there; the editor doesn't
|
||||
/// take it back.
|
||||
#[test]
|
||||
fn an_empty_editor_leaves_the_focus_on_the_height_field() {
|
||||
let state =
|
||||
State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None, focused: false };
|
||||
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).build_ui_state(
|
||||
|ui, state| {
|
||||
toolbar(ui, state, 2.5);
|
||||
text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get());
|
||||
},
|
||||
state,
|
||||
);
|
||||
harness.run();
|
||||
assert!(harness.ctx.memory(|m| m.has_focus(edit_id())), "the editor opens with the focus");
|
||||
harness.get_by_role_and_label(Role::SpinButton, "Text height").click();
|
||||
harness.run();
|
||||
harness.run();
|
||||
assert!(!harness.ctx.memory(|m| m.has_focus(edit_id())), "the height field keeps the focus");
|
||||
}
|
||||
|
||||
/// The ten toolbar controls listed in the test are found by semantic name under the harness's default bundled fonts, and no toolbar widget is unnamed.
|
||||
#[test]
|
||||
fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() {
|
||||
let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
|
||||
let mut state =
|
||||
State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None, focused: false };
|
||||
let mut harness = Harness::builder().with_size(vec2(1100.0, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5));
|
||||
harness.run();
|
||||
for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] {
|
||||
@@ -712,4 +753,74 @@ mod tests {
|
||||
let found = d.model.iter().any(|e| matches!(&e.kind, cadcraft_engine::doc::EntityKind::MText(m) if m.contents == contents));
|
||||
assert!(found, "{contents}");
|
||||
}
|
||||
|
||||
/// #842: TEXTEDIT on an existing MTEXT (what a double-click starts) opens it in the editor
|
||||
/// with its text and formats; finishing writes the edited contents back to that MTEXT.
|
||||
#[test]
|
||||
fn textedit_opens_an_existing_mtext_with_its_contents() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let frame = |app: &mut CadApp| {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "{\\fArial|b1;first}\\Psecond", "height": 2.5, "width": 20 })).unwrap();
|
||||
let h = app.session.doc().unwrap().model.last().unwrap().handle;
|
||||
app.start("textedit");
|
||||
let at = cadcraft_geom::Vec2::new(1.0, 19.0);
|
||||
app.session.input(cadcraft_engine::Input::Point(at)).unwrap();
|
||||
let m = app.session.editing_mtext().expect("an MTEXT to edit");
|
||||
assert!(m.contents.contains("second"), "{}", m.contents);
|
||||
frame(&mut app);
|
||||
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
|
||||
assert_eq!(st.fmt.text(), "first\nsecond");
|
||||
assert!(st.fmt.fmt_at(0).bold && !st.fmt.fmt_at(7).bold, "formats kept");
|
||||
let mut st = st;
|
||||
st.fmt.set_text("first\nsecond\nthird", &CharFmt::default());
|
||||
app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).is_none(), "editor closed");
|
||||
match &app.session.doc().unwrap().entity(h).unwrap().kind {
|
||||
cadcraft_engine::doc::EntityKind::MText(m) => assert!(m.contents.contains("third") && m.contents.contains("b1"), "{}", m.contents),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A multileader's text with a relative height code (`\H2x;`) opens in the editor at twice
|
||||
/// its text height, so saving it back keeps the bigger letters.
|
||||
#[test]
|
||||
fn a_relative_height_in_a_multileaders_text_is_kept() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let frame = |app: &mut CadApp| {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
app.run("mleader", serde_json::json!({ "points": [[16, -10], [20, -5]], "text": "{\\H2x;big} small" })).unwrap();
|
||||
let (at, height) = app
|
||||
.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
if let cadcraft_engine::doc::EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (t.insert.xy(), t.height)) } else { None }
|
||||
})
|
||||
.unwrap();
|
||||
app.start("textedit");
|
||||
app.session.input(cadcraft_engine::Input::Point(at + cadcraft_geom::Vec2::new(0.5, -0.05))).unwrap();
|
||||
assert!(app.session.editing_mtext().is_some(), "the multileader's text is being edited");
|
||||
frame(&mut app);
|
||||
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
|
||||
assert_eq!(st.fmt.text(), "big small");
|
||||
let big = st.fmt.fmt_at(0).height.expect("the relative height is kept");
|
||||
assert!((big - 2.0 * height).abs() < 1e-9, "{big} for a {height} text");
|
||||
assert_eq!(st.fmt.fmt_at(5).height, None, "the rest keeps the text's height");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1162,6 +1162,17 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, ids);
|
||||
num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, ids);
|
||||
}
|
||||
// A multileader's text (#1081): it had no field, so it couldn't be changed.
|
||||
EntityKind::MLeader(ml) => {
|
||||
if let Some(tx) = &ml.text {
|
||||
prop_row(ui, "Contents", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("mltxt", &ids)), tx.contents.clone()) {
|
||||
set = Some(json!({ "handles": ids, "text": s }));
|
||||
}
|
||||
});
|
||||
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids);
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, ids);
|
||||
let g = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
|
||||
|
||||
@@ -105,3 +105,27 @@ fn double_click_elsewhere_starts_no_command() {
|
||||
assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), None, "{at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1081: double-clicking a multileader's text edits it, as for TEXT and MTEXT.
|
||||
#[test]
|
||||
fn double_click_on_a_multileaders_text_starts_textedit() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("mleader", json!({ "points": [[16, -10], [20, -5]], "text": "example text" })).unwrap();
|
||||
h.run_steps(3);
|
||||
let at = h
|
||||
.state()
|
||||
.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
|
||||
.unwrap();
|
||||
double_click(&mut h, at + Vec2::new(1.0, -0.05));
|
||||
let s = &h.state().session;
|
||||
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("textedit"));
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Enter new text");
|
||||
assert_eq!(p.default.as_deref(), Some("example text"));
|
||||
assert!(s.editing_mtext().is_some(), "the in-place editor opens on it");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user