Browse / github.com/storytold/cadcraft / patch
Batch Plot across open drawings, AutoCAD .ctb/.stb plot style tables, Plot Preview zoom, paper units
Batch Plot across open drawings, AutoCAD .ctb/.stb plot style tables, Plot Preview zoom, paper units Split out of #2034 (the AutoCAD-parity UI work), as asked in review: one theme per PR. #2034 stays open as the source. **Builds on the icons (`split/icons`) and Hebrew interface (`split/rtl-fields`) PRs.** Until they merge this PR also shows their commits; this part is the last commit. Feature overview with screenshots (PDF): https://github.com/storytold/cadcraft/pull/2034#issuecomment-6436084948 ## What Printing real projects: - **Batch Plot** (`PUBLISH`, `batchplot.rs`): a print manager. Sheets of any open drawing in a list that can be reordered (drag or the Move buttons), repeated or r
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- luminatiguy (imported from pull request #2328, open)
- Base
d864a08b013353143086bd8bb568c26cb6256796- Size
- +1882 −107 in 29 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6af5adfd8d7989a590df92ea
How it went
No other operator has reported using it yet.
Diff
raw · apps/cadcraft-cli/src/main.rs apps/cadcraft/src/main.rs crates/engine/src/cmd/exchange.rs crates/engine/src/cmd/file.rs crates/engine/src/cmd/layout/plotstyle.rs crates/engine/src/cmd/view.rs crates/io/src/dxf_read/entities.rs crates/io/src/dxf_read/tables.rs crates/io/src/lib.rs crates/io/src/pdf.rs crates/io/src/pia.rs crates/io/src/plotter.rs crates/io/tests/it/paperview_marker.rs crates/io/tests/it/tables_objects_roundtrip.rs crates/render/src/paper.rs crates/ui-egui/src/batchplot.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/closing.rs crates/ui-egui/src/cmdline.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/exchange.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/palettes.rs crates/ui-egui/tests/it/clipboard_between_drawings.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/rtl_names.rs
@@ -177,6 +177,8 @@ fn install_io() { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), });+ cadcraft_engine::cmd::file::set_plot_style_reader(cadcraft_io::pia::read);+ cadcraft_engine::cmd::file::set_plot_book(|items, opts| cadcraft_io::plotter::plot_book(items, opts).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) });@@ -139,6 +139,8 @@ pub fn install_io() { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), });+ cadcraft_engine::cmd::file::set_plot_style_reader(cadcraft_io::pia::read);+ cadcraft_engine::cmd::file::set_plot_book(|items, opts| cadcraft_io::plotter::plot_book(items, opts).map_err(|e| e.to_string())); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) });@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> { .noundo(), CommandSpec::new("publish", "Batch Publish...", run_publish) .menu(&["File", "Batch Publish..."])- .params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")+ .params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order), sheets?: [{drawing?: index of an open drawing (default the current one), layout: name | \"Model\"}] (in place of layouts; a sheet may repeat), combine?: bool (one PDF of all the sheets, at `path` itself)} → {sheets: [{layout, bytes, path? | data}]}") .noundo(), CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook) .menu(&["File", "Sheet Books to PDF..."])@@ -384,7 +384,154 @@ fn file_part(name: &str) -> String { name.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') { c } else { '_' }).collect() } +/// One sheet of a PUBLISH list: an open drawing (index in [`Session::docs`]) and a layout of it.+struct PubSheet {+ doc: usize,+ space: Space,+}++/// The `sheets` list of PUBLISH: each names an open drawing and one of its layouts.+fn publish_sheets(s: &Session, v: &Value) -> Result<Vec<PubSheet>> {+ let a = v.as_array().ok_or_else(|| bad("publish", "`sheets` must be a list of {drawing?, layout}"))?;+ if a.len() > MAX_SHEETS {+ return Err(bad("publish", format!("at most {MAX_SHEETS} sheets can be published at once")));+ }+ a.iter()+ .map(|it| {+ let doc = match it.get("drawing") {+ None | Some(Value::Null) => s.active,+ Some(n) => n+ .as_u64()+ .and_then(|n| usize::try_from(n).ok())+ .ok_or_else(|| bad("publish", "`drawing` must be the index of an open drawing"))?,+ };+ let d = s.docs.get(doc).ok_or_else(|| bad("publish", format!("no open drawing {doc}")))?;+ let name = it.get("layout").and_then(Value::as_str).ok_or_else(|| bad("publish", "each sheet needs a `layout`"))?;+ let space = if name.eq_ignore_ascii_case("model") {+ Space::Model+ } else {+ Space::Paper(d.doc.layout(name).map(|l| l.name.clone()).ok_or_else(|| bad("publish", format!("{}: no layout `{name}`", d.title)))?)+ };+ Ok(PubSheet { doc, space })+ })+ .collect()+}++/// Write `bytes` to `path` through a temporary file, as PLOT does.+fn write_pdf(path: &str, bytes: &[u8]) -> Result<()> {+ #[cfg(not(target_arch = "wasm32"))]+ {+ // Through an unpredictable temporary file, as files are saved: a planted symlink at a+ // fixed temporary name can't redirect the write (CWE-59).+ crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("publish", format!("{path}: {e}")))+ }+ #[cfg(target_arch = "wasm32")]+ {+ let _ = (path, bytes);+ Err(bad("publish", "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))+ }+}++/// PUBLISH of a `sheets` list: sheets of any open drawing, in order, into one PDF (`combine`) or+/// one PDF each.+fn publish_list(s: &mut Session, p: &Value, list: Vec<PubSheet>) -> Result<Value> {+ if list.is_empty() {+ return Err(bad("publish", "there are no sheets to publish"));+ }+ let path = str_param(p, "path").map(str::to_string);+ let several = list.iter().any(|x| x.doc != list[0].doc);+ if p.get("combine").and_then(Value::as_bool) == Some(true) {+ let hook = super::file::plot_book().ok_or_else(|| bad("publish", "combining sheets is not available in this build"))?;+ let items: Vec<(&Drawing, Space)> = list.iter().map(|x| (&*s.docs[x.doc].doc, x.space.clone())).collect();+ let bytes = hook(&items, &json!({})).map_err(|e| bad("publish", e))?;+ let r = match &path {+ Some(path) => {+ write_pdf(path, &bytes)?;+ json!({ "path": path, "bytes": bytes.len(), "pages": list.len() })+ }+ None => json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "pages": list.len() }),+ };+ let message = match &path {+ Some(path) => format!("Published {} sheet(s) to {path}", list.len()),+ None => format!("Published {} sheet(s)", list.len()),+ };+ return Ok(json!({ "sheets": [r], "message": message }));+ }+ let base = path.as_deref().map(|path| {+ let pb = std::path::Path::new(path);+ let stem = pb.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Drawing".into());+ (pb.parent().map(std::path::Path::to_path_buf).unwrap_or_default(), stem)+ });+ let back = s.active;+ let mut used = std::collections::HashSet::new();+ let mut out = Vec::new();+ let mut failed = None;+ for x in &list {+ let name = x.space.tab_name().to_string();+ let mut q = json!({ "layout": name });+ if let Some((dir, stem)) = &base {+ // Sheets of other drawings carry the drawing's name; a repeated sheet is numbered.+ let mut file = if several {+ let title = std::path::Path::new(&s.docs[x.doc].title).file_stem().map(|t| t.to_string_lossy().to_string()).unwrap_or_default();+ format!("{stem}-{}-{}", file_part(&title), file_part(&name))+ } else {+ format!("{stem}-{}", file_part(&name))+ };+ let first = file.clone();+ let mut n = 1;+ while !used.insert(file.clone()) {+ n += 1;+ file = format!("{first}-{n}");+ }+ q["path"] = json!(dir.join(format!("{file}.pdf")).to_string_lossy());+ }+ s.active = x.doc;+ let r = s.execute("plot", &q);+ s.active = back;+ match r {+ Ok(mut r) => {+ if let Some(o) = r.as_object_mut() {+ o.insert("layout".into(), json!(name));+ o.insert("drawing".into(), json!(s.docs[x.doc].title));+ }+ out.push(r);+ }+ Err(e) => {+ failed = Some(bad("publish", format!("{}: {name}: {e}", s.docs[x.doc].title)));+ break;+ }+ }+ }+ if let Some(e) = failed {+ return Err(e);+ }+ let message = match &base {+ Some(_) => format!("Published {} sheet(s): {}", out.len(), out.iter().filter_map(|r| r["path"].as_str()).collect::<Vec<_>>().join(", ")),+ None => format!("Published {} sheet(s)", out.len()),+ };+ Ok(json!({ "sheets": out, "message": message }))+}+ fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {+ if let Some(v) = p.get("sheets") {+ let list = publish_sheets(s, v)?;+ return publish_list(s, p, list);+ }+ if p.get("combine").and_then(Value::as_bool) == Some(true) {+ let names: Vec<Value> = match p.get("layouts") {+ Some(Value::Array(a)) => a.iter().map(|n| json!({ "layout": n })).collect(),+ Some(_) => return Err(bad("publish", "`layouts` must be a list of layout names")),+ None => {+ let d = s.doc()?;+ let mut layouts: Vec<_> = d.layouts.iter().collect();+ layouts.sort_by_key(|l| l.tab_order);+ let model = (!d.model.is_empty()).then(|| json!({ "layout": "Model" }));+ model.into_iter().chain(layouts.iter().map(|l| json!({ "layout": l.name }))).collect()+ }+ };+ let list = publish_sheets(s, &Value::Array(names))?;+ return publish_list(s, p, list);+ } let d = s.doc()?; let sheets: Vec<String> = match p.get("layouts") { Some(v) => {@@ -794,6 +941,10 @@ mod tests { for p in [json!({ "layouts": [] }), json!({ "layouts": ["Nope"] }), json!({ "layouts": "Layout1" }), json!({ "layouts": [1] })] { assert!(s.execute("publish", &p).is_err(), "{p}"); }+ // Combined into one PDF: the same check, not every layout instead.+ let path = temp("combined.pdf");+ assert!(s.execute("publish", &json!({ "combine": true, "layouts": "Layout1", "path": path.to_string_lossy() })).is_err());+ assert!(!path.exists()); } #[test]@@ -42,6 +42,81 @@ pub struct IoHooks { static IO: OnceLock<IoHooks> = OnceLock::new(); +/// Plot sheets of several drawings into one PDF, one page each in order, with JSON options for+/// the whole file (installed by the app; PUBLISH with `combine` across open drawings).+pub type PlotBookHook = fn(&[(&Drawing, cadcraft_doc::Space)], &Value) -> std::result::Result<Vec<u8>, String>;++static PLOT_BOOK: OnceLock<PlotBookHook> = OnceLock::new();++/// Read an AutoCAD plot style table file (`.ctb`/`.stb` bytes, the file name) into a table+/// (installed by the app; the io layer reads the format).+pub type PlotStyleReader = fn(&[u8], &str) -> std::result::Result<cadcraft_doc::PlotStyleTable, String>;++static PLOT_STYLE_READER: OnceLock<PlotStyleReader> = OnceLock::new();++pub fn set_plot_style_reader(h: PlotStyleReader) {+ let _ = PLOT_STYLE_READER.set(h);+}+pub fn plot_style_reader() -> Option<PlotStyleReader> {+ PLOT_STYLE_READER.get().copied()+}++/// Whether `bytes` are an AutoCAD plot style table file.+pub fn is_pia(bytes: &[u8]) -> bool {+ bytes.starts_with(b"PIAFILEVERSION")+}++/// The folders a missing plot style table is looked for in: `extra` (the drawing's folder), then+/// AutoCAD's plot style folders of this user (Windows: under `%APPDATA%\Autodesk`).+#[cfg(not(target_arch = "wasm32"))]+fn plot_style_dirs(extra: Vec<std::path::PathBuf>) -> Vec<std::path::PathBuf> {+ let mut dirs = extra;+ if let Some(appdata) = std::env::var_os("APPDATA") {+ // Autodesk\<product>\<release>\<language>\Plotters\Plot Styles+ let list = |p: &std::path::Path| -> Vec<std::path::PathBuf> {+ std::fs::read_dir(p).map(|r| r.flatten().map(|e| e.path()).filter(|p| p.is_dir()).take(64).collect()).unwrap_or_default()+ };+ for product in list(&std::path::Path::new(&appdata).join("Autodesk")) {+ for release in list(&product) {+ for lang in list(&release) {+ let ps = lang.join("Plotters").join("Plot Styles");+ if ps.is_dir() {+ dirs.push(ps);+ }+ }+ }+ }+ }+ dirs+}++/// Find the table file `name` (case-insensitive) in `dirs` and read it.+#[cfg(not(target_arch = "wasm32"))]+fn find_plot_style(name: &str, dirs: &[std::path::PathBuf]) -> Option<cadcraft_doc::PlotStyleTable> {+ let reader = plot_style_reader()?;+ for dir in dirs {+ let Ok(rd) = std::fs::read_dir(dir) else { continue };+ for e in rd.flatten().take(5000) {+ let p = e.path();+ if p.file_name().is_some_and(|f| f.to_string_lossy().eq_ignore_ascii_case(name.trim()))+ && std::fs::metadata(&p).is_ok_and(|m| m.len() <= cadcraft_doc::MAX_TABLE_FILE as u64)+ && let Ok(bytes) = std::fs::read(&p)+ && let Ok(t) = reader(&bytes, name.trim())+ {+ return Some(t);+ }+ }+ }+ None+}++pub fn set_plot_book(h: PlotBookHook) {+ let _ = PLOT_BOOK.set(h);+}+pub fn plot_book() -> Option<PlotBookHook> {+ PLOT_BOOK.get().copied()+}+ pub fn set_io(h: IoHooks) { let _ = IO.set(h); }@@ -298,6 +373,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> { let ReadFile { drawing, binary_dxf, name, path } = f; let i = add_read(s, drawing, binary_dxf, &name, path); super::blocks::xref::after_open(s, i);+ find_plot_styles(s, i); Ok(opened(s, i, true)) } @@ -350,6 +426,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> { crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?; let i = open_bytes(s, &bytes, path, Some(path.to_string()))?; super::blocks::xref::after_open(s, i);+ find_plot_styles(s, i); return Ok(opened(s, i, true)); } #[cfg(target_arch = "wasm32")]@@ -363,6 +440,52 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> { Ok(opened(s, i, false)) } +/// The plot style tables the drawing's layouts name that it doesn't hold and CADCraft doesn't+/// have built in (`SIVAN.ctb`): read from the drawing's folder or AutoCAD's plot style folders,+/// so the layouts plot with their colours and lineweights.+pub(crate) fn find_plot_styles(s: &mut Session, i: usize) {+ #[cfg(not(target_arch = "wasm32"))]+ {+ let Some(st) = s.docs.get_mut(i) else { return };+ let mut wanted: Vec<String> = Vec::new();+ for l in &st.doc.layouts {+ let n = l.page.plot_style_table.trim();+ if !cadcraft_doc::no_plot_style_table(n)+ && cadcraft_doc::plot_style_table(&st.doc, n).is_none()+ && !wanted.iter().any(|w| w.eq_ignore_ascii_case(n))+ {+ wanted.push(n.to_string());+ }+ }+ if wanted.is_empty() {+ return;+ }+ let mut extra = Vec::new();+ if let Some(dir) = st.path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {+ extra.push(dir.to_path_buf());+ }+ let dirs = plot_style_dirs(extra);+ let found: Vec<cadcraft_doc::PlotStyleTable> = wanted.iter().filter_map(|n| find_plot_style(n, &dirs)).collect();+ let missing: Vec<&String> = wanted.iter().filter(|n| !found.iter().any(|t| t.name.eq_ignore_ascii_case(n))).collect();+ let names: Vec<String> = found.iter().map(|t| t.name.clone()).collect();+ if !found.is_empty() {+ let d = std::sync::Arc::make_mut(&mut st.doc);+ d.plot_style_tables.extend(found);+ st.saved = st.doc.clone();+ }+ for n in names {+ s.echo(format!("Plot style table {n} found and used for plotting."));+ }+ for n in missing {+ s.echo(format!(+ "Plot style table {n} not found: layouts plot with the objects' own colours and lineweights. Put the file next to the drawing or load it in the Plot Style Manager."+ ));+ }+ }+ #[cfg(target_arch = "wasm32")]+ let _ = (s, i);+}+ /// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error. fn run_close(s: &mut Session, p: &Value) -> Result<Value> { let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);@@ -307,7 +307,21 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> { if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 { return Err(bad(cmd, format!("{_path}: the plot style table file is too large"))); }- std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?+ let bytes = std::fs::read(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;+ // AutoCAD's own .ctb/.stb files, read as they are.+ if super::super::file::is_pia(&bytes) {+ let reader = super::super::file::plot_style_reader()+ .ok_or_else(|| bad(cmd, "AutoCAD plot style table files can't be read in this build"))?;+ let file = std::path::Path::new(_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+ let mut t = reader(&bytes, &file).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;+ if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {+ t.name = n.chars().take(255).collect();+ }+ let out = table_summary(s.doc()?, &t);+ store(s.doc_mut()?, t);+ return Ok(out);+ }+ String::from_utf8(bytes).map_err(|_| bad(cmd, format!("{_path}: not a plot style table file")))? } #[cfg(target_arch = "wasm32")] return Err(bad(cmd, "files can't be read here: pass `text`"));@@ -283,6 +283,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> { Space::Paper(l.name.clone()) }; s.pending_window = None;+ let (px_w, px_h) = s.viewport_px; let st = s.state_mut()?; st.space = space.clone(); st.mspace = None;@@ -290,11 +291,17 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> { if let Space::Paper(n) = &space && !st.views.iter().any(|(sp, _)| sp == &space) {- // Default paper view: the sheet.- let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();- let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };- let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };- st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });+ // A layout shown for the first time: the whole sheet fits the screen, with a margin.+ let b = cadcraft_render::sheet(&st.doc, n).map(|sh| sh.bounds()).filter(|b| !b.is_empty() && b.width() > 0.0 && b.height() > 0.0);+ let b = b.unwrap_or_else(|| {+ let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();+ let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };+ let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };+ cadcraft_geom::Bounds2::new(Vec2::ZERO, Vec2::new(w * unit, h * unit))+ });+ let aspect = if px_w > 1.0 && px_h > 1.0 { px_w / px_h } else { 1.5 };+ let height = b.height().max(b.width() / aspect).max(1e-9) * 1.08;+ st.set_view_quiet(View { center: b.center(), height }); } s.touch(); ok()@@ -230,7 +230,13 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef center: t.p(10), width: t.fd(40, 1.0), height: t.fd(41, 1.0),- view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)),+ // The view centre (12/22) is relative to the view target (17/27): AutoCAD often keeps+ // the centre near zero and the target where the drawing is.+ view_center: {+ let target = Vec2::new(t.fd(17, 0.0), t.fd(27, 0.0));+ let target = if target.is_finite() { target } else { Vec2::ZERO };+ Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)) + target+ }, view_height: t.fd(45, 1.0), id: t.i(69).unwrap_or(0) as u32, locked: t.i(90).unwrap_or(0) & 16384 != 0,@@ -235,7 +235,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { continue; }- let center = t.p(10).xy();+ // The centre is relative to the view target (12).+ let target = t.p(12).xy();+ let center = t.p(10).xy() + if target.is_finite() { target } else { Vec2::ZERO }; if !(center.x.is_finite() && center.y.is_finite()) { continue; }@@ -307,7 +309,9 @@ const MAX_VPORT_TILES: usize = 64; /// are fractions of the drawing area; unusable tiles are skipped. fn vport_record(d: &mut Drawing, t: &T) { let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default();- let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy());+ // The view centre (12) is relative to the view target (17).+ let target = t.p(17).xy();+ let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy() + if target.is_finite() { target } else { Vec2::ZERO }); let height = t.fd(40, 0.0); let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit)@@ -13,6 +13,7 @@ mod dxf_table; mod dxf_write; mod dxf_xref; pub mod pdf;+pub mod pia; pub mod plotter; pub mod svg; mod svg_read;@@ -491,6 +491,52 @@ impl Groups { } } +/// Write a vector PDF of sheets from several drawings, one page per `(drawing, space)` in order+/// (at most [`MAX_PAGES`]). Each drawing has its own layers, so none become PDF layers.+pub fn pdf_book(items: &[(&Drawing, Space)], o: &PdfOptions) -> Result<Vec<u8>> {+ if items.is_empty() {+ return Err(IoError::Format("there are no sheets to plot".into()));+ }+ if items.len() > MAX_PAGES {+ return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));+ }+ let mut po = o.clone();+ po.layers = false;+ match write_book(items, &po, po.text_as_geometry) {+ // As for one drawing: a font the writer can't embed plots the text as outlines instead.+ Err(Failure::Write(e)) if !po.text_as_geometry && font_failure(&e) => write_book(items, &po, true).map_err(Failure::into_io),+ r => r.map_err(Failure::into_io),+ }+}++/// [`write_pdf`] for sheets of several drawings, each with its own links; no PDF layers.+fn write_book(items: &[(&Drawing, Space)], o: &PdfOptions, text_as_geometry: bool) -> std::result::Result<Vec<u8>, Failure> {+ write::guard(|| {+ let title = if o.title.is_empty() { items.first().map(|(_, s)| sheet_name(s)).unwrap_or_default() } else { o.title.clone() };+ let metadata = Metadata {+ creator: "CADCraft".into(),+ producer: "CADCraft".into(),+ title: Some(title.chars().take(MAX_TEXT).collect()),+ ..Metadata::default()+ };+ let settings = kr::SerializeSettings {+ compress_content_streams: o.compress,+ xmp_metadata: false,+ enable_tagging: false,+ ..kr::SerializeSettings::default()+ };+ let mut writer = Writer::new(settings, metadata);+ let mut fonts = fonts::Fonts::new(!text_as_geometry);+ let groups = Groups::default();+ for (d, s) in items {+ let links = all_links(d, o);+ let page = sheet(d, s, o, &links)?;+ write_page(writer.document_mut(), &page, &groups, &mut fonts)?;+ }+ Ok(writer.finish()?)+ })+}+ /// One plotted sheet, ready to be written as a page. struct SheetPage { /// Page size in points.new file mode 100644@@ -0,0 +1,265 @@+//! Plot style table files as AutoCAD writes them (`.ctb` colour-dependent, `.stb` named): a+//! `PIAFILEVERSION_2.0,CTBVER1,compress` header line, `pmzlibcodec`, two 32-bit words (a check+//! value and the text length), the compressed text length, then zlib-compressed text of nested+//! `key=value` and `name{ … }` blocks. Read into CADCraft's [`PlotStyleTable`] so a drawing whose+//! layouts name the user's own table plots with its colours and lineweights.++use cadcraft_color::Rgb;+use cadcraft_doc::{EndStyle, JoinStyle, PlotStyle, PlotStyleKind, PlotStyleTable};++/// Largest file and decompressed text we accept.+const MAX_FILE: usize = 4 << 20;+const MAX_TEXT: usize = 16 << 20;+/// "Use the object's colour" values of a style's `color`.+const OBJECT_COLOR: i64 = -1;+const OBJECT_COLOR2: i64 = -1_006_632_961;++/// Whether `bytes` look like an AutoCAD plot style table file.+pub fn is_pia(bytes: &[u8]) -> bool {+ bytes.starts_with(b"PIAFILEVERSION")+}++/// A node of the text: a value or a block of children, in file order.+enum Node {+ Value(String),+ Block(Vec<(String, Node)>),+}++/// The decompressed text of a PIA file.+fn text(bytes: &[u8]) -> Result<String, String> {+ if bytes.len() > MAX_FILE {+ return Err("the plot style table file is too large".into());+ }+ if !is_pia(bytes) {+ return Err("not a plot style table file".into());+ }+ let marker = b"pmzlibcodec";+ let at = bytes.windows(marker.len()).position(|w| w == marker).ok_or("the plot style table is not compressed as expected")?;+ let data = bytes.get(at + marker.len() + 12..).ok_or("the plot style table file is cut short")?;+ let out = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, MAX_TEXT)+ .map_err(|_| "the plot style table can't be decompressed".to_string())?;+ Ok(String::from_utf8_lossy(&out).into_owned())+}++/// Parse the nested text. Lines are `key=value` (a value starting with `"` runs to the end of+/// the line, without a closing quote), `key{` opening a block and `}` closing it.+fn parse(text: &str) -> Vec<(String, Node)> {+ let mut stack: Vec<(String, Vec<(String, Node)>)> = vec![(String::new(), Vec::new())];+ for line in text.lines().take(200_000) {+ let l = line.trim();+ if l.is_empty() {+ continue;+ }+ if l == "}" {+ if stack.len() > 1+ && let Some((name, kids)) = stack.pop()+ && let Some(top) = stack.last_mut()+ {+ top.1.push((name, Node::Block(kids)));+ }+ continue;+ }+ if let Some(name) = l.strip_suffix('{') {+ if stack.len() < 8 {+ stack.push((name.trim().to_string(), Vec::new()));+ }+ continue;+ }+ if let Some((k, v)) = l.split_once('=')+ && let Some(top) = stack.last_mut()+ {+ let v = v.strip_prefix('"').unwrap_or(v);+ top.1.push((k.trim().to_string(), Node::Value(v.to_string())));+ }+ }+ while stack.len() > 1 {+ if let Some((name, kids)) = stack.pop()+ && let Some(top) = stack.last_mut()+ {+ top.1.push((name, Node::Block(kids)));+ }+ }+ stack.pop().map(|(_, k)| k).unwrap_or_default()+}++fn value<'a>(nodes: &'a [(String, Node)], key: &str) -> Option<&'a str> {+ nodes.iter().find_map(|(k, n)| match n {+ Node::Value(v) if k == key => Some(v.as_str()),+ _ => None,+ })+}++fn block<'a>(nodes: &'a [(String, Node)], key: &str) -> Option<&'a [(String, Node)]> {+ nodes.iter().find_map(|(k, n)| match n {+ Node::Block(b) if k == key => Some(b.as_slice()),+ _ => None,+ })+}++fn int(nodes: &[(String, Node)], key: &str) -> Option<i64> {+ value(nodes, key).and_then(|v| v.trim().parse().ok())+}++/// One style: its colour (object or RGB, grey, screened), lineweight (`0` = the object's, else+/// an index into the file's lineweight table, in millimetres), ends and joins.+fn style(nodes: &[(String, Node)], weights: &[f64]) -> PlotStyle {+ let name = value(nodes, "name").unwrap_or("").to_string();+ let mut s = PlotStyle::object(&name);+ s.description = value(nodes, "description").unwrap_or("").to_string();+ let policy = int(nodes, "color_policy").unwrap_or(1);+ if let Some(c) = int(nodes, "color").or_else(|| int(nodes, "mode_color"))+ && c != OBJECT_COLOR+ && c != OBJECT_COLOR2+ {+ let v = c as u32;+ s.color = Some(Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8));+ }+ s.grayscale = policy & 2 != 0;+ s.screening = int(nodes, "screen").map_or(100, |v| v.clamp(0, 100) as u8);+ s.lineweight = match int(nodes, "lineweight") {+ Some(i) if i > 0 => usize::try_from(i).ok().and_then(|i| weights.get(i)).map(|mm| (mm * 100.0).round().clamp(0.0, 211.0) as u16),+ _ => None,+ };+ s.end = match int(nodes, "end_style") {+ Some(0) => EndStyle::Butt,+ Some(1) => EndStyle::Square,+ Some(2) => EndStyle::Round,+ Some(3) => EndStyle::Diamond,+ _ => EndStyle::Object,+ };+ s.join = match int(nodes, "join_style") {+ Some(0) => JoinStyle::Miter,+ Some(1) => JoinStyle::Bevel,+ Some(2) => JoinStyle::Round,+ Some(3) => JoinStyle::Diamond,+ _ => JoinStyle::Object,+ };+ s+}++/// Read a `.ctb` or `.stb` file into a table named `name` (the file's name, as page setups+/// name it).+pub fn read(bytes: &[u8], name: &str) -> Result<PlotStyleTable, String> {+ let t = text(bytes)?;+ let nodes = parse(&t);+ // A named table says it has no colour (ACI) table; both kinds share the CTBVER1 header.+ let named = match value(&nodes, "aci_table_available") {+ Some(v) => v.trim().eq_ignore_ascii_case("FALSE"),+ None => bytes.get(..60).is_some_and(|h| h.windows(6).any(|w| w == b"STBVER")),+ };+ // Lineweights are stored in millimetres; `custom_lineweight_display_units` only says how the+ // plot style editor shows them.+ let mut weights: Vec<(usize, f64)> = block(&nodes, "custom_lineweight_table")+ .unwrap_or(&[])+ .iter()+ .filter_map(|(k, n)| match n {+ Node::Value(v) => Some((k.parse().ok()?, v.trim().parse::<f64>().ok()?)),+ Node::Block(_) => None,+ })+ .filter(|(_, v)| v.is_finite() && *v >= 0.0)+ .collect();+ weights.sort_by_key(|(i, _)| *i);+ let weights: Vec<f64> = weights.into_iter().map(|(_, v)| v).collect();+ let mut styles: Vec<(usize, PlotStyle)> = block(&nodes, "plot_style")+ .unwrap_or(&[])+ .iter()+ .filter_map(|(k, n)| match n {+ Node::Block(b) => Some((k.parse().ok()?, style(b, &weights))),+ Node::Value(_) => None,+ })+ .collect();+ styles.sort_by_key(|(i, _)| *i);+ if styles.is_empty() {+ return Err("the plot style table has no styles".into());+ }+ let table = PlotStyleTable {+ name: name.to_string(),+ description: value(&nodes, "description").unwrap_or("").to_string(),+ kind: if named { PlotStyleKind::Named } else { PlotStyleKind::ColorDependent },+ styles: styles.into_iter().map(|(_, s)| s).collect(),+ };+ Ok(table.sanitized())+}++#[cfg(test)]+mod tests {+ /// A small table in the file format, made here (a colour-dependent table whose colour 1 is red+ /// at 0.35 mm and colour 2 black at the object's lineweight).+ fn file() -> Vec<u8> {+ made(true, 0)+ }++ /// The test table: colour-dependent (`aci`) or named, lineweights shown in millimetres (0) or+ /// inches (1). Style 0 plots at lineweight index 10, 0.35 mm.+ fn made(aci: bool, units: u8) -> Vec<u8> {+ let mut text = format!(+ "description=\"Test\naci_table_available={}\nscale_factor=1.0\napply_factor=FALSE\ncustom_lineweight_display_units={units}\nplot_style{{\n",+ if aci { "TRUE" } else { "FALSE" }+ );+ let count = if aci { 255 } else { 3 };+ for i in 0..count {+ let (color, lw) = match i {+ 0 => ("-1006698496", 10),+ 1 => ("-16777216", 0),+ _ => ("-1", 0),+ };+ let name = if aci { format!("Color_{}", i + 1) } else { ["Normal", "Walls", "Hidden"].get(i).copied().unwrap_or("Style").to_string() };+ text.push_str(&format!(+ " {i}{{\n name=\"{name}\n color={color}\n color_policy=5\n screen=100\n linetype=31\n lineweight={lw}\n end_style=4\n join_style=5\n }}\n"+ ));+ }+ text.push_str("}\ncustom_lineweight_table{\n");+ for (i, w) in [0.0, 0.05, 0.09, 0.1, 0.13, 0.15, 0.18, 0.2, 0.25, 0.3, 0.35, 0.4].iter().enumerate() {+ text.push_str(&format!(" {i}={w}\n"));+ }+ text.push_str("}\n");+ let z = miniz_oxide::deflate::compress_to_vec_zlib(text.as_bytes(), 6);+ let mut out = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec".to_vec();+ out.extend_from_slice(&0u32.to_le_bytes());+ out.extend_from_slice(&(text.len() as u32).to_le_bytes());+ out.extend_from_slice(&(z.len() as u32).to_le_bytes());+ out.extend_from_slice(&z);+ out+ }++ #[test]+ fn a_color_table_file_gives_colours_and_lineweights() {+ let t = super::read(&file(), "Mine.ctb").unwrap();+ assert!(t.is_color_dependent());+ let red = t.by_color(1).unwrap();+ assert_eq!(red.lineweight, Some(35));+ assert_eq!(red.color.map(|c| (c.0, c.1, c.2)), Some((255, 0, 0)));+ let black = t.by_color(2).unwrap();+ assert_eq!(black.color.map(|c| (c.0, c.1, c.2)), Some((0, 0, 0)));+ assert_eq!(black.lineweight, None);+ assert!(t.by_color(3).unwrap().color.is_none());+ }++ /// A named table has the same CTBVER1 header as a colour one; it says `aci_table_available=FALSE`.+ /// Its style names are kept, and a lineweight index reads the table at that index.+ #[test]+ fn a_named_table_is_read_as_named_with_its_style_names() {+ let t = super::read(&made(false, 0), "Mine.stb").unwrap();+ assert!(!t.is_color_dependent());+ let names: Vec<&str> = t.styles.iter().map(|s| s.name.as_str()).collect();+ assert!(names.contains(&"Walls") && names.contains(&"Hidden"), "{names:?}");+ let normal = t.styles.iter().find(|s| s.name == "Normal").unwrap();+ assert_eq!(normal.lineweight, Some(35), "index 10 is 0.35 mm, not the one before it");+ }++ /// Lineweights shown in inches are still stored in millimetres.+ #[test]+ fn inch_display_units_do_not_scale_stored_lineweights() {+ let t = super::read(&made(true, 1), "Inch.ctb").unwrap();+ assert_eq!(t.by_color(1).unwrap().lineweight, Some(35));+ }++ #[test]+ fn hostile_files_are_refused_not_panicked_on() {+ assert!(super::read(b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec\x01\x02", "x.ctb").is_err());+ assert!(super::read(b"hello", "x.ctb").is_err());+ let mut f = file();+ f.truncate(f.len() / 2);+ assert!(super::read(&f, "x.ctb").is_err());+ }+}@@ -50,6 +50,11 @@ pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> { } } +/// One PDF of sheets from several drawings ([`pdf::pdf_book`]), with the file-wide options of `opts`.+pub fn plot_book(items: &[(&Drawing, Space)], opts: &Value) -> Result<Vec<u8>> {+ pdf::pdf_book(items, &PdfOptions::from_json(opts))+}+ /// The sheet a plot draws on (a model-space plot's printable area inside the device margins). fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<(Sheet, cadcraft_doc::PageSetup)> { let page = pdf::page_for(d, space, o)?;@@ -40,3 +40,36 @@ fn viewport_with_a_layer_named_paperview_survives_dxf_roundtrip() { assert_eq!(vps[0].frozen_layers, ["PAPERVIEW"]); assert_eq!(vps[1].layer_colors, [("PAPERVIEW".to_string(), Color::Index(1))]); }++#[test]+fn a_viewport_looks_at_its_view_target_plus_its_view_centre() {+ // AutoCAD often writes the view centre (12/22) near zero and the view target (17/27) where+ // the drawing is: the model point at the viewport's middle is their sum.+ let mut d = Drawing::new_metric();+ let sheet = Space::Paper(d.layouts[0].name.clone());+ d.add(&sheet, Common::default(), viewport(2, vec![], vec![])).unwrap();+ let text = cadcraft_io::write_dxf(&d);+ // The viewport's view centre 10,10 becomes 2,3 with the target at 8,7: rewrite the group+ // values after "12" and "22" in the viewport whose centre (10) is 50.+ let lines: Vec<&str> = text.lines().collect();+ let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();+ let start = lines.windows(2).position(|w| w[0].trim() == "10" && w[1].trim().parse::<f64>().ok() == Some(50.0)).expect("the viewport");+ let mut done = false;+ let mut i = start;+ while i + 1 < lines.len() && !done {+ if lines[i].trim() == "12" {+ out[i + 1] = "2.0".into();+ }+ if lines[i].trim() == "22" {+ out[i + 1] = "3.0".into();+ out[i + 1].push_str("\n 17\n8.0\n 27\n7.0");+ done = true;+ }+ i += 2;+ }+ assert!(done, "the writer's view centre groups");+ let edited = out.join("\n");+ let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();+ let v = viewports(&back.layouts[0]).into_iter().find(|v| v.id == 2).unwrap();+ assert!((v.view_center.x - 10.0).abs() < 1e-9 && (v.view_center.y - 10.0).abs() < 1e-9, "{:?}", v.view_center);+}@@ -106,6 +106,23 @@ fn layout_page_setup_and_paper_view_round_trip() { assert_eq!(r.layouts.len(), d.layouts.len()); } +#[test]+fn a_layout_in_millimetres_in_an_inch_drawing_is_drawn_in_millimetres() {+ // As AutoCAD writes it: MEASUREMENT 0 (inches) but the layout's plot paper units are mm, so+ // paper space is in millimetres and an A3 sheet is 420 × 297 units, not 16.5 × 11.7.+ let mut d = Drawing::new_imperial();+ let l = d.layouts.get_mut(0).expect("Layout1");+ l.page.width_mm = 297.0;+ l.page.height_mm = 420.0;+ l.page.margins_mm = [0.0; 4];+ l.page.paper_mm = true;+ let r = round_trip(&d);+ let page = &r.layouts.first().expect("Layout1").page;+ assert!(page.paper_mm);+ let sheet = cadcraft_render::paper::Sheet::from_page(page, cadcraft_render::paper::page_unit_mm(&r, page));+ assert!((sheet.size.x - 420.0).abs() < 1e-9 && (sheet.size.y - 297.0).abs() < 1e-9, "{:?}", sheet.size);+}+ #[test] fn named_views_ucss_and_groups_round_trip() { let mut d = Drawing::new_imperial();@@ -257,3 +274,26 @@ fn foreign_and_hostile_tables_objects() { assert_eq!(again.groups, d.groups); assert_eq!(again.views, d.views); }++#[test]+fn a_millimetre_layout_of_an_inch_drawing_plots_on_a_page_its_own_size() {+ // The PDF page of an A3 layout set up in millimetres is A3 (1190.55 × 841.89 pt), not 25.4+ // times larger, so a printed line keeps its lineweight instead of shrinking 25.4 times.+ let mut d = Drawing::new_imperial();+ let name = {+ let l = d.layouts.get_mut(0).expect("Layout1");+ l.page.width_mm = 297.0;+ l.page.height_mm = 420.0;+ l.page.landscape = true;+ l.page.margins_mm = [0.0; 4];+ l.page.paper_mm = true;+ l.name.clone()+ };+ d.add(&Space::Paper(name.clone()), Common::default(), EntityKind::Line(Line { a: Vec3::new(10.0, 10.0, 0.0), b: Vec3::new(400.0, 280.0, 0.0) }))+ .expect("line");+ let pdf = cadcraft_io::pdf::pdf(&d, &Space::Paper(name), &cadcraft_io::PdfOptions::default()).expect("pdf");+ let doc = crate::pdf_read::read(&pdf);+ let m = doc.pages.first().expect("a page").media;+ let (w, h) = (m[2] - m[0], m[3] - m[1]);+ assert!((w - 1190.55).abs() < 1.0 && (h - 841.89).abs() < 1.0, "{w} x {h}");+}@@ -109,7 +109,7 @@ impl Sheet { pub fn bounds(&self) -> Bounds2 { Bounds2::new(Vec2::ZERO, self.size) }- /// The sheet for a page setup in a drawing whose paper units are `unit_mm` millimetres.+ /// The sheet for a page setup whose paper units are `unit_mm` millimetres ([`page_unit_mm`]). pub fn from_page(page: &PageSetup, unit_mm: f64) -> Sheet { let sane = |v: f64, d: f64| if v.is_finite() && v > 0.0 { v.min(100_000.0) } else { d }; let w = sane(page.width_mm, 215.9);new file mode 100644@@ -0,0 +1,609 @@+//! Batch Plot: a print manager. The sheets to plot in a list that can be reordered (drag, or the+//! Move buttons), with sheets repeated or removed; sheets of any open drawing can be added; the+//! result is one PDF with every sheet or one PDF per sheet (PUBLISH with `sheets`).++use egui::{Rect, RichText, Sense, Stroke, pos2, vec2};+use serde_json::{Value, json};++use crate::CadApp;+use crate::icons::{self, Icon};+use crate::theme::Tokens;++/// One sheet in the list: the drawing it belongs to and the layout (or `Model`). The drawing is+/// known by its identity ([`cadcraft_engine::DocState::uid`]), not its title: two open files can+/// both be `plan.dwg`.+#[derive(Clone, Debug, PartialEq)]+pub struct Sheet {+ pub doc: u64,+ /// The drawing's title, for the list.+ pub drawing: String,+ pub layout: String,+}++/// The window's state between frames.+#[derive(Clone, Default)]+struct State {+ sheets: Vec<Sheet>,+ /// The drawing the list was filled from when the window opened.+ filled_for: Option<u64>,+ /// The picked rows (Ctrl and Shift pick several).+ selected: Vec<usize>,+ /// Where Shift+click extends from.+ anchor: Option<usize>,+ /// The picked rows are being dragged.+ dragging: bool,+ /// Where a selection rectangle started (in the list's empty space).+ band_from: Option<egui::Pos2>,+ /// The drawing whose sheets the Add section shows.+ add_from: Option<u64>,+ separate: bool,+ note: Option<String>,+}++fn state_id() -> egui::Id {+ egui::Id::unique("cc_batch_plot")+}++/// The sheets of drawing `d` in tab order: Model when it has objects, then the layouts.+pub fn sheets_of(d: &cadcraft_doc::Drawing, doc: u64, title: &str) -> Vec<Sheet> {+ let mut layouts: Vec<_> = d.layouts.iter().collect();+ layouts.sort_by_key(|l| l.tab_order);+ let model = (!d.model.is_empty()).then(|| "Model".to_string());+ model.into_iter().chain(layouts.iter().map(|l| l.name.clone())).map(|layout| Sheet { doc, drawing: title.to_string(), layout }).collect()+}++/// Move the sheet at `from` to stand before `slot` (0..=len).+pub fn move_sheet(v: &mut Vec<Sheet>, from: usize, slot: usize) -> Option<usize> {+ if from >= v.len() {+ return None;+ }+ let s = v.remove(from);+ let to = if slot > from { slot - 1 } else { slot }.min(v.len());+ v.insert(to, s);+ Some(to)+}++/// The PUBLISH parameters for `sheets`, each resolved to the open drawing it came from (`docs`:+/// the open drawings' identities in order); an error names a sheet whose drawing was closed.+pub fn publish_params(docs: &[u64], sheets: &[Sheet], combine: bool, path: Option<&str>) -> Result<Value, String> {+ let list: Vec<Value> = sheets+ .iter()+ .map(|s| {+ let i = docs.iter().position(|d| *d == s.doc).ok_or_else(|| format!("{}: {}", crate::tl!("The drawing is closed"), s.drawing))?;+ Ok(json!({ "drawing": i, "layout": s.layout }))+ })+ .collect::<Result<_, String>>()?;+ let mut p = json!({ "sheets": list, "combine": combine });+ if let Some(path) = path {+ p["path"] = json!(path);+ }+ Ok(p)+}++/// Move the sheets at `picked` (any order) as one block to stand before `slot` (0..=len), keeping+/// their order; the indices they end up at.+pub fn move_sheets(v: &mut Vec<Sheet>, picked: &[usize], slot: usize) -> Vec<usize> {+ let mut idx: Vec<usize> = picked.iter().copied().filter(|i| *i < v.len()).collect();+ idx.sort_unstable();+ idx.dedup();+ if idx.is_empty() {+ return Vec::new();+ }+ let before = idx.iter().filter(|i| **i < slot).count();+ let moved: Vec<Sheet> = idx.iter().filter_map(|i| v.get(*i).cloned()).collect();+ for i in idx.iter().rev() {+ v.remove(*i);+ }+ let at = slot.saturating_sub(before).min(v.len());+ for (k, sh) in moved.into_iter().enumerate() {+ v.insert(at + k, sh);+ }+ (at..at + idx.len()).collect()+}++/// What a click asked for, applied once the list is drawn.+enum Act {+ /// A click on row `i`: with Ctrl it toggles, with Shift it extends from the anchor.+ Click(usize, egui::Modifiers),+ /// A rubber-band rectangle picked these rows (added to the selection with Ctrl).+ Band(Vec<usize>, bool),+ SelectAll,+ /// The selected rows to stand before this slot.+ MoveTo(usize),+ Up,+ Down,+ Duplicate,+ Remove,+ Add(Sheet),+ AddAll(u64),+ Clear,+ Plot,+}++/// File ▸ Batch Plot: the print manager window. While it is open the rest of the program waits.+pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {+ let mut st = ctx.data(|d| d.get_temp::<State>(state_id())).unwrap_or_default();+ let current = app.session.state().map(|s| s.title.clone()).unwrap_or_default();+ let current_doc = app.session.state().map_or(0, |s| s.uid);+ // Opened for a drawing: its sheets, in tab order (others are added by hand).+ if st.filled_for != Some(current_doc) {+ st.sheets = app.session.doc().map(|d| sheets_of(d, current_doc, ¤t)).unwrap_or_default();+ st.filled_for = Some(current_doc);+ st.selected.clear();+ st.add_from = Some(current_doc);+ st.note = None;+ }+ st.selected.retain(|i| *i < st.sheets.len());+ let uids: Vec<u64> = app.session.docs.iter().map(|d| d.uid).collect();+ // The open drawings as the Add list names them: the folder too where two share a title.+ let titles: Vec<(u64, String)> = app+ .session+ .docs+ .iter()+ .map(|d| {+ let twin = app.session.docs.iter().filter(|o| o.title == d.title).count() > 1;+ let folder = d.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|f| f.display().to_string());+ let name = match folder {+ Some(f) if twin => format!("{} — {f}", d.title),+ _ => d.title.clone(),+ };+ (d.uid, name)+ })+ .collect();+ let paper = |s: &Sheet| -> String {+ let Some(d) = app.session.docs.iter().find(|d| d.uid == s.doc) else { return crate::tl!("closed").to_string() };+ if s.layout == "Model" {+ return String::new();+ }+ d.doc.layout(&s.layout).map(|l| l.page.paper.clone()).unwrap_or_default()+ };+ let rows: Vec<(String, String, String)> = st.sheets.iter().map(|s| (s.layout.clone(), s.drawing.clone(), paper(s))).collect();+ let can_plot = app.services.pick_save.is_some();+ let mut act: Option<Act> = None;+ let mut close = false;+ // Modal: a dimmed backdrop over the whole program takes every click outside the window.+ let screen = ctx.content_rect();+ egui::Area::new(egui::Id::unique("cc_batch_plot_backdrop")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {+ let (r, _) = ui.allocate_exact_size(screen.size(), Sense::click_and_drag());+ ui.painter().rect_filled(r, 0.0, egui::Color32::from_black_alpha(110));+ });+ egui::Window::new(crate::tl!("Batch Plot"))+ .id(egui::Id::unique("cc_batch_plot_window"))+ .order(egui::Order::Foreground)+ .open(open)+ .default_size(vec2(700.0, 520.0))+ .min_size(vec2(520.0, 380.0))+ .resizable(true)+ .show(ctx, |ui| {+ let t = Tokens::get();+ ui.label(RichText::new(crate::tl!("Sheets to plot, in order")).strong());+ ui.horizontal_top(|ui| {+ let list_w = (ui.available_width() - 190.0).max(260.0);+ let list_h = (ui.available_height() - 190.0).max(140.0);+ ui.allocate_ui_with_layout(vec2(list_w, list_h), egui::Layout::top_down(egui::Align::LEFT), |ui| {+ egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).inner_margin(2.0).show(ui, |ui| {+ let w = list_w - 10.0;+ // Columns: number, sheet, drawing, paper, with a separator between each.+ let cols = [34.0_f32, (w - 34.0) * 0.36, (w - 34.0) * 0.34, (w - 34.0) * 0.30];+ let col_x = |k: usize| cols.iter().take(k).sum::<f32>();+ let (hr, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());+ ui.painter().rect_filled(hr, 0.0, t.chrome_dark);+ for (k, title) in ["#", crate::tl!("Sheet"), crate::tl!("Drawing"), crate::tl!("Paper")].into_iter().enumerate() {+ let cell = Rect::from_min_size(pos2(hr.left() + col_x(k), hr.top()), vec2(cols[k], hr.height()));+ ui.painter().with_clip_rect(cell.shrink2(vec2(3.0, 0.0))).text(+ pos2(cell.left() + 5.0, cell.center().y),+ egui::Align2::LEFT_CENTER,+ title,+ crate::theme::small(),+ t.text_dim,+ );+ if k > 0 {+ ui.painter().vline(cell.left(), hr.y_range(), Stroke::new(1.0, t.border));+ }+ }+ let body_h = list_h - 30.0;+ egui::ScrollArea::vertical().id_salt("cc_batch_list").auto_shrink([false, false]).max_height(body_h).show(ui, |ui| {+ ui.set_width(w);+ let mut rects: Vec<Rect> = Vec::new();+ for (i, (layout, drawing, paper)) in rows.iter().enumerate() {+ let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click_and_drag());+ rects.push(r);+ let on = st.selected.contains(&i);+ if on {+ ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(0.35));+ } else if resp.hovered() {+ ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.6));+ }+ let y = r.center().y;+ let cell = |k: usize| Rect::from_min_size(pos2(r.left() + col_x(k), r.top()), vec2(cols[k], r.height()));+ // A grip that shows rows can be dragged, and the number.+ let c0 = cell(0);+ for dy in [-4.0, 0.0, 4.0] {+ ui.painter().hline((c0.left() + 3.0)..=(c0.left() + 9.0), y + dy, Stroke::new(1.0, t.text_faint));+ }+ ui.painter().with_clip_rect(c0).text(+ pos2(c0.left() + 12.0, y),+ egui::Align2::LEFT_CENTER,+ format!("{}", i + 1),+ crate::theme::small(),+ t.text_dim,+ );+ let c1 = cell(1);+ ui.painter().with_clip_rect(c1.shrink2(vec2(3.0, 0.0))).text(+ pos2(c1.left() + 5.0, y),+ egui::Align2::LEFT_CENTER,+ crate::i18n::t(layout),+ crate::theme::body(),+ t.text,+ );+ // Hebrew names read right to left, as in the file manager.+ let c2 = cell(2);+ let job = crate::i18n::job(drawing, crate::theme::small(), t.text_dim);+ let g = ui.ctx().fonts_mut(|f| f.layout_job(job));+ ui.painter().with_clip_rect(c2.shrink2(vec2(3.0, 0.0))).galley(+ pos2(c2.left() + 5.0, y - g.size().y / 2.0),+ g,+ t.text_dim,+ );+ let c3 = cell(3);+ ui.painter().with_clip_rect(c3.shrink2(vec2(3.0, 0.0))).text(+ pos2(c3.left() + 5.0, y),+ egui::Align2::LEFT_CENTER,+ paper,+ crate::theme::small(),+ t.text_dim,+ );+ for k in 1..4 {+ ui.painter().vline(cell(k).left(), r.y_range(), Stroke::new(1.0, t.border.gamma_multiply(0.6)));+ }+ if resp.clicked() {+ act = Some(Act::Click(i, ui.input(|inp| inp.modifiers)));+ }+ if resp.drag_started() {+ // Dragging an unselected row drags it alone.+ if !on {+ act = Some(Act::Click(i, egui::Modifiers::NONE));+ }+ st.dragging = true;+ }+ resp.context_menu(|ui| {+ if !on {+ act = Some(Act::Click(i, egui::Modifiers::NONE));+ }+ if ui.button(crate::tl!("Duplicate")).clicked() {+ act = Some(Act::Duplicate);+ ui.close();+ }+ if ui.button(crate::tl!("Delete")).clicked() {+ act = Some(Act::Remove);+ ui.close();+ }+ });+ }+ if rows.is_empty() {+ ui.label(RichText::new(crate::tl!("No sheets. Add sheets below.")).color(t.text_dim));+ }+ // The space below the rows: a drag there draws a selection rectangle.+ let rest = ui.available_size().max(vec2(w, 30.0));+ let (blank, bresp) = ui.allocate_exact_size(vec2(w, rest.y), Sense::click_and_drag());+ if bresp.clicked() && !ui.input(|i| i.modifiers.command || i.modifiers.shift) {+ act = Some(Act::Band(Vec::new(), false));+ }+ if bresp.drag_started() {+ st.band_from = bresp.interact_pointer_pos();+ }+ if let (Some(from), Some(to)) = (st.band_from, ui.ctx().pointer_latest_pos()) {+ let band = Rect::from_two_pos(from, to);+ ui.painter().rect_filled(band, 0.0, t.accent.gamma_multiply(0.15));+ ui.painter().rect_stroke(band, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);+ let hit: Vec<usize> = rects.iter().enumerate().filter(|(_, r)| r.intersects(band)).map(|(i, _)| i).collect();+ if ui.input(|i| i.pointer.any_released()) {+ act = Some(Act::Band(hit, ui.input(|i| i.modifiers.command)));+ st.band_from = None;+ }+ }+ let _ = blank;+ // Dragging rows: a line where they will land.+ if st.dragging {+ let pointer = ui.ctx().pointer_latest_pos();+ let slot = pointer.map(|p| rects.iter().position(|r| p.y < r.center().y).unwrap_or(rects.len()));+ if let Some(slot) = slot {+ let y = rects.get(slot).map_or_else(|| rects.last().map_or(0.0, |r| r.bottom()), |r| r.top());+ if let Some(first) = rects.first() {+ ui.painter().hline(first.left()..=first.right(), y, Stroke::new(2.0, t.accent));+ }+ }+ ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);+ if ui.input(|i| i.pointer.any_released()) {+ if let Some(slot) = slot {+ act = Some(Act::MoveTo(slot));+ }+ st.dragging = false;+ }+ }+ });+ });+ });+ // The order and list buttons.+ ui.vertical(|ui| {+ ui.set_width(180.0);+ let any = !st.selected.is_empty();+ let n = st.sheets.len();+ let first = st.selected.iter().min().copied().unwrap_or(0);+ let last = st.selected.iter().max().copied().unwrap_or(0);+ let row = |ui: &mut egui::Ui, icon: Icon, label: &str, enabled: bool| -> bool {+ ui.add_enabled_ui(enabled, |ui| {+ ui.horizontal(|ui| {+ let r = ui.add_sized(vec2(176.0, 26.0), egui::Button::new(""));+ let ir = Rect::from_center_size(pos2(r.rect.left() + 15.0, r.rect.center().y), vec2(18.0, 18.0));+ icons::paint(ui.painter(), ir, icon, !enabled);+ let col = if enabled { Tokens::get().text } else { Tokens::get().text_faint };+ ui.painter().text(+ pos2(r.rect.left() + 30.0, r.rect.center().y),+ egui::Align2::LEFT_CENTER,+ label,+ crate::theme::body(),+ col,+ );+ r.clicked()+ })+ .inner+ })+ .inner+ };+ if row(ui, Icon::MoveTop, crate::tl!("Move to Top"), any && first > 0) {+ act = Some(Act::MoveTo(0));+ }+ if row(ui, Icon::MoveUp, crate::tl!("Move Higher"), any && first > 0) {+ act = Some(Act::Up);+ }+ if row(ui, Icon::MoveDown, crate::tl!("Move Lower"), any && last + 1 < n) {+ act = Some(Act::Down);+ }+ if row(ui, Icon::MoveBottom, crate::tl!("Move to Bottom"), any && last + 1 < n) {+ act = Some(Act::MoveTo(n));+ }+ ui.add_space(6.0);+ if row(ui, Icon::Copy, crate::tl!("Duplicate"), any) {+ act = Some(Act::Duplicate);+ }+ if row(ui, Icon::Erase, crate::tl!("Delete"), any) {+ act = Some(Act::Remove);+ }+ if row(ui, Icon::Close, crate::tl!("Clear List"), n > 0) {+ act = Some(Act::Clear);+ }+ ui.add_space(4.0);+ ui.label(+ RichText::new(crate::tl!("Ctrl and Shift pick several sheets; drag in the empty space to pick with a rectangle."))+ .small()+ .color(t.text_dim),+ );+ });+ });+ ui.add_space(6.0);+ ui.separator();+ // Adding sheets: from any open drawing.+ ui.horizontal(|ui| {+ ui.label(crate::tl!("Add sheets from"));+ let from = st.add_from.filter(|f| uids.contains(f)).unwrap_or(current_doc);+ let from_name = titles.iter().find(|(u, _)| *u == from).map(|(_, n)| n.clone()).unwrap_or_default();+ let shown = crate::i18n::widget(ui, &from_name, egui::TextStyle::Body);+ egui::ComboBox::from_id_salt("cc_batch_from").selected_text(shown).width(220.0).show_ui(ui, |ui| {+ for (uid, title) in &titles {+ let name = crate::i18n::widget(ui, title, egui::TextStyle::Body);+ if ui.selectable_label(*uid == from, name).clicked() {+ st.add_from = Some(*uid);+ }+ }+ });+ if ui.button(crate::tl!("Add All")).clicked() {+ act = Some(Act::AddAll(from));+ }+ });+ let from = st.add_from.filter(|f| uids.contains(f)).unwrap_or(current_doc);+ let available = app.session.docs.iter().find(|d| d.uid == from).map(|d| sheets_of(&d.doc, d.uid, &d.title)).unwrap_or_default();+ egui::ScrollArea::horizontal().id_salt("cc_batch_add").max_height(34.0).show(ui, |ui| {+ ui.horizontal(|ui| {+ for s in available {+ if ui+ .button(format!("+ {}", crate::i18n::t(&s.layout)))+ .on_hover_text(crate::tl!("Add this sheet to the end of the list"))+ .clicked()+ {+ act = Some(Act::Add(s));+ }+ }+ });+ });+ ui.separator();+ ui.horizontal(|ui| {+ ui.radio_value(&mut st.separate, false, crate::tl!("One PDF file with all sheets"));+ ui.radio_value(&mut st.separate, true, crate::tl!("A separate PDF file for each sheet"));+ });+ if let Some(n) = &st.note {+ ui.label(RichText::new(n).color(t.text_dim));+ }+ ui.add_space(4.0);+ ui.horizontal(|ui| {+ let enabled = can_plot && !st.sheets.is_empty();+ let r = ui.add_enabled(enabled, egui::Button::new(crate::tl!("Plot...")));+ let r = if can_plot { r } else { r.on_disabled_hover_text(crate::tl!("There is no file dialog here")) };+ if r.clicked() {+ act = Some(Act::Plot);+ }+ if ui.button(crate::tl!("Close")).clicked() {+ close = true;+ }+ ui.label(RichText::new(format!("{} {}", st.sheets.len(), crate::tl!("sheets"))).color(t.text_dim));+ });+ // Ctrl+A picks every sheet.+ if ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::A)) {+ act = Some(Act::SelectAll);+ }+ });+ let n = st.sheets.len();+ match act {+ Some(Act::Click(i, m)) => {+ if m.shift {+ let a = st.anchor.unwrap_or(i).min(n.saturating_sub(1));+ let (lo, hi) = (a.min(i), a.max(i));+ if !m.command {+ st.selected.clear();+ }+ for k in lo..=hi {+ if !st.selected.contains(&k) {+ st.selected.push(k);+ }+ }+ } else if m.command {+ if let Some(p) = st.selected.iter().position(|k| *k == i) {+ st.selected.remove(p);+ } else {+ st.selected.push(i);+ }+ st.anchor = Some(i);+ } else {+ st.selected = vec![i];+ st.anchor = Some(i);+ }+ }+ Some(Act::Band(hit, add)) => {+ if !add {+ st.selected.clear();+ }+ for k in hit {+ if !st.selected.contains(&k) {+ st.selected.push(k);+ }+ }+ st.anchor = st.selected.first().copied();+ }+ Some(Act::SelectAll) => st.selected = (0..n).collect(),+ Some(Act::MoveTo(slot)) => {+ let picked = st.selected.clone();+ st.selected = move_sheets(&mut st.sheets, &picked, slot);+ }+ Some(Act::Up) => {+ let first = st.selected.iter().min().copied().unwrap_or(0);+ let picked = st.selected.clone();+ st.selected = move_sheets(&mut st.sheets, &picked, first.saturating_sub(1));+ }+ Some(Act::Down) => {+ let last = st.selected.iter().max().copied().unwrap_or(0);+ let picked = st.selected.clone();+ st.selected = move_sheets(&mut st.sheets, &picked, (last + 2).min(n));+ }+ Some(Act::Duplicate) => {+ // Each picked sheet gets its copy right after the block of picked sheets.+ let mut idx = st.selected.clone();+ idx.sort_unstable();+ let copies: Vec<Sheet> = idx.iter().filter_map(|i| st.sheets.get(*i).cloned()).collect();+ let at = idx.last().map_or(n, |l| l + 1);+ let k = copies.len();+ for (j, c) in copies.into_iter().enumerate() {+ st.sheets.insert(at + j, c);+ }+ st.selected = (at..at + k).collect();+ }+ Some(Act::Remove) => {+ let mut idx = st.selected.clone();+ idx.sort_unstable();+ idx.dedup();+ for i in idx.iter().rev() {+ if *i < st.sheets.len() {+ st.sheets.remove(*i);+ }+ }+ st.selected = idx.first().filter(|_| !st.sheets.is_empty()).map(|f| vec![(*f).min(st.sheets.len() - 1)]).unwrap_or_default();+ }+ Some(Act::Add(s)) => {+ st.sheets.push(s);+ st.selected = vec![st.sheets.len() - 1];+ }+ Some(Act::AddAll(uid)) => {+ if let Some(d) = app.session.docs.iter().find(|d| d.uid == uid) {+ st.sheets.extend(sheets_of(&d.doc, d.uid, &d.title));+ }+ }+ Some(Act::Clear) => {+ st.sheets.clear();+ st.selected.clear();+ }+ Some(Act::Plot) => {+ let stem = std::path::Path::new(¤t)+ .file_stem()+ .map(|s| s.to_string_lossy().to_string())+ .filter(|s| !s.is_empty())+ .unwrap_or_else(|| "Drawing".into());+ let picked = app.services.pick_save.as_ref().and_then(|pick| pick(&format!("{stem}.pdf")));+ if let Some(path) = picked {+ match publish_params(&app.session.docs.iter().map(|d| d.uid).collect::<Vec<_>>(), &st.sheets, !st.separate, Some(&path))+ .and_then(|p| app.run("publish", p))+ {+ Ok(r) => {+ let msg = r["message"].as_str().unwrap_or("").to_string();+ app.session.echo(msg);+ close = true;+ }+ Err(e) => st.note = Some(e),+ }+ }+ }+ None => {}+ }+ if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !st.dragging {+ *open = false;+ }+ if *open {+ ctx.data_mut(|d| d.insert_temp(state_id(), st));+ } else {+ // Opened again later: filled anew from the drawing then current.+ ctx.data_mut(|d| d.remove::<State>(state_id()));+ }+}++#[cfg(test)]+mod tests {+ use super::{Sheet, move_sheet, move_sheets, publish_params};++ fn s(n: &str) -> Sheet {+ Sheet { doc: 1, drawing: "A.dwg".into(), layout: n.into() }+ }++ #[test]+ fn sheets_move_to_a_slot() {+ let mut v = vec![s("1"), s("2"), s("3"), s("4")];+ assert_eq!(move_sheet(&mut v, 3, 0), Some(0));+ assert_eq!(v.iter().map(|x| x.layout.as_str()).collect::<Vec<_>>(), ["4", "1", "2", "3"]);+ assert_eq!(move_sheet(&mut v, 0, 4), Some(3));+ assert_eq!(v.iter().map(|x| x.layout.as_str()).collect::<Vec<_>>(), ["1", "2", "3", "4"]);+ assert_eq!(move_sheet(&mut v, 1, 3), Some(2));+ assert_eq!(v.iter().map(|x| x.layout.as_str()).collect::<Vec<_>>(), ["1", "3", "2", "4"]);+ assert_eq!(move_sheet(&mut v, 9, 0), None);+ }++ #[test]+ fn several_sheets_move_as_a_block() {+ let names = |v: &[Sheet]| v.iter().map(|x| x.layout.clone()).collect::<Vec<_>>().join("");+ let mut v = vec![s("1"), s("2"), s("3"), s("4"), s("5")];+ assert_eq!(move_sheets(&mut v, &[3, 1], 0), vec![0, 1]);+ assert_eq!(names(&v), "24135");+ assert_eq!(move_sheets(&mut v, &[0, 1], 5), vec![3, 4]);+ assert_eq!(names(&v), "13524");+ assert_eq!(move_sheets(&mut v, &[2], 1), vec![1]);+ assert_eq!(names(&v), "15324");+ }++ /// Two open files with the same name (`/A/plan.dwg`, `/B/plan.dwg`): each sheet plots from+ /// the drawing it was added from, and a closed drawing is an error, not another drawing.+ #[test]+ fn sheets_resolve_by_drawing_identity_not_title() {+ let a = Sheet { doc: 7, drawing: "plan.dwg".into(), layout: "A1".into() };+ let b = Sheet { doc: 9, drawing: "plan.dwg".into(), layout: "A1".into() };+ let p = publish_params(&[7, 9], &[b.clone(), a.clone()], true, None).unwrap();+ assert_eq!(p["sheets"][0]["drawing"], 1);+ assert_eq!(p["sheets"][1]["drawing"], 0);+ assert!(publish_params(&[7], &[b], true, None).is_err());+ }+}@@ -77,7 +77,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { }; // Centred on the traffic lights' centre line (the row's middle): with the system font // the capitals' middle is the text box's middle, so no optical nudge.- let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim);+ let galley = ui.painter().layout_job(crate::i18n::job(&title, egui::FontId::proportional(13.0), t.text_dim)); let x = title_left(r.left(), r.width(), galley.size().x); ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim); }@@ -262,7 +262,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { /// `text` laid out on one line no wider than `max_width`, ending in "…" when it had to be cut; /// and whether it was. fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color32, max_width: f32) -> (std::sync::Arc<egui::Galley>, bool) {- let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);+ let mut job = crate::i18n::job(text, font, color); job.wrap = egui::text::TextWrapping { max_width: max_width.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; let galley = p.layout_job(job); let cut = galley.elided;@@ -208,7 +208,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { ui.set_width(360.0); ui.label(RichText::new(crate::tl!("CADCraft")).strong()); ui.add_space(4.0);- ui.label(crate::tf!("Save changes to {name}?", name = title));+ ui.label(crate::i18n::widget(ui, &crate::tf!("Save changes to {name}?", name = title), egui::TextStyle::Body)); ui.label(RichText::new(crate::tl!("Your changes will be lost if you don't save them.")).color(t.text_dim)); ui.add_space(10.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {@@ -284,7 +284,8 @@ fn enable_ime(app: &mut CadApp, ctx: &egui::Context) { /// Handle keyboard input destined for the command line (when no text field has focus). pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {- if ctx.egui_wants_keyboard_input() {+ // A modal window (Batch Plot) has the keyboard.+ if ctx.egui_wants_keyboard_input() || app.ui.dialog.as_deref() == Some("publish") { app.cmd.preedit.clear(); app.cmd.ime_on = false; return;@@ -453,6 +454,13 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { app.ui.history_lines.min(MAX_LINES).min(available) } +/// One line of command-line text laid out in reading order: text holding right-to-left letters+/// (a Hebrew text or layer name in a prompt, history line or typed value) would otherwise be+/// turned around as a whole by the shaper, English words and numbers included.+fn lay(p: &egui::Painter, s: String, font: egui::FontId, color: egui::Color32) -> std::sync::Arc<egui::Galley> {+ p.layout_job(crate::i18n::job(&s, font, color))+}+ /// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get();@@ -469,7 +477,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let hr = Rect::from_min_size(pos2(bar.left(), bar.top() - lh * lines.len() as f32 - 4.0), vec2(w, lh * lines.len() as f32 + 2.0)); p.rect_filled(hr, 3.0, t.cmd_history); for (i, l) in lines.iter().enumerate() {- p.text(pos2(hr.left() + 8.0, hr.top() + 1.0 + lh * i as f32), egui::Align2::LEFT_TOP, l, crate::theme::small(), t.text_dim);+ crate::i18n::paint(+ &p,+ pos2(hr.left() + 8.0, hr.top() + 1.0 + lh * i as f32),+ egui::Align2::LEFT_TOP,+ l,+ crate::theme::small(),+ t.text_dim,+ ); } } p.rect_filled(bar, 3.0, t.cmd_bg);@@ -483,24 +498,24 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { Some(pr) => { // The grip modes name themselves ("** STRETCH **…"). let name = app.session.running.as_ref().filter(|r| r.id != "grip").map(|r| r.id.to_ascii_uppercase() + " ").unwrap_or_default();- let g = p.layout_no_wrap(name, font.clone(), t.text_faint);+ let g = lay(&p, name, font.clone(), t.text_faint); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); x += gw; // A LISP `get…` prompt shows as the routine wrote it (no keywords, default or colon added). let message = if pr.raw { pr.display() } else { crate::i18n::t(&pr.message).to_string() };- let g = p.layout_no_wrap(message, font.clone(), t.text);+ let g = lay(&p, message, font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; if !pr.raw && !pr.keywords.is_empty() {- let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text);+ let g = lay(&p, if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; let kws = pr.keywords.clone(); for (i, k) in kws.iter().enumerate() {- let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword);+ let g = lay(&p, k.clone(), font.clone(), t.cmd_keyword); let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click()); if resp.hovered() {@@ -513,25 +528,25 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let _ = app.session.input(Input::Keyword(k.clone())); } if i + 1 < kws.len() {- let g = p.layout_no_wrap("/".into(), font.clone(), t.text);+ let g = lay(&p, "/".into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; } }- let g = p.layout_no_wrap("]".into(), font.clone(), t.text);+ let g = lay(&p, "]".into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; } if let Some(d) = pr.default.as_ref().filter(|_| !pr.raw) {- let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text);+ let g = lay(&p, format!(" <{d}>"), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; } if !pr.raw {- let g = p.layout_no_wrap(": ".into(), font.clone(), t.text);+ let g = lay(&p, ": ".into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw;@@ -551,7 +566,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } } }- let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text);+ let g = lay(&p, app.cmd.buffer.clone(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); let mut caret_x = x + gw;@@ -708,7 +723,7 @@ fn history_window(app: &CadApp, ui: &mut egui::Ui, r: Rect) { for l in log.get(rows).into_iter().flatten() { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), LINE_H), Sense::hover()); let p = ui.painter().with_clip_rect(row.intersect(ui.clip_rect()));- p.text(pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim);+ crate::i18n::paint(&p, pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim); } }, );@@ -25,6 +25,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open), "preview" => crate::exchange::dialog(app, ctx, &mut open),+ "publish" => crate::batchplot::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open), "blocks" => blocks(app, ctx, &mut open), "bedit" => crate::bedit::dialog(app, ctx, &mut open),@@ -31,11 +31,9 @@ pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> { } } "publish" => {- let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "PUBLISH", "publish {}") };- match pick(&format!("{stem}.pdf")) {- Some(path) => app.run("publish", json!({ "path": path })),- None => Ok(Value::Null),- }+ // The Batch Plot window: which sheets, in what order, into one file or several.+ app.ui.dialog = Some("publish".into());+ Ok(Value::Null) } _ => { let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "EXPORT", r#"export {"format": "svg"}"#) };@@ -53,6 +51,15 @@ fn no_picker(app: &mut CadApp, name: &str, example: &str) -> Result<Value, Strin Err(msg) } +/// The preview's zoom (1 = the sheet fits the window) and a scroll position to apply.+#[derive(Clone)]+struct View {+ generation: u32,+ zoom: f32,+ offset: Option<egui::Vec2>,+ last_offset: egui::Vec2,+}+ #[derive(Clone)] struct Preview { generation: u32,@@ -76,20 +83,33 @@ pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, app.ui.dialog = Some("preview".into()); } +/// `img` made small enough for the graphics card's largest texture (egui refuses a larger one).+fn fit_texture(ctx: &egui::Context, img: image::RgbaImage) -> image::RgbaImage {+ let max = u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(2048).max(256);+ let (w, h) = (img.width(), img.height());+ if w <= max && h <= max {+ return img;+ }+ let k = f64::from(max) / f64::from(w.max(h));+ let nw = ((f64::from(w) * k).floor() as u32).clamp(1, max);+ let nh = ((f64::from(h) * k).floor() as u32).clamp(1, max);+ image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)+}+ /// Render the plot preview of the current layout (or model), or of the Plot dialog's settings, /// as a texture. fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> { if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))) { let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?; let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;- let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();+ let img = fit_texture(ctx, image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8()); let size = [img.width() as usize, img.height() as usize]; let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR); return Ok((tex, f.info)); }- let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?;+ let r = app.session.execute("preview", &json!({ "width": 2400 })).map_err(|e| e.to_string())?; let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;- let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();+ let img = fit_texture(ctx, image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8()); let size = [img.width() as usize, img.height() as usize]; let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR); let mut info = format!("{} · {}", r["layout"].as_str().unwrap_or(""), r["paper"].as_str().unwrap_or(""));@@ -113,24 +133,116 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } }; let (mut close, mut print) = (false, false);- egui::Window::new("Plot Preview").open(open).collapsible(false).resizable(false).show(ctx, |ui| {- match &preview.shown {- Ok((tex, info)) => {- ui.label(info);- let size = tex.size_vec2();- let k = (760.0 / size.x).min(540.0 / size.y).min(1.0);- ui.image((tex.id(), size * k));- }- Err(e) => {- ui.label(e);+ let view_id = id.with("view");+ let mut view = ctx.data(|d| d.get_temp::<View>(view_id)).filter(|v| v.generation == generation).unwrap_or(View {+ generation,+ zoom: 1.0,+ offset: None,+ last_offset: egui::Vec2::ZERO,+ });+ egui::Window::new(crate::tl!("Plot Preview"))+ .id(egui::Id::unique("Plot Preview"))+ .open(open)+ .collapsible(false)+ .resizable(true)+ .default_size(egui::vec2(820.0, 640.0))+ .min_size(egui::vec2(360.0, 300.0))+ .show(ctx, |ui| {+ // Print, zoom in and out, fit, the zoom level and Close.+ let mut zoom_by: Option<f32> = None;+ ui.horizontal(|ui| {+ print = ui.button(crate::tl!("Print...")).clicked();+ ui.separator();+ if crate::icons::button(ui, crate::icons::Icon::ZoomIn, 22.0, crate::tl!("Zoom In"), false).clicked() {+ zoom_by = Some(1.25);+ }+ if crate::icons::button(ui, crate::icons::Icon::ZoomOut, 22.0, crate::tl!("Zoom Out"), false).clicked() {+ zoom_by = Some(0.8);+ }+ if crate::icons::button(ui, crate::icons::Icon::ZoomFit, 22.0, crate::tl!("Zoom to Fit"), false).clicked() {+ view.zoom = 1.0;+ view.offset = Some(egui::Vec2::ZERO);+ }+ ui.label(format!("{:.0}%", view.zoom * 100.0));+ ui.separator();+ close = ui.button(crate::tl!("Close")).clicked();+ if let Ok((_, info)) = &preview.shown {+ ui.label(egui::RichText::new(info).weak());+ }+ });+ ui.separator();+ match &preview.shown {+ Ok((tex, _)) => {+ let size = tex.size_vec2();+ let avail = ui.available_size();+ let fit = (avail.x / size.x).min(avail.y / size.y).max(1e-3);+ let viewport = ui.available_rect_before_wrap();+ let wheel = ui.ctx().input(|i| {+ if viewport.contains(i.pointer.hover_pos().unwrap_or_default()) {+ i.smooth_scroll_delta.y + i.zoom_delta().ln() * 200.0+ } else {+ 0.0+ }+ });+ let pointer = ui.ctx().pointer_hover_pos().filter(|p| viewport.contains(*p));+ // The mouse wheel zooms about the cursor; the buttons about the middle.+ let (factor, about) = match zoom_by {+ Some(k) => (k, viewport.center()),+ None if wheel.abs() > 0.0 && pointer.is_some() => ((wheel / 200.0).exp(), pointer.unwrap_or(viewport.center())),+ None => (1.0, viewport.center()),+ };+ let old_zoom = view.zoom;+ view.zoom = (view.zoom * factor).clamp(1.0 / 4.0, 16.0);+ let mut area = egui::ScrollArea::both()+ .id_salt("cc_plot_preview_scroll")+ .auto_shrink([false, false])+ .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)+ .scroll_source(egui::scroll_area::ScrollSource {+ scroll_bar: true,+ drag: egui::scroll_area::DragScroll::Never,+ mouse_wheel: false,+ });+ if (view.zoom - old_zoom).abs() > f32::EPSILON {+ // Keep the point under `about` where it is.+ let ratio = view.zoom / old_zoom;+ let rel = about - viewport.min;+ let old = view.offset.unwrap_or(view.last_offset);+ view.offset = Some(((old + rel) * ratio - rel).max(egui::Vec2::ZERO));+ }+ if let Some(o) = view.offset.take() {+ area = area.scroll_offset(o);+ }+ let shown = size * fit * view.zoom;+ // Scroll bars that stay visible (not the thin floating ones) once the sheet is larger.+ ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();+ let mut pan = egui::Vec2::ZERO;+ let out = area.show(ui, |ui| {+ // Centred while smaller than the window.+ let pad = ((ui.available_size() - shown) / 2.0).max(egui::Vec2::ZERO);+ let (r, resp) = ui.allocate_exact_size(shown + pad * 2.0, egui::Sense::drag());+ let img = egui::Rect::from_min_size(r.min + pad, shown);+ ui.painter().rect_filled(img.translate(egui::vec2(3.0, 3.0)), 0.0, egui::Color32::from_black_alpha(90));+ ui.painter().image(tex.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);+ if resp.hovered() || resp.dragged() {+ ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });+ }+ // Dragging the sheet moves it with the pointer.+ if resp.dragged() {+ pan = resp.drag_delta();+ }+ });+ view.last_offset = out.state.clamped_offset();+ if pan != egui::Vec2::ZERO {+ view.offset = Some((out.state.clamped_offset() - pan).max(egui::Vec2::ZERO));+ ui.ctx().request_repaint();+ }+ }+ Err(e) => {+ ui.label(e);+ } }- }- ui.add_space(4.0);- ui.horizontal(|ui| {- print = ui.button("Print...").clicked();- close = ui.button("Close").clicked(); });- });+ ctx.data_mut(|d| d.insert_temp(view_id, view)); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; }@@ -187,8 +299,9 @@ mod tests { app.start("export"); app.cmdline("PUBLISH"); app.start("imp");- assert_eq!(*asked.borrow(), ["Drawing1.svg", "Drawing1.pdf", "open"]);- assert!(app.ui.dialog.is_none());+ assert_eq!(*asked.borrow(), ["Drawing1.svg", "open"]);+ assert_eq!(app.ui.dialog.as_deref(), Some("publish"), "PUBLISH opens the Batch Plot window");+ app.ui.dialog = None; app.cmdline("pre"); assert_eq!(app.ui.dialog.as_deref(), Some("preview")); app.ui.dialog = None;@@ -201,7 +314,7 @@ mod tests { let _ = app.run("publish", json!({ "layouts": [] })); let r = app.run("preview", json!({ "width": 64 })).unwrap(); assert!(r["data"].is_string());- assert_eq!(asked.borrow().len(), 3, "JSON calls never open a file dialog");+ assert_eq!(asked.borrow().len(), 2, "JSON calls never open a file dialog"); assert!(app.ui.dialog.is_none(), "nor the preview window"); let e = app.run("share", json!({})).unwrap_err();@@ -401,6 +401,106 @@ macro_rules! tf { #[cfg(test)] mod tests; +/// Whether `c` is written right to left (Hebrew, Arabic).+fn is_rtl(c: char) -> bool {+ matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF)+}++/// Whether `s` holds right-to-left letters, so it needs [`job`] to be drawn in reading order.+pub fn has_rtl(s: &str) -> bool {+ s.chars().any(is_rtl)+}++/// Whether `c` attaches to the letter before it (combining marks, Hebrew points, joiners).+fn is_mark(c: char) -> bool {+ matches!(u32::from(c), 0x0300..=0x036F | 0x0591..=0x05BD | 0x05BF | 0x05C1..=0x05C2 | 0x05C4..=0x05C5 | 0x05C7 | 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x200C..=0x200D | 0xFE00..=0xFE0F | 0xFE20..=0xFE2F)+}++/// The pieces of `s` in display order for a left-to-right line, each to be shaped on its own:+/// left-to-right runs whole, right-to-left runs letter by letter (with their marks) reversed and+/// brackets mirrored. The text shaper turns any piece holding a right-to-left letter around, and+/// with a font that covers Latin and Hebrew alike (Segoe UI) it would turn the whole name+/// (extension included) around; one letter per piece leaves nothing to turn.+fn display_pieces(s: &str) -> Vec<String> {+ let chars: Vec<char> = s.chars().collect();+ let mut out = Vec::new();+ for (r, rtl) in cadcraft_fonts::bidi::visual_runs(&chars, Some(false)) {+ let run = chars.get(r).unwrap_or_default();+ if !rtl {+ out.push(run.iter().collect());+ continue;+ }+ let mut clusters: Vec<String> = Vec::new();+ for c in run {+ match clusters.last_mut() {+ Some(last) if is_mark(*c) => last.push(*c),+ _ => clusters.push(cadcraft_fonts::bidi::mirror(*c).to_string()),+ }+ }+ out.extend(clusters.into_iter().rev());+ }+ out+}++/// `s` (a file, drawing, block or layer name…) laid out on one line in reading order: right-to-left+/// runs read as they do in the file manager, `שלום.dwg` with the extension on the right.+pub fn job(s: &str, font: egui::FontId, color: egui::Color32) -> egui::text::LayoutJob {+ let mut job = egui::text::LayoutJob::simple_singleline(s.to_string(), font, color);+ // The text shaper orders right-to-left runs itself (UAX #9); the line reads left to right, as+ // the interface does, so a Hebrew name keeps its extension on the right.+ if has_rtl(s) {+ job.direction = egui::epaint::text::TextDirection::LeftToRight;+ }+ job+}++/// `s` as widget text (button, label, menu item) in reading order; the widget's own colour.+pub fn widget(ui: &egui::Ui, s: &str, style: egui::TextStyle) -> egui::WidgetText {+ if !has_rtl(s) {+ return s.to_string().into();+ }+ egui::WidgetText::from(job(s, style.resolve(ui.style()), egui::Color32::PLACEHOLDER))+}++/// Paints `s` in reading order, anchored like [`egui::Painter::text`]; returns its rectangle.+pub fn paint(p: &egui::Painter, pos: egui::Pos2, anchor: egui::Align2, s: &str, font: egui::FontId, color: egui::Color32) -> egui::Rect {+ let galley = p.layout_job(job(s, font, color));+ let rect = anchor.anchor_size(pos, galley.size());+ p.galley(rect.min, galley, color);+ rect+}++/// `s` in display order as one plain string, for tests and logs (what [`job`] shows).+pub fn ltr(s: &str) -> std::borrow::Cow<'_, str> {+ if !has_rtl(s) {+ return std::borrow::Cow::Borrowed(s);+ }+ std::borrow::Cow::Owned(display_pieces(s).concat())+}++#[cfg(test)]+mod ltr_tests {+ #[test]+ fn hebrew_file_names_keep_the_extension_on_the_right() {+ assert_eq!(super::ltr("שלום.dwg"), "םולש.dwg");+ assert_eq!(super::ltr("plan 2.dwg"), "plan 2.dwg");+ assert_eq!(super::ltr("תוכנית קומה 1.dwg"), "1 המוק תינכות.dwg");+ }++ #[test]+ fn every_right_to_left_letter_is_shaped_alone() {+ // Pieces: each Hebrew letter by itself, the extension whole, so the shaper (which turns+ // any piece holding Hebrew around) cannot reverse the name or its extension.+ assert_eq!(super::display_pieces("שלום.dwg"), ["ם", "ו", "ל", "ש", ".dwg"]);+ assert_eq!(super::display_pieces("plan.dwg"), ["plan.dwg"]);+ // Points stay on their letter.+ assert_eq!(super::display_pieces("שָׁלוֹם"), ["ם", "וֹ", "ל", "שָׁ"]);+ // The shaper orders the runs itself, on a left-to-right line.+ let job = super::job("שלום.dwg", egui::FontId::proportional(12.0), egui::Color32::WHITE);+ assert_eq!(job.direction, egui::epaint::text::TextDirection::LeftToRight);+ }+}+ /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are. fn name_sort_key(name: &str) -> String {@@ -149,6 +149,15 @@ pub enum Icon { PickAdd, SelectObjects, QuickSelect,+ ZoomIn,+ ZoomOut,+ MoveTop,+ MoveUp,+ MoveDown,+ MoveBottom,+ ZoomFit,+ TextStyle,+ DimStyle, } struct Pen<'a> {@@ -219,6 +228,13 @@ impl Pen<'_> { let (nx, ny) = (-uy, ux); self.tri([(x, y), (x - ux * 3.0 + nx * 1.4, y - uy * 3.0 + ny * 1.4), (x - ux * 3.0 - nx * 1.4, y - uy * 3.0 - ny * 1.4)], c); }+ /// A larger arrowhead than [`Pen::arrow_tip`]: `len` long, `half` wide either side.+ fn big_arrow(&self, x: f32, y: f32, dx: f32, dy: f32, len: f32, half: f32, c: Color32) {+ let l = (dx * dx + dy * dy).sqrt().max(1e-3);+ let (ux, uy) = (dx / l, dy / l);+ let (nx, ny) = (-uy, ux);+ self.tri([(x, y), (x - ux * len + nx * half, y - uy * len + ny * half), (x - ux * len - nx * half, y - uy * len - ny * half)], c);+ } fn text(&self, x: f32, y: f32, s: &str, size: f32, c: Color32) { self.p.text(self.at(x, y), egui::Align2::CENTER_CENTER, s, egui::FontId::proportional(size * self.sc()), c); }@@ -245,26 +261,50 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.rect(8.0, 4.0, 8.0, 5.0, b); pen.rect(7.0, 13.0, 10.0, 7.0, b); if icon == SaveAs {- pen.la(15.0, 22.0, 22.0, 15.0);- pen.fill_c(15.0, 22.0, 1.2, a);+ // A large pencil across the lower-right corner: body, ferrule line and a filled tip.+ pen.p.add(egui::Shape::convex_polygon(+ [(12.6, 19.4), (20.6, 11.4), (23.6, 14.4), (15.6, 22.4)].iter().map(|(x, y)| pen.at(*x, *y)).collect(),+ t.panel,+ Stroke::NONE,+ ));+ pen.poly(&[(12.6, 19.4), (20.6, 11.4), (23.6, 14.4), (15.6, 22.4)], true, a);+ pen.la(19.0, 13.0, 22.0, 16.0);+ pen.tri([(12.6, 19.4), (15.6, 22.4), (10.8, 24.0)], a); } }- Undo => {- pen.arc(12.0, 13.0, 7.0, 150.0, -40.0, b);- pen.arrow_tip(5.0, 9.0, -1.0, 2.5, b);- }- Redo => {- pen.arc(12.0, 13.0, 7.0, 30.0, 220.0, b);- pen.arrow_tip(19.0, 9.0, 1.0, 2.5, b);- }- Plot | Preview => {+ Undo | Redo => {+ // As in Office 2010: a thick blue hook over the top, its arrowhead at the open end+ // pointing down (Undo: on the left, Redo: mirrored on the right).+ let m = |x: f32| if icon == Undo { x } else { 24.0 - x };+ let (cx, cy, r) = (12.5_f32, 13.5_f32, 6.5_f32);+ let blue = if dim { a } else { Color32::from_rgb(0x3d, 0x8e, 0xe0) };+ let pts: Vec<egui::Pos2> = (0..=28)+ .map(|i| {+ let ang = (-35.0 + 205.0 * i as f32 / 28.0).to_radians();+ pen.at(m(cx + r * ang.cos()), cy - r * ang.sin())+ })+ .collect();+ p.add(egui::Shape::line(pts, Stroke::new(pen.w * 2.4, blue)));+ // The arrowhead at the arc's open end (170°), pointing down and a little out.+ let ang = 170.0_f32.to_radians();+ let (ex, ey) = (cx + r * ang.cos(), cy - r * ang.sin());+ let (dx, dy) = (-ang.sin(), -ang.cos());+ let (tx, ty) = (ex + dx * 4.6, ey + dy * 4.6);+ let ddx = if icon == Undo { dx } else { -dx };+ pen.big_arrow(m(tx), ty, ddx, dy, 7.0, 4.8, blue);+ }+ Plot => { pen.rect(4.0, 9.0, 16.0, 8.0, b); pen.rect(7.0, 4.0, 10.0, 5.0, b); pen.rect(7.0, 14.0, 10.0, 7.0, b);- if icon == Preview {- pen.c(17.0, 17.0, 3.0, a);- pen.la(19.0, 19.0, 22.0, 22.0);- }+ }+ Preview => {+ // A smaller printer on the left and the magnifier clear of it on the right.+ pen.rect(1.5, 8.0, 12.0, 6.5, b);+ pen.rect(3.8, 4.0, 7.4, 4.0, b);+ pen.rect(3.8, 12.0, 7.4, 6.0, b);+ pen.c(17.5, 13.0, 4.0, a);+ p.line_segment([pen.at(20.4, 15.9), pen.at(23.0, 18.5)], Stroke::new(pen.w * 1.8, a)); } Publish => { pen.rect(3.0, 10.0, 13.0, 7.0, b);@@ -278,12 +318,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { } Import | Export => { pen.poly(&[(5.0, 3.0), (14.0, 3.0), (18.0, 7.0), (18.0, 21.0), (5.0, 21.0)], true, b);+ // A large, thick arrow: into the page for Import, out of it for Export.+ let thick = Stroke::new(pen.w * 2.0, a); if icon == Import {- pen.la(1.0, 13.0, 11.0, 13.0);- pen.arrow_tip(12.0, 13.0, 1.0, 0.0, a);+ pen.p.line_segment([pen.at(0.5, 13.0), pen.at(8.0, 13.0)], thick);+ pen.big_arrow(14.5, 13.0, 1.0, 0.0, 7.0, 5.0, a); } else {- pen.la(11.0, 13.0, 21.0, 13.0);- pen.arrow_tip(23.0, 13.0, 1.0, 0.0, a);+ pen.p.line_segment([pen.at(9.0, 13.0), pen.at(17.0, 13.0)], thick);+ pen.big_arrow(24.0, 13.0, 1.0, 0.0, 7.0, 5.0, a); } } Attach => {@@ -486,20 +528,24 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.la(14.0, 21.0, 19.0, 21.0); } Move => {- pen.la(12.0, 3.0, 12.0, 21.0);- pen.la(3.0, 12.0, 21.0, 12.0);- pen.arrow_tip(12.0, 2.0, 0.0, -1.0, a);- pen.arrow_tip(12.0, 22.0, 0.0, 1.0, a);- pen.arrow_tip(2.0, 12.0, -1.0, 0.0, a);- pen.arrow_tip(22.0, 12.0, 1.0, 0.0, a);+ pen.la(12.0, 6.0, 12.0, 18.0);+ pen.la(6.0, 12.0, 18.0, 12.0);+ pen.big_arrow(12.0, 1.5, 0.0, -1.0, 5.5, 3.3, a);+ pen.big_arrow(12.0, 22.5, 0.0, 1.0, 5.5, 3.3, a);+ pen.big_arrow(1.5, 12.0, -1.0, 0.0, 5.5, 3.3, a);+ pen.big_arrow(22.5, 12.0, 1.0, 0.0, 5.5, 3.3, a); } Copy => { pen.rect(3.0, 3.0, 11.0, 13.0, b); pen.rect(10.0, 8.0, 11.0, 13.0, a); } Rotate => {- pen.arc(12.0, 12.0, 8.0, 100.0, 380.0, a);- pen.arrow_tip(10.6, 4.1, -1.0, 0.1, a);+ // Most of a circle, counter-clockwise, ending in an arrowhead along it.+ pen.arc(12.0, 12.0, 8.0, 50.0, 300.0, a);+ let end = 300f32.to_radians();+ let (px, py) = (12.0 + 8.0 * end.cos(), 12.0 - 8.0 * end.sin());+ let (ux, uy) = (-end.sin(), -end.cos());+ pen.big_arrow(px + ux * 4.5, py + uy * 4.5, ux, uy, 5.5, 3.3, a); pen.dot(12.0, 12.0); } Scale => {@@ -524,20 +570,22 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.la(4.0, 18.0, 4.0, 22.0); } Trim => {- pen.l(8.0, 2.0, 8.0, 22.0);- pen.l(16.0, 2.0, 16.0, 22.0);- pen.l(2.0, 12.0, 8.0, 12.0);- pen.l(16.0, 12.0, 22.0, 12.0);- for x in [9.5, 12.0, 14.5] {- pen.la(x, 12.0, x + 1.0, 12.0);- }- pen.poly(&[(10.0, 17.0), (12.0, 15.0), (14.0, 17.0)], false, a);+ // Scissors cutting a line in two: the line's halves apart under the blades.+ pen.l(1.5, 20.0, 9.0, 20.0);+ pen.l(15.0, 20.0, 22.5, 20.0);+ pen.c(7.5, 4.5, 3.0, a);+ pen.c(16.5, 4.5, 3.0, a);+ pen.poly(&[(9.3, 6.9), (14.6, 18.5)], false, a);+ pen.poly(&[(14.7, 6.9), (9.4, 18.5)], false, a);+ pen.tri([(14.6, 18.5), (13.0, 13.5), (11.8, 14.6)], a);+ pen.tri([(9.4, 18.5), (11.0, 13.5), (12.2, 14.6)], a);+ pen.fill_c(12.0, 12.6, 1.3, pen.pt); } Extend => {- pen.l(20.0, 2.0, 20.0, 22.0);- pen.l(3.0, 12.0, 11.0, 12.0);- pen.la(11.0, 12.0, 19.0, 12.0);- pen.arrow_tip(19.5, 12.0, 1.0, 0.0, a);+ pen.l(21.0, 2.0, 21.0, 22.0);+ pen.l(2.0, 12.0, 9.0, 12.0);+ pen.la(9.0, 12.0, 14.0, 12.0);+ pen.big_arrow(20.5, 12.0, 1.0, 0.0, 7.0, 4.0, a); } Fillet => { pen.l(4.0, 20.0, 4.0, 12.0);@@ -604,7 +652,21 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.poly(&[(20.0, 6.0), (20.0, 11.0), (11.0, 11.0), (11.0, 14.0)], false, b); pen.fill_rect(9.5, 14.0, 3.0, 8.0, a); }- DimLinear | DimAligned | DimBaseline | DimContinue | QDim => {+ DimAligned => {+ // The linear dimension's figure turned 13° counter-clockwise, as an aligned+ // dimension follows a slanted object.+ let (sin, cos) = 13f32.to_radians().sin_cos();+ let r = |x: f32, y: f32| (12.0 + (x - 12.0) * cos + (y - 12.0) * sin, 12.0 - (x - 12.0) * sin + (y - 12.0) * cos);+ let line = |x1: f32, y1: f32, x2: f32, y2: f32, c: Color32| pen.poly(&[r(x1, y1), r(x2, y2)], false, c);+ let (y0, y1) = (9.0, 19.0);+ line(4.0, y1, 4.0, 6.0, b);+ line(20.0, y1, 20.0, 6.0, b);+ line(4.0, y0, 20.0, y0, a);+ let ((ax, ay), (bx, by)) = (r(4.0, y0), r(20.0, y0));+ pen.arrow_tip(ax, ay, -cos, sin, a);+ pen.arrow_tip(bx, by, cos, -sin, a);+ }+ DimLinear | DimBaseline | DimContinue | QDim => { let (y0, y1) = (8.0, 18.0); pen.l(4.0, y1, 4.0, 5.0); pen.l(20.0, y1, 20.0, 5.0);@@ -617,7 +679,6 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.l(12.0, y1, 12.0, 5.0); } QDim => pen.text(12.0, 15.0, "Q", 7.0, b),- DimAligned => {} _ => pen.text(12.0, 14.5, "1.5", 6.0, b), } }@@ -781,15 +842,20 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.l(17.0, 13.0, 17.0, 21.0); } Gear => {- pen.c(12.0, 12.0, 4.0, b);+ // A gear wheel: eight square teeth on a rim, and the hole in the middle.+ let mut outline = Vec::new(); for i in 0..8 {- let ang = (i as f32 * 45.0).to_radians();- p.line_segment(- [pen.at(12.0 + 6.0 * ang.cos(), 12.0 + 6.0 * ang.sin()), pen.at(12.0 + 9.0 * ang.cos(), 12.0 + 9.0 * ang.sin())],- Stroke::new(2.2 * pen.sc(), b),- );+ let c = i as f32 * 45.0;+ for (da, rr) in [(-17.0, 7.0), (-11.0, 9.6), (11.0, 9.6), (17.0, 7.0)] {+ let a = (c + da).to_radians();+ outline.push(pen.at(12.0 + rr * a.cos(), 12.0 + rr * a.sin()));+ } }- pen.c(12.0, 12.0, 6.5, b);+ if let Some(f) = outline.first().copied() {+ outline.push(f);+ }+ p.add(egui::Shape::line(outline, Stroke::new(pen.w * 1.2, b)));+ pen.c(12.0, 12.0, 3.2, b); } Annotation => { pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], true, b);@@ -814,6 +880,59 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.fill_rect(x, y, 8.0, 8.0, if x == 3.0 && y == 3.0 { a } else { b }); } }+ ZoomIn | ZoomOut => {+ pen.c(10.0, 10.0, 7.0, b);+ p.line_segment([pen.at(15.0, 15.0), pen.at(21.5, 21.5)], Stroke::new(pen.w * 2.2, b));+ pen.la(6.5, 10.0, 13.5, 10.0);+ if icon == ZoomIn {+ pen.la(10.0, 6.5, 10.0, 13.5);+ }+ }+ MoveTop | MoveBottom => {+ // A bar at the end the arrow points to.+ let (bar, tip, tail, dir) = if icon == MoveTop { (4.0, 7.0, 21.0, -1.0) } else { (20.0, 17.0, 3.0, 1.0) };+ p.line_segment([pen.at(5.0, bar), pen.at(19.0, bar)], Stroke::new(pen.w * 1.6, a));+ pen.l(12.0, tail, 12.0, tip - 5.0 * dir);+ pen.big_arrow(12.0, tip, 0.0, dir, 6.0, 4.5, b);+ }+ MoveUp | MoveDown => {+ let dir = if icon == MoveUp { -1.0 } else { 1.0 };+ pen.l(12.0, 12.0 - 8.0 * dir, 12.0, 12.0 + 2.0 * dir);+ pen.big_arrow(12.0, 12.0 + 8.0 * dir, 0.0, dir, 7.0, 5.0, b);+ }+ ZoomFit => {+ // Four right-angle corners framing a rectangle.+ let s = Stroke::new(pen.w * 1.6, b);+ for (x, y, dx, dy) in [(3.0, 5.0, 1.0, 1.0), (21.0, 5.0, -1.0, 1.0), (3.0, 19.0, 1.0, -1.0), (21.0, 19.0, -1.0, -1.0)] {+ p.line(vec![pen.at(x + dx * 5.0, y), pen.at(x, y), pen.at(x, y + dy * 4.5)], s);+ }+ pen.rect(8.0, 9.0, 8.0, 6.0, a);+ }+ TextStyle | DimStyle => {+ if icon == TextStyle {+ pen.text(9.5, 10.5, "A", 15.0, b);+ } else {+ pen.l(2.0, 4.0, 2.0, 12.0);+ pen.l(15.0, 4.0, 15.0, 12.0);+ pen.l(2.0, 8.0, 15.0, 8.0);+ pen.arrow_tip(2.0, 8.0, -1.0, 0.0, b);+ pen.arrow_tip(15.0, 8.0, 1.0, 0.0, b);+ }+ // A small gear: the style's settings.+ let mut outline = Vec::new();+ for i in 0..6 {+ let c = i as f32 * 60.0;+ for (da, rr) in [(-20.0, 3.6), (-12.0, 5.0), (12.0, 5.0), (20.0, 3.6)] {+ let ang = (c + da).to_radians();+ outline.push(pen.at(17.5 + rr * ang.cos(), 17.5 + rr * ang.sin()));+ }+ }+ if let Some(f) = outline.first().copied() {+ outline.push(f);+ }+ p.add(egui::Shape::line(outline, Stroke::new(pen.w, a)));+ pen.c(17.5, 17.5, 1.5, a);+ } Close => { pen.l(6.0, 6.0, 18.0, 18.0); pen.l(18.0, 6.0, 6.0, 18.0);@@ -945,6 +1064,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { /// A shared button interaction with CADCraft's original multicolor icon painting. pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response {+ button_bare(ui, icon, size, tooltip, selected).on_hover_text(crate::i18n::t(tooltip))+}++/// [`button`] without its hover text, for callers that show a richer tooltip+/// ([`crate::toolbar::tool_button`]).+pub fn button_bare(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response { let t = Tokens::get(); let visuals = |fill| ButtonVisuals { fill, stroke: Stroke::NONE, icon: t.icon }; let style = IconButtonStyle {@@ -961,7 +1086,7 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled()); }); crate::control::record_widget(ui, &resp, tooltip, "button", Some(selected));- resp.on_hover_text(crate::i18n::t(tooltip))+ resp } /// Labels and focus outlines for controls whose existing geometry is painted by the app.@@ -980,9 +1105,8 @@ pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, s node.set_selected(selected); } });- if response.clicked() {- response.request_focus();- }+ // A click leaves the keyboard with the drawing (shortcuts such as Ctrl+V and typed+ // commands keep working after switching drawings); Tab still reaches the tab. } if response.has_focus() && response.rect.width() > 4.0 && response.rect.height() > 4.0 { ui.painter().rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.0, Tokens::get().accent), egui::StrokeKind::Inside);@@ -11,6 +11,7 @@ pub mod aliasdlg; pub mod animation; pub mod autosave;+pub mod batchplot; pub mod bedit; pub mod canvas; pub mod capture;@@ -581,7 +581,7 @@ pub(crate) fn shortcut_label(s: &str, mac: bool) -> String { pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String>) { match e { Entry::Item { label, id, shortcut, enabled } => {- let mut b = egui::Button::new(crate::i18n::t(label));+ let mut b = egui::Button::new(crate::i18n::widget(ui, crate::i18n::t(label), egui::TextStyle::Button)); if let Some(s) = shortcut { b = b.shortcut_text(shortcut_label(s, ui.ctx().os().is_mac())); }@@ -556,7 +556,18 @@ fn edit_field(ui: &mut egui::Ui, id: egui::Id, value: String) -> Option<String> let mut buf = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| value.clone()); let w = ui.available_width() - 10.0; let te = id.with("te");- let resp = ui.add_sized([w, 20.0], egui::TextEdit::singleline(&mut buf).id(te).font(crate::theme::body()));+ let focused = ui.memory(|m| m.has_focus(te));+ // Unfocused, a value holding right-to-left letters (a Hebrew text) shows in reading order;+ // the shaper would otherwise turn it around as a whole, English words and numbers included.+ let mut reading_order = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _wrap: f32| {+ let job = crate::i18n::job(text.as_str(), crate::theme::body(), ui.visuals().text_color());+ ui.fonts_mut(|f| f.layout_job(job))+ };+ let mut edit = egui::TextEdit::singleline(&mut buf).id(te).font(crate::theme::body());+ if !focused && crate::i18n::has_rtl(&value) {+ edit = edit.layouter(&mut reading_order);+ }+ let resp = ui.add_sized([w, 20.0], edit); let mut out = None; // Keyboard focus from memory: `Response::has_focus` is false while the window itself is // unfocused, which dropped the typed text every frame.new file mode 100644@@ -0,0 +1,83 @@+//! Ctrl+C in one open drawing, Ctrl+V in another: the objects are pasted there.++use cadcraft_engine::doc::EntityKind;+use cadcraft_engine::geom::Vec2;+use cadcraft_engine::{Input, Session};+use cadcraft_ui_egui::{CadApp, Services};+use egui::{Event, vec2};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;++fn harness(app: CadApp) -> Harness<'static, CadApp> {+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_ui_state(+ |ui, app: &mut CadApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(5);+ h+}++/// The text the app put on the system clipboard in the last frames, if any.+fn copied(h: &Harness<'_, CadApp>) -> Option<String> {+ h.output().platform_output.commands.iter().find_map(|c| match c {+ egui::OutputCommand::CopyText(t) => Some(t.clone()),+ _ => None,+ })+}++#[test]+fn copy_in_one_drawing_and_paste_in_another() {+ let mut app = CadApp::new(Session::new(), Services::default());+ app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap();+ app.run("circle", json!({ "center": [5, 5], "radius": 2 })).unwrap();+ app.run("block", json!({ "name": "DOT", "base": [5, 5], "handles": [app.session.doc().unwrap().model.iter().nth(1).unwrap().handle.hex()], "keep": "convert" }))+ .unwrap();+ let first = app.session.active;+ let all: Vec<_> = app.session.doc().unwrap().model.iter().map(|e| e.handle).collect();+ app.session.set_selection(all);+ let mut h = harness(app);+ h.event(Event::Copy);+ let mut text = None;+ for _ in 0..4 {+ h.run_steps(1);+ text = text.or_else(|| copied(&h));+ }+ assert_eq!(+ h.state().session.clipboard.len(),+ 2,+ "{:?}",+ h.state().session.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>()+ );+ // Another drawing, made active by clicking its tab (which must not keep the keyboard).+ h.state_mut().run("new", json!({})).unwrap();+ h.run_steps(3);+ let second = h.state().session.active;+ let title = h.state().session.docs[second].title.clone();+ h.state_mut().run("document.switch", json!({ "index": first })).unwrap();+ h.run_steps(3);+ h.get_by_label(&title).click();+ h.run_steps(3);+ assert_eq!(h.state().session.active, second);+ assert_ne!(second, first);+ assert!(h.state().session.doc().unwrap().model.is_empty());+ assert!(!h.ctx.egui_wants_keyboard_input(), "the clicked tab doesn't hold the keyboard");+ // Ctrl+V: egui reports it as a Paste event with the system clipboard's text.+ h.event(Event::Paste(text.unwrap_or_else(|| "something else".into())));+ h.run_steps(3);+ assert_eq!(+ h.state().session.running.as_ref().map(|r| r.id.as_str()),+ Some("pasteclip"),+ "{:?}",+ h.state().session.log.iter().rev().take(5).collect::<Vec<_>>()+ );+ h.state_mut().session.input(Input::Point(Vec2::new(100.0, 100.0))).unwrap();+ h.run_steps(2);+ let d = h.state().session.doc().unwrap();+ assert_eq!(d.model.len(), 2);+ assert!(d.model.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "DOT")));+ assert!(d.block("DOT").is_some());+}@@ -1,5 +1,6 @@ mod animation_effects; mod chrome_geometry;+mod clipboard_between_drawings; mod context_menu; mod context_menus; mod control_cmdline;@@ -24,6 +25,7 @@ mod parameters_rename; mod pickdrag; mod properties_units; mod rapid_clicks;+mod rtl_names; mod selection_cycling; mod shared_controls; mod shortcut_menus;new file mode 100644@@ -0,0 +1,17 @@+//! A Hebrew file name reads as it does in the file manager: the letters right to left, the+//! extension on the right (`שלום.dwg`), whatever the text shaper does with right-to-left runs.++#[test]+fn hebrew_file_name_is_laid_out_in_reading_order() {+ let ctx = egui::Context::default();+ let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});+ out.textures_delta.clear();+ let job = cadcraft_ui_egui::i18n::job("שלום.dwg", egui::FontId::proportional(14.0), egui::Color32::WHITE);+ let galley = ctx.fonts_mut(|f| f.layout_job(job));+ let mut glyphs: Vec<(f32, char)> = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.pos.x, g.chr))).collect();+ glyphs.sort_by(|a, b| a.0.total_cmp(&b.0));+ let shown: String = glyphs.iter().map(|(_, c)| c).collect();+ // Left to right on screen: the last letter first, the extension last and not reversed.+ assert_eq!(shown, "םולש.dwg");+ ctx.tex_manager().write().take_delta().clear();+}