Compare commits

..
8 Commits
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 32238e83c2 Release 0.18.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:30:56 +01:00
FredAppsandClaude Opus 5.5 e4d409341a Merge main into release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:30:56 +01:00
Dylan M. Taylor f02ee5c0b8 Merge main into release 2026-11-04 21:13:29 -05:00
Dylan M. Taylor d4a9a37a09 Merge main into release 2026-11-04 21:08:13 -05:00
Dylan M. Taylor a54d694f4f Release 0.17.1 2026-11-04 19:50:38 -05:00
Dylan M. Taylor d12f592d80 Release 0.17.1 2026-11-04 19:48:04 -05:00
Dylan M. Taylor 567dfd49ed Run the signed release from the release environment 2026-11-04 19:35:54 -05:00
Dylan M. Taylor 8d90dec9ec Release 0.17.1 2026-11-04 18:50:12 -05:00
41 changed files with 158 additions and 3316 deletions
Generated
+17 -30
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -644,7 +644,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -660,14 +660,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -677,7 +677,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -688,7 +688,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"acadrust",
"log",
@@ -696,7 +696,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"encoding_rs",
"log",
@@ -705,7 +705,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -726,7 +726,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-geom",
"encoding_rs",
@@ -738,14 +738,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -767,7 +767,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -781,7 +781,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -795,7 +795,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -822,17 +822,15 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
"js-sys",
"log",
"serde_json",
"wasm-bindgen",
@@ -1234,21 +1232,10 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -5990,7 +5977,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"chrono",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.17.0"
version = "0.18.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -4
View File
@@ -20,9 +20,7 @@ cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.cadcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -35,5 +33,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
-61
View File
@@ -1,61 +0,0 @@
//! `window.cadcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
//! own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is CADCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on drops its [`PendingReply`], which cancels a request the app has
//! not started, so the app skips it instead of running it late.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, channel};
use cadcraft_ui_egui::{ControlRequest, PendingReply};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
/// The app id in `window.<app>`.
const APP: &str = "cadcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// Put `window.cadcraft` on the page and return the queue the app drains each frame (hand it to
/// `CadApp::with_control`). An existing `window.cadcraft` keeps the functions it already has.
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
let (queue, requests) = channel::<ControlRequest>();
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
if queue.send(control).is_err() {
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(wait(reply))
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
requests
}
/// The app's reply, polled about once a frame. Dropping this future (the page's timeout) drops
/// `reply`, which cancels the request if the app has not started it.
async fn wait(reply: PendingReply) -> Value {
loop {
if let Some(reply) = reply.try_reply() {
return reply;
}
sleep(POLL_MS).await;
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
+1 -4
View File
@@ -10,8 +10,6 @@
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
pub mod download;
#[cfg(target_arch = "wasm32")]
@@ -162,8 +160,7 @@ mod web {
Box::new(move |cc| {
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
let control = crate::api::install(cc.egui_ctx.clone());
let mut app = CadApp::new(session, services()).with_control(control);
let mut app = CadApp::new(session, services());
app.system_languages =
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
-8
View File
@@ -181,14 +181,6 @@ fn services() -> Services {
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
pick_pgp: Some(Box::new(|dir: Option<&str>| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
let dialog = match dir {
Some(dir) => dialog.set_directory(dir),
None => dialog,
};
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_co
use crate::{Accept, Input, Prompt, Result, Session, Step};
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
pub use pgp::{MAX_ALIASES, alias_name, command_id};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
+2 -83
View File
@@ -1,16 +1,11 @@
//! The user's command alias file (the publicly documented `.pgp` format: `ALIAS, *COMMAND` lines,
//! `;` comments): ALIASEDIT and REINIT.
//!
//! A loaded file replaces the user aliases, which add to or override the built-in ones for every
//! drawing; command names themselves always win. Lines that define external commands
//! A loaded file adds aliases or overrides the built-in ones for the rest of the session, for
//! every drawing; command names themselves always win. Lines that define external commands
//! (programs the file would have the application start) are never run: they are skipped with a
//! note. CADCraft ships no alias file; the aliases built into the command catalog stay the
//! defaults.
//!
//! The aliases are user settings, never part of a drawing: [`alias_profile`] gives them (and the
//! file REINIT reads) to the host's user profile, and [`restore_aliases`] brings them back on the
//! next start. The desktop and web hosts keep that profile; the headless CLI has no profile, so
//! aliases set in a script last for that session only.
use std::collections::BTreeMap;
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
@@ -25,21 +20,10 @@ use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
pub const MAX_ALIASES: usize = 10_000;
/// Longest alias name.
const MAX_ALIAS_CHARS: usize = 64;
/// Longest command id (the catalog's longest is `texttofront.dimensions`; a test keeps it true).
const MAX_COMMAND_ID_CHARS: usize = 32;
/// Longest line read.
const MAX_LINE_CHARS: usize = 4096;
/// Most warnings reported.
const MAX_WARNINGS: usize = 100;
/// Longest alias file path kept in the user profile.
const MAX_ALIAS_FILE_CHARS: usize = 1024;
/// The largest alias section [`alias_profile`] can produce: [`MAX_ALIASES`] entries, each a
/// [`MAX_ALIAS_CHARS`]-character name (up to four UTF-8 bytes per character, and JSON adds its
/// quotes) mapped to a command id, plus the alias file path and the section's keys. A host that
/// keeps the section in its user profile allows at least this much, so a full set always comes
/// back after a restart.
pub const MAX_ALIAS_PROFILE_BYTES: usize = MAX_ALIASES * (MAX_ALIAS_CHARS * 4 + MAX_COMMAND_ID_CHARS + 6) + MAX_ALIAS_FILE_CHARS * 4 + 64;
#[derive(Default)]
struct Aliases {
@@ -59,49 +43,6 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
store().map.get(name).cloned()
}
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
pub fn alias_profile() -> Value {
let st = store();
// A path [`restore_aliases`] would refuse is left out, so it can't grow the profile either.
json!({ "aliases": st.map, "file": st.file.as_deref().filter(|f| keepable_path(f)) })
}
/// Whether the profile keeps an alias file path: short enough, and no control characters.
fn keepable_path(f: &str) -> bool {
f.chars().count() <= MAX_ALIAS_FILE_CHARS && !f.chars().any(char::is_control)
}
/// Restore aliases saved by [`alias_profile`], replacing the user aliases and the file.
///
/// The saved set is untrusted (a hand-edited or older profile): entries that are not aliases of
/// real commands are skipped, command names are never aliased, at most [`MAX_ALIASES`] are kept,
/// and a malformed section changes nothing.
pub fn restore_aliases(saved: &Value) {
let Some(obj) = saved.as_object() else { return };
let file = match obj.get("file") {
None | Some(Value::Null) => None,
Some(Value::String(f)) if keepable_path(f) => Some(f.clone()),
Some(Value::String(_)) => None,
// A file entry that is not a path makes the section malformed: keep the aliases as they are.
Some(_) => return,
};
let Some(map) = obj.get("aliases").and_then(Value::as_object) else { return };
let mut aliases = BTreeMap::new();
// Entries that are skipped don't use up the cap.
for (alias, target) in map {
if aliases.len() >= MAX_ALIASES {
break;
}
let Ok(name) = alias_name(alias) else { continue };
let Some(id) = target.as_str().and_then(command_id) else { continue };
aliases.insert(name, id);
}
let mut st = store();
st.map = aliases;
st.file = file;
}
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
pub fn command_id(target: &str) -> Option<String> {
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
@@ -260,25 +201,3 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
CommandSpec::new("reinit", "Reinitialize", run_reinit).params("{file? | text?} (default: the alias file loaded last)").enabled(always).noundo(),
]
}
#[cfg(test)]
mod tests {
use super::MAX_COMMAND_ID_CHARS;
/// [`super::MAX_ALIAS_PROFILE_BYTES`] bounds a saved alias's command id by the longest id in
/// the catalog: keep that true as commands are added.
#[test]
fn every_command_id_fits_the_saved_alias_bound() {
for spec in crate::cmd::command_specs() {
assert!(spec.id.chars().count() <= MAX_COMMAND_ID_CHARS, "`{}` exceeds the alias profile bound", spec.id);
}
}
/// An alias file path the profile couldn't restore isn't saved in it.
#[test]
fn only_paths_the_profile_restores_are_saved() {
assert!(super::keepable_path("C:\\support\\mine.pgp"));
assert!(!super::keepable_path(&"x".repeat(super::MAX_ALIAS_FILE_CHARS + 1)));
assert!(!super::keepable_path("mine\n.pgp"));
}
}
+2 -34
View File
@@ -11,8 +11,6 @@ fn text_of(k: &EntityKind) -> Option<String> {
EntityKind::Text(t) => Some(t.value.clone()),
EntityKind::AttDef(a) => Some(a.text.value.clone()),
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
// A multileader's text (one with a block instead has none, #1081).
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
_ => None,
}
@@ -27,16 +25,11 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
EntityKind::Text(t) => t.value = text.to_string(),
EntityKind::AttDef(a) => a.text.value = text.to_string(),
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
EntityKind::MLeader(m) if m.text.is_some() => {
if let Some(t) = &mut m.text {
t.contents = text.replace('\n', "\\P");
}
}
EntityKind::Dimension(d) => {
d.text = if text == "<>" { String::new() } else { text.to_string() };
d.block = None;
}
_ => return Err(other("Select a text, mtext, multileader, attribute definition or dimension.")),
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
}
set_kind(s, h, k)
}
@@ -60,18 +53,12 @@ pub(super) struct TextEditM {
single: bool,
/// Asking for the edit mode.
mode: bool,
/// The MTEXT being edited, for the in-place editor (#842).
mtext: Option<cadcraft_doc::MText>,
}
impl Interactive for TextEditM {
fn name(&self) -> &'static str {
"TEXTEDIT"
}
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
self.h.as_ref().and(self.mtext.clone())
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.mode {
let current = if self.single { "Single" } else { "Multiple" };
@@ -96,24 +83,7 @@ impl Interactive for TextEditM {
match (&self.h, i) {
(None, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
// Refused when picked, not after the new text has been typed.
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let kind = curves::entity(s, h)?.kind;
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?;
self.mtext = match kind {
EntityKind::MText(m) => Some(m),
// A multileader's text opens in the same editor (#1081).
EntityKind::MLeader(m) => m.text,
_ => None,
};
// The editor would drop part of a text this long, and saving would lose it: the
// command line asks for it instead.
if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) {
self.mtext = None;
s.echo("This text is too long for the editor; enter the new text at the command line.");
}
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
self.h = Some((h, old));
Ok(Step::Continue)
}
@@ -123,12 +93,10 @@ impl Interactive for TextEditM {
set_text(s, h, &t)?;
self.edits.push((h, before));
self.h = None;
self.mtext = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(Some(_), Input::Enter) => {
self.h = None;
self.mtext = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(None, Input::Enter) => Ok(Step::Done),
-11
View File
@@ -402,17 +402,6 @@ fn apply_kind_properties(
t.rotation = x.to_radians();
}
}
// A multileader's text: its contents (#1081) and height.
EntityKind::MLeader(m) => {
if let Some(t) = &mut m.text {
if let Some(v) = str_param(params, "text") {
t.contents = v.replace('\n', "\\P");
}
if let Some(x) = num("height").filter(|x| *x > 0.0) {
t.height = x;
}
}
}
EntityKind::LwPolyline(pl) => {
if let Some(x) = num("width").filter(|x| *x >= 0.0) {
pl.const_width = x;
+11 -112
View File
@@ -33,9 +33,6 @@ pub struct CharFmt {
pub font: Option<String>,
/// Absolute character height set inline (`\H`); `None`: the text's.
pub height: Option<f64>,
/// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written
/// back as it was so the part keeps scaling with the text's height.
pub height_rel: Option<f64>,
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
pub color: Option<MTextColor>,
}
@@ -102,60 +99,26 @@ fn arg_end(chars: &[char], from: usize) -> usize {
impl Formatted {
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
pub fn from_contents(contents: &str) -> Formatted {
Self::from_contents_with_height(contents, None)
}
/// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no
/// inline height before it scales that one instead of being dropped.
pub fn from_contents_with_height(contents: &str, height: Option<f64>) -> Formatted {
Self::parse(contents, height).0
}
/// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters, [`MAX_PARAS`]
/// paragraphs or [`MAX_ATOMS`] kept codes the rest would be dropped, and saving would lose it.
pub fn fits(contents: &str) -> bool {
Self::parse(contents, None).1
}
/// Whether `edited`, what the editor gives back for `original` (a text `height` high), is the
/// text as it was: the same, or what opening it and pressing OK without an edit gives (a
/// non-canonical `{\fArial|b1;first}` comes back written another way).
pub fn leaves_unchanged(original: &str, height: Option<f64>, edited: &str) -> bool {
edited == original || edited == Self::from_contents_with_height(original, height).to_contents()
}
/// The parse, and whether it took in all of `contents`.
fn parse(contents: &str, height: Option<f64>) -> (Formatted, bool) {
let mut f = Formatted::default();
let chars: Vec<char> = contents.chars().collect();
let mut stack: Vec<CharFmt> = Vec::new();
let mut cur = CharFmt::default();
let mut props = ParaProps::default();
f.paras = vec![props.clone()];
// Cleared when a paragraph, a kept code or a nesting level doesn't fit.
let mut whole = true;
// Groups opened past the 64 levels kept: their closing braces pop nothing.
let mut unkept = 0usize;
let mut i = 0;
while let Some(&c) = chars.get(i) {
// Braces after the last character the editor holds add no text.
if f.fmt.len() >= MAX_CHARS && !matches!(c, '{' | '}' | '\r') {
return (f, false);
if f.fmt.len() >= MAX_CHARS {
break;
}
match c {
'{' => {
if stack.len() < 64 {
stack.push(cur.clone());
} else {
unkept = unkept.saturating_add(1);
whole = false;
}
i += 1;
}
'}' => {
if unkept > 0 {
unkept -= 1;
} else if let Some(p) = stack.pop() {
if let Some(p) = stack.pop() {
cur = p;
}
i += 1;
@@ -165,8 +128,6 @@ impl Formatted {
if f.paras.len() < MAX_PARAS {
f.push_char('\n', &cur);
f.paras.push(props.clone());
} else {
whole = false;
}
i += 1;
}
@@ -179,7 +140,6 @@ impl Formatted {
f.push_char('\n', &cur);
f.paras.push(props.clone());
}
Some('P') => whole = false,
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
Some('L') => cur.underline = true,
Some('l') => cur.underline = false,
@@ -191,7 +151,7 @@ impl Formatted {
i = end + 1;
match k {
'f' | 'F' => apply_font(&mut cur, &a),
'H' => apply_height(&mut cur, &a, height),
'H' => cur.height = parse_height(&a, cur.height),
'C' => {
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
}
@@ -217,7 +177,6 @@ impl Formatted {
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
i = end + 1;
}
whole &= f.atoms.len() < MAX_ATOMS;
f.push_atom(code, &cur);
}
}
@@ -228,7 +187,7 @@ impl Formatted {
}
}
}
(f, whole)
f
}
fn push_char(&mut self, c: char, fmt: &CharFmt) {
@@ -486,9 +445,7 @@ impl Formatted {
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
}
if let Some(m) = f.height_rel {
s.push_str(&format!("\\H{}x;", trim(m)));
} else if let Some(h) = f.height {
if let Some(h) = f.height {
s.push_str(&format!("\\H{};", trim(h)));
}
match f.color {
@@ -505,32 +462,12 @@ fn trim(v: f64) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no
/// inline height before it, or a relative one) stays relative; over an inline absolute height it
/// becomes absolute.
fn apply_height(cur: &mut CharFmt, a: &str, base: Option<f64>) {
let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h);
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
let a = a.trim();
let Some(r) = a.strip_suffix(['x', 'X']) else {
if let Some(h) = a.parse::<f64>().ok().and_then(valid) {
cur.height = Some(h);
cur.height_rel = None;
}
return;
};
let Some(m) = r.trim().parse::<f64>().ok().and_then(valid) else { return };
match (cur.height, cur.height_rel) {
(_, Some(r0)) => {
if let Some(m) = valid(r0 * m) {
cur.height_rel = Some(m);
cur.height = base.and_then(|b| valid(b * m));
}
}
(None, None) => {
cur.height_rel = Some(m);
cur.height = base.and_then(|b| valid(b * m));
}
(Some(h), None) => cur.height = valid(h * m).or(cur.height),
match a.strip_suffix(['x', 'X']) {
// A relative height needs the current one; without it the code is dropped.
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
}
}
@@ -556,22 +493,6 @@ fn apply_font(cur: &mut CharFmt, a: &str) {
mod tests {
use super::*;
#[test]
fn a_relative_height_is_written_back_relative() {
let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0));
assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height");
let out = f.to_contents();
assert!(out.contains("\\H2x;big"), "{out}");
assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}");
// Opened again for a taller text, the part scales with it.
assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0));
// Relative over relative multiplies; over an absolute one it is absolute.
let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents();
assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}");
// With no height known it is still kept.
assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x"));
}
#[test]
fn round_trips_formats_and_atoms() {
for src in [
@@ -642,26 +563,4 @@ mod tests {
let _ = f.fmt_at(usize::MAX);
let _ = Formatted::from_contents(&"{".repeat(100_000));
}
/// A text with more paragraphs or kept codes than the editor holds doesn't fit, so TEXTEDIT
/// leaves it to the command line instead of dropping the rest on save.
#[test]
fn too_many_paragraphs_or_codes_do_not_fit() {
assert!(Formatted::fits("one\\Ptwo\\A1;three"));
assert!(!Formatted::fits(&"a\\P".repeat(MAX_PARAS)));
assert!(!Formatted::fits(&"a\n".repeat(MAX_PARAS)));
assert!(!Formatted::fits(&"\\A1;".repeat(MAX_ATOMS + 1)));
assert!(Formatted::fits(&"\\A1;".repeat(MAX_ATOMS)));
// A full formatted run's closing brace adds no text: it still fits.
assert!(Formatted::fits(&format!("{{\\L{}}}", "x".repeat(MAX_CHARS))));
assert!(!Formatted::fits(&format!("{{\\L{}}}y", "x".repeat(MAX_CHARS))));
// OK without an edit leaves a text alone, also one the editor writes another way.
let odd = "{\\fArial|b1;first}";
assert!(Formatted::leaves_unchanged(odd, Some(2.5), &Formatted::from_contents_with_height(odd, Some(2.5)).to_contents()));
assert!(Formatted::leaves_unchanged(odd, Some(2.5), odd));
assert!(!Formatted::leaves_unchanged(odd, Some(2.5), "second"));
// Nesting past the 64 levels the editor keeps doesn't fit.
assert!(Formatted::fits(&format!("{}a{}", "{".repeat(64), "}".repeat(64))));
assert!(!Formatted::fits(&format!("{}a{}", "{".repeat(65), "}".repeat(65))));
}
}
+2 -3
View File
@@ -286,9 +286,8 @@ pub trait Interactive: Send {
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
None
}
/// The multiline text being typed (MTEXT, once its box is known, with empty contents) or edited
/// (TEXTEDIT on an MTEXT or a multileader's text, with its contents): where the UI places its
/// in-place editor and with which height, width, justification and starting text.
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
/// the UI places its in-place editor and with which height, width and justification.
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
None
}
-51
View File
@@ -75,57 +75,6 @@ fn reinit_reads_the_alias_file_again() {
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
}
#[test]
fn a_saved_alias_profile_restores_without_overriding_commands() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
s.execute("aliasedit", &json!({ "set": { "zzp": "qsave", "zzcmd": "line" } })).unwrap();
let saved = cadcraft_engine::cmd::file::alias_profile();
assert_eq!(saved["aliases"]["zzp"], json!("qsave"));
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
assert_eq!(resolve_alias("zzp"), "zzp");
cadcraft_engine::cmd::file::restore_aliases(&saved);
assert_eq!(resolve_alias("zzp"), "qsave");
assert_eq!(resolve_alias("zzcmd"), "line");
assert_eq!(resolve_alias("line"), "line", "command names win");
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
// Hostile saved entries are skipped, and an oversized set is capped without panicking.
let mut hostile = serde_json::Map::new();
for i in 0..12_000 {
hostile.insert(format!("ZZ{i}"), json!("line"));
}
hostile.insert("LINE".into(), json!("circle"));
hostile.insert("ZZLONG".into(), json!("x".repeat(5000)));
hostile.insert("ZZNUM".into(), json!(3));
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": hostile, "file": "mine.pgp" }));
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
assert_eq!(resolve_alias("zzlong"), "zzlong");
assert_eq!(resolve_alias("zzn"), "zzn");
let listing = s.execute("aliasedit", &json!({})).unwrap();
let restored = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
assert!(restored <= cadcraft_engine::cmd::file::MAX_ALIASES, "the restored set is capped, not {restored}");
assert!(restored > 9_000, "the valid entries restore, not just {restored}");
assert_eq!(listing["file"], json!("mine.pgp"));
// Skipped entries don't use up the cap: invalid ones sorted first leave a full valid set.
let mut first_bad = serde_json::Map::new();
for i in 0..cadcraft_engine::cmd::file::MAX_ALIASES {
first_bad.insert(format!("A{i:05}"), json!("nosuchcommand"));
first_bad.insert(format!("B{i:05}"), json!("line"));
}
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": first_bad, "file": null }));
let listing = s.execute("aliasedit", &json!({})).unwrap();
assert_eq!(listing["aliases"].as_object().map_or(0, serde_json::Map::len), cadcraft_engine::cmd::file::MAX_ALIASES);
// A malformed section is skipped without changing what ALIASEDIT holds.
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": { "zzchange": "circle" }, "file": 7 }));
assert_eq!(resolve_alias("zzchange"), "zzchange", "a malformed section restores nothing");
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
assert_eq!(resolve_alias("zz0"), "zz0", "clear still empties the restored set");
}
#[test]
fn hostile_alias_files_never_panic() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
-1
View File
@@ -86,7 +86,6 @@ mod minsert;
mod mirror_degenerate_line;
mod mleader_content;
mod mleader_remember;
mod mleader_text_edit;
mod mline;
mod mocoro;
mod modify_curves;
-124
View File
@@ -1,124 +0,0 @@
//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and
//! hands it to the in-place editor, and the Properties palette's Contents field changes it.
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session};
use serde_json::json;
fn text_of(s: &Session) -> String {
s.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None })
.unwrap()
}
#[test]
fn textedit_and_properties_change_a_multileaders_text() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let (h, at) = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
.unwrap();
// TEXTEDIT on the text: the current contents are the default, and the in-place editor gets
// the multileader's MTEXT.
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
let p = s.current_prompt().unwrap();
assert_eq!(p.message, "Enter new text");
assert_eq!(p.default.as_deref(), Some("example text"));
assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string()));
s.input(Input::Text("edited\nsecond line".into())).unwrap();
assert_eq!(text_of(&s), "edited\\Psecond line");
s.cancel();
// The Properties palette's Contents field.
s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
assert_eq!(text_of(&s), "from properties");
}
/// An edited multileader text is undone in one step and is what a DXF save keeps.
#[test]
fn an_edited_multileader_text_undoes_and_survives_a_dxf_save() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let at = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
.unwrap();
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
s.input(Input::Text("edited\nsecond line".into())).unwrap();
s.cancel();
// 2007 DXF: older versions keep a multileader as a LEADER and an MTEXT.
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
let reopened = cadcraft_io::read_dxf(&cadcraft_io::write_with(s.doc().unwrap(), "x.dxf", &opts).unwrap()).unwrap();
let saved =
reopened.model.iter().find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None });
assert_eq!(saved.as_deref(), Some("edited\\Psecond line"));
s.undo().unwrap();
assert_eq!(text_of(&s), "example text");
}
/// A multileader on a locked layer is refused when it is picked, before the editor opens, and
/// Properties leaves it alone.
#[test]
fn a_multileader_on_a_locked_layer_is_refused_when_picked() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let (h, at) = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
.unwrap();
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("The object is on a locked layer."));
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
assert_eq!(s.current_prompt().unwrap().message, "Select an annotation object");
s.cancel();
let r = s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
assert_eq!(text_of(&s), "example text");
}
#[test]
fn a_text_too_long_for_the_editor_is_asked_for_at_the_command_line() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let (h, at) = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
.unwrap();
let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS));
s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap();
assert_eq!(text_of(&s), long);
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("This text is too long for the editor; enter the new text at the command line."));
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
assert_eq!(s.current_prompt().unwrap().message, "Enter new text");
// Enter keeps it whole; a typed answer still replaces it, as for any other text.
s.input(Input::Enter).unwrap();
assert_eq!(text_of(&s), long, "the text is not shortened");
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
s.input(Input::Text("short".into())).unwrap();
assert_eq!(text_of(&s), "short");
s.cancel();
}
+1 -308
View File
@@ -33,18 +33,8 @@ pub struct AliasDlgState {
pub filter: String,
pub form: Option<Form>,
pub error: Option<String>,
/// The warnings the last imported file reported (skipped lines, external commands, unknown
/// commands); the first few are shown in the dialog and all of them on the command line.
pub warnings: Vec<String>,
/// ALIASEDIT typed again while open: bring the window to the front on the next frame.
pub raise: bool,
/// Import… was clicked over aliases of the user's own: the dialog asks before a file replaces them.
pub confirm_import: bool,
}
/// Whether Import… asks first: the file would replace the user's aliases or the edits not applied yet.
pub fn import_replaces_something(st: &AliasDlgState) -> bool {
!st.user.is_empty() || !st.applied.is_empty()
}
/// The user aliases and alias file ALIASEDIT holds now.
@@ -153,82 +143,6 @@ pub fn apply(app: &mut CadApp) -> bool {
}
}
/// Whether `path` names a `.pgp` file, whatever its case.
fn is_pgp(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("pgp"))
}
/// Report a failed import in the dialog (when it is open) and on the command line; returns it so
/// the caller can too.
fn refused(app: &mut CadApp, msg: String) -> String {
app.session.echo(msg.clone());
if let Some(st) = app.alias_dlg.as_mut() {
st.error = Some(msg.clone());
st.warnings.clear();
}
msg
}
/// Load the `.pgp` alias file at `path` through ALIASEDIT, replacing the user aliases as typing
/// `ALIASEDIT {file}` does. The dialog refreshes to what ALIASEDIT holds now, so OK and Apply see
/// no pending difference; the file's warnings are kept for display. Non-`.pgp` and unreadable
/// files are ordinary errors, never a panic.
pub fn import(app: &mut CadApp, path: &str) -> Result<(), String> {
if !is_pgp(path) {
return Err(refused(app, format!("Import: {path} is not a .pgp alias file")));
}
match app.run("aliasedit", json!({ "file": path })) {
Ok(v) => {
let aliases: BTreeMap<String, String> = v
.get("aliases")
.and_then(Value::as_object)
.map(|m| m.iter().filter_map(|(a, c)| Some((a.clone(), c.as_str()?.to_string()))).collect())
.unwrap_or_default();
let file = v.get("file").and_then(Value::as_str).unwrap_or(path).to_string();
let loaded = v.get("loaded").and_then(Value::as_u64).unwrap_or(0);
let warnings: Vec<String> = v
.get("warnings")
.and_then(Value::as_array)
.map(|w| w.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
app.ui.last_pgp_dir = Some(dir.to_string_lossy().to_string());
}
app.session.echo(format!("{loaded} command alias(es) imported from {path}."));
for w in &warnings {
app.session.echo(format!("Import {path}: {w}"));
}
if let Some(st) = app.alias_dlg.as_mut() {
st.user = aliases.clone();
st.applied = aliases;
st.file = Some(file);
st.form = None;
st.selected = None;
st.error = None;
st.warnings = warnings;
}
Ok(())
}
Err(e) => Err(refused(app, e)),
}
}
/// Import…: ask the host for a `.pgp` file (starting in the folder the last import came from),
/// then load it. A cancelled picker is a no-op; without a native picker (the web) the error names
/// the ALIASEDIT `text` form, since the web has no file system to read a path from.
pub fn pick_and_import(app: &mut CadApp) -> Result<(), String> {
let dir = app.ui.last_pgp_dir.clone();
let picked = app.services.pick_pgp.as_ref().map(|pick| pick(dir.as_deref()));
match picked {
None => Err(refused(
app,
crate::tl!("Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter.").to_string(),
)),
Some(None) => Ok(()),
Some(Some(path)) => import(app, &path),
}
}
/// Command Aliases window that edits the in-memory alias set in `app.alias_dlg`; OK/Apply send the differences to ALIASEDIT, read the resulting aliases and source file back, and show any error.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if app.alias_dlg.is_none() {
@@ -236,9 +150,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
}
let built = built_in();
let warn = Tokens::get().warn;
let (mut ok, mut cancel, mut apply_clicked, mut import_clicked) = (false, false, false, false);
// Without a native file picker (the web) Import… can't open one; ALIASEDIT's `text` form loads a file there.
let can_pick = app.services.pick_pgp.is_some();
let (mut ok, mut cancel, mut apply_clicked) = (false, false, false);
let Some(st) = app.alias_dlg.as_mut() else { return };
let id = egui::Id::unique("aliasedit_dialog");
if std::mem::take(&mut st.raise) {
@@ -249,7 +161,6 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
Some(f) => ui.label(crate::tf!("Alias file: {file}", file = f)),
None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")),
};
ui.label(RichText::new(crate::tl!("Aliases are saved in your CADCraft profile and survive restarts.")).small());
ui.horizontal(|ui| {
let label = ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut st.filter).labelled_by(label.id);
@@ -310,31 +221,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
{
st.user.remove(s);
}
let import = ui
.add_enabled(can_pick, egui::Button::new(crate::tl!("Import…")))
.on_disabled_hover_text(crate::tl!("Run ALIASEDIT with the file's contents using its text parameter."));
if import.clicked() {
if import_replaces_something(st) {
st.confirm_import = true;
} else {
import_clicked = true;
}
}
});
if st.confirm_import {
ui.group(|ui| {
ui.label(crate::tf!("Importing a file replaces your {n} aliases, and any changes not applied yet.", n = st.user.len()));
ui.horizontal(|ui| {
if ui.button(crate::tl!("Import and replace")).clicked() {
st.confirm_import = false;
import_clicked = true;
}
if ui.button(crate::tl!("Keep my aliases")).clicked() {
st.confirm_import = false;
}
});
});
}
let mut save = false;
let mut discard = false;
if let Some(form) = st.form.as_mut() {
@@ -374,15 +261,6 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if let Some(e) = &st.error {
ui.colored_label(warn, e.as_str());
}
if !st.warnings.is_empty() {
for w in st.warnings.iter().take(3) {
ui.colored_label(warn, w.as_str());
}
let more = st.warnings.len().saturating_sub(3);
if more > 0 {
ui.label(crate::tf!("{n} more warnings; see the command line.", n = more));
}
}
ui.separator();
ui.horizontal(|ui| {
ok = ui.button(crate::tl!("OK")).clicked();
@@ -390,9 +268,6 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
apply_clicked = ui.button(crate::tl!("Apply")).clicked();
});
});
if import_clicked {
let _ = pick_and_import(app);
}
if (ok && apply(app)) || cancel {
*open = false;
} else if apply_clicked {
@@ -402,185 +277,3 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
app.alias_dlg = None;
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use cadcraft_engine::Session;
use serde_json::json;
use crate::{CadApp, Services};
use super::{open, pick_and_import};
/// The alias store is process-wide, so the tests that load files take turns and clear it.
fn alone() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}
fn temp_dir(name: &str) -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!("cadcraft-alias-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::create_dir_all(&dir);
dir
}
/// An app whose Import picker answers `answer` and records the folder it was asked to start in.
fn app_with_pick(answer: Option<String>) -> (CadApp, Rc<RefCell<Vec<Option<String>>>>) {
let asked = Rc::new(RefCell::new(Vec::new()));
let log = Rc::clone(&asked);
let answer = Rc::new(RefCell::new(answer));
let services = Services {
pick_pgp: Some(Box::new(move |dir: Option<&str>| {
log.borrow_mut().push(dir.map(str::to_string));
answer.borrow().clone()
})),
..Services::default()
};
(CadApp::new(Session::new(), services), asked)
}
fn clear(app: &mut CadApp) {
let _ = app.run("aliasedit", json!({ "clear": true }));
}
#[test]
fn import_loads_the_aliases_shows_the_path_and_warnings() {
let _guard = alone();
let dir = temp_dir("good");
let path = dir.join("aliases.pgp");
std::fs::write(&path, "; our own aliases\nXXL, *LINE\nXXC, *CIRCLE\nNOPE, *NOTACOMMAND\nEXT, notepad.exe\n").unwrap_or_default();
let picked = path.to_string_lossy().to_string();
let (mut app, asked) = app_with_pick(Some(picked.clone()));
open(&mut app);
pick_and_import(&mut app).expect("the file loads");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user.get("xxl").map(String::as_str), Some("line"));
assert_eq!(st.user.get("xxc").map(String::as_str), Some("circle"));
assert_eq!(st.file.as_deref(), Some(picked.as_str()), "the imported path is shown");
assert_eq!(st.applied, st.user, "an import leaves nothing for Apply to resend");
assert!(st.error.is_none());
assert!(st.warnings.iter().any(|w| w.contains("unknown command")), "unknown commands are reported: {:?}", st.warnings);
assert!(st.warnings.iter().any(|w| w.contains("external command")), "external commands are reported: {:?}", st.warnings);
assert!(app.session.log.iter().any(|l| l.contains("unknown command")), "the command line shows the full warnings");
assert_eq!(app.ui.last_pgp_dir.as_deref(), Some(dir.to_string_lossy().as_ref()), "the import folder is remembered");
assert_eq!(asked.borrow().as_slice(), [None], "the first import has no folder yet");
// The next import starts where the last file was.
open(&mut app);
let _ = pick_and_import(&mut app);
assert_eq!(asked.borrow().as_slice(), [None, Some(dir.to_string_lossy().to_string())]);
clear(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
/// Import… asks first when a file would replace aliases of the user's own or unapplied edits;
/// with neither it opens the picker at once.
#[test]
fn import_asks_before_replacing_the_users_aliases() {
let mut st = super::AliasDlgState::default();
assert!(!super::import_replaces_something(&st));
st.user.insert("xxl".into(), "line".into());
assert!(super::import_replaces_something(&st), "an edit not applied yet");
st.applied = st.user.clone();
assert!(super::import_replaces_something(&st), "an applied user alias");
st.user.clear();
assert!(super::import_replaces_something(&st), "a removal not applied yet");
}
#[test]
fn a_cancelled_picker_changes_nothing() {
let _guard = alone();
let (mut app, asked) = app_with_pick(None);
open(&mut app);
let before = app.alias_dlg.clone();
pick_and_import(&mut app).expect("cancelling is not an error");
assert_eq!(asked.borrow().len(), 1, "the picker was offered");
assert_eq!(app.alias_dlg, before, "a cancelled picker leaves the aliases, file and errors alone");
clear(&mut app);
}
#[test]
fn non_pgp_and_malformed_files_are_ordinary_errors() {
let _guard = alone();
let dir = temp_dir("bad");
let text = dir.join("aliases.txt");
std::fs::write(&text, "XXL, *LINE\n").unwrap_or_default();
let (mut app, _) = app_with_pick(Some(text.to_string_lossy().to_string()));
open(&mut app);
let before = app.alias_dlg.clone().unwrap_or_default();
let e = pick_and_import(&mut app).expect_err("a .txt file is not an alias file");
assert!(e.contains(".pgp"), "{e}");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user, before.user, "the refused file changes no alias");
assert_eq!(st.file, before.file);
assert!(st.error.as_deref().is_some_and(|e| e.contains(".pgp")), "{:?}", st.error);
// Malformed lines are warnings with the aliases that did parse, never a panic.
let broken = dir.join("broken.pgp");
std::fs::write(&broken, "not an alias line\nNOSUCH, *NOTACOMMAND\n; comment\nXXL, *LINE\n").unwrap_or_default();
let (mut app, _) = app_with_pick(Some(broken.to_string_lossy().to_string()));
open(&mut app);
pick_and_import(&mut app).expect("malformed lines are warnings");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user.get("xxl").map(String::as_str), Some("line"));
assert!(st.warnings.len() >= 2, "{:?}", st.warnings);
assert!(st.error.is_none(), "{:?}", st.error);
// A file that cannot be read is an error, reported in the dialog.
let missing = dir.join("missing.pgp");
let (mut app, _) = app_with_pick(Some(missing.to_string_lossy().to_string()));
open(&mut app);
let e = pick_and_import(&mut app).expect_err("a missing file is an ordinary error");
assert!(e.contains("missing.pgp"), "{e}");
assert!(app.alias_dlg.as_ref().is_some_and(|st| st.error.is_some()));
clear(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_last_import_folder_round_trips_through_prefs() {
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.last_pgp_dir = Some("/tmp/our-aliases".into());
let saved = app.prefs_json();
assert!(saved.contains("lastPgpDir"), "{saved}");
let mut restored = CadApp::new(Session::new(), Services::default());
restored.load_prefs(&saved);
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"));
restored.load_prefs(r#"{"lastPgpDir": 3}"#);
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"), "malformed values are ignored");
restored.load_prefs(r#"{"lastPgpDir": ""}"#);
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"), "an empty value is ignored");
}
#[test]
fn no_picker_points_at_aliasedit_text_without_changing_aliases() {
let mut app = CadApp::new(Session::new(), Services::default());
open(&mut app);
let before = app.alias_dlg.clone();
let e = pick_and_import(&mut app).expect_err("the web has no native picker");
assert!(e.contains("ALIASEDIT"), "{e}");
assert!(e.contains("contents"), "the web is told to give the file's contents: {e}");
assert!(e.contains("text"), "the web is told which parameter carries the contents: {e}");
assert!(!e.contains("path"), "the web has no file system, so a path is not the supported form: {e}");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user, before.as_ref().map(|b| b.user.clone()).unwrap_or_default());
assert!(st.error.as_deref().is_some_and(|e| e.contains("ALIASEDIT")));
}
}
+3 -7
View File
@@ -26,16 +26,12 @@ pub(super) fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOption
}
/// The model space tiles on screen (none without tiling): each tile's rectangle in `full`.
/// Start TEXTEDIT on the text, mtext, attribute definition or multileader text at `p` (the objects
/// its shortcut menu offers Edit Text for, and multileaders, #1081); false when there is none.
/// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu
/// offers Edit Text for); false when there is none.
pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {
use cadcraft_engine::doc::EntityKind;
let is_text = cadcraft_engine::cmd::curves::pick_at(&app.session, p).and_then(|h| {
app.session.doc().ok()?.entity(h).map(|e| match &e.kind {
EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) => true,
EntityKind::MLeader(m) => m.text.is_some(),
_ => false,
})
app.session.doc().ok()?.entity(h).map(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_)))
});
if is_text != Some(true) {
return false;
+7 -37
View File
@@ -6,12 +6,8 @@
//! Pan/Zoom, Quick Select, Find, Properties and Options.
//! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the
//! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click; Options ▸ User
//! Preferences ▸ Right-Click Customization sets how long, `rightClickDuration`). With that turned
//! off (`timeSensitiveRightClick`), Command mode says what a right-click does
//! (`rightClickCommand`: the menu, Enter, or the menu only when the prompt has options).
//! `rightClickDefault` and `rightClickEdit` can make a right-click with no command running
//! repeat the last command instead of opening the menu.
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). With that turned off
//! in OPTIONS (`timeSensitiveRightClick`), every right-click opens the menu.
//! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point
//! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt.
//!
@@ -45,8 +41,7 @@ const SETTINGS: &[&str] = &["options"];
/// How many recent commands and inputs the menus list.
pub const RECENT: usize = 8;
/// How long the right button must be held while a command runs to open the command menu instead
/// of acting as Enter, by default (AutoCAD's time-sensitive right-click; 250 ms there by
/// default). OPTIONS `rightClickDuration` changes it.
/// of acting as Enter (AutoCAD's time-sensitive right-click; 250 ms there by default).
pub const HOLD_SECS: f64 = 0.25;
/// Item ids that are not commands: keys, typed text and snap menu items.
@@ -318,21 +313,6 @@ pub fn groups_ui(ui: &mut egui::Ui, groups: &[Vec<Entry>], clicked: &mut Option<
pub fn show(app: &mut CadApp, resp: &egui::Response) {
let mut clicked = None;
let snap = app.canvas.snap_menu;
// Right-Click Customization: with nothing selected (Default mode) or a selection (Edit mode),
// a right-click may repeat the last command instead of opening the menu. With no last command
// yet, it opens the menu.
let o = &app.options;
let mode =
if o.right_click_default == o.right_click_edit || app.session.selection().is_empty() { o.right_click_default } else { o.right_click_edit };
if !snap
&& mode == crate::options::IdleClick::Repeat
&& let Some(last) = app.session.last_command.clone()
{
if resp.secondary_clicked() {
app.start(&last);
}
return;
}
resp.context_menu(|ui| {
let groups = if snap { snap_entries(app) } else { entries(app) };
groups_ui(ui, &groups, &mut clicked);
@@ -350,9 +330,8 @@ struct Press(f64, egui::Pos2);
/// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a
/// quick click is Enter — returns `true` when the caller should send it — and a click held for
/// OPTIONS `rightClickDuration` opens the command menu ([`command_entries`]) at the pointer. With
/// the time-sensitive right-click turned off, `rightClickCommand` says whether a click opens the
/// menu or is Enter. Call it every frame
/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer (any click does with
/// the time-sensitive right-click turned off). Call it every frame
/// with whether the pointer is on the canvas and whether a right-click ended there; it also shows
/// the menu while it is open.
pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool {
@@ -369,23 +348,14 @@ pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked:
ctx.data_mut(|d| d.insert_temp(press_id, Press(now, p)));
}
let running = app.session.prompting() && !app.canvas.snap_menu;
let o = &app.options;
let timed = o.time_sensitive_right_click;
// Without the time-sensitive right-click, Command mode says whether a click opens the menu.
let menu = match o.right_click_command {
crate::options::CommandClick::Menu => true,
crate::options::CommandClick::Enter => false,
crate::options::CommandClick::MenuWithOptions => app.session.current_prompt().is_some_and(|p| !p.keywords.is_empty()),
};
let hold = f64::from(o.right_click_hold_ms) / 1000.0;
let mut open = false;
if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::<Press>(press_id)) {
// A drag isn't a click; a click held long enough asks for the menu.
let still = pos.is_some_and(|p| p.distance(p0) <= 6.0);
let held = if timed { now - t0 >= hold } else { menu };
let held = now - t0 >= HOLD_SECS || !app.options.time_sensitive_right_click;
open = running && still && held;
}
let enter = running && inside && right_clicked && !open && (timed || !menu);
let enter = running && inside && right_clicked && !open && app.options.time_sensitive_right_click;
if !running {
return false;
}
+2 -77
View File
@@ -25,7 +25,7 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::sync::mpsc::{Receiver, Sender};
use std::time::Duration;
use cadcraft_engine::Input;
@@ -87,25 +87,6 @@ impl PendingReply {
// Started: a reply that arrived just now still counts.
self.rx.try_recv().unwrap_or_else(|_| json!({"ok": false, "error": "timeout", "state": "may-have-run"}))
}
/// The reply if the app has answered, without waiting: a transport that cannot block (the web
/// shell) polls this once a frame. A request the app dropped without answering reports that.
pub fn try_reply(&self) -> Option<ControlResponse> {
match self.rx.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app dropped the request"})),
}
}
}
impl Drop for PendingReply {
/// A reply nobody waits for any more cancels a request the app has not started, as a timed-out
/// [`PendingReply::wait`] does: the client was told it failed, so the app must not run it late.
/// A request the app already started, or answered, is unaffected.
fn drop(&mut self) {
let _ = self.state.compare_exchange(QUEUED, CANCELLED, Ordering::AcqRel, Ordering::Acquire);
}
}
pub enum Outcome {
@@ -475,9 +456,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
}
}
match serde_json::from_value::<crate::UiState>(cur) {
Ok(mut u) => {
// Shortcuts are checked as the preferences check them.
u.shortcuts = crate::keys::from_prefs(&json!(u.shortcuts));
Ok(u) => {
if let Err(error) = crate::docking::validate(&u.docking) {
return err(error);
}
@@ -747,10 +726,6 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64)
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else {
return json!({"ok": false, "error": "bad image", "request": token});
};
// No file system to write to (the web build): the PNG comes back inline, as `ui.render`'s does.
if cfg!(target_arch = "wasm32") {
return inline_screenshot(&img, token);
}
let saved = match path {
Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other),
None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()),
@@ -761,60 +736,10 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64)
}
}
/// A screenshot as an inline PNG reply (`pngBase64`, `width`, `height`, `bytes`, like `ui.render`), for a
/// build that has no file system to write it to: the web build, where `std::env::temp_dir` panics.
fn inline_screenshot(img: &image::RgbaImage, token: u64) -> Value {
let (w, h) = img.dimensions();
let mut png = Vec::new();
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) {
return json!({"ok": false, "error": e.to_string(), "request": token});
}
json!({"ok": true, "request": token, "result": {"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "bytes": png.len()}})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_inline_screenshot_carries_the_png_and_its_size() {
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255]));
let reply = inline_screenshot(&img, 7);
assert_eq!(reply["ok"], true);
assert_eq!(reply["request"], 7);
assert_eq!((reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()), (Some(3), Some(2)));
let b64 = reply["result"]["pngBase64"].as_str().unwrap_or_default();
assert!(b64.starts_with("iVBORw0KGgo"), "a PNG signature in base64: {b64:.20}");
assert!(reply["result"]["bytes"].as_u64().is_some_and(|n| n > 30), "the PNG byte count is reported");
}
#[test]
fn dropping_a_pending_reply_cancels_a_request_the_app_has_not_started() {
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
drop(pending);
assert!(!request.begin(), "the app must skip a request nobody waits for");
}
#[test]
fn dropping_a_pending_reply_leaves_a_started_request_alone() {
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
assert!(request.begin());
drop(pending);
assert_eq!(request.state.load(Ordering::Acquire), STARTED, "a started request is not cancelled");
}
#[test]
fn try_reply_polls_without_blocking() {
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
assert_eq!(pending.try_reply(), None, "no answer yet");
assert!(request.begin());
request.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
assert_eq!(pending.try_reply(), Some(json!({"ok": true, "result": 1})));
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
drop(request);
assert_eq!(pending.try_reply().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
}
#[test]
fn render_view_fit_finds_offscreen_drawing() {
let far = cadcraft_geom::Bounds2::new(Vec2::new(9000.0, 9000.0), Vec2::new(9100.0, 9050.0));
-1
View File
@@ -24,7 +24,6 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
"hatch" => crate::hatchdlg::dialog(app, ctx, &mut open),
"aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),
"find" => crate::finddlg::dialog(app, ctx, &mut open),
crate::keys::DIALOG => crate::keys::dialog(app, ctx, &mut open),
"about" => about(ctx, &mut open),
"preview" => crate::exchange::dialog(app, ctx, &mut open),
"commands" => commands(app, ctx, &mut open),
-7
View File
@@ -1029,10 +1029,3 @@
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Le dessin a changé. Fermez la fenêtre et rouvrez-la.
Keyboard Shortcuts... Raccourcis clavier...
Keyboard Shortcuts Raccourcis clavier
Replaces the shortcut of {commands} Remplace le raccourci de : {commands}
Needs Ctrl, Alt or a function key Il faut Ctrl, Alt ou une touche de fonction
Press the keys Appuyez sur les touches
Back to CADCraft's shortcut Revenir au raccourci de CADCraft
A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Un raccourci que vous définissez l'emporte sur celui de CADCraft. Les modifications s'appliquent tout de suite et sont conservées.
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
1029
1030
1031
-17
View File
@@ -1381,20 +1381,3 @@ menu Modify Edita
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Il disegno è cambiato. Chiudere la finestra e riaprirla.
Right-Click Customization... Personalizzazione pulsante destro...
Right-Click Customization Personalizzazione pulsante destro
Turn on time-sensitive right-click Attiva clic destro sensibile al tempo
Longer click duration: Durata clic lungo:
milliseconds millisecondi
While a command runs, a quick click is Enter and a longer click opens the shortcut menu. Durante un comando un clic rapido equivale a Invio, un clic più lungo apre il menu di scelta rapida.
Default Mode Modalità predefinita
No objects selected and no command running: Nessun oggetto selezionato e nessun comando in corso:
Edit Mode Modalità modifica
Objects selected and no command running: Oggetti selezionati e nessun comando in corso:
Repeat last command Ripeti ultimo comando
Shortcut Menu Menu di scelta rapida
Command Mode Modalità comando
While a command runs: Durante un comando:
ENTER INVIO
Shortcut Menu: always enabled Menu di scelta rapida: sempre attivo
Shortcut Menu: enabled when command options are present Menu di scelta rapida: attivo se il comando ha opzioni
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
1381
1382
1383
-1
View File
@@ -105,7 +105,6 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
"dialogs",
"hatchdlg",
"aliasdlg",
"keys",
"mtext_editor",
"layers",
"bedit",
-29
View File
@@ -1364,17 +1364,12 @@
More than {n} aliases. Понад {n} псевдонімів.
Alias file: {file} Файл псевдонімів: {file}
No alias file loaded. REINIT replaces these aliases with a file's. Файл псевдонімів не завантажено. REINIT замінює ці псевдоніми псевдонімами з файлу.
Aliases are saved in your CADCraft profile and survive restarts. Псевдоніми зберігаються в профілі CADCraft і не зникають після перезапуску.
Alias Псевдонім
Command Команда
overrides built-in замінює вбудований
built-in вбудований
Add… Додати…
Edit… Змінити…
Import… Імпортувати…
Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter. Імпорт: тут немає діалогу вибору файлів; запустіть ALIASEDIT із вмістом файлу в параметрі text.
{n} more warnings; see the command line. Ще {n} попереджень; див. командний рядок.
Alias files (PGP) Файли псевдонімів (PGP)
Save alias Зберегти псевдонім
Discard Скасувати зміни
Customize Налаштування інтерфейсу
@@ -1398,27 +1393,3 @@
Ctrl+Enter to apply Ctrl+Enter to apply
Edit Text Edit Text
The drawing changed. Close the window and open it again. Креслення змінилося. Закрийте вікно та відкрийте його знову.
Keyboard Shortcuts... Комбінації клавіш...
Keyboard Shortcuts Комбінації клавіш
Replaces the shortcut of {commands} Замінює комбінацію клавіш команди: {commands}
Needs Ctrl, Alt or a function key Потрібна клавіша Ctrl, Alt або функціональна клавіша
Press the keys Натисніть клавіші
Back to CADCraft's shortcut Повернути комбінацію CADCraft
A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Ваша комбінація клавіш має перевагу над комбінацією CADCraft. Зміни застосовуються одразу й зберігаються.
Right-Click Customization... Налаштування правої кнопки...
Right-Click Customization Налаштування правої кнопки
Turn on time-sensitive right-click Увімкнути залежне від часу клацання правою кнопкою
Longer click duration: Тривалість довгого клацання:
milliseconds мілісекунд
While a command runs, a quick click is Enter and a longer click opens the shortcut menu. Під час команди коротке клацання — Enter, довше відкриває контекстне меню.
Default Mode Режим за замовчуванням
No objects selected and no command running: Нічого не вибрано й команда не виконується:
Edit Mode Режим редагування
Objects selected and no command running: Об'єкти вибрано, команда не виконується:
Repeat last command Повторити останню команду
Shortcut Menu Контекстне меню
Command Mode Режим команди
While a command runs: Під час виконання команди:
ENTER Клавіша Enter
Shortcut Menu: always enabled Контекстне меню: завжди
Shortcut Menu: enabled when command options are present Контекстне меню: коли команда має параметри
Can't render this file because it contains an unexpected character in line 1380 and column 24.
-1
View File
@@ -362,7 +362,6 @@
Join 合并
Join the ArtCraft Discord… 加入 ArtCraft Discord…
Join us on Discord 在 Discord 上加入我们
Keyboard Shortcuts... 键盘快捷键...
Language 语言
Last 最后
Last commit 最后提交
1 # CADCraft Simplified Chinese interface catalog — original translations.
362
363
364
365
366
367
-697
View File
@@ -1,697 +0,0 @@
//! The user's keyboard shortcuts (Tools ▸ Customize ▸ Keyboard Shortcuts...): any command can get
//! one, and a shortcut the user gives wins over CADCraft's own; the command that had it loses it,
//! and the dialog says so. They are kept with the other preferences (`shortcuts`).
//!
//! Recording a shortcut (click, press the keys, Esc to cancel) and the conflict rules come from
//! the storey shortcuts of #1703 (@Alex-BB44).
use std::collections::BTreeMap;
use egui::{Key, KeyboardShortcut, Modifiers, RichText, vec2};
use serde_json::{Value, json};
use crate::CadApp;
use crate::theme::Tokens;
/// The user's shortcuts: command id → registry shortcut ("Cmd+Shift+K"). Empty: the command has
/// none (its built-in one is removed).
pub type UserKeys = BTreeMap<String, String>;
/// At most this many user shortcuts are kept; longer shortcut strings are refused (preferences
/// and JSON calls are hostile input).
pub const MAX_KEYS: usize = 1000;
const MAX_LEN: usize = 64;
/// Commands whose built-in key is handled outside the shortcut table and stays as it is: the
/// clipboard keys, Shift ones included, arrive as clipboard events, Delete belongs to the command
/// line, Cmd+Q to the macOS app menu.
const FIXED: &[&str] = &["copyclip", "cutclip", "pasteclip", "copybase", "pasteblock", "erase.selection", "ui.quit"];
/// The dialog name in `UiState::dialog`.
pub const DIALOG: &str = "shortcuts";
/// Whether two shortcuts are one key press. Outside macOS Ctrl is the command key, and one Ctrl
/// press runs a Ctrl and a Cmd shortcut alike, so they are compared as Cmd.
fn same(a: &KeyboardShortcut, b: &KeyboardShortcut) -> bool {
let pressed = |m: Modifiers| {
if !cfg!(target_os = "macos") && (m.ctrl || m.command || m.mac_cmd) {
Modifiers { ctrl: false, command: true, mac_cmd: false, ..m }
} else {
m
}
};
a.logical_key == b.logical_key && pressed(a.modifiers) == pressed(b.modifiers)
}
/// A registry shortcut string ("Cmd+Shift+G", "Alt+4", "F3") as a shortcut; `None` when empty or
/// not understood. Cmd is the command key (Ctrl on Windows and Linux); Ctrl alone is the Control
/// key of macOS, and Cmd+Ctrl both macOS keys.
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
let mut m = Modifiers::NONE;
let mut key = None;
for part in s.split('+').map(str::trim).filter(|p| !p.is_empty()) {
match part.to_ascii_lowercase().as_str() {
"cmd" | "command" => m |= Modifiers::COMMAND,
// Control on macOS (on Windows and Linux, Ctrl is written Cmd: it is the command key).
"ctrl" => m |= Modifiers::CTRL,
"shift" => m |= Modifiers::SHIFT,
"alt" | "option" => m |= Modifiers::ALT,
_ if key.is_none() => key = Some(Key::from_name(part)?),
_ => return None,
}
}
if m.command && m.ctrl {
m.mac_cmd = true;
}
key.map(|k| KeyboardShortcut::new(m, k))
}
/// A shortcut as a registry string ("Cmd+Shift+G"), the same on every platform: Cmd for the
/// command key, Ctrl for a Control that isn't it (macOS), so [`parse`] gives it back.
pub fn format(m: Modifiers, k: Key) -> String {
let mut s = String::new();
if m.command || m.mac_cmd {
s.push_str("Cmd+");
}
if m.ctrl && (m.mac_cmd || !m.command) {
s.push_str("Ctrl+");
}
if m.shift {
s.push_str("Shift+");
}
if m.alt {
s.push_str("Alt+");
}
// "+" and "-" would read as separators.
s.push_str(match k {
Key::Plus => "Plus",
Key::Minus => "Minus",
k => k.name(),
});
s
}
/// Whether `s` can be a user shortcut: with Cmd, Ctrl or Alt, or a function or paging key alone.
/// The keys the drawing area and the command line live on (letters as typing, Space, Enter, Tab,
/// Esc, Delete, Backspace, the arrows), the clipboard keys and Cmd+Q never are.
pub fn allowed(s: &KeyboardShortcut) -> bool {
let k = s.logical_key;
let never =
[Key::Space, Key::Enter, Key::Tab, Key::Escape, Key::Delete, Key::Backspace, Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight];
if never.contains(&k) {
return false;
}
let m = s.modifiers;
let command = m.command || m.ctrl || m.mac_cmd;
// Cmd+C, X and V arrive as clipboard events whatever else is held (Shift+Cmd+C is Copy with
// Base Point, Shift+Cmd+V Paste as Block).
let reserved = command && ([Key::C, Key::X, Key::V].contains(&k) || k == Key::Q && !m.shift && !m.alt);
let alone_ok = matches!(k, Key::PageUp | Key::PageDown | Key::Home | Key::End) || k.name().starts_with('F') && k.name().len() > 1;
!reserved && (command || m.alt || alone_ok)
}
/// Shortcuts are tried most specific first (egui lets a shortcut match with Shift or Alt held on
/// top), so Cmd+Shift+G reaches its own command before Cmd+G does.
pub fn by_specificity<T>(pairs: &mut [(KeyboardShortcut, T)]) {
let weight = |s: &KeyboardShortcut| {
let m = s.modifiers;
u8::from(m.shift) + u8::from(m.alt) + u8::from(m.ctrl && (m.mac_cmd || !m.command)) + u8::from(m.command || m.mac_cmd)
};
pairs.sort_by_key(|(s, _)| std::cmp::Reverse(weight(s)));
}
/// CADCraft's own shortcuts: the shortcut table, plus Cmd+8 (QuickCalc), which the shortcut
/// handler reads before a focused text field can take it.
fn defaults() -> Vec<(KeyboardShortcut, &'static str)> {
let mut v = crate::menus::builtin_shortcuts();
v.push((KeyboardShortcut::new(Modifiers::COMMAND, Key::Num8), "ui.toggle.quickcalc"));
v
}
/// The command a built-in shortcut belongs to in the editor: the table runs some commands' UI
/// form (`ui.open` for OPEN), which is the same command for the user.
fn owner(id: &str) -> &str {
id.strip_prefix("ui.").filter(|c| cadcraft_engine::find_command(c).is_some()).unwrap_or(id)
}
fn known(id: &str) -> bool {
cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id)
}
/// The shortcuts in force: the user's first, then CADCraft's own unless the user gave that
/// command another shortcut or gave the key to another command. Most specific first.
pub fn effective(user: &UserKeys) -> Vec<(KeyboardShortcut, String)> {
let mut out: Vec<(KeyboardShortcut, String)> = Vec::new();
for (id, key) in user {
if let Some(s) = parse(key).filter(allowed)
&& !out.iter().any(|(o, _)| same(o, &s))
{
out.push((s, id.clone()));
}
}
for (s, id) in defaults() {
if !user.contains_key(owner(id)) && !out.iter().any(|(o, _)| same(o, &s)) {
out.push((s, id.to_string()));
}
}
by_specificity(&mut out);
out
}
/// [`effective`], worked out again only when the user's shortcuts change (the shortcut handler
/// asks every frame).
pub fn effective_cached(ctx: &egui::Context, user: &UserKeys) -> Vec<(KeyboardShortcut, String)> {
let id = egui::Id::unique("user_keys_effective");
if let Some((k, v)) = ctx.data(|m| m.get_temp::<(UserKeys, Vec<(KeyboardShortcut, String)>)>(id))
&& k == *user
{
return v;
}
let v = effective(user);
ctx.data_mut(|m| m.insert_temp(id, (user.clone(), v.clone())));
v
}
/// The shortcut a menu or the editor shows for `id`, whose built-in one is `default`: the user's,
/// or the built-in one unless the user gave that key to another command.
pub fn shown(user: &UserKeys, id: &str, default: Option<&str>) -> Option<String> {
if let Some(k) = user.get(id) {
return (!k.is_empty()).then(|| k.clone());
}
let d = default?;
let s = parse(d);
let taken = s.is_some_and(|s| user.iter().any(|(o, k)| o != id && parse(k).is_some_and(|u| same(&u, &s))));
(!taken).then(|| d.to_string())
}
/// The built-in shortcut a command shows (its registry `key`, or a UI command's).
pub fn default_key(id: &str) -> Option<&'static str> {
cadcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3))
}
/// Give `id` the shortcut `key` ("" removes its shortcut). The commands that had the key, built
/// in or the user's, lose it; they are returned so the caller can say so.
pub fn assign(user: &mut UserKeys, id: &str, key: &str) -> Result<Vec<String>, String> {
if !known(id) {
return Err(format!("{id}: unknown command"));
}
if FIXED.contains(&id) {
return Err(format!("{id}: this shortcut can't be changed"));
}
let key = key.trim();
if key.len() > MAX_LEN {
return Err("shortcut too long".into());
}
if user.len() >= MAX_KEYS && !user.contains_key(id) {
return Err(format!("more than {MAX_KEYS} shortcuts"));
}
if key.is_empty() {
user.insert(id.to_string(), String::new());
return Ok(Vec::new());
}
let s = parse(key).ok_or_else(|| format!("{key}: not a shortcut"))?;
if !allowed(&s) {
return Err(format!("{key}: needs Ctrl, Alt or a function key"));
}
let mut replaced = Vec::new();
for (other, k) in user.iter_mut() {
if other != id && parse(k).is_some_and(|o| same(&o, &s)) {
k.clear();
replaced.push(other.clone());
}
}
// A built-in owner already lost the key to the user command that had it.
let held = !replaced.is_empty();
for (o, other) in defaults() {
let other = owner(other);
if !held && other != id && same(&o, &s) && !user.contains_key(other) && !replaced.iter().any(|r| r == other) {
replaced.push(other.to_string());
}
}
user.insert(id.to_string(), format(s.modifiers, s.logical_key));
Ok(replaced)
}
/// The user's shortcuts from saved preferences: unknown commands, bad strings, a shortcut an
/// earlier command already has (it would show on both, run one) and anything past [`MAX_KEYS`]
/// are dropped.
pub fn from_prefs(v: &Value) -> UserKeys {
let Some(m) = v.as_object() else { return UserKeys::new() };
let mut out = UserKeys::new();
let mut taken: Vec<KeyboardShortcut> = Vec::new();
for (id, k) in m.iter().filter_map(|(id, k)| Some((id, k.as_str()?))) {
if out.len() >= MAX_KEYS {
break;
}
if id.len() > MAX_LEN || k.len() > MAX_LEN || !known(id) || FIXED.contains(&id.as_str()) {
continue;
}
if !k.is_empty() {
let Some(s) = parse(k).filter(allowed) else { continue };
if taken.iter().any(|o| same(o, &s)) {
continue;
}
taken.push(s);
}
out.insert(id.clone(), k.to_string());
}
out
}
/// `ui.shortcuts`: from a menu or typed, the Keyboard Shortcuts dialog; with parameters
/// `{set?: {command: shortcut or ""}, reset?: bool}` the JSON form, which answers the user's
/// shortcuts and the commands that lost theirs.
pub fn command(app: &mut CadApp, params: &Value) -> Result<Value, String> {
if params.is_null() {
app.ui.dialog = Some(DIALOG.into());
return Ok(Value::Null);
}
let mut keys = app.ui.shortcuts.clone();
if params.get("reset").and_then(Value::as_bool) == Some(true) {
keys.clear();
}
let mut replaced = Vec::new();
if let Some(set) = params.get("set") {
let set = set.as_object().ok_or("`set` must be an object of command: shortcut")?;
if set.len() > MAX_KEYS {
return Err(format!("more than {MAX_KEYS} shortcuts"));
}
for (id, key) in set {
let key = key.as_str().ok_or_else(|| format!("{id}: the shortcut must be a string"))?;
replaced.extend(assign(&mut keys, &id.to_ascii_lowercase(), key)?);
}
}
app.ui.shortcuts = keys;
Ok(json!({ "shortcuts": app.ui.shortcuts, "replaced": replaced }))
}
const RECORDING: &str = "user_keys_recording";
/// Whether a shortcut is being recorded (every shortcut stays quiet meanwhile).
pub fn recording(ctx: &egui::Context) -> bool {
ctx.data(|m| m.get_temp::<String>(egui::Id::unique(RECORDING))).is_some()
}
pub fn stop_recording(ctx: &egui::Context) {
ctx.data_mut(|m| m.remove::<String>(egui::Id::unique(RECORDING)));
}
/// The commands the editor lists, sorted by the label shown: (id, label, what tells same labels
/// apart: the command name to type, or the menu an interface command sits in).
fn rows() -> Vec<(&'static str, String, String)> {
let mut v: Vec<(&'static str, String, String)> = Vec::new();
for c in cadcraft_engine::command_specs() {
if !c.label.is_empty() && !v.iter().any(|(id, ..)| *id == c.id) {
v.push((c.id, crate::i18n::t(c.label).to_string(), c.id.to_ascii_uppercase()));
}
}
for (id, label, menu, _) in crate::menus::UI_COMMANDS {
if !label.is_empty() && !menu.is_empty() && owner(id) == *id && !v.iter().any(|(i, ..)| i == id) {
let path: Vec<&str> = menu.iter().take(menu.len().saturating_sub(1)).map(|m| crate::i18n::menu(m)).collect();
v.push((id, crate::i18n::t(label).to_string(), path.join(" ▸ ")));
}
}
v.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(b.0)));
v
}
fn label_of(id: &str) -> String {
cadcraft_engine::find_command(id)
.map(|c| crate::i18n::t(c.label).to_string())
.or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| crate::i18n::t(c.1).to_string()))
.unwrap_or_else(|| id.to_string())
}
/// The Keyboard Shortcuts dialog: every command with its shortcut, a filter, a click to record a
/// new one. Changes apply at once.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
let mac = ctx.os().is_mac();
let rec_id = egui::Id::unique(RECORDING);
let filter_id = egui::Id::unique("user_keys_filter");
let note_id = egui::Id::unique("user_keys_note");
let mut recording: Option<String> = ctx.data(|m| m.get_temp(rec_id));
let mut filter: String = ctx.data(|m| m.get_temp(filter_id)).unwrap_or_default();
let mut note: Option<(String, bool)> = ctx.data(|m| m.get_temp(note_id));
// A key pressed while recording becomes that command's shortcut; Esc stops recording.
if let Some(id) = recording.clone() {
let pressed = ctx.input(|inp| {
inp.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
});
if let Some((k, m)) = pressed {
if k != Key::Escape {
note = Some(match assign(&mut app.ui.shortcuts, &id, &format(m, k)) {
Ok(lost) if lost.is_empty() => (String::new(), false),
Ok(lost) => {
let names: Vec<String> = lost.iter().map(|l| label_of(l)).collect();
(crate::tf!("Replaces the shortcut of {commands}", commands = names.join(", ")), false)
}
Err(_) => (crate::tl!("Needs Ctrl, Alt or a function key").to_string(), true),
});
}
recording = None;
ctx.input_mut(|inp| inp.events.clear());
}
}
let mut reset_all = false;
egui::Window::new(crate::tl!("Keyboard Shortcuts"))
.id(egui::Id::unique("shortcuts_dialog"))
.open(open)
.collapsible(false)
.default_size(vec2(600.0, 520.0))
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut filter);
});
let f = filter.trim().to_lowercase();
let rows: Vec<(&'static str, String, String)> = rows()
.into_iter()
.filter(|(_, label, detail)| f.is_empty() || label.to_lowercase().contains(&f) || detail.to_lowercase().contains(&f))
.collect();
let row_h = ui.spacing().interact_size.y + 4.0;
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show_rows(ui, row_h, rows.len(), |ui, range| {
for (id, label, detail) in rows.get(range).unwrap_or_default() {
ui.horizontal(|ui| {
let cell = |ui: &mut egui::Ui, w: f32, text: RichText| {
ui.allocate_ui_with_layout(vec2(w, row_h - 4.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(w);
ui.add(egui::Label::new(text).truncate());
});
};
cell(ui, 190.0, RichText::new(label.as_str()));
cell(ui, 140.0, RichText::new(detail.as_str()).color(t.text_dim).small());
let fixed = FIXED.contains(id);
let key = shown(&app.ui.shortcuts, id, default_key(id));
let text = if recording.as_deref() == Some(*id) {
crate::tl!("Press the keys").to_string()
} else {
key.as_deref().map_or_else(|| crate::tl!("None").to_string(), |k| crate::menus::shortcut_label(k, mac))
};
let b = egui::Button::new(text).selected(recording.as_deref() == Some(*id)).min_size(vec2(120.0, 0.0));
if ui.add_enabled(!fixed, b).clicked() {
recording = Some(id.to_string());
note = None;
}
if !fixed && key.is_some() && ui.small_button(crate::tl!("Clear")).clicked() {
let _ = assign(&mut app.ui.shortcuts, id, "");
}
if app.ui.shortcuts.contains_key(*id)
&& ui.small_button(crate::tl!("Default")).on_hover_text(crate::tl!("Back to CADCraft's shortcut")).clicked()
{
app.ui.shortcuts.remove(*id);
}
});
}
});
match &note {
Some((n, true)) => {
ui.colored_label(t.warn, n.as_str());
}
Some((n, false)) if !n.is_empty() => {
ui.label(RichText::new(n.as_str()).color(t.text_dim));
}
_ => {}
}
ui.separator();
ui.horizontal(|ui| {
reset_all = ui.button(crate::tl!("Reset to Defaults")).clicked();
});
ui.label(
RichText::new(crate::tl!("A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time."))
.color(t.text_dim)
.small(),
);
});
if reset_all {
app.ui.shortcuts.clear();
note = None;
}
if !*open {
recording = None;
note = None;
}
ctx.data_mut(|m| {
match &recording {
Some(r) => {
m.insert_temp(rec_id, r.clone());
}
None => {
m.remove::<String>(rec_id);
}
}
m.insert_temp(filter_id, filter);
match note {
Some(n) => {
m.insert_temp(note_id, n);
}
None => {
m.remove::<(String, bool)>(note_id);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_engine::Session;
fn app() -> CadApp {
CadApp::new(Session::new(), crate::Services::default())
}
fn key(s: &str) -> KeyboardShortcut {
parse(s).unwrap_or_else(|| panic!("{s}"))
}
fn runs(user: &UserKeys, s: &str) -> Option<String> {
effective(user).into_iter().find(|(o, _)| same(o, &key(s))).map(|(_, id)| id)
}
#[test]
fn shortcut_strings_round_trip() {
for s in ["Cmd+Shift+G", "Cmd+4", "F3", "Alt+PageUp", "Cmd+Plus", "Cmd+Minus", "Ctrl+K", "Cmd+Ctrl+K"] {
let k = key(s);
assert_eq!(format(k.modifiers, k.logical_key), s);
}
// Keys as egui reports them: Ctrl on Linux and Windows is the command key; macOS Control
// and Command+Control keep their Control.
let linux_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
let mac_ctrl = Modifiers { ctrl: true, ..Modifiers::NONE };
let mac_cmd_ctrl = Modifiers { ctrl: true, command: true, mac_cmd: true, ..Modifiers::NONE };
assert_eq!(format(linux_ctrl, Key::K), "Cmd+K");
assert_eq!(format(mac_ctrl, Key::K), "Ctrl+K");
assert_eq!(format(mac_cmd_ctrl, Key::K), "Cmd+Ctrl+K");
assert!(parse("").is_none() && parse("Cmd+").is_none() && parse("Cmd+G+H").is_none() && parse("Hyper+G").is_none());
}
#[test]
fn typing_keys_clipboard_keys_and_quit_are_never_user_shortcuts() {
for s in [
"L",
"Shift+L",
"Space",
"Enter",
"Escape",
"Cmd+C",
"Cmd+V",
"Cmd+X",
"Cmd+Shift+C",
"Cmd+Shift+V",
"Cmd+Alt+C",
"Cmd+Alt+X",
"Cmd+Alt+V",
"Cmd+Q",
"ArrowUp",
"Delete",
] {
assert!(!allowed(&key(s)), "{s}");
}
for s in ["Cmd+L", "Alt+L", "F4", "PageDown", "Cmd+Shift+Q", "Ctrl+K"] {
assert!(allowed(&key(s)), "{s}");
}
}
#[test]
fn any_command_gets_a_shortcut_and_it_runs_it() {
let mut user = UserKeys::new();
assert_eq!(assign(&mut user, "line", "Cmd+L"), Ok(vec![]));
assert_eq!(runs(&user, "Cmd+L").as_deref(), Some("line"));
// The built-in ones are untouched.
assert_eq!(runs(&user, "Cmd+S").as_deref(), Some("qsave"));
assert_eq!(runs(&UserKeys::new(), "Cmd+L"), None);
}
#[test]
fn control_and_command_control_shortcuts_keep_their_control() {
let mut user = UserKeys::new();
assert_eq!(assign(&mut user, "line", "Ctrl+K"), Ok(vec![]));
// Outside macOS both are one Ctrl press, so Circle takes it from Line.
let lost = if cfg!(target_os = "macos") { vec![] } else { vec!["line".to_string()] };
assert_eq!(assign(&mut user, "circle", "Cmd+Ctrl+K"), Ok(lost));
assert_eq!(user.get("circle").map(String::as_str), Some("Cmd+Ctrl+K"));
let eff = effective(&user);
let of = |id: &str| eff.iter().find(|(_, i)| i == id).map(|(s, _)| s.modifiers);
assert_eq!(of("circle"), Some(Modifiers { ctrl: true, command: true, mac_cmd: true, ..Modifiers::NONE }));
if cfg!(target_os = "macos") {
assert_eq!(user.get("line").map(String::as_str), Some("Ctrl+K"));
assert_eq!(of("line"), Some(Modifiers::CTRL));
// Command+Control+K is tried before Control+K, which it holds too.
let at = |id: &str| eff.iter().position(|(_, i)| i == id);
assert!(at("circle") < at("line"));
}
}
#[test]
fn a_user_shortcut_wins_over_a_built_in_one_and_says_which() {
let mut user = UserKeys::new();
assert_eq!(assign(&mut user, "line", "Cmd+P"), Ok(vec!["plot".to_string()]));
assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("line"));
assert_eq!(shown(&user, "plot", default_key("plot")), None, "Plot no longer shows Cmd+P");
assert_eq!(shown(&user, "line", default_key("line")).as_deref(), Some("Cmd+P"));
// Given to a third command, the first user one loses it too.
assert_eq!(assign(&mut user, "circle", "Cmd+P"), Ok(vec!["line".to_string()]));
assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("circle"));
assert_eq!(shown(&user, "line", default_key("line")), None);
// A table entry that runs a command's UI form belongs to that command (Cmd+O, OPEN).
assert_eq!(assign(&mut user, "line", "Cmd+O"), Ok(vec!["open".to_string()]));
assert_eq!(assign(&mut user, "line", "Cmd+8"), Ok(vec!["ui.toggle.quickcalc".to_string()]));
}
/// On Windows and Linux one Ctrl press runs a Ctrl and a Cmd shortcut alike, so they are one
/// key: giving it takes it from the command that had it, and preferences keep it once.
#[test]
#[cfg(not(target_os = "macos"))]
fn ctrl_and_cmd_are_one_key_outside_macos() {
let mut user = UserKeys::new();
assert_eq!(assign(&mut user, "line", "Ctrl+P"), Ok(vec!["plot".to_string()]));
assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("line"));
assert_eq!(assign(&mut user, "circle", "Cmd+P"), Ok(vec!["line".to_string()]));
let user = from_prefs(&json!({"circle": "Cmd+K", "line": "Ctrl+K"}));
assert_eq!(user.values().filter(|k| !k.is_empty()).count(), 1, "{user:?}");
}
#[test]
fn a_new_shortcut_replaces_the_commands_own_and_clear_removes_it() {
let mut user = UserKeys::new();
assert_eq!(assign(&mut user, "qsave", "Cmd+Shift+K"), Ok(vec![]));
assert_eq!(runs(&user, "Cmd+S"), None, "Save's own Cmd+S is replaced");
assert_eq!(runs(&user, "Cmd+Shift+K").as_deref(), Some("qsave"));
assert_eq!(assign(&mut user, "plot", ""), Ok(vec![]));
assert_eq!(runs(&user, "Cmd+P"), None);
assert_eq!(shown(&user, "plot", default_key("plot")), None);
// OPEN through its UI form: its Cmd+O goes when OPEN gets another key.
assert_eq!(assign(&mut user, "open", "F4"), Ok(vec![]));
assert_eq!(runs(&user, "Cmd+O"), None);
}
#[test]
fn bad_shortcuts_and_commands_are_refused() {
let mut user = UserKeys::new();
assert!(assign(&mut user, "nosuchcommand", "Cmd+L").is_err());
assert!(assign(&mut user, "line", "L").is_err());
assert!(assign(&mut user, "line", "Cmd+Nothing").is_err());
assert!(assign(&mut user, "line", &"Cmd+".repeat(1000)).is_err());
assert!(assign(&mut user, "copyclip", "Cmd+K").is_err(), "the clipboard keys stay");
assert!(assign(&mut user, "pasteblock", "Cmd+K").is_err(), "Shift+Cmd+V is a clipboard event too");
assert!(user.is_empty());
}
#[test]
fn the_json_form_sets_resets_and_never_opens_the_dialog() {
let mut app = app();
let v = app.run("ui.shortcuts", json!({"set": {"LINE": "Cmd+P"}})).unwrap();
assert_eq!(v["shortcuts"], json!({"line": "Cmd+P"}));
assert_eq!(v["replaced"], json!(["plot"]));
assert_eq!(app.ui.dialog, None);
assert!(app.run("ui.shortcuts", json!({"set": {"line": 3}})).is_err());
assert!(app.run("ui.shortcuts", json!({"set": "Cmd+P"})).is_err());
assert_eq!(app.ui.shortcuts.get("line").map(String::as_str), Some("Cmd+P"), "a refused call changes nothing");
let v = app.run("ui.shortcuts", json!({"reset": true})).unwrap();
assert_eq!(v["shortcuts"], json!({}));
app.start("ui.shortcuts");
assert_eq!(app.ui.dialog.as_deref(), Some(DIALOG));
}
#[test]
fn shortcuts_are_kept_with_the_preferences_and_hostile_ones_dropped() {
let mut a = app();
let _ = a.run("ui.shortcuts", json!({"set": {"line": "Cmd+L", "plot": ""}}));
let mut b = app();
b.load_prefs(&a.prefs_json());
assert_eq!(b.ui.shortcuts, a.ui.shortcuts);
let hostile =
json!({"shortcuts": {"line": "L", "nosuch": "Cmd+K", "copyclip": "Cmd+K", "circle": 5, "arc": "Cmd+K", "x".repeat(5000): "Cmd+J"}});
let mut c = app();
c.load_prefs(&hostile.to_string());
assert_eq!(c.ui.shortcuts, UserKeys::from([("arc".to_string(), "Cmd+K".to_string())]));
let many: serde_json::Map<String, Value> = (0..5000).map(|i| (format!("c{i}"), json!("Cmd+K"))).collect();
c.load_prefs(&json!({ "shortcuts": many }).to_string());
assert!(c.ui.shortcuts.len() <= MAX_KEYS);
c.load_prefs(r#"{"shortcuts": "Cmd+K"}"#);
// One key for two commands (an edited file): the first keeps it, the other is dropped.
c.load_prefs(r#"{"shortcuts": {"arc": "Cmd+K", "line": "Cmd+K", "circle": "Cmd+J"}}"#);
assert_eq!(c.ui.shortcuts, UserKeys::from([("arc".to_string(), "Cmd+K".to_string()), ("circle".to_string(), "Cmd+J".to_string())]));
}
/// Window ▸ Reset Palettes puts the palettes back, not the user's shortcuts.
#[test]
fn reset_palettes_keeps_the_users_shortcuts() {
let mut app = app();
let _ = app.run("ui.shortcuts", json!({"set": {"line": "Cmd+L"}}));
app.run("ui.resetpalettes", json!({})).unwrap();
assert_eq!(app.ui.shortcuts, UserKeys::from([("line".to_string(), "Cmd+L".to_string())]));
}
#[test]
fn the_menus_show_the_shortcut_in_force() {
let mut app = app();
let _ = app.run("ui.shortcuts", json!({"set": {"line": "Cmd+P"}}));
let mut found = (None, None);
fn walk(es: &[crate::menus::Entry], found: &mut (Option<Option<String>>, Option<Option<String>>)) {
for e in es {
match e {
crate::menus::Entry::Item { id, shortcut, .. } if id == "line" => found.0 = Some(shortcut.clone()),
crate::menus::Entry::Item { id, shortcut, .. } if id == "plot" => found.1 = Some(shortcut.clone()),
crate::menus::Entry::Sub { children, .. } => walk(children, found),
_ => {}
}
}
}
for (_, es) in crate::menus::tree(&app) {
walk(&es, &mut found);
}
assert_eq!(found, (Some(Some("Cmd+P".into())), Some(None)));
}
#[test]
fn a_recorded_key_runs_its_command_and_quiets_shortcuts_while_recording() {
let ctx = egui::Context::default();
let mut app = app();
let _ = app.run("ui.shortcuts", json!({"set": {"line": "F4"}}));
let press = |key: Key| egui::RawInput {
events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
..Default::default()
};
let mut out = ctx.run_ui(press(Key::F4), |ui| crate::menus::shortcuts(&mut app, ui.ctx()));
out.textures_delta.clear();
assert_eq!(app.session.summary()["running"], json!("line"));
app.session.cancel();
// Recording in the dialog: F4 becomes the recorded key, nothing runs.
app.ui.dialog = Some(DIALOG.into());
ctx.data_mut(|m| m.insert_temp(egui::Id::unique(RECORDING), "circle".to_string()));
let mut out = ctx.run_ui(press(Key::F6), |ui| {
crate::menus::shortcuts(&mut app, ui.ctx());
let mut open = true;
dialog(&mut app, ui.ctx(), &mut open);
});
out.textures_delta.clear();
assert_eq!(app.session.summary()["running"], Value::Null);
assert_eq!(app.ui.shortcuts.get("circle").map(String::as_str), Some("F6"));
assert!(!recording(&ctx));
}
}
-21
View File
@@ -51,7 +51,6 @@ pub mod gpu;
pub mod grip_dyn;
pub mod i18n;
pub mod icons;
pub mod keys;
pub mod layers;
pub mod managers;
pub mod menus;
@@ -120,8 +119,6 @@ pub struct UiState {
pub start_tab: bool,
pub dialog: Option<String>,
pub history_lines: usize,
/// The user's keyboard shortcuts ([`keys`]).
pub shortcuts: keys::UserKeys,
/// Interface theme: "system" (follow the OS light/dark appearance), "light" or "dark".
pub theme: theme::ThemePref,
/// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS
@@ -136,8 +133,6 @@ pub struct UiState {
pub recent_files: Vec<String>,
/// The folder the last APPLOAD file came from: the next pick starts there.
pub last_lisp_dir: Option<String>,
/// The folder the Command Aliases dialog last imported a `.pgp` file from.
pub last_pgp_dir: Option<String>,
}
/// A DXF/DWG file version (2000 … 2018).
@@ -210,14 +205,12 @@ impl Default for UiState {
start_tab: false,
dialog: None,
history_lines: 3,
shortcuts: keys::UserKeys::new(),
theme: theme::ThemePref::default(),
animations: animation::AnimationPref::default(),
save_format: SaveFormat::default(),
save_version: SaveVersion::default().year().to_string(),
recent_files: Vec::new(),
last_lisp_dir: None,
last_pgp_dir: None,
}
}
}
@@ -234,8 +227,6 @@ pub struct Services {
/// The host has no file system to read a path from (the browser): a dropped file's contents
/// are what it loads, and APPLOAD says so instead of asking for a name it cannot read.
pub no_file_system: bool,
/// Pick a `.pgp` alias file, starting in the folder given (the last import's).
pub pick_pgp: Option<Box<dyn Fn(Option<&str>) -> Option<String>>>,
}
pub struct CadApp {
@@ -499,12 +490,6 @@ impl CadApp {
if let Some(dir) = &self.ui.last_lisp_dir {
prefs["lispDir"] = json!(dir);
}
if let Some(dir) = self.ui.last_pgp_dir.as_deref().filter(|d| !d.is_empty()) {
prefs["lastPgpDir"] = json!(dir);
}
if !self.ui.shortcuts.is_empty() {
prefs["shortcuts"] = json!(self.ui.shortcuts);
}
prefs.to_string()
}
@@ -538,12 +523,6 @@ impl CadApp {
if let Some(dir) = v.get("lispDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
self.ui.last_lisp_dir = Some(dir.to_string());
}
if let Some(dir) = v.get("lastPgpDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
self.ui.last_pgp_dir = Some(dir.to_string());
}
if let Some(keys) = v.get("shortcuts").filter(|k| k.is_object()) {
self.ui.shortcuts = keys::from_prefs(keys);
}
}
/// Make the system cursor the interface default (`on`) and turn it on; saved preferences and
+8 -24
View File
@@ -134,7 +134,6 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.cmdline.clear", "Clear Command History", &[], None),
("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None),
("ui.dialog.find", "Find and Replace", &[], Some("Cmd+F")),
("ui.shortcuts", "Keyboard Shortcuts...", &["Tools", "Customize", "Keyboard Shortcuts..."], None),
("ui.language", "Language", &[], None),
("ui.noop", "", &[], None),
("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")),
@@ -162,9 +161,6 @@ fn route_ui_subsystems(app: &mut CadApp, id: &str, params: &Value) -> Option<Res
if let Some(r) = crate::managers::route(app, id, params) {
return Some(r);
}
if id == "ui.shortcuts" {
return Some(crate::keys::command(app, params));
}
if matches!(id, "ui.dock" | "ui.dock.reset") {
return Some(crate::docking::command(app, id == "ui.dock.reset", params));
}
@@ -494,8 +490,6 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format);
let (animations, save_version) = (app.ui.animations, std::mem::take(&mut app.ui.save_version));
let system_cursor = app.system_cursor_default;
// The user's shortcuts and import folder are preferences, not palette layout.
let (shortcuts, last_pgp_dir) = (std::mem::take(&mut app.ui.shortcuts), app.ui.last_pgp_dir.take());
app.ui = crate::UiState {
in_window_menu: menu,
theme,
@@ -504,8 +498,6 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
save_format,
save_version,
system_cursor,
shortcuts,
last_pgp_dir,
..Default::default()
};
Ok(Value::Null)
@@ -659,7 +651,6 @@ pub enum Entry {
/// The full menu tree from the command registry plus UI commands, in registration order.
pub fn tree(app: &CadApp) -> Vec<(String, Vec<Entry>)> {
let mut out: Vec<(String, Vec<Entry>)> = MENUS.iter().map(|m| (m.to_string(), Vec::new())).collect();
let user = &app.ui.shortcuts;
let mut add = |path: &[&str], label: &str, id: &str, shortcut: Option<&str>, enabled: bool| {
let Some((top, rest)) = path.split_first() else { return };
let Some((_, entries)) = out.iter_mut().find(|(m, _)| m == top) else { return };
@@ -670,7 +661,7 @@ pub fn tree(app: &CadApp) -> Vec<(String, Vec<Entry>)> {
cur.push(Entry::Item {
label: if seg.is_empty() { label.to_string() } else { seg.to_string() },
id: id.to_string(),
shortcut: crate::keys::shown(user, id, shortcut),
shortcut: shortcut.map(str::to_string),
enabled,
});
} else {
@@ -922,40 +913,33 @@ pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)>
]
}
/// Keyboard shortcuts and function keys: the user's ([`crate::keys`]) and CADCraft's own.
/// Keyboard shortcuts and function keys.
pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
// A recording ends with the dialog it was started in; while it lasts the keys are its own.
if app.ui.dialog.as_deref() != Some(crate::keys::DIALOG) {
crate::keys::stop_recording(ctx);
}
if crate::keys::recording(ctx) {
return;
}
let pairs = crate::keys::effective_cached(ctx, &app.ui.shortcuts);
let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k);
let cmd = Modifiers::COMMAND;
let pairs = builtin_shortcuts();
crate::clipboard::mirror(app, ctx);
if app.closing.is_some() {
return;
}
// A palette toggle is global even while its expression field owns keyboard focus.
let quickcalc = KeyboardShortcut::new(Modifiers::COMMAND, Key::Num8);
if pairs.iter().any(|(s, id)| *s == quickcalc && id == "ui.toggle.quickcalc") && ctx.input_mut(|i| i.consume_shortcut(&quickcalc)) {
if ctx.input_mut(|i| i.consume_shortcut(&sc(cmd, Key::Num8))) {
run_transparent(app, "ui.toggle.quickcalc");
}
if ctx.egui_wants_keyboard_input() {
return;
}
// Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses.
let mut fire: Vec<String> = crate::clipboard::shortcut_commands(app, ctx).into_iter().map(str::to_string).collect();
let mut fire = crate::clipboard::shortcut_commands(app, ctx);
ctx.input_mut(|i| {
for (s, id) in &pairs {
if i.consume_shortcut(s) && !fire.contains(id) {
fire.push(id.clone());
fire.push(*id);
}
}
});
for id in fire {
let id = id.as_str();
// ISOPLANE (F5, Ctrl+E) switches the plane without cancelling the active command.
if id == "isoplane" {
if let Some(m) = app.session.execute(id, &json!({})).ok().and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) {
+14 -184
View File
@@ -2,8 +2,7 @@
//! the text's location and width with a formatting toolbar (font, height, bold, italic,
//! underline, overline, colour, alignment, lists, tabs) and a ruler for the paragraph's indents
//! and tab stops. The text and its formats live in the engine's
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits. TEXTEDIT
//! (and so a double-click) on an existing MTEXT opens it here with its contents (#842).
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits.
use std::sync::Arc;
@@ -73,12 +72,6 @@ struct State {
sel: (usize, usize),
/// A caret position to set on the text box in the next frame (after a toolbar insertion).
caret: Option<usize>,
/// The text box has had keyboard focus: it takes it once when the editor opens, so typing
/// goes into the text, not to the command line.
focused: bool,
/// The MTEXT the state was made for (its insertion point and contents when it opened): a
/// state kept for another one is not reused, so one MTEXT's text never goes to the next.
target: Option<(cadcraft_engine::geom::Vec3, String)>,
}
fn state_id() -> egui::Id {
@@ -98,8 +91,7 @@ fn color32(c: MTextColor) -> Color32 {
Color32::from_rgb(rgb.0, rgb.1, rgb.2)
}
/// Show the editor while MTEXT, or TEXTEDIT on an MTEXT, collects its text; clears its state
/// when neither is running.
/// Show the editor while MTEXT collects its text; clears its state when it isn't running.
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
let mtext = app.session.editing_mtext();
let asking = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
@@ -109,20 +101,15 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
};
let t = Tokens::get();
let (rect, scale) = app.canvas.xf.map_or((ctx.content_rect(), 20.0), |x| (x.rect, x.scale.max(1e-6)));
let target = (mt.insert, mt.contents.clone());
let kept = ctx.data_mut(|d| d.get_temp::<State>(state_id())).filter(|st| st.target.as_ref() == Some(&target));
let mut st = kept.unwrap_or_else(|| {
// An existing MTEXT (TEXTEDIT) starts with its contents and their formats.
let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents_with_height(&mt.contents, Some(mt.height)) };
let mut st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).unwrap_or_else(|| {
let mut fmt = Formatted::default();
if let Some(f) = app.session.doc().ok().and_then(|d| d.text_style(&mt.style).map(|s| s.font.clone())) {
// A family name, not a font file (`txt.shx`, `arial.ttf`).
if !f.is_empty() && !f.contains('.') {
fmt.base_font = f;
}
}
// An existing text opens with the caret at its end.
let caret = (!fmt.is_empty()).then(|| fmt.len());
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret, focused: false, target: Some(target.clone()) }
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }
});
let base_h = if mt.height > 0.0 { mt.height } else { 0.2 };
let px = ((base_h * scale) as f32).clamp(MIN_PX, MAX_PX);
@@ -171,15 +158,8 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
Done::Ok => {
ctx.data_mut(|d| d.remove::<State>(state_id()));
// The editor holds the whole contents: one input, then Enter ends MTEXT's text entry.
// Emptied while editing an existing MTEXT: it keeps its text (Enter), as TEXTEDIT
// keeps it for an empty answer, and for one left unchanged (no undo step).
let textedit = app.session.running.as_ref().is_some_and(|r| r.id == "textedit");
let contents = st.fmt.to_contents();
let unchanged = textedit && Formatted::leaves_unchanged(&mt.contents, Some(mt.height), &contents);
if !st.fmt.is_empty() && !unchanged {
let _ = app.session.input(cadcraft_engine::Input::Text(contents));
} else if textedit {
let _ = app.session.input(cadcraft_engine::Input::Enter);
if !st.fmt.is_empty() {
let _ = app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents()));
}
if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") {
let _ = app.session.input(cadcraft_engine::Input::Enter);
@@ -268,10 +248,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
let mut h = cur.height.unwrap_or(base_h);
if height_field(ui, &mut h, base_h) {
let none = (h - base_h).abs() < 1e-9;
change(st, |c| {
c.height = (!none).then_some(h);
c.height_rel = None;
});
change(st, |c| c.height = (!none).then_some(h));
}
ui.separator();
for (flag, label, tip) in [
@@ -530,8 +507,9 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) {
/// MTEXT editor area with the formatted preview; restores a pending caret position before laying text out with `base_h`, `px`, `width` and the theme tokens.
fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) {
let id = edit_id();
if let Some(at) = st.caret.take() {
let mut s = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
if let Some(at) = st.caret.take()
&& let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), id)
{
s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at))));
s.store(ui.ctx(), id);
}
@@ -590,13 +568,9 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32,
.layouter(&mut layouter)
.show(ui);
out.response.response.clone().accessible_name(crate::tl!("Text"));
// On opening, and while the text is empty, the editor takes the focus; a field of the toolbar
// the user clicked keeps it.
let free = ui.memory(|m| m.focused().is_none());
if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty() && free)) {
if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() {
out.response.request_focus();
}
st.focused = true;
if out.response.changed() {
let typing = st.typing.clone();
st.fmt.set_text(&buf, &typing);
@@ -629,16 +603,7 @@ mod tests {
/// Clicking Diameter inserts U+2300 into both the formatted text and `to_contents()`, selects it, and leaves no unnamed widget.
#[test]
fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() {
let state = State {
fmt: Formatted::default(),
typing: CharFmt::default(),
typing_at: 0,
top: 70.0,
sel: (0, 0),
caret: None,
focused: false,
target: None,
};
let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).with_context_setup(install_diameter_glyph).build_ui_state(
|ui, state| {
toolbar(ui, state, 2.5);
@@ -655,48 +620,10 @@ mod tests {
assert!(harness.unnamed_widgets().is_empty());
}
/// With the text empty, a click on the height field keeps the focus there; the editor doesn't
/// take it back.
#[test]
fn an_empty_editor_leaves_the_focus_on_the_height_field() {
let state = State {
fmt: Formatted::default(),
typing: CharFmt::default(),
typing_at: 0,
top: 70.0,
sel: (0, 0),
caret: None,
focused: false,
target: None,
};
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).build_ui_state(
|ui, state| {
toolbar(ui, state, 2.5);
text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get());
},
state,
);
harness.run();
assert!(harness.ctx.memory(|m| m.has_focus(edit_id())), "the editor opens with the focus");
harness.get_by_role_and_label(Role::SpinButton, "Text height").click();
harness.run();
harness.run();
assert!(!harness.ctx.memory(|m| m.has_focus(edit_id())), "the height field keeps the focus");
}
/// The ten toolbar controls listed in the test are found by semantic name under the harness's default bundled fonts, and no toolbar widget is unnamed.
#[test]
fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() {
let mut state = State {
fmt: Formatted::default(),
typing: CharFmt::default(),
typing_at: 0,
top: 70.0,
sel: (0, 0),
caret: None,
focused: false,
target: None,
};
let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
let mut harness = Harness::builder().with_size(vec2(1100.0, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5));
harness.run();
for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] {
@@ -785,101 +712,4 @@ mod tests {
let found = d.model.iter().any(|e| matches!(&e.kind, cadcraft_engine::doc::EntityKind::MText(m) if m.contents == contents));
assert!(found, "{contents}");
}
/// #842: TEXTEDIT on an existing MTEXT (what a double-click starts) opens it in the editor
/// with its text and formats; finishing writes the edited contents back to that MTEXT.
/// TEXTEDIT moving on to another MTEXT between two frames (Enter keeps the first, a point picks
/// the next) opens the next one with its own text, never the first one's.
#[test]
fn textedit_on_the_next_mtext_does_not_keep_the_first_ones_text() {
let ctx = egui::Context::default();
let mut app = CadApp::new(Session::new(), Services::default());
let frame = |app: &mut CadApp| {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
for _ in 0..2 {
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
};
app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "first one", "height": 2.5, "width": 20 })).unwrap();
app.run("mtext", serde_json::json!({ "at": [0, 60], "text": "second one", "height": 2.5, "width": 20 })).unwrap();
app.start("textedit");
app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(1.0, 19.0))).unwrap();
frame(&mut app);
assert_eq!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open").fmt.text(), "first one");
// Both inputs between two frames: the first MTEXT kept as it is, then the second picked.
app.session.input(cadcraft_engine::Input::Enter).unwrap();
app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(1.0, 59.0))).unwrap();
assert!(app.session.editing_mtext().is_some_and(|m| m.contents.contains("second")), "TEXTEDIT is on the second MTEXT");
frame(&mut app);
assert_eq!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open").fmt.text(), "second one");
}
#[test]
fn textedit_opens_an_existing_mtext_with_its_contents() {
let ctx = egui::Context::default();
let mut app = CadApp::new(Session::new(), Services::default());
let frame = |app: &mut CadApp| {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
for _ in 0..2 {
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
};
app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "{\\fArial|b1;first}\\Psecond", "height": 2.5, "width": 20 })).unwrap();
let h = app.session.doc().unwrap().model.last().unwrap().handle;
app.start("textedit");
let at = cadcraft_geom::Vec2::new(1.0, 19.0);
app.session.input(cadcraft_engine::Input::Point(at)).unwrap();
let m = app.session.editing_mtext().expect("an MTEXT to edit");
assert!(m.contents.contains("second"), "{}", m.contents);
frame(&mut app);
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
assert_eq!(st.fmt.text(), "first\nsecond");
assert!(st.fmt.fmt_at(0).bold && !st.fmt.fmt_at(7).bold, "formats kept");
let mut st = st;
st.fmt.set_text("first\nsecond\nthird", &CharFmt::default());
app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())).unwrap();
frame(&mut app);
assert!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).is_none(), "editor closed");
match &app.session.doc().unwrap().entity(h).unwrap().kind {
cadcraft_engine::doc::EntityKind::MText(m) => assert!(m.contents.contains("third") && m.contents.contains("b1"), "{}", m.contents),
_ => panic!(),
}
}
/// A multileader's text with a relative height code (`\H2x;`) opens in the editor at twice
/// its text height, so saving it back keeps the bigger letters.
#[test]
fn a_relative_height_in_a_multileaders_text_is_kept() {
let ctx = egui::Context::default();
let mut app = CadApp::new(Session::new(), Services::default());
let frame = |app: &mut CadApp| {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
for _ in 0..2 {
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
};
app.run("mleader", serde_json::json!({ "points": [[16, -10], [20, -5]], "text": "{\\H2x;big} small" })).unwrap();
let (at, height) = app
.session
.doc()
.unwrap()
.model
.iter()
.find_map(|e| {
if let cadcraft_engine::doc::EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (t.insert.xy(), t.height)) } else { None }
})
.unwrap();
app.start("textedit");
app.session.input(cadcraft_engine::Input::Point(at + cadcraft_geom::Vec2::new(0.5, -0.05))).unwrap();
assert!(app.session.editing_mtext().is_some(), "the multileader's text is being edited");
frame(&mut app);
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
assert_eq!(st.fmt.text(), "big small");
let big = st.fmt.fmt_at(0).height.expect("the relative height is kept");
assert!((big - 2.0 * height).abs() < 1e-9, "{big} for a {height} text");
assert_eq!(st.fmt.fmt_at(5).height, None, "the rest keeps the text's height");
}
}
+13 -165
View File
@@ -16,10 +16,7 @@
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
//! | `SAVEFILEPATH` | the folder automatic saves go to |
//! | `ISAVEBAK` | 1: saving over a drawing keeps the previous file as `<name>.bak` (default); 0: no backup |
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: `rightClickCommand` decides |
//! | `rightClickDuration` | how long the right button is held to open the menu while a command runs, 100–2000 ms (250) |
//! | `rightClickDefault`, `rightClickEdit` | a right-click with no command running and nothing (`Default`) or something (`Edit`) selected: `"menu"` (the shortcut menu, default) or `"repeat"` (the last command) |
//! | `rightClickCommand` | a right-click while a command runs, with the time-sensitive right-click off: `"menu"` (default), `"enter"`, or `"menuWithOptions"` (the menu when the prompt has options, else Enter) |
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu |
//! | `systemCursor` | keep the system cursor visible over the drawing area |
//! | `historyLines` | command-line history lines, 0–12 |
//! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default |
@@ -30,9 +27,9 @@
//! SAVETIME, SAVEFILEPATH and ISAVEBAK are the engine's save settings, kept with the autosave state
//! rather than in the profile.
//!
//! The profile variables, the user's ALIASEDIT aliases and the interface preferences survive
//! restarts: the host saves [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface
//! prefs and restores it with [`CadApp::load_profile`].
//! The profile variables and the interface preferences survive restarts: the host saves
//! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with
//! [`CadApp::load_profile`].
mod dialog;
@@ -41,8 +38,6 @@ pub use dialog::dialog;
use egui::Color32;
use serde_json::{Map, Value, json};
use cadcraft_engine::cmd::file::MAX_ALIAS_PROFILE_BYTES;
use crate::CadApp;
use crate::animation::AnimationPref;
use crate::theme::{self, ThemePref};
@@ -50,14 +45,8 @@ use crate::theme::{self, ThemePref};
/// The host storage key for [`CadApp::profile_json`].
pub const PROFILE_KEY: &str = "cadcraft.profile";
/// The largest saved profile [`CadApp::load_profile`] reads: everything [`CadApp::profile_json`]
/// writes — a full ALIASEDIT alias set (up to [`MAX_ALIAS_PROFILE_BYTES`]) and the profile
/// variables, colours and interface preferences — plus the JSON around them. A valid profile is
/// never refused; anything larger is corrupt or hostile.
pub const MAX_PROFILE_BYTES: usize = MAX_ALIAS_PROFILE_BYTES + PROFILE_OVERHEAD_BYTES;
/// Room for the profile variables, the interface preferences and the JSON around them.
const PROFILE_OVERHEAD_BYTES: usize = 64 * 1024;
/// The largest saved profile [`CadApp::load_profile`] reads.
const MAX_PROFILE_BYTES: usize = 64 * 1024;
/// The longest SAVEFILEPATH the JSON form accepts.
const MAX_PATH_CHARS: usize = 1024;
@@ -68,66 +57,6 @@ pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive<f32> = 5.0..=31.0;
/// Interface scales the JSON form accepts (1: the system's own size).
pub const INTERFACE_SCALES: std::ops::RangeInclusive<f32> = 0.5..=3.0;
/// How long a right-click may be held before it opens the menu, in milliseconds.
pub const RIGHT_CLICK_DURATIONS: std::ops::RangeInclusive<u32> = 100..=2000;
/// What a right-click does with no command running (Right-Click Customization's Default and Edit
/// modes).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IdleClick {
/// Repeat the last command.
Repeat,
/// The shortcut menu.
#[default]
Menu,
}
impl IdleClick {
pub const ALL: [IdleClick; 2] = [IdleClick::Repeat, IdleClick::Menu];
/// The JSON name.
pub fn as_str(self) -> &'static str {
match self {
IdleClick::Repeat => "repeat",
IdleClick::Menu => "menu",
}
}
pub fn parse(s: &str) -> Option<IdleClick> {
IdleClick::ALL.into_iter().find(|c| c.as_str().eq_ignore_ascii_case(s.trim()))
}
}
/// What a right-click does while a command runs with the time-sensitive right-click off
/// (Right-Click Customization's Command mode).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CommandClick {
/// Enter.
Enter,
/// The shortcut menu, always.
#[default]
Menu,
/// The shortcut menu when the prompt has options, else Enter.
MenuWithOptions,
}
impl CommandClick {
pub const ALL: [CommandClick; 3] = [CommandClick::Enter, CommandClick::Menu, CommandClick::MenuWithOptions];
/// The JSON name.
pub fn as_str(self) -> &'static str {
match self {
CommandClick::Enter => "enter",
CommandClick::Menu => "menu",
CommandClick::MenuWithOptions => "menuWithOptions",
}
}
pub fn parse(s: &str) -> Option<CommandClick> {
CommandClick::ALL.into_iter().find(|c| c.as_str().eq_ignore_ascii_case(s.trim()))
}
}
/// The interface's own preferences (the drafting ones are engine settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UiOptions {
@@ -140,16 +69,8 @@ pub struct UiOptions {
/// The selection highlight colour; `None` keeps the theme's.
pub selection_color: Option<Color32>,
/// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu
/// (AutoCAD's time-sensitive right-click). Off: [`Self::right_click_command`] decides.
/// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu.
pub time_sensitive_right_click: bool,
/// How long the right button is held to open the menu while a command runs, in milliseconds.
pub right_click_hold_ms: u32,
/// A right-click with no command running and nothing selected (Default mode) or a selection
/// (Edit mode).
pub right_click_default: IdleClick,
pub right_click_edit: IdleClick,
/// A right-click while a command runs, with the time-sensitive right-click off.
pub right_click_command: CommandClick,
/// The size of every menu, palette, dialog and the command line (1: the system's own).
pub interface_scale: f32,
}
@@ -162,10 +83,6 @@ impl Default for UiOptions {
snap_marker_size: theme::SNAP_MARKER,
selection_color: None,
time_sensitive_right_click: true,
right_click_hold_ms: (crate::context_menu::HOLD_SECS * 1000.0) as u32,
right_click_default: IdleClick::Menu,
right_click_edit: IdleClick::Menu,
right_click_command: CommandClick::Menu,
interface_scale: 1.0,
}
}
@@ -233,10 +150,6 @@ struct UiChanges {
savefilepath: Option<String>,
isavebak: Option<bool>,
time_sensitive_right_click: Option<bool>,
right_click_hold_ms: Option<u32>,
right_click_default: Option<IdleClick>,
right_click_edit: Option<IdleClick>,
right_click_command: Option<CommandClick>,
interface_scale: Option<f32>,
}
@@ -280,17 +193,6 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?);
}
"timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?),
"rightClickDuration" => {
let (lo, hi) = (f64::from(*RIGHT_CLICK_DURATIONS.start()), f64::from(*RIGHT_CLICK_DURATIONS.end()));
let n =
v.as_f64().filter(|n| n.is_finite() && (lo..=hi).contains(n)).ok_or("rightClickDuration must be from 100 to 2000 milliseconds")?;
c.right_click_hold_ms = Some(n.round() as u32);
}
"rightClickDefault" => c.right_click_default = Some(IdleClick::parse(text()?).ok_or("rightClickDefault must be menu or repeat")?),
"rightClickEdit" => c.right_click_edit = Some(IdleClick::parse(text()?).ok_or("rightClickEdit must be menu or repeat")?),
"rightClickCommand" => {
c.right_click_command = Some(CommandClick::parse(text()?).ok_or("rightClickCommand must be menu, enter or menuWithOptions")?)
}
k if k.eq_ignore_ascii_case("SAVETIME") => {
let max = cadcraft_engine::autosave::MAX_SAVETIME;
let n = v
@@ -332,10 +234,6 @@ fn ui_json(app: &CadApp) -> Map<String, Value> {
m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath()));
m.insert("ISAVEBAK".into(), json!(i32::from(cadcraft_engine::autosave::isavebak())));
m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click));
m.insert("rightClickDuration".into(), json!(o.right_click_hold_ms));
m.insert("rightClickDefault".into(), json!(o.right_click_default.as_str()));
m.insert("rightClickEdit".into(), json!(o.right_click_edit.as_str()));
m.insert("rightClickCommand".into(), json!(o.right_click_command.as_str()));
m
}
@@ -412,26 +310,9 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
if let Some(b) = ui.time_sensitive_right_click {
app.options.time_sensitive_right_click = b;
}
apply_right_click(&mut app.options, &ui);
Ok(report(app))
}
/// Right-Click Customization's settings (the time-sensitive switch aside) from `c`.
fn apply_right_click(o: &mut UiOptions, c: &UiChanges) {
if let Some(ms) = c.right_click_hold_ms {
o.right_click_hold_ms = ms;
}
if let Some(m) = c.right_click_default {
o.right_click_default = m;
}
if let Some(m) = c.right_click_edit {
o.right_click_edit = m;
}
if let Some(m) = c.right_click_command {
o.right_click_command = m;
}
}
/// The interface preferences back to their defaults (the interface language stays).
fn reset_ui(app: &mut CadApp) {
let d = crate::UiState::default();
@@ -446,24 +327,19 @@ fn reset_ui(app: &mut CadApp) {
impl CadApp {
/// The user profile kept across restarts, as JSON for the host's storage ([`PROFILE_KEY`]):
/// the engine's profile variables, the user's aliases (ALIASEDIT, not drawing data) and the
/// interface preferences the interface prefs ([`CadApp::prefs_json`]) and the desktop
/// workspace file (panels, command line) do not already keep.
/// the engine's profile variables and the interface preferences the interface prefs
/// ([`CadApp::prefs_json`]) and the desktop workspace file (panels, command line) do not
/// already keep.
pub fn profile_json(&self) -> String {
json!({
"version": 1,
"sysvars": cadcraft_engine::cmd::options::profile(&self.session),
"aliases": cadcraft_engine::cmd::file::alias_profile(),
"systemCursor": self.ui.system_cursor,
"modelBackground": color_json(self.options.model_background),
"paperBackground": color_json(self.options.paper_background),
"selectionColor": color_json(self.options.selection_color),
"snapMarkerSize": self.options.snap_marker_size,
"timeSensitiveRightClick": self.options.time_sensitive_right_click,
"rightClickDuration": self.options.right_click_hold_ms,
"rightClickDefault": self.options.right_click_default.as_str(),
"rightClickEdit": self.options.right_click_edit.as_str(),
"rightClickCommand": self.options.right_click_command.as_str(),
"interfaceScale": self.options.interface_scale,
})
.to_string()
@@ -479,25 +355,10 @@ impl CadApp {
if let Some(vars) = v.get("sysvars") {
cadcraft_engine::cmd::options::restore(&mut self.session, vars);
}
// The user's aliases (ALIASEDIT) are settings, not drawing data; a malformed section is
// skipped by the engine without touching the aliases already in use.
if let Some(aliases) = v.get("aliases") {
cadcraft_engine::cmd::file::restore_aliases(aliases);
}
let mut c = UiChanges::default();
for key in [
"systemCursor",
"modelBackground",
"paperBackground",
"selectionColor",
"snapMarkerSize",
"timeSensitiveRightClick",
"rightClickDuration",
"rightClickDefault",
"rightClickEdit",
"rightClickCommand",
"interfaceScale",
] {
for key in
["systemCursor", "modelBackground", "paperBackground", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick", "interfaceScale"]
{
if let Some(value) = v.get(key) {
let _ = parse_ui(key, value, &mut c);
}
@@ -521,7 +382,6 @@ impl CadApp {
if let Some(b) = c.time_sensitive_right_click {
self.options.time_sensitive_right_click = b;
}
apply_right_click(&mut self.options, &c);
if let Some(n) = c.interface_scale {
self.options.interface_scale = n;
}
@@ -541,16 +401,4 @@ mod tests {
assert_eq!(parse_hex(bad), None, "{bad}");
}
}
#[test]
fn idle_and_command_clicks_parse_their_names() {
for c in IdleClick::ALL {
assert_eq!(IdleClick::parse(c.as_str()), Some(c));
}
for c in CommandClick::ALL {
assert_eq!(CommandClick::parse(c.as_str()), Some(c));
}
assert_eq!(IdleClick::parse("enter"), None);
assert_eq!(CommandClick::parse("repeat"), None);
}
}
+2 -91
View File
@@ -537,9 +537,8 @@ fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map<String,
});
}
/// Preferences tab: interface language, largest-array limit, time-sensitive right-click and the Right-Click Customization window; edits accumulate in `changes`.
/// Preferences tab: interface language, largest-array limit and time-sensitive right-click; edits accumulate in `changes`.
fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
let mut right_click = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::unique(RIGHT_CLICK)).unwrap_or(false));
grid(ui, "options_preferences", |ui| {
let language_caption = ui.label(crate::tl!("Interface language"));
let mut lang = app.ui.interface_language;
@@ -571,95 +570,7 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>
if ui.checkbox(&mut timed, crate::tl!("Time-sensitive right-click: a quick click is Enter, holding opens the menu")).changed() {
changes.insert("timeSensitiveRightClick".into(), json!(timed));
}
hint(ui, crate::tl!("Turned off, a right-click while a command runs does what Right-Click Customization sets for Command mode."));
if ui.button(crate::tl!("Right-Click Customization...")).clicked() {
right_click = true;
}
if right_click {
right_click_window(&app.options, ui.ctx(), &mut right_click, changes);
}
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(RIGHT_CLICK), right_click));
}
/// Whether the Right-Click Customization window is open (egui temp data).
const RIGHT_CLICK: &str = "cc_options_right_click";
/// Right-Click Customization (Options ▸ User Preferences): the time-sensitive right-click and its
/// duration, and what a right-click does in Default, Edit and Command modes. Changes apply at
/// once, as on the Options tabs.
fn right_click_window(o: &super::UiOptions, ctx: &egui::Context, open: &mut bool, changes: &mut Map<String, Value>) {
use super::{CommandClick, IdleClick};
let mut close = false;
egui::Window::new(crate::tl!("Right-Click Customization"))
.id(egui::Id::unique("cc_right_click"))
.open(open)
.resizable(false)
.collapsible(false)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
ui.set_width(460.0);
let mut timed = o.time_sensitive_right_click;
if ui.checkbox(&mut timed, crate::tl!("Turn on time-sensitive right-click")).changed() {
changes.insert("timeSensitiveRightClick".into(), json!(timed));
}
ui.add_enabled_ui(o.time_sensitive_right_click, |ui| {
ui.horizontal(|ui| {
ui.add_space(20.0);
let caption = ui.label(crate::tl!("Longer click duration:"));
let mut ms = o.right_click_hold_ms;
let range = super::RIGHT_CLICK_DURATIONS;
if ui.add(egui::DragValue::new(&mut ms).range(range).speed(10.0)).labelled_by(caption.id).changed() {
changes.insert("rightClickDuration".into(), json!(ms));
}
ui.label(crate::tl!("milliseconds"));
});
hint(ui, crate::tl!("While a command runs, a quick click is Enter and a longer click opens the shortcut menu."));
});
ui.add_space(6.0);
for (title, note, key, current) in [
(crate::tl!("Default Mode"), crate::tl!("No objects selected and no command running:"), "rightClickDefault", o.right_click_default),
(crate::tl!("Edit Mode"), crate::tl!("Objects selected and no command running:"), "rightClickEdit", o.right_click_edit),
] {
heading(ui, title);
ui.group(|ui| {
ui.set_width(ui.available_width());
hint(ui, note);
for (choice, label) in [(IdleClick::Repeat, crate::tl!("Repeat last command")), (IdleClick::Menu, crate::tl!("Shortcut Menu"))] {
if ui.radio(current == choice, label).clicked() && current != choice {
changes.insert(key.into(), json!(choice.as_str()));
}
}
});
}
ui.add_enabled_ui(!o.time_sensitive_right_click, |ui| {
heading(ui, crate::tl!("Command Mode"));
ui.group(|ui| {
ui.set_width(ui.available_width());
hint(ui, crate::tl!("While a command runs:"));
for (choice, label) in [
(CommandClick::Enter, crate::tl!("ENTER")),
(CommandClick::Menu, crate::tl!("Shortcut Menu: always enabled")),
(CommandClick::MenuWithOptions, crate::tl!("Shortcut Menu: enabled when command options are present")),
] {
if ui.radio(o.right_click_command == choice, label).clicked() && o.right_click_command != choice {
changes.insert("rightClickCommand".into(), json!(choice.as_str()));
}
}
});
});
ui.separator();
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(crate::tl!("Close")).clicked() {
close = true;
}
});
});
});
if close {
*open = false;
}
hint(ui, crate::tl!("Turned off, a right-click while a command runs always opens the shortcut menu."));
}
fn profiles(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
+64 -642
View File
@@ -705,85 +705,6 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
/// What the palette shows for a property that differs between the selected objects.
const VARIES: &str = "*VARIES*";
/// What the palette works out from the selection, for one drawing revision and one selection.
#[derive(Default)]
struct SelectionValues {
key: u64,
ids: std::sync::Arc<Vec<String>>,
values: std::collections::HashMap<&'static str, Box<dyn std::any::Any + Send + Sync>>,
}
/// The shared handle to a [`SelectionValues`], kept in `ui.data` between frames.
#[derive(Clone, Default)]
struct SelectionMemo(std::sync::Arc<std::sync::Mutex<SelectionValues>>);
impl SelectionMemo {
/// The value kept under `key`, worked out by `f` the first time.
fn get<T: Clone + Send + Sync + 'static>(&self, key: &'static str, f: impl FnOnce() -> T) -> T {
let mut m = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(v) = m.values.get(key).and_then(|v| v.downcast_ref::<T>()) {
return v.clone();
}
let v = f();
m.values.insert(key, Box::new(v.clone()));
v
}
/// The selected handles as hex strings, as `properties.set` takes them.
fn ids(&self) -> std::sync::Arc<Vec<String>> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).ids.clone()
}
}
/// The memo for the current drawing revision and selection; `extra` holds whatever else the shown
/// values depend on (units, angle settings).
fn selection_memo(ui: &egui::Ui, app: &CadApp, sel: &[cadcraft_engine::doc::Handle], extra: impl std::hash::Hash) -> SelectionMemo {
use std::hash::{Hash, Hasher};
let fresh = || {
let ids = sel.iter().map(|h| h.hex()).collect();
SelectionMemo(std::sync::Arc::new(std::sync::Mutex::new(SelectionValues { ids: std::sync::Arc::new(ids), ..Default::default() })))
};
let Ok(st) = app.session.state() else { return fresh() };
let mut hasher = std::collections::hash_map::DefaultHasher::new();
(st.uid, st.revision, extra, sel).hash(&mut hasher);
let key = hasher.finish();
let id = egui::Id::unique("properties-selection-memo");
if let Some(memo) = ui.data(|d| d.get_temp::<SelectionMemo>(id))
&& memo.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).key == key
{
return memo;
}
let memo = fresh();
memo.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).key = key;
ui.data_mut(|d| d.insert_temp(id, memo.clone()));
memo
}
/// Separates what a numeric row shows from the value it was worked out from, so that two objects
/// whose values differ by less than the display shows still count as different. The value is
/// compared to 12 significant digits, which keeps float noise (a length that comes out as
/// 5.000000000000001) from reading as a difference.
const EXACT_SEP: char = '\u{1f}';
/// `shown`, tagged with the number `v` it was formatted from. Round-off next to zero (a Delta Y
/// of 1.2e-16 on a level line) is tagged as zero, since 12 significant digits of it are noise.
fn exact(shown: String, v: f64) -> String {
let v = if v.abs() < 1e-12 { 0.0 } else { v };
format!("{shown}{EXACT_SEP}{:.11e}", v + 0.0)
}
/// What a row shows of a value that [`exact`] tagged (a plain string is shown as it is, also one
/// that holds the separator: only a tail that reads as the tagged number is taken off).
fn shown_part(s: &str) -> String {
match s.rsplit_once(EXACT_SEP) {
Some((shown, tag)) if tag.parse::<f64>().is_ok() => shown.to_string(),
_ => s.to_string(),
}
}
/// Properties palette: the selection's common properties, or the current defaults when nothing is selected; formats lengths per drawing units and dispatches setter commands.
pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
@@ -795,7 +716,6 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
// Lengths are shown as the drawing's units say (UNITS), and read back in any format.
let (lu, lp) = (h.i64("LUNITS", 2), h.i64("LUPREC", 4));
let len = |v: f64| cadcraft_engine::units::format_distance(v, lu, lp);
let lenx = |v: f64| exact(len(v), v);
let mut bad: Option<String> = None;
if sel.is_empty() {
// Current properties for new objects; each row runs the command that sets it.
@@ -950,40 +870,33 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
// Selected objects: common + geometry for a single object.
let first = sel.first().and_then(|h| d.entity(*h)).map(|e| (**e).clone());
let Some(e) = first else { return };
// What the palette works out from the selection is kept while the drawing and the selection
// stay as they are: with thousands of objects selected, redoing it every frame is slow.
let memo =
selection_memo(ui, app, &sel, (lu, lp, app.session.angle_settings().shown(0.0).to_bits(), app.session.angle_settings().shown(1.0).to_bits()));
let same = |key: &'static str, f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String {
memo.get(key, || {
let vals: Vec<String> = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect();
match vals.first() {
Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(),
_ => VARIES.into(),
}
})
let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String {
let vals: Vec<String> = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect();
match vals.first() {
Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(),
_ => "*VARIES*".into(),
}
};
let selected_color = memo.get("color", || Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c)));
let color = selected_color.map_or_else(|| VARIES.into(), Color::name);
let selected_color = Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c));
let color = selected_color.map_or_else(|| "*VARIES*".into(), Color::name);
let effective_color = |e: &cadcraft_doc::Entity| {
let layer = d.layer(&e.common.layer).map_or(Color::WHITE, |l| l.color);
e.common.color.resolve(layer, Color::WHITE)
};
let resolved_color = memo.get("resolved color", || {
sel.first()
.and_then(|h| d.entity(*h))
.map(|e| effective_color(e))
.filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c))
.unwrap_or(Rgb(255, 255, 255))
});
let layer = same("layer", &|e| e.common.layer.clone());
let linetype = same("linetype", &|e| e.common.linetype.clone());
let lts = same("ltscale", &|e| format!("{:.4}", e.common.ltscale));
let lw = same("lineweight", &|e| e.common.lineweight.name());
let tr = same("transparency", &|e| e.common.transparency.name());
let resolved_color = sel
.first()
.and_then(|h| d.entity(*h))
.map(|e| effective_color(e))
.filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c))
.unwrap_or(Rgb(255, 255, 255));
let layer = same(&|e| e.common.layer.clone());
let linetype = same(&|e| e.common.linetype.clone());
let lts = same(&|e| format!("{:.4}", e.common.ltscale));
let lw = same(&|e| e.common.lineweight.name());
let tr = same(&|e| e.common.transparency.name());
let layers: Vec<String> = d.layers.iter().map(|l| l.name.clone()).collect();
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
let ids = &*memo.ids();
let ids: Vec<String> = sel.iter().map(|h| h.hex()).collect();
let mut set: Option<Value> = None;
let mut custom_set: Option<Value> = None;
let group = |ui: &mut egui::Ui, title: &str| {
@@ -996,7 +909,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
let width = (ui.available_width() - 10.0).max(0.0);
if let Some(c) = crate::layers::color_button_resolved(
ui,
egui::Id::unique(("properties_color", ids)),
egui::Id::unique(("properties_color", &ids)),
selected_color.unwrap_or(Color::ByLayer),
resolved_color,
&color,
@@ -1032,7 +945,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
});
});
prop_row(ui, "Linetype scale", |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", ids)), lts.clone()), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) {
set = Some(json!({ "handles": ids, "ltscale": f }));
}
});
@@ -1062,7 +975,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
ui.separator();
// A fixed percentage, committed when the drag or edit ends.
let tid = egui::Id::unique(("prop_tr", ids));
let tid = egui::Id::unique(("prop_tr", &ids));
let cur = tr.parse::<u8>().ok();
let mut pct = ui.data_mut(|d| d.get_temp::<u8>(tid)).or(cur).unwrap_or(0);
ui.horizontal(|ui| {
@@ -1080,38 +993,37 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
});
});
});
let real = |v: f64| format!("{v:.4}");
let realx = |v: f64| exact(real(v), v);
// Rotations show as the drawing measures directions (ANGBASE, ANGDIR).
let angles = app.session.angle_settings();
let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
prop_row(ui, label, |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) {
*set = Some(json!({ "handles": ids, key: f }));
}
});
};
if sel.len() == 1 {
group(ui, "Geometry");
let real = |v: f64| format!("{v:.4}");
// Rotations show as the drawing measures directions (ANGBASE, ANGDIR).
let angles = app.session.angle_settings();
let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
prop_row(ui, label, |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) {
*set = Some(json!({ "handles": ids, key: f }));
}
});
};
match &e.kind {
EntityKind::Line(l) => {
prop_row(ui, "Start X", |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", ids)), len(l.a.x)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) {
set = Some(json!({ "handles": ids, "start": [f, l.a.y] }));
}
});
prop_row(ui, "Start Y", |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", ids)), len(l.a.y)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) {
set = Some(json!({ "handles": ids, "start": [l.a.x, f] }));
}
});
prop_row(ui, "End X", |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", ids)), len(l.b.x)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) {
set = Some(json!({ "handles": ids, "end": [f, l.b.y] }));
}
});
prop_row(ui, "End Y", |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", ids)), len(l.b.y)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) {
set = Some(json!({ "handles": ids, "end": [l.b.x, f] }));
}
});
@@ -1119,16 +1031,16 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
prop_row(ui, "Delta X", |ui| read_only(ui, &len(dlt.x)));
prop_row(ui, "Delta Y", |ui| read_only(ui, &len(dlt.y)));
// Length and angle keep the start point and move the end point.
num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, ids);
num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, ids);
num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, &ids);
num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, &ids);
}
EntityKind::Circle(c) => {
num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, ids);
num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, ids);
num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, ids);
num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, ids);
num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, ids);
num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, ids);
num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, &ids);
num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, &ids);
num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, &ids);
num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, &ids);
num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, &ids);
num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, &ids);
if let Some(v) = &mut set
&& let Some(o) = v.as_object_mut()
{
@@ -1141,42 +1053,31 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
EntityKind::Arc(a) => {
num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, ids);
num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, ids);
num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, ids);
num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, &ids);
num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, &ids);
num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, &ids);
}
EntityKind::Text(tx) => {
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", ids)), tx.value.clone()) {
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, ids);
num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, ids);
num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, ids);
num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, &ids);
num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, &ids);
num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, &ids);
}
EntityKind::MText(tx) => {
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", ids)), tx.contents.clone()) {
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, ids);
num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, ids);
}
// A multileader's text (#1081): it had no field, so it couldn't be changed.
EntityKind::MLeader(ml) => {
if let Some(tx) = &ml.text {
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("mltxt", &ids)), tx.contents.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, ids);
}
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids);
num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, &ids);
}
EntityKind::LwPolyline(pl) => {
num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, ids);
num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, &ids);
let g = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
prop_row(ui, "Length", |ui| read_only(ui, &len(g.len())));
if pl.closed {
@@ -1199,12 +1100,12 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
});
});
num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, ids);
num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, ids);
num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, &ids);
num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, &ids);
}
EntityKind::Dimension(dm) => {
prop_row(ui, "Text override", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", ids)), dm.text.clone()) {
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) {
set = Some(json!({ "handles": ids, "textOverride": s }));
}
});
@@ -1216,9 +1117,9 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
// The style's values with this dimension's own overrides; editing one overrides
// it for the selected dimensions, as AutoCAD's Properties palette does.
let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, ids);
num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, ids);
num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, ids);
num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, &ids);
num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, &ids);
num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, &ids);
prop_row(ui, "Precision", |ui| {
let cur = st.decimals.to_string();
if let Some(n) = pick_menu(ui, &cur, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::<u8>().ok()) {
@@ -1235,8 +1136,8 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
prop_row(ui, "Position X", |ui| read_only(ui, &len(i.insert.x)));
prop_row(ui, "Position Y", |ui| read_only(ui, &len(i.insert.y)));
prop_row(ui, "Position Z", |ui| read_only(ui, &len(i.insert.z)));
num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, ids);
num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, ids);
num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, &ids);
num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, &ids);
if let Some((_, props)) = custom.filter(|(_, p)| !p.is_empty()) {
group(ui, "Custom");
for p in &props {
@@ -1258,230 +1159,6 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
_ => {}
}
} else if let Some(dims) = sel
.iter()
.map(|h| match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Dimension(dm)) => Some(dm),
_ => None,
})
.collect::<Option<Vec<_>>>()
{
// Several dimensions: every property of one dimension, `*VARIES*` where they differ; an
// edit applies to all of them.
group(ui, "Geometry");
let shared = |key: &'static str, f: &dyn Fn(&cadcraft_doc::Dimension, &cadcraft_doc::DimStyle) -> String| -> String {
memo.get(key, || {
let vals: Vec<String> = dims
.iter()
.map(|dm| {
let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
f(dm, &st)
})
.collect();
match vals.first() {
Some(v0) if vals.iter().all(|v| v == v0) => shown_part(v0),
_ => VARIES.into(),
}
})
};
let text = shared("dim.text", &|dm, _| dm.text.clone());
prop_row(ui, "Text override", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", ids)), text.clone()) {
set = Some(json!({ "handles": ids, "textOverride": s }));
}
});
let style = shared("dim.style", &|dm, _| dm.style.clone());
prop_row(ui, "Dim style", |ui| {
if let Some(n) = pick_menu(ui, &style, d.dim_styles.iter().map(|st| st.name.clone())) {
set = Some(json!({ "handles": ids, "dimStyle": n }));
}
});
let numbers = [
("Text height", "textHeight", shared("dim.text_height", &|_, st| lenx(st.text_height))),
("Arrow size", "arrowSize", shared("dim.arrow_size", &|_, st| lenx(st.arrow_size))),
("Dim scale overall", "dimScale", shared("dim.scale", &|_, st| realx(st.scale))),
];
for (label, key, shown) in numbers {
prop_row(ui, label, |ui| {
if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), &mut bad) {
set = Some(json!({ "handles": ids, key: f }));
}
});
}
let precision = shared("dim.precision", &|_, st| st.decimals.to_string());
prop_row(ui, "Precision", |ui| {
if let Some(n) = pick_menu(ui, &precision, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::<u8>().ok()) {
set = Some(json!({ "handles": ids, "precision": n }));
}
});
} else if sel.iter().filter_map(|h| d.entity(*h)).all(|x| std::mem::discriminant(&x.kind) == std::mem::discriminant(&e.kind)) {
// Several objects of one type: the properties of one object, `*VARIES*` where they differ.
// Edits apply to all of them; positions are shown but stay read-only, since typing one
// would move every object to the same place.
group(ui, "Geometry");
let shared = |key: &'static str, f: &dyn Fn(&EntityKind) -> Option<String>| -> String {
memo.get(key, || {
let vals: Vec<Option<String>> = sel.iter().filter_map(|h| d.entity(*h)).map(|x| f(&x.kind)).collect();
match vals.first() {
Some(Some(v0)) if vals.iter().all(|v| v.as_ref() == Some(v0)) => shown_part(v0),
_ => VARIES.into(),
}
})
};
let deg = |v: f64| real(angles.shown(v).to_degrees());
let degx = |v: f64| exact(deg(v), angles.shown(v).to_degrees());
match &e.kind {
EntityKind::Line(_) => {
let line = |k: &EntityKind| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None };
for (label, value) in [
("Start X", shared("line.ax", &|k| line(k).map(|l| lenx(l.a.x)))),
("Start Y", shared("line.ay", &|k| line(k).map(|l| lenx(l.a.y)))),
("End X", shared("line.bx", &|k| line(k).map(|l| lenx(l.b.x)))),
("End Y", shared("line.by", &|k| line(k).map(|l| lenx(l.b.y)))),
("Delta X", shared("line.dx", &|k| line(k).map(|l| lenx(l.b.x - l.a.x)))),
("Delta Y", shared("line.dy", &|k| line(k).map(|l| lenx(l.b.y - l.a.y)))),
] {
prop_row(ui, label, |ui| read_only(ui, &value));
}
num(ui, "Length", "length", shared("line.length", &|k| line(k).map(|l| lenx((l.b.xy() - l.a.xy()).len()))), &mut set, &mut bad, ids);
num(
ui,
"Angle",
"angle",
shared("line.angle", &|k| line(k).map(|l| realx((l.b.xy() - l.a.xy()).angle().to_degrees()))),
&mut set,
&mut bad,
ids,
);
}
EntityKind::Circle(_) => {
let circle = |k: &EntityKind| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None };
prop_row(ui, "Center X", |ui| read_only(ui, &shared("circle.cx", &|k| circle(k).map(|c| lenx(c.center.x)))));
prop_row(ui, "Center Y", |ui| read_only(ui, &shared("circle.cy", &|k| circle(k).map(|c| lenx(c.center.y)))));
num(ui, "Radius", "radius", shared("circle.radius", &|k| circle(k).map(|c| lenx(c.radius))), &mut set, &mut bad, ids);
num(ui, "Diameter", "diameter", shared("circle.diameter", &|k| circle(k).map(|c| lenx(c.radius * 2.0))), &mut set, &mut bad, ids);
num(
ui,
"Circumference",
"circumference",
shared("circle.circumference", &|k| circle(k).map(|c| lenx(c.radius * std::f64::consts::TAU))),
&mut set,
&mut bad,
ids,
);
num(
ui,
"Area",
"area",
shared("circle.area", &|k| circle(k).map(|c| realx(c.radius * c.radius * std::f64::consts::PI))),
&mut set,
&mut bad,
ids,
);
}
EntityKind::Arc(_) => {
let arc = |k: &EntityKind| if let EntityKind::Arc(a) = k { Some(a.clone()) } else { None };
num(ui, "Radius", "radius", shared("arc.radius", &|k| arc(k).map(|a| lenx(a.radius))), &mut set, &mut bad, ids);
num(ui, "Start angle", "startAngle", shared("arc.start", &|k| arc(k).map(|a| realx(a.start.to_degrees()))), &mut set, &mut bad, ids);
num(ui, "End angle", "endAngle", shared("arc.end", &|k| arc(k).map(|a| realx(a.end.to_degrees()))), &mut set, &mut bad, ids);
}
EntityKind::Text(_) => {
let contents = shared("text.value", &|k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None });
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", ids)), contents.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
let height = shared("text.height", &|k| if let EntityKind::Text(t) = k { Some(lenx(t.height)) } else { None });
num(ui, "Height", "height", height, &mut set, &mut bad, ids);
let rotation = shared("text.rotation", &|k| if let EntityKind::Text(t) = k { Some(degx(t.rotation)) } else { None });
num(ui, "Rotation", "rotation", rotation, &mut set, &mut bad, ids);
let width_factor = shared("text.width_factor", &|k| if let EntityKind::Text(t) = k { Some(realx(t.width_factor)) } else { None });
num(ui, "Width factor", "widthFactor", width_factor, &mut set, &mut bad, ids);
}
EntityKind::MText(_) => {
let contents = shared("mtext.contents", &|k| if let EntityKind::MText(t) = k { Some(t.contents.clone()) } else { None });
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", ids)), contents.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
let height = shared("mtext.height", &|k| if let EntityKind::MText(t) = k { Some(lenx(t.height)) } else { None });
num(ui, "Text height", "height", height, &mut set, &mut bad, ids);
let width = shared("mtext.width", &|k| if let EntityKind::MText(t) = k { Some(lenx(t.width)) } else { None });
num(ui, "Defined width", "width", width, &mut set, &mut bad, ids);
}
EntityKind::LwPolyline(_) => {
// Only Length and Area need the vertices; the other rows read one field.
let geom = |p: &cadcraft_doc::LwPolyline| cadcraft_geom::Polyline { vertices: p.vertices.clone(), closed: p.closed };
let width = shared("poly.width", &|k| if let EntityKind::LwPolyline(p) = k { Some(lenx(p.const_width)) } else { None });
num(ui, "Global width", "width", width, &mut set, &mut bad, ids);
let length = shared("poly.length", &|k| if let EntityKind::LwPolyline(p) = k { Some(lenx(geom(p).len())) } else { None });
prop_row(ui, "Length", |ui| read_only(ui, &length));
let area = shared("poly.area", &|k| {
if let EntityKind::LwPolyline(p) = k { Some(if p.closed { real(geom(p).area().abs()) } else { String::new() }) } else { None }
});
prop_row(ui, "Area", |ui| read_only(ui, &area));
let closed = shared("poly.closed", &|k| {
if let EntityKind::LwPolyline(p) = k { Some(if p.closed { "Yes" } else { "No" }.to_string()) } else { None }
});
// Kept untranslated, so a change of language does not show stale text.
let closed = if closed == VARIES { closed } else { crate::i18n::t(&closed).to_string() };
prop_row(ui, "Closed", |ui| {
if let Some(n) = pick_menu(ui, &closed, ["Yes", "No"].map(|s| crate::i18n::t(s).to_string())) {
set = Some(json!({ "handles": ids, "closed": n == crate::i18n::t("Yes") }));
}
});
}
EntityKind::Hatch(_) => {
let pattern = shared("hatch.pattern", &|k| if let EntityKind::Hatch(h) = k { Some(h.pattern.clone()) } else { None });
prop_row(ui, "Pattern", |ui| {
ui.menu_button(pattern.clone(), |ui| {
if let Some(name) = patterns::menu(ui, &pattern) {
set = Some(json!({ "handles": ids, "pattern": name }));
ui.close();
}
});
});
let scale = shared("hatch.scale", &|k| if let EntityKind::Hatch(h) = k { Some(realx(h.scale)) } else { None });
num(ui, "Scale", "scale", scale, &mut set, &mut bad, ids);
let angle = shared("hatch.angle", &|k| if let EntityKind::Hatch(h) = k { Some(realx(h.angle.to_degrees())) } else { None });
num(ui, "Angle", "angle", angle, &mut set, &mut bad, ids);
}
EntityKind::Insert(_) => {
// A dynamic block reference shows its dynamic block's name, as with one reference;
// its anonymous blocks (`*U12`) differ from one reference to the next.
let name = memo.get("insert.name", || {
let names: Vec<String> = sel
.iter()
.filter_map(|h| match d.entity(*h).map(|x| &x.kind) {
Some(EntityKind::Insert(i)) => Some(custom_properties(ui, app, *h).map_or_else(|| i.block.clone(), |(n, _)| n)),
_ => None,
})
.collect();
match names.first() {
Some(n0) if names.iter().all(|n| n == n0) => n0.clone(),
_ => VARIES.to_string(),
}
});
let position = |key: &'static str, f: fn(&cadcraft_doc::Insert) -> f64| {
shared(key, &|k| if let EntityKind::Insert(i) = k { Some(len(f(i))) } else { None })
};
for (label, value) in [
("Name", name),
("Position X", position("insert.x", |i| i.insert.x)),
("Position Y", position("insert.y", |i| i.insert.y)),
("Position Z", position("insert.z", |i| i.insert.z)),
] {
prop_row(ui, label, |ui| read_only(ui, &value));
}
let rotation = shared("insert.rotation", &|k| if let EntityKind::Insert(i) = k { Some(degx(i.rotation)) } else { None });
num(ui, "Rotation", "rotation", rotation, &mut set, &mut bad, ids);
let scale = shared("insert.scale", &|k| if let EntityKind::Insert(i) = k { Some(realx(i.scale.x)) } else { None });
num(ui, "Scale", "scale", scale, &mut set, &mut bad, ids);
}
_ => {}
}
}
if let Some(mut p) = set {
// A rotation is typed as shown (from ANGBASE, in the ANGDIR sense); properties.set takes it
@@ -1505,15 +1182,6 @@ mod tests {
use crate::Services;
use cadcraft_engine::Session;
use egui::{Color32, Shape};
use egui_kittest::kittest::Queryable;
#[test]
fn shown_part_takes_off_only_the_number_tag() {
assert_eq!(shown_part(&exact("5.00".into(), 5.0)), "5.00");
assert_eq!(shown_part("plain"), "plain");
assert_eq!(shown_part("a\u{1f}b"), "a\u{1f}b");
assert_eq!(shown_part(&format!("a{EXACT_SEP}b")), "a\u{1f}b");
}
fn contains_swatch(shape: &Shape, color: Color32) -> bool {
match shape {
@@ -1541,250 +1209,4 @@ mod tests {
}
assert!(painted_blue, "a ByLayer entity on a blue layer needs a blue Properties swatch");
}
/// The texts of a painted shape, one string per text.
fn collect_text(shape: &Shape, out: &mut Vec<String>) {
match shape {
Shape::Text(t) => out.push(t.galley.text().to_string()),
Shape::Vec(shapes) => shapes.iter().for_each(|s| collect_text(s, out)),
_ => {}
}
}
/// Everything the Properties palette paints for the current selection, one string per text.
fn painted_texts(app: &mut CadApp, section: fn(&mut CadApp, &mut egui::Ui)) -> Vec<String> {
let ctx = egui::Context::default();
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() };
let mut texts = Vec::new();
for _ in 0..2 {
app.logic(&ctx);
let mut output = ctx.run_ui(input.clone(), |ui| section(app, ui));
texts.clear();
output.shapes.iter().for_each(|s| collect_text(&s.shape, &mut texts));
output.textures_delta.clear();
}
texts
}
/// Several dimensions selected: the Properties palette shows all of a dimension's properties,
/// `*VARIES*` for those that differ, and an edit applies to every dimension.
#[test]
fn several_dimensions_show_every_property_with_varies_for_different_ones() {
let mut app = CadApp::new(Session::default(), Services::default());
let first = app.run("dimlinear", json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 3] })).unwrap()["handle"].as_str().unwrap().to_string();
let second =
app.run("dimlinear", json!({ "p1": [0, 0], "p2": [20, 0], "at": [5, 6], "text": "X" })).unwrap()["handle"].as_str().unwrap().to_string();
app.run("select", json!({ "handles": [first, second] })).unwrap();
let texts = painted_texts(&mut app, properties_section);
for want in ["Text override", "Dim style", "Text height", "Arrow size", "Dim scale overall", "Precision"] {
assert!(texts.iter().any(|t| t.as_str() == crate::i18n::t(want)), "the palette shows `{want}`; painted: {texts:?}");
}
assert!(texts.iter().any(|t| t == VARIES), "the differing text override shows {VARIES}; painted: {texts:?}");
app.run("properties.set", json!({ "handles": [first, second], "textHeight": 7 })).unwrap();
let texts = painted_texts(&mut app, properties_section);
assert!(texts.iter().any(|t| t == "7.0000" || t == "7"), "the common text height is shown; painted: {texts:?}");
}
/// Values the display rounds alike still count as different: two dimensions whose text heights
/// differ by a millionth show `*VARIES*`, as do two circles whose radii do. Float noise (a
/// ten-trillionth) is not a difference.
#[test]
fn round_off_next_to_zero_is_no_difference() {
let tag = |v: f64| super::exact("0.0000".into(), v);
assert_eq!(tag(0.0), tag(1.2e-16));
assert_eq!(tag(0.0), tag(-3e-15));
assert_ne!(tag(0.0), tag(1e-6), "a real difference below the display precision still shows");
}
#[test]
fn values_that_differ_below_the_display_precision_still_vary() {
let mut app = CadApp::new(Session::default(), Services::default());
let dims: Vec<String> = [10, 20]
.map(|x| app.run("dimlinear", json!({ "p1": [0, 0], "p2": [x, 0], "at": [5, 3] })).unwrap()["handle"].as_str().unwrap().to_string())
.to_vec();
app.run("select", json!({ "handles": dims.clone() })).unwrap();
app.run("properties.set", json!({ "handles": [dims[0].clone()], "textHeight": 7.0 })).unwrap();
app.run("properties.set", json!({ "handles": [dims[1].clone()], "textHeight": 7.0 + 1e-13 })).unwrap();
let texts = painted_texts(&mut app, properties_section);
assert!(!texts.iter().any(|t| t == VARIES), "noise below 12 digits is not a difference; painted: {texts:?}");
app.run("properties.set", json!({ "handles": [dims[1].clone()], "textHeight": 7.000001 })).unwrap();
let texts = painted_texts(&mut app, properties_section);
assert!(texts.iter().any(|t| t == VARIES), "7 and 7.000001 differ although both show as 7.0000; painted: {texts:?}");
let mut app = CadApp::new(Session::default(), Services::default());
let circles = circles(&mut app, &[2, 2], json!({}));
app.run("select", json!({ "handles": circles.clone() })).unwrap();
app.run("properties.set", json!({ "handles": [circles[1].clone()], "radius": 2.000001 })).unwrap();
let h = palette(app);
assert!(h.query_all_by_value(VARIES).count() > 0, "radii 2 and 2.000001 vary");
}
/// Two objects of each type, selected together: the palette shows the type's properties (not
/// just General), `*VARIES*` for the ones that differ, and an edit reaches both objects.
#[test]
fn several_objects_of_one_type_show_their_properties_with_varies() {
let cases: [(&str, [Value; 2], &[&str]); 6] = [
("line", [json!({ "points": [[0, 0], [10, 0]] }), json!({ "points": [[0, 5], [20, 5]] })], &["Start X", "Length", "Angle"]),
("circle", [json!({ "center": [0, 0], "radius": 2 }), json!({ "center": [9, 9], "radius": 5 })], &["Center X", "Radius", "Area"]),
(
"text",
[json!({ "at": [0, 0], "text": "A", "height": 2 }), json!({ "at": [0, 9], "text": "B", "height": 3 })],
&["Contents", "Height", "Rotation"],
),
(
"pline",
[json!({ "vertices": [[0, 0], [4, 0], [4, 4]] }), json!({ "vertices": [[0, 9], [8, 9], [8, 12]] })],
&["Global width", "Length", "Closed"],
),
(
"arc",
[json!({ "center": [0, 0], "radius": 3, "start": 0, "end": 90 }), json!({ "center": [9, 0], "radius": 6, "start": 10, "end": 80 })],
&["Radius", "Start angle", "End angle"],
),
(
"mtext",
[json!({ "at": [0, 0], "text": "A", "height": 2 }), json!({ "at": [0, 9], "text": "B", "height": 3 })],
&["Contents", "Text height", "Defined width"],
),
];
for (command, params, labels) in cases {
let mut app = CadApp::new(Session::default(), Services::default());
let handles: Vec<String> = params
.iter()
.map(|p| {
let out = app.run(command, p.clone()).unwrap();
out["handle"].as_str().map_or_else(|| out["handles"][0].as_str().unwrap().to_string(), str::to_string)
})
.collect();
app.run("select", json!({ "handles": handles })).unwrap();
let texts = painted_texts(&mut app, properties_section);
for want in labels {
assert!(texts.iter().any(|t| t.as_str() == crate::i18n::t(want)), "{command}: the palette shows `{want}`; painted: {texts:?}");
}
assert!(texts.iter().any(|t| t == VARIES), "{command}: differing values show {VARIES}; painted: {texts:?}");
}
}
/// The radius of every selected circle.
fn selected_radii(app: &CadApp) -> Vec<f64> {
let d = app.session.doc().unwrap();
app.session
.selection()
.iter()
.filter_map(|h| d.entity(*h))
.filter_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None })
.collect()
}
/// The radius of each circle in `handles`, selected or not.
fn radii_of(app: &CadApp, handles: &[String]) -> Vec<f64> {
let d = app.session.doc().unwrap();
handles
.iter()
.filter_map(|h| d.entity(cadcraft_doc::Handle::parse_hex(h)?))
.filter_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None })
.collect()
}
/// A palette showing `app`'s selection, a few frames in.
fn palette(app: CadApp) -> egui_kittest::Harness<'static, CadApp> {
let mut h =
egui_kittest::Harness::builder().with_size(vec2(400.0, 1200.0)).build_ui_state(|ui, app: &mut CadApp| properties_section(app, ui), app);
h.run_steps(3);
h
}
/// Types `text` into the first `*VARIES*` field (the Radius, whose row comes first) and commits it.
fn type_into_first_varies(h: &mut egui_kittest::Harness<'static, CadApp>, text: &str) {
let field = h.get_all_by_value(VARIES).next().unwrap();
field.focus();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(2);
h.get_all_by_value(VARIES).next().unwrap().type_text(text);
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(3);
}
fn circles(app: &mut CadApp, radii: &[i32], extra: Value) -> Vec<String> {
radii
.iter()
.map(|r| {
let mut p = json!({ "center": [0, 0], "radius": r });
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
app.run("circle", p).unwrap()["handle"].as_str().unwrap().to_string()
})
.collect()
}
/// A value typed into the palette goes to every selected object, not just the first.
#[test]
fn typing_a_shared_value_changes_every_selected_object() {
let mut app = CadApp::new(Session::default(), Services::default());
let handles = circles(&mut app, &[2, 5, 9], json!({}));
app.run("select", json!({ "handles": handles })).unwrap();
let mut h = palette(app);
type_into_first_varies(&mut h, "7");
assert_eq!(selected_radii(h.state()), vec![7.0, 7.0, 7.0], "all three circles take the typed radius");
}
/// The edit to several objects is one undo step.
#[test]
fn editing_several_objects_is_one_undo_step() {
let mut app = CadApp::new(Session::default(), Services::default());
let handles = circles(&mut app, &[2, 5, 9], json!({}));
app.run("select", json!({ "handles": handles.clone() })).unwrap();
let mut h = palette(app);
type_into_first_varies(&mut h, "7");
assert_eq!(radii_of(h.state(), &handles), vec![7.0, 7.0, 7.0], "the edit reached all three");
h.state_mut().run("undo", json!({})).unwrap();
assert_eq!(radii_of(h.state(), &handles), vec![2.0, 5.0, 9.0], "one undo puts back all three radii");
}
/// A mixed selection has no type to show: only the properties every object has (General), so
/// no Radius row, however different the circles' radii are. One object is unchanged: its own
/// values, nothing varies.
#[test]
fn mixed_types_show_general_only_and_one_object_is_unchanged() {
let mut app = CadApp::new(Session::default(), Services::default());
let mut handles = circles(&mut app, &[2, 5], json!({}));
let line = app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string();
let circles_only = handles.clone();
handles.push(line);
app.run("select", json!({ "handles": handles })).unwrap();
let mut h = palette(app);
assert_eq!(h.query_all_by_value(VARIES).count(), 0, "two circles and a line show no Radius to vary");
h.state_mut().run("select", json!({ "handles": circles_only.clone() })).unwrap();
h.run_steps(3);
assert!(h.query_all_by_value(VARIES).count() > 0, "the two circles alone do show it");
h.state_mut().run("select", json!({ "handles": [circles_only[0].clone()] })).unwrap();
h.run_steps(3);
assert_eq!(h.query_all_by_value(VARIES).count(), 0, "one circle shows its own radius");
assert!(h.query_all_by_value("2.0000").count() > 0);
}
/// The values the palette works out are kept for a drawing revision and a selection, and
/// worked out again when either changes.
#[test]
fn shared_values_follow_the_drawing_and_the_selection() {
let mut app = CadApp::new(Session::default(), Services::default());
let handles: Vec<String> =
[2, 5].map(|r| app.run("circle", json!({ "center": [0, 0], "radius": r })).unwrap()["handle"].as_str().unwrap().to_string()).to_vec();
app.run("select", json!({ "handles": handles.clone() })).unwrap();
let mut h =
egui_kittest::Harness::builder().with_size(vec2(400.0, 1200.0)).build_ui_state(|ui, app: &mut CadApp| properties_section(app, ui), app);
h.run_steps(3);
assert!(h.query_all_by_value(VARIES).count() > 0, "two different radii show {VARIES}");
h.state_mut().run("properties.set", json!({ "handles": handles, "radius": 4.0 })).unwrap();
h.run_steps(3);
assert!(h.query_all_by_value(VARIES).count() == 0, "after both radii are 4 nothing varies");
h.state_mut().run("select", json!({ "handles": [handles[0].clone()] })).unwrap();
h.run_steps(3);
assert!(h.query_all_by_value("4.0000").count() > 0, "one circle alone shows its radius");
}
}
-177
View File
@@ -1,177 +0,0 @@
//! ALIASEDIT aliases are user settings (#1899): they are saved in the user profile the desktop
//! and web hosts keep across restarts (`profile_json` → `load_profile`), never in the drawing.
//! The alias store is process-wide, so these tests run one at a time and use their own names.
use std::sync::Mutex;
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::MAX_ALIASES;
use cadcraft_engine::cmd::resolve_alias;
use cadcraft_ui_egui::options::MAX_PROFILE_BYTES;
use cadcraft_ui_egui::{CadApp, Services};
use serde_json::{Value, json};
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
fn app() -> CadApp {
CadApp::new(Session::new(), Services::default())
}
fn lock() -> std::sync::MutexGuard<'static, ()> {
ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner())
}
fn profile(value: &str) -> Value {
serde_json::from_str(value).unwrap_or(Value::Null)
}
#[test]
fn aliases_survive_a_restart_and_command_names_still_win() {
let _one = lock();
let mut a = app();
a.run("aliasedit", json!({ "set": { "zzpersist": "qsave" } })).unwrap();
let saved = a.profile_json();
assert!(profile(&saved)["aliases"]["aliases"].get("zzpersist").is_some(), "the profile holds the alias: {saved}");
// A fresh app restores the profile: the alias works, built-ins and command names unchanged.
let mut b = app();
b.load_profile(&saved);
assert_eq!(resolve_alias("zzpersist"), "qsave");
assert_eq!(resolve_alias("ZZPERSIST"), "qsave");
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
assert_eq!(resolve_alias("line"), "line", "command names still win");
assert_eq!(resolve_alias("zzunknown"), "zzunknown");
// A hand-edited profile can't make an alias take over a command name.
let mut edited = profile(&saved);
edited["aliases"]["aliases"]["line"] = json!("circle");
let mut c = app();
c.load_profile(&edited.to_string());
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
a.run("aliasedit", json!({ "clear": true })).unwrap();
}
#[test]
fn removed_and_cleared_aliases_stay_removed() {
let _one = lock();
let mut a = app();
a.run("aliasedit", json!({ "set": { "zzgone": "line", "zzkeep": "circle" } })).unwrap();
assert_eq!(resolve_alias("zzgone"), "line");
a.run("aliasedit", json!({ "remove": ["zzgone"] })).unwrap();
let saved = a.profile_json();
let mut b = app();
b.load_profile(&saved);
assert_eq!(resolve_alias("zzgone"), "zzgone", "a removed alias stays removed");
assert_eq!(resolve_alias("zzkeep"), "circle", "the other aliases still restore");
a.run("aliasedit", json!({ "clear": true })).unwrap();
let cleared = a.profile_json();
let mut c = app();
c.load_profile(&cleared);
assert_eq!(resolve_alias("zzkeep"), "zzkeep", "a cleared alias stays cleared");
assert_eq!(profile(&cleared)["aliases"]["aliases"], json!({}), "the profile holds no user aliases");
}
#[test]
fn malformed_or_oversized_saved_aliases_are_skipped_safely() {
let _one = lock();
let mut a = app();
a.run("aliasedit", json!({ "set": { "zzsafe": "line" } })).unwrap();
let saved = profile(&a.profile_json());
assert_eq!(resolve_alias("zzsafe"), "line");
let mut huge = serde_json::Map::new();
for i in 0..MAX_ALIASES.saturating_add(2_000) {
huge.insert(format!("zz{i}"), json!("line"));
}
let mut huge_saved = saved.clone();
huge_saved["aliases"] = json!({ "aliases": huge, "file": "mine.pgp" });
let mut bad_values = saved.clone();
bad_values["aliases"] = json!({ "aliases": { "zzbad": 5, "zznames": { "x": 1 }, "zzreal": "line" } });
let mut not_an_object = saved.clone();
not_an_object["aliases"] = json!("zzsafe, *LINE");
let oversized = format!(r#"{{"aliases": {}, "pad": "{}"}}"#, saved["aliases"], "x".repeat(MAX_PROFILE_BYTES));
let mut b = app();
b.load_profile(&huge_saved.to_string()); // must not panic
let listing = b.run("aliasedit", json!({})).unwrap();
let count = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
assert!(count <= MAX_ALIASES, "restored {count} aliases");
for bad in [bad_values.to_string(), not_an_object.to_string(), oversized] {
b.load_profile(&bad); // must not panic
}
// Entries that aren't aliases of real commands are skipped.
assert_eq!(resolve_alias("zzbad"), "zzbad", "a non-string command is skipped");
assert_eq!(resolve_alias("zzreal"), "line", "valid entries beside bad ones still restore");
a.run("aliasedit", json!({ "clear": true })).unwrap();
}
#[test]
fn a_full_alias_set_saved_by_the_profile_restores_after_a_restart() {
let _one = lock();
// ALIASEDIT holds up to MAX_ALIASES aliases. Long names make the saved profile far larger
// than a small one, and the loader must still accept everything `profile_json` writes.
let mut a = app();
let mut set = serde_json::Map::new();
let mut names = Vec::new();
for i in 0..MAX_ALIASES {
let name = format!("zz{i:05}alias{}", "x".repeat(40));
names.push(name.clone());
set.insert(name, json!("line"));
}
a.run("aliasedit", json!({ "set": set })).unwrap();
let saved = a.profile_json();
assert!(saved.len() > 64 * 1024, "the saved profile crosses 64 KiB: {} bytes", saved.len());
assert!(saved.len() <= MAX_PROFILE_BYTES, "the limit covers a full alias set: {} bytes", saved.len());
let mut b = app();
b.load_profile(&saved);
let listing = b.run("aliasedit", json!({})).unwrap();
let count = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
assert_eq!(count, MAX_ALIASES, "every saved alias restores");
for name in [names.first(), names.last()].into_iter().flatten() {
assert_eq!(resolve_alias(name), "line", "restored {name}");
}
a.run("aliasedit", json!({ "clear": true })).unwrap();
}
#[test]
fn a_profile_one_byte_over_the_limit_is_refused_safely() {
let _one = lock();
let defaults = app().run("options", json!({})).unwrap();
// Still valid JSON that would change PICKBOX, so accepting it would show.
let overhead = r#"{"sysvars":{"PICKBOX":9},"pad":""}"#.len();
let over = format!(r#"{{"sysvars":{{"PICKBOX":9}},"pad":"{}"}}"#, "x".repeat(MAX_PROFILE_BYTES + 1 - overhead));
assert_eq!(over.len(), MAX_PROFILE_BYTES + 1);
let mut b = app();
b.load_profile(&over); // must not panic
assert_eq!(b.run("options", json!({})).unwrap(), defaults, "an oversized profile changes nothing");
}
#[test]
fn a_loaded_alias_file_survives_with_its_path_and_reinit_rereads_it() {
let _one = lock();
let dir = std::env::temp_dir().join(format!("cadcraft-alias-profile-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("mine.pgp").to_string_lossy().into_owned();
std::fs::write(&file, "ZZPROFILE, *RECTANG\n").unwrap();
let mut a = app();
a.run("aliasedit", json!({ "file": file.clone() })).unwrap();
assert_eq!(resolve_alias("zzprofile"), "rectang");
let saved = a.profile_json();
assert_eq!(profile(&saved)["aliases"]["file"], json!(file), "the profile remembers the alias file");
// A restart keeps both the aliases and the file REINIT reads.
let mut b = app();
b.load_profile(&saved);
assert_eq!(resolve_alias("zzprofile"), "rectang");
let listing = b.run("aliasedit", json!({})).unwrap();
assert_eq!(listing["file"], json!(file));
std::fs::write(&file, "ZZPROFILE, *CIRCLE\n").unwrap();
b.run("reinit", json!({})).unwrap();
assert_eq!(resolve_alias("zzprofile"), "circle", "REINIT re-reads the restored file");
a.run("aliasedit", json!({ "clear": true })).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
-2
View File
@@ -23,7 +23,6 @@ mod named_viewports_dialog;
mod native_menu_text_keys;
mod native_windows;
mod options_dialog;
mod options_right_click;
mod parameters_rename;
mod pickdrag;
mod properties_units;
@@ -31,7 +30,6 @@ mod rapid_clicks;
mod selection_cycling;
mod shared_controls;
mod shortcut_menus;
mod shortcuts_checked;
mod status_bar_toggles;
mod style_dialogs;
mod system_cursor;
+1 -1
View File
@@ -138,7 +138,7 @@ fn hostile_saved_profiles_are_ignored() {
"[]".into(),
r#"{"sysvars": "PICKBOX"}"#.into(),
r#"{"sysvars": {"PICKBOX": "x", "LUNITS": 4, "FONTALT": "evil.ttf"}, "modelBackground": "red", "snapMarkerSize": -1}"#.into(),
format!(r#"{{"sysvars": {{"PICKBOX": 5}}, "pad": "{}"}}"#, "x".repeat(options::MAX_PROFILE_BYTES)),
format!(r#"{{"sysvars": {{"PICKBOX": 5}}, "pad": "{}"}}"#, "x".repeat(70_000)),
] {
let mut b = app();
b.load_profile(&bad);
@@ -1,210 +0,0 @@
//! Options ▸ User Preferences ▸ Right-Click Customization: the time-sensitive right-click's
//! duration, and what a right-click does with nothing selected (Default mode), with a selection
//! (Edit mode) and while a command runs (Command mode), through the OPTIONS JSON form, the
//! drawing area and the dialog.
use cadcraft_engine::Session;
use cadcraft_ui_egui::options::{CommandClick, IdleClick};
use cadcraft_ui_egui::{CadApp, Services, menus};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn app() -> CadApp {
CadApp::new(Session::new(), Services::default())
}
/// One frame at `time` (seconds) with `events`; returns the painted text.
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<egui::Event>, time: f64) -> String {
fn collect(shape: &egui::Shape, text: &mut String) {
match shape {
egui::Shape::Text(s) => {
text.push_str(s.galley.text());
text.push('\n');
}
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)),
_ => {}
}
}
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
time: Some(time),
events,
..Default::default()
};
app.logic(ctx);
let mut out = ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
let mut text = String::new();
out.shapes.iter().for_each(|s| collect(&s.shape, &mut text));
text
}
const AT: egui::Pos2 = egui::pos2(800.0, 500.0);
fn button(pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos: AT, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }
}
/// A right-click on the drawing area from `t` held for `held` seconds; returns the text painted
/// just after.
fn right_click(app: &mut CadApp, ctx: &egui::Context, t: f64, held: f64) -> String {
frame(app, ctx, vec![egui::Event::PointerMoved(AT)], t);
frame(app, ctx, vec![button(true)], t + 0.01);
frame(app, ctx, vec![button(false)], t + 0.01 + held);
frame(app, ctx, Vec::new(), t + 0.05 + held)
}
#[test]
fn right_click_settings_are_checked_and_survive_a_restart() {
let mut a = app();
let all = a.run("options", json!({})).unwrap();
assert_eq!(
(all["rightClickDuration"].clone(), all["rightClickDefault"].clone(), all["rightClickEdit"].clone(), all["rightClickCommand"].clone()),
(json!(250), json!("menu"), json!("menu"), json!("menu"))
);
let r = a
.run(
"options",
json!({"rightClickDuration": 400, "rightClickDefault": "repeat", "rightClickEdit": "Repeat", "rightClickCommand": "menuWithOptions"}),
)
.unwrap();
assert_eq!((r["rightClickDuration"].clone(), r["rightClickEdit"].clone()), (json!(400), json!("repeat")));
assert_eq!(a.options.right_click_command, CommandClick::MenuWithOptions);
let before = a.run("options", json!({})).unwrap();
for bad in [
json!({"rightClickDuration": 50}),
json!({"rightClickDuration": 2001}),
json!({"rightClickDuration": f64::NAN}),
json!({"rightClickDuration": "long"}),
json!({"rightClickDefault": "enter"}),
json!({"rightClickEdit": 1}),
json!({"rightClickCommand": "repeat", "PICKBOX": 9}),
] {
assert!(a.run("options", bad.clone()).is_err(), "{bad}");
assert_eq!(a.run("options", json!({})).unwrap(), before, "{bad} changed nothing");
}
let mut b = app();
b.load_profile(&a.profile_json());
assert_eq!(b.options, a.options);
let mut c = app();
c.load_profile(r#"{"rightClickDuration": 99999, "rightClickDefault": "fly", "rightClickCommand": null}"#);
assert_eq!(c.options, app().options, "hostile saved values are skipped");
let r = a.run("options", json!({"reset": true})).unwrap();
assert_eq!((r["rightClickDuration"].clone(), r["rightClickDefault"].clone()), (json!(250), json!("menu")));
}
#[test]
fn an_idle_right_click_can_repeat_the_last_command() {
let mut a = app();
a.run("options", json!({"rightClickDefault": "repeat"})).unwrap();
a.cmdline("circle 0,0 5");
assert!(!a.session.prompting());
let ctx = egui::Context::default();
frame(&mut a, &ctx, Vec::new(), 0.0);
right_click(&mut a, &ctx, 0.1, 0.05);
assert!(a.session.prompting(), "CIRCLE runs again: {}", a.session.prompt_text());
a.cmdline("10,10 2");
// With a selection, Edit mode decides: the menu (its default) opens instead.
a.run("selectall", json!({})).unwrap();
assert!(!a.session.selection().is_empty());
let text = right_click(&mut a, &ctx, 1.0, 0.05);
assert!(!a.session.prompting(), "no command started: {}", a.session.prompt_text());
assert!(text.contains("Erase"), "the shortcut menu opens:\n{text}");
}
#[test]
fn command_mode_and_click_duration_decide_enter_or_the_menu() {
let ctx = egui::Context::default();
// A longer duration: a 0.5 s click is still Enter.
let mut a = app();
a.run("options", json!({"rightClickDuration": 1000})).unwrap();
frame(&mut a, &ctx, Vec::new(), 0.0);
a.cmdline("LINE 0,0 10,0");
right_click(&mut a, &ctx, 0.1, 0.5);
assert!(a.session.running.is_none(), "a click shorter than the duration is Enter: {}", a.session.prompt_text());
// Time-sensitive off, Command mode Enter: every right-click is Enter.
let ctx = egui::Context::default();
let mut a = app();
a.run("options", json!({"timeSensitiveRightClick": false, "rightClickCommand": "enter"})).unwrap();
frame(&mut a, &ctx, Vec::new(), 0.0);
a.cmdline("LINE 0,0 10,0");
let text = right_click(&mut a, &ctx, 0.1, 0.6);
assert!(a.session.running.is_none(), "Enter ends LINE: {}", a.session.prompt_text());
assert!(!text.contains("Snap Overrides"), "no menu:\n{text}");
// Command mode "enabled when command options are present": CIRCLE's first prompt has
// options, so the menu opens and the command keeps running.
a.run("options", json!({"rightClickCommand": "menuWithOptions"})).unwrap();
a.cmdline("CIRCLE");
let text = right_click(&mut a, &ctx, 2.0, 0.05);
assert!(text.contains("Cancel"), "the command menu opens:\n{text}");
assert!(a.session.prompting(), "CIRCLE still runs");
}
/// With `CADCRAFT_OPTIONS_SHOTS` set to a folder, the dialog is rendered there for a look.
const SHOTS: &str = "CADCRAFT_OPTIONS_SHOTS";
fn harness(app: CadApp) -> Harness<'static, CadApp> {
let builder = Harness::builder().with_size(egui::vec2(1400.0, 900.0));
let builder = if std::env::var_os(SHOTS).is_some() { builder.wgpu() } else { builder };
let mut h = builder.build_ui_state(
|ui, app: &mut CadApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
fn shot(h: &mut Harness<'static, CadApp>, name: &str) {
if let Some(dir) = std::env::var_os(SHOTS).map(std::path::PathBuf::from) {
std::fs::create_dir_all(&dir).unwrap();
h.render().unwrap().save(dir.join(name)).unwrap();
}
}
#[test]
fn the_customization_window_sets_every_mode() {
let mut a = app();
menus::activate(&mut a, "options");
a.ui.dialog = Some("options:preferences".into());
let mut h = harness(a);
h.get_by_label("Right-Click Customization...").click();
h.run_steps(3);
h.get_by_label("Longer click duration:");
shot(&mut h, "options-right-click.png");
// Default mode's "Repeat last command" comes first, Edit mode's second.
if let Some(n) = h.get_all_by_label("Repeat last command").next() {
n.click();
}
h.run_steps(3);
assert_eq!(h.state().options.right_click_default, IdleClick::Repeat);
assert_eq!(h.state().options.right_click_edit, IdleClick::Menu);
// Command mode applies once the time-sensitive right-click is off.
h.get_by_label("Turn on time-sensitive right-click").click();
h.run_steps(3);
assert!(!h.state().options.time_sensitive_right_click);
h.get_by_label("Shortcut Menu: enabled when command options are present").click();
h.run_steps(3);
assert_eq!(h.state().options.right_click_command, CommandClick::MenuWithOptions);
let unnamed = h.unnamed_widgets();
assert!(unnamed.is_empty(), "unnamed controls: {unnamed:?}");
// The window's Close comes after the Options dialog's in the tree.
if let Some(n) = h.get_all_by_label("Close").last() {
n.click();
}
h.run_steps(3);
assert!(h.query_by_label("Longer click duration:").is_none());
assert_eq!(h.state().ui.dialog.as_deref(), Some("options:preferences"));
}
@@ -1,39 +0,0 @@
//! User shortcuts are checked however they arrive: `ui.set` drops what the preferences would,
//! and a malformed preference value leaves the shortcuts as they are.
use cadcraft_engine::Session;
use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle};
use cadcraft_ui_egui::{CadApp, Services};
use serde_json::{Value, json};
fn call(app: &mut CadApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
let (req, _reply) = ControlRequest::new(method, params);
match handle(app, ctx, &req) {
Outcome::Done(v) => v,
_ => panic!("unexpected screenshot"),
}
}
#[test]
fn ui_set_keeps_only_shortcuts_the_preferences_would() {
let mut app = CadApp::new(Session::new(), Services::default());
let ctx = egui::Context::default();
let v = call(
&mut app,
&ctx,
"ui.set",
json!({"shortcuts": {"line": "Ctrl+Shift+L", "circle": "not a key", "nosuchcommand": "Ctrl+Shift+K", "move": "Ctrl+Shift+L"}}),
);
assert_eq!(v["ok"], true, "{v}");
let ids: Vec<&String> = app.ui.shortcuts.keys().collect();
assert_eq!(ids, vec!["line"], "a bad key, an unknown command and a key taken twice are dropped: {:?}", app.ui.shortcuts);
}
#[test]
fn a_malformed_preference_keeps_the_shortcuts() {
let mut app = CadApp::new(Session::new(), Services::default());
app.load_prefs(r#"{"shortcuts": {"line": "Ctrl+Shift+L"}}"#);
assert_eq!(app.ui.shortcuts.len(), 1);
app.load_prefs(r#"{"shortcuts": "Ctrl+Shift+L"}"#);
assert_eq!(app.ui.shortcuts.get("line").map(String::as_str), Some("Ctrl+Shift+L"), "not cleared");
}
@@ -105,27 +105,3 @@ fn double_click_elsewhere_starts_no_command() {
assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), None, "{at:?}");
}
}
/// #1081: double-clicking a multileader's text edits it, as for TEXT and MTEXT.
#[test]
fn double_click_on_a_multileaders_text_starts_textedit() {
let mut h = harness();
h.state_mut().run("mleader", json!({ "points": [[16, -10], [20, -5]], "text": "example text" })).unwrap();
h.run_steps(3);
let at = h
.state()
.session
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
.unwrap();
double_click(&mut h, at + Vec2::new(1.0, -0.05));
let s = &h.state().session;
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("textedit"));
let p = s.current_prompt().unwrap();
assert_eq!(p.message, "Enter new text");
assert_eq!(p.default.as_deref(), Some("example text"));
assert!(s.editing_mtext().is_some(), "the in-place editor opens on it");
}
+1 -1
View File
@@ -60,7 +60,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |
| `ui.resize` | `{width, height}` | Resize the window. |
| `ui.focus` | | Activate the native window. Does not assign focus to a widget. |
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. In the web build there is no file system: `path` is ignored and the PNG comes back inline (`pngBase64`, `width`, `height`, `bytes`). |
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. |
| `ui.render` | `{path?, width?, height?, fit?, window?, background?, plot_style?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view; `window` `[[x0, y0], [x1, y1]]` shows just that area (drawing units). `background`: `"dark"` (the canvas; the default without a plot style), `"white"` (the default with one, as a plot prints on paper), `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, built-in (`monochrome.ctb` prints every colour black, `grayscale.ctb` grey; `default.ctb`, `default.stb`, `monochrome.stb`) or one the drawing holds; `"none"` (or `""`) draws every object with its own properties, also in a layout whose page setup shows plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown `background` or `plot_style` is refused. Replies `{pngBase64, width, height, bytes, path?}`. |
| `app.open` / `app.save` / `app.quit` | `{path}` | File operations. |
| `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. |
+4 -13
View File
@@ -8,7 +8,7 @@ and what is not there yet.
| LISP routines (`.lsp`) | Run with APPLOAD or `(load "name")`. The core language, `command`, entity lists, selection sets, `get…` input and `c:` commands work. | [autolisp.md](autolisp.md) |
| Startup LISP (`acad.lsp`, `acaddoc.lsp`) | Loaded from the support folder at startup and for each drawing. | [autolisp.md](autolisp.md#at-startup) |
| Scripts (`.scr`) | SCRIPT (Tools > Run Script...), `cadcraft-cli run --script-file`, and script text over MCP and the control channel. | below |
| Command aliases (`.pgp`) | ALIASEDIT `{file}` loads an alias file and keeps it; the aliases and the file survive restarts. REINIT reads it again. | below |
| Command aliases (`.pgp`) | ALIASEDIT `{file}` loads an alias file for the session; REINIT reads it again. | below |
| Compiled plug-ins (C++, .NET) | Not supported. A plug-in API is planned, for plug-ins written in Rust and compiled to WebAssembly. | [gaps.md](gaps.md) (E4) |
| Programs that drive the CAD program from outside | The JSON control channel, the MCP server and `cadcraft-cli`. Any language that can open a TCP socket or start a process can use them. | below |
@@ -34,18 +34,9 @@ cadcraft-cli run drawing.dxf --script-file setup.scr --save out.dxf
## Command aliases
ALIASEDIT reads a `.pgp` alias file (`ALIAS, *COMMAND` lines); its aliases replace the ones you
had. Lines that would start external programs are skipped. From a script or the control channel:
`aliasedit {"file": "/path/to/aliases.pgp"}`. The Command Aliases dialog's **Import…** button picks
a `.pgp` file, loads it the same way, shows its path and warnings, and remembers the folder it came
from; on the web (no native file dialog) type `ALIASEDIT` with the file's contents in its `text`
parameter instead.
Aliases are user settings, never part of a drawing. The desktop and web apps keep them, and the
file REINIT reads, with your other preferences, so aliases added, edited, imported or removed in
the Command Aliases dialog (or with ALIASEDIT's `set`, `remove` and `clear`) are there after a
restart, and REINIT reads the same file again. The headless CLI keeps no preferences: there,
aliases last for that run.
ALIASEDIT reads a `.pgp` alias file (`ALIAS, *COMMAND` lines) and adds its aliases for the rest of
the session. Lines that would start external programs are skipped. From a script or the control
channel: `aliasedit {"file": "/path/to/aliases.pgp"}`.
## Driving CADCraft from another program
-12
View File
@@ -159,18 +159,6 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the app's open size limit and a larger drawing is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `cadcraft:ready`, `cadcraft:open`, `cadcraft:save`, `cadcraft:dirty` and `cadcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
### Driving it from the page (`window.cadcraft`)
Every CADCraft web build puts `window.cadcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `cadcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app drops it unrun if it had not started it.
```js
const drawing = await cadcraft.docInspect(); // counts, extents, layers, styles, selection
await cadcraft.execute("ui.theme", { theme: "dark" });
const { data } = await cadcraft.screenshot(); // the canvas as PNG (base64)
```
The API is there in every mode, with or without a host. Opening a drawing, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
## Renderer selection and fallback flags
CADCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to