mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-15 10:23:43 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb6b6e1f1 | ||
|
|
b0f91a8116 |
@@ -26,7 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
# Diagnostic-only pin to validate the shared FreeBSD sysroot change.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@25a199081a84a25cb43f860659a93cfad84686a1
|
||||
with:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
architectures: '["aarch64"]'
|
||||
|
||||
+1
-2
@@ -62,7 +62,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | @neronek22 (Echo Rozumu & Gadajło; AI-assisted) | Original Polish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
@@ -77,6 +76,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted); 46 later rows: @V1dalTheAbidal with Claude Code (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; the 46 later rows were read by their contributor, a native Greek speaker; independent native proofreading of the whole file pending |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/ka.tsv` | Davit Matchakhelidze (@devius), native speaker, with Claude Code | Original Georgian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; terminology and doubtful strings decided by a native Georgian speaker; no font assets added (the Georgian face comes from craft-fonts) |
|
||||
|
||||
Generated
-19
@@ -758,8 +758,6 @@ dependencies = [
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"craft-jsonrpc",
|
||||
"craft-mcp",
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1185,23 +1183,6 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jsonrpc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-mcp"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
|
||||
dependencies = [
|
||||
"craft-jsonrpc",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -25,8 +25,6 @@ cadcraft-io = { path = "crates/io" }
|
||||
cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
craft-mcp = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
|
||||
craft-jsonrpc = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||
|
||||
@@ -118,6 +118,3 @@ sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.V1dalTheAbidal]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -184,10 +184,8 @@ impl Interactive for BlockreplaceM {
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]).takes_block(),
|
||||
Stage::New => {
|
||||
Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]).takes_block()
|
||||
}
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
|
||||
@@ -557,15 +557,9 @@ fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let frozen = s.viewport_frozen();
|
||||
let hs: Vec<Handle> = d
|
||||
.space(&space)
|
||||
.map(|st| {
|
||||
st.iter()
|
||||
.filter(|e| crate::select::selectable(d, e, frozen) && d.layer(&e.common.layer).is_none_or(|l| !l.locked))
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
})
|
||||
.map(|st| st.iter().filter(|e| d.is_visible(e) && d.layer(&e.common.layer).is_none_or(|l| !l.locked)).map(|e| e.handle).collect())
|
||||
.unwrap_or_default();
|
||||
let n = hs.len();
|
||||
s.set_selection(hs);
|
||||
@@ -586,19 +580,16 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let add = bool_or(p, "add", false);
|
||||
let picked: Vec<Handle> = if let Some(w) = points_param(p, "window") {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("select", "window needs 2 corners")) };
|
||||
crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), Bounds2::new(*a, *b), bool_or(p, "crossing", false))
|
||||
crate::select::select_window(s.doc()?, &space, Bounds2::new(*a, *b), bool_or(p, "crossing", false))
|
||||
} else if let Some(at) = point_param(p, "at") {
|
||||
let ap = f64_or(p, "aperture", s.pixel_size() * s.settings.pickbox * 1.5);
|
||||
crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), at, ap).into_iter().collect()
|
||||
crate::select::pick(s.doc()?, &space, at, ap).into_iter().collect()
|
||||
} else if let Some(f) = points_param(p, "fence") {
|
||||
crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &f)
|
||||
} else if ["handles", "handle"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
targets(s, p)?
|
||||
crate::select::select_fence(s.doc()?, &space, &f)
|
||||
} else {
|
||||
// Nothing to select with: the selection stays, also what the active viewport does not draw.
|
||||
s.stored_selection()
|
||||
targets(s, p)?
|
||||
};
|
||||
let mut sel = if add { s.stored_selection() } else { Vec::new() };
|
||||
let mut sel = if add { s.selection() } else { Vec::new() };
|
||||
sel.extend(picked);
|
||||
s.set_selection(sel);
|
||||
let sel = s.selection();
|
||||
|
||||
@@ -17,7 +17,7 @@ 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(crate) use pgp::{greek_alias, specs as alias_specs, user_alias};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
|
||||
|
||||
@@ -59,12 +59,6 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// The user alias that is the Greek word `word` in another case or accent.
|
||||
pub(crate) fn greek_alias(word: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(word)?;
|
||||
store().map.keys().find(|alias| same(alias)).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 {
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) struct GripEdit {
|
||||
/// Typed `GRIP`: still collecting grips ("Select grips").
|
||||
picking: bool,
|
||||
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
|
||||
/// selection that the view being edited shows.
|
||||
/// selection that the drawing shows.
|
||||
targets: Vec<Handle>,
|
||||
base: Vec2,
|
||||
mode: GripMode,
|
||||
@@ -70,11 +70,10 @@ fn positive(v: Option<f64>) -> Result<f64> {
|
||||
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
|
||||
}
|
||||
|
||||
/// Whether the view being edited shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer,
|
||||
/// a layer frozen in the active MSPACE viewport) takes no part in a grip edit, even when Undo or
|
||||
/// a selection command put it back into the selection.
|
||||
/// Whether the drawing shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer) takes no
|
||||
/// part in a grip edit, even when Undo or a selection command put it back into the selection.
|
||||
fn shown(s: &Session, h: Handle) -> bool {
|
||||
s.state().is_ok_and(|st| st.doc.entity(h).is_some_and(|e| st.shows(e)))
|
||||
s.doc().is_ok_and(|d| d.entity(h).is_some_and(|e| d.is_visible(e)))
|
||||
}
|
||||
|
||||
impl GripEdit {
|
||||
@@ -96,8 +95,7 @@ impl GripEdit {
|
||||
}
|
||||
|
||||
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
|
||||
/// the view being edited does not show is left out, and so are the hidden objects of the
|
||||
/// selection.
|
||||
/// the drawing does not show is left out, and so are the hidden objects of the selection.
|
||||
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
|
||||
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
|
||||
return Ok(());
|
||||
@@ -121,13 +119,13 @@ impl GripEdit {
|
||||
/// The grips of the selected objects at `p` (typed `GRIP`): all objects with a grip there.
|
||||
fn grips_at(s: &Session, p: Vec2) -> Vec<(Handle, usize)> {
|
||||
let tol = (s.pixel_size() * (s.settings.gripsize + 3.0)).max(1e-9 * (1.0 + p.len()));
|
||||
let Ok(st) = s.state() else { return Vec::new() };
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for h in s.selection().into_iter().take(MAX_HOT) {
|
||||
if super::curves::is_locked(s, h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = st.doc.entity(h).filter(|e| st.shows(e)) else { continue };
|
||||
let Some(e) = d.entity(h).filter(|e| d.is_visible(e)) else { continue };
|
||||
let gs = e.kind.grips();
|
||||
// The nearest grip of this object within the tolerance.
|
||||
let best = gs
|
||||
@@ -485,8 +483,8 @@ impl Session {
|
||||
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
|
||||
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
|
||||
/// modes and their options then run at the command line like any prompt. Grips of objects the
|
||||
/// view being edited does not show are left out (the first grip left is the base grip), and it
|
||||
/// is an error when none is left.
|
||||
/// drawing does not show are left out (the first grip left is the base grip), and it is an
|
||||
/// error when none is left.
|
||||
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
|
||||
@@ -28,8 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
const MEMBER_PICKS: &[&str] = &["groupedit", "-group"];
|
||||
|
||||
/// `hs` plus the other members of the selectable groups they belong to (in the space being
|
||||
/// edited, visible, and inside an MSPACE viewport not on a layer frozen in it), when PICKSTYLE
|
||||
/// has group selection on. Order: `hs` first.
|
||||
/// edited, visible), when PICKSTYLE has group selection on. Order: `hs` first.
|
||||
pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
if s.settings.pickstyle & 1 == 0 || hs.is_empty() || s.running.as_ref().is_some_and(|r| MEMBER_PICKS.contains(&r.id.as_str())) {
|
||||
return hs;
|
||||
@@ -40,7 +39,6 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
}
|
||||
let space = s.space();
|
||||
let Some(store) = d.space(&space) else { return hs };
|
||||
let frozen = s.viewport_frozen();
|
||||
// Member → its selectable groups, built once (window selections may hold many objects).
|
||||
let mut of: HashMap<Handle, Vec<usize>> = HashMap::new();
|
||||
for (i, g) in d.groups.iter().enumerate().filter(|(_, g)| g.selectable) {
|
||||
@@ -58,7 +56,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
}
|
||||
let Some(g) = d.groups.get(*gi) else { continue };
|
||||
for m in &g.members {
|
||||
if store.get(*m).is_some_and(|e| crate::select::selectable(d, e, frozen)) && seen.insert(*m) {
|
||||
if store.get(*m).is_some_and(|e| d.is_visible(e)) && seen.insert(*m) {
|
||||
out.push(*m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,19 +533,6 @@ fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": format!("{n} Region(s) created.") }))
|
||||
}
|
||||
|
||||
/// Whether the upper-case `name` is a pattern: a standard one or a loaded one.
|
||||
fn pattern_exists(name: &str) -> bool {
|
||||
cadcraft_doc::library::pattern(name).is_some() || patfile::user_pattern(name).is_some()
|
||||
}
|
||||
|
||||
/// The existing pattern `name` means, typed at the pattern prompt: read upper-cased, else a loaded
|
||||
/// pattern's own name when only its Greek spelling differs in case or accent (σολιδ for ΣΟΛΙΔ).
|
||||
pub(crate) fn pattern_named(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let up = name.to_ascii_uppercase();
|
||||
if pattern_exists(&up) { Some(up) } else { patfile::user_pattern_name(name) }
|
||||
}
|
||||
|
||||
/// The origin sub-prompt's keywords (a point sets a new origin).
|
||||
const ORIGIN_KW: [&str; 6] = ["Bottom left", "bottom Right", "Top left", "top riGht", "Center", "Use current"];
|
||||
|
||||
@@ -610,7 +597,7 @@ impl HatchM {
|
||||
Some(n) => n,
|
||||
None => t.trim().to_ascii_uppercase(),
|
||||
};
|
||||
if pattern_exists(&up) {
|
||||
if cadcraft_doc::library::pattern(&up).is_some() || patfile::user_pattern(&up).is_some() {
|
||||
self.settings.pattern = up.clone();
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
|
||||
@@ -28,13 +28,6 @@ pub(crate) fn user_pattern(name: &str) -> Option<UserPattern> {
|
||||
store().iter().find(|p| p.name.eq_ignore_ascii_case(name)).cloned()
|
||||
}
|
||||
|
||||
/// The name of the user pattern that is the Greek word `name` in another case or accent
|
||||
/// ([`user_pattern`] folds ASCII only).
|
||||
pub(crate) fn user_pattern_name(name: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(name)?;
|
||||
store().iter().find(|p| same(&p.name)).map(|p| p.name.clone())
|
||||
}
|
||||
|
||||
/// The line families a new hatch of pattern `name` carries: a user pattern's, else none (the
|
||||
/// library's patterns are drawn and saved from the library).
|
||||
pub(crate) fn lines_for(name: &str) -> Vec<PatternLine> {
|
||||
|
||||
@@ -591,7 +591,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::<Vec<_>>(),
|
||||
"layouts": d.layouts.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"selection": st.shown_selection().iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
|
||||
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
|
||||
// Records of the file the drawing came from that the reader could not place.
|
||||
|
||||
@@ -21,17 +21,14 @@ impl StretchArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to `hs` the multilines in the edit space that a selection can take there (visible, and
|
||||
/// inside an MSPACE viewport not on a layer frozen in it) with a control vertex inside `area`. A
|
||||
/// crossing selection tests the drawn element lines, which sit away from the control vertices by
|
||||
/// the multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(s: &Session, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let Some(store) = d.space(&s.space()) else { return };
|
||||
let frozen = s.viewport_frozen();
|
||||
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
|
||||
/// selection tests the drawn element lines, which sit away from the control vertices by the
|
||||
/// multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return };
|
||||
for e in store.iter() {
|
||||
if let EntityKind::MLine(m) = &e.kind
|
||||
&& crate::select::selectable(d, e, frozen)
|
||||
&& d.is_visible(e)
|
||||
&& !hs.contains(&e.handle)
|
||||
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
|
||||
{
|
||||
@@ -97,19 +94,19 @@ pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// A fence marks no area: the objects it crosses move whole.
|
||||
if let Some(fence) = listed("fence", 2)? {
|
||||
let hs = crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &fence);
|
||||
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
|
||||
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
|
||||
return Ok(json!({ "stretched": moved.len() }));
|
||||
}
|
||||
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
|
||||
(crate::select::select_polygon_in(s.doc()?, &space, s.viewport_frozen(), &poly, true), StretchArea::Polygon(poly))
|
||||
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
|
||||
} else {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
(crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, true), StretchArea::Box(bx))
|
||||
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
add_mline_vertex_hits(s, &area, &mut hs);
|
||||
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
|
||||
hs.retain(|h| !crate::cmd::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
@@ -176,7 +173,7 @@ impl StretchM {
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(mut hs) = m.select_in(s.doc()?, &space, s.viewport_frozen()) else { return Ok(()) };
|
||||
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
|
||||
(SelectKind::Box, Some((a, b))) => b.x < a.x,
|
||||
@@ -192,7 +189,7 @@ impl StretchM {
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), true, false) = (&area, crossing, self.removing) {
|
||||
add_mline_vertex_hits(s, a, &mut hs);
|
||||
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
|
||||
}
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
@@ -231,10 +228,10 @@ impl StretchM {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let space = s.space();
|
||||
let mut hs = crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, p.x < c.x);
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
if p.x < c.x {
|
||||
add_mline_vertex_hits(s, &StretchArea::Box(bx), &mut hs);
|
||||
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
|
||||
}
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
@@ -243,7 +240,7 @@ impl StretchM {
|
||||
None => {
|
||||
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), p, aperture) {
|
||||
match crate::select::pick(s.doc()?, &space, p, aperture) {
|
||||
Some(h) => self.add(s, vec![h], true),
|
||||
None => self.corner = Some(p),
|
||||
}
|
||||
@@ -254,7 +251,6 @@ impl StretchM {
|
||||
self.corner = None;
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let frozen = s.viewport_frozen();
|
||||
let word = t.trim().to_ascii_lowercase();
|
||||
// `_C`, `_L`: the English name, as scripts write it.
|
||||
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
|
||||
@@ -271,14 +267,9 @@ impl StretchM {
|
||||
self.removing = false;
|
||||
None
|
||||
}
|
||||
"all" => Some(
|
||||
store.map(|st| st.iter().filter(|e| crate::select::selectable(d, e, frozen)).map(|e| e.handle).collect()).unwrap_or_default(),
|
||||
),
|
||||
"l" | "last" => Some(store.and_then(|st| crate::select::last_in(st, frozen)).map(|h| vec![h]).unwrap_or_default()),
|
||||
"p" | "previous" => {
|
||||
let st = s.state()?;
|
||||
Some(st.drawn_in_viewport(&st.previous_selection))
|
||||
}
|
||||
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
|
||||
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
|
||||
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
|
||||
_ => {
|
||||
s.echo("*Invalid selection*");
|
||||
None
|
||||
|
||||
@@ -280,7 +280,7 @@ fn run_qselect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel = match mode.as_str() {
|
||||
"exclude" => excluded,
|
||||
"append" => {
|
||||
let mut cur = s.stored_selection();
|
||||
let mut cur = s.selection();
|
||||
for h in matched {
|
||||
if !cur.contains(&h) {
|
||||
cur.push(h);
|
||||
|
||||
@@ -99,21 +99,14 @@ pub(crate) fn similar(e: &Entity, r: &Entity, mode: u32) -> bool {
|
||||
&& (mode & NAME == 0 || same_text(object_name(e), object_name(r)))
|
||||
}
|
||||
|
||||
/// The objects of the current space like any of `refs` that a selection can take there: visible,
|
||||
/// and inside an MSPACE viewport not on a layer frozen in it.
|
||||
/// The visible objects of the current space like any of `refs`.
|
||||
fn similar_objects(s: &Session, d: &Drawing, refs: &[Handle], mode: u32) -> Vec<Handle> {
|
||||
let refs: Vec<&Entity> = refs.iter().filter_map(|h| d.entity(*h).map(|e| e.as_ref())).collect();
|
||||
if refs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(store) = d.space(&s.space()) else { return Vec::new() };
|
||||
let frozen = s.viewport_frozen();
|
||||
store
|
||||
.iter()
|
||||
.take(MAX_SCAN)
|
||||
.filter(|e| crate::select::selectable(d, e, frozen) && refs.iter().any(|r| similar(e, r, mode)))
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
store.iter().take(MAX_SCAN).filter(|e| d.is_visible(e) && refs.iter().any(|r| similar(e, r, mode))).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
fn mode_param(s: &Session, p: &Value) -> Result<u32> {
|
||||
|
||||
@@ -59,7 +59,7 @@ fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bo
|
||||
let fb = Bounds2::from_points(pts.iter().copied());
|
||||
let tol = crate::select::window_tol(&fb);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_fence_in(d, &space, s.viewport_frozen(), pts) {
|
||||
for h in crate::select::select_fence(d, &space, pts) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let polys = crate::select::hit_polylines(d, e, tol);
|
||||
let mut xs: Vec<Vec2> = Vec::new();
|
||||
@@ -96,7 +96,7 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
|
||||
];
|
||||
let inside = |p: Vec2| bx.expand(tol).contains(p);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, true) {
|
||||
for h in crate::select::select_window(d, &space, bx, true) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let mut best: Option<Vec2> = None;
|
||||
for pl in crate::select::hit_polylines(d, e, tol) {
|
||||
|
||||
@@ -8,12 +8,6 @@ pub struct Snapshot {
|
||||
pub selection: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// [`DocState::drawn_in_viewport`] looks its handles up one by one while there is fewer than one
|
||||
/// of them for every `LOOKUP_COST` objects of model space, and passes over model space once
|
||||
/// otherwise. Timed on drawings of 40,000 and of 200,000 lines, the two cost the same at about
|
||||
/// one handle for every 47 and for every 18 objects.
|
||||
const LOOKUP_COST: usize = 32;
|
||||
|
||||
/// One open drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
@@ -125,65 +119,13 @@ impl DocState {
|
||||
}
|
||||
/// The active MSPACE viewport, if it still exists in the current layout.
|
||||
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
|
||||
self.active_viewport_ref().map(|(h, v)| (h, v.clone()))
|
||||
}
|
||||
/// [`DocState::active_viewport`] without copying the viewport.
|
||||
fn active_viewport_ref(&self) -> Option<(Handle, &cadcraft_doc::Viewport)> {
|
||||
let h = self.mspace?;
|
||||
let e = self.doc.space(&self.space)?.get(h)?;
|
||||
match &e.kind {
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v)),
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The layers frozen in the active MSPACE viewport (VPLAYER, "VP Freeze"): that viewport does
|
||||
/// not draw their objects, so picks, selections and grips made through it leave them out.
|
||||
/// Empty in paper space and on the Model tab.
|
||||
pub fn viewport_frozen(&self) -> &[String] {
|
||||
match self.active_viewport_ref() {
|
||||
Some((_, v)) => &v.frozen_layers,
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
/// Whether `e` is a model-space object the active MSPACE viewport does not draw: it is on a
|
||||
/// layer frozen in that viewport ([`DocState::viewport_frozen`]). Objects on the sheet are not
|
||||
/// drawn through the viewport, so its frozen layers do not hide them.
|
||||
pub fn hidden_in_viewport(&self, e: &cadcraft_doc::Entity) -> bool {
|
||||
crate::select::on_frozen_layer(self.viewport_frozen(), e) && self.doc.model.contains(e.handle)
|
||||
}
|
||||
/// Whether the view being edited shows `e`: the drawing shows it ([`Drawing::is_visible`]),
|
||||
/// and the active MSPACE viewport, if there is one, draws it
|
||||
/// ([`DocState::hidden_in_viewport`]).
|
||||
pub fn shows(&self, e: &cadcraft_doc::Entity) -> bool {
|
||||
self.doc.is_visible(e) && !self.hidden_in_viewport(e)
|
||||
}
|
||||
/// `handles` without the objects the active MSPACE viewport does not draw
|
||||
/// ([`DocState::hidden_in_viewport`]): a set of objects made earlier (the selection, the
|
||||
/// Previous set) as that viewport takes it. All of them anywhere else.
|
||||
pub(crate) fn drawn_in_viewport(&self, handles: &[Handle]) -> Vec<Handle> {
|
||||
let frozen = self.viewport_frozen();
|
||||
if frozen.is_empty() || handles.is_empty() {
|
||||
return handles.to_vec();
|
||||
}
|
||||
let model = &self.doc.model;
|
||||
let hidden = |e: &Arc<cadcraft_doc::Entity>| crate::select::on_frozen_layer(frozen, e);
|
||||
// The UI asks for the selection several times a frame. A few handles are looked up one
|
||||
// by one; beyond that, one pass over model space costs less than the lookups.
|
||||
if handles.len().saturating_mul(LOOKUP_COST) < model.len() {
|
||||
return handles.iter().copied().filter(|h| !model.get(*h).is_some_and(hidden)).collect();
|
||||
}
|
||||
let mut gone: Vec<Handle> = model.iter().filter(|e| hidden(e)).map(|e| e.handle).collect();
|
||||
gone.sort_unstable();
|
||||
handles.iter().copied().filter(|h| gone.binary_search(h).is_err()).collect()
|
||||
}
|
||||
/// The selection as the view being edited has it: [`DocState::selection`] without the objects
|
||||
/// the active MSPACE viewport does not draw. Those stay in [`DocState::selection`] until the
|
||||
/// selection is replaced or cleared, and count as selected again once that viewport draws
|
||||
/// them; until then nothing that starts from the selection (a command, a grip, a palette)
|
||||
/// edits them through that viewport.
|
||||
pub fn shown_selection(&self) -> Vec<Handle> {
|
||||
self.drawn_in_viewport(&self.selection)
|
||||
}
|
||||
pub fn paper_view(&self) -> View {
|
||||
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
//! Words typed on a Greek keyboard layout.
|
||||
//!
|
||||
//! With the Greek layout active, the keys that type LINE produce ΛΙΝΕ. Command names, aliases and
|
||||
//! prompt keywords are Latin, so a Greek speaker would have to switch layouts for every command.
|
||||
//! [`latin_keys`] reads a word as the Latin keys that produced it (the layout puts each Greek
|
||||
//! letter on the key of the Latin letter it sounds like): the command line then accepts ΛΙΝΕ and
|
||||
//! ΣΤΡΕΤΨΗ as LINE and STRETCH. Accents (the layout's dead key) are ignored.
|
||||
|
||||
/// The Latin key a Greek letter is typed on (lower case, accents removed).
|
||||
fn key_of(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'α' | 'ά' => 'a',
|
||||
'β' => 'b',
|
||||
'γ' => 'g',
|
||||
'δ' => 'd',
|
||||
'ε' | 'έ' => 'e',
|
||||
'ζ' => 'z',
|
||||
'η' | 'ή' => 'h',
|
||||
'θ' => 'u',
|
||||
'ι' | 'ί' | 'ϊ' | 'ΐ' => 'i',
|
||||
'κ' => 'k',
|
||||
'λ' => 'l',
|
||||
'μ' => 'm',
|
||||
'ν' => 'n',
|
||||
'ξ' => 'j',
|
||||
'ο' | 'ό' => 'o',
|
||||
'π' => 'p',
|
||||
'ρ' => 'r',
|
||||
'σ' => 's',
|
||||
// The W key types the final sigma.
|
||||
'ς' => 'w',
|
||||
'τ' => 't',
|
||||
'υ' | 'ύ' | 'ϋ' | 'ΰ' => 'y',
|
||||
'φ' => 'f',
|
||||
'χ' => 'x',
|
||||
'ψ' => 'c',
|
||||
'ω' | 'ώ' => 'v',
|
||||
// The Q key types the semicolon, which Greek text writes as ";" or U+037E.
|
||||
';' | '\u{37e}' => 'q',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `c` is a Greek letter (the basic and accented ranges the layout types).
|
||||
fn is_greek_letter(c: char) -> bool {
|
||||
matches!(c, '\u{386}'..='\u{3ce}') && c != '\u{387}'
|
||||
}
|
||||
|
||||
/// `word` read as the Latin keys of a Greek-layout keyboard, lower case: `Some` only when the word
|
||||
/// has a Greek letter and every character is a Greek letter the layout types, the semicolon on
|
||||
/// the Q key, or ASCII (itself, in lower case). A combining tonos or dialytika, the accent of
|
||||
/// decomposed text, is ignored like the accent of a precomposed letter, but only straight after
|
||||
/// the Greek letter it accents and once each: anywhere else the word is not Greek-layout typing.
|
||||
pub fn latin_keys(word: &str) -> Option<String> {
|
||||
let mut greek = false;
|
||||
// What the previous Greek letter already carries (tonos, dialytika); `None` when the previous
|
||||
// character is not a Greek letter, which leaves a combining mark nothing to accent.
|
||||
let mut marks: Option<(bool, bool)> = None;
|
||||
let mut out = String::with_capacity(word.len());
|
||||
for c in word.chars() {
|
||||
if matches!(c, '\u{301}' | '\u{308}') {
|
||||
let (tonos, dialytika) = marks.as_mut()?;
|
||||
if std::mem::replace(if c == '\u{301}' { tonos } else { dialytika }, true) {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c.is_ascii() {
|
||||
// The Q key types the ASCII semicolon in the Greek layout; every other ASCII character is itself.
|
||||
out.push(if c == ';' { 'q' } else { c.to_ascii_lowercase() });
|
||||
marks = None;
|
||||
continue;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
out.push(key_of(lower)?);
|
||||
let letter = is_greek_letter(lower);
|
||||
greek |= letter;
|
||||
marks = letter.then_some((false, false));
|
||||
}
|
||||
greek.then_some(out)
|
||||
}
|
||||
|
||||
/// `word` with its case, accents and final sigma folded away, when it is Greek-layout typing: every
|
||||
/// spelling of one Greek word gives the same string. It is the word's keys, with the final sigma
|
||||
/// read as the letter sigma instead of the W key.
|
||||
pub fn greek_fold(word: &str) -> Option<String> {
|
||||
latin_keys(&word.replace('ς', "σ"))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are one Greek word, whatever their case, accents or final sigma.
|
||||
pub fn same_greek_word(a: &str, b: &str) -> bool {
|
||||
same_greek_word_as(b).is_some_and(|same| same(a))
|
||||
}
|
||||
|
||||
/// A test for "is the Greek word `word`, whatever the case, accents or final sigma", or `None`
|
||||
/// when `word` is not Greek-layout typing. `word` is folded here, once: looking a typed word up
|
||||
/// among many names costs its length once, not once for every name.
|
||||
pub fn same_greek_word_as(word: &str) -> Option<impl Fn(&str) -> bool> {
|
||||
let fold = greek_fold(word)?;
|
||||
Some(move |name: &str| greek_fold(name).as_deref() == Some(fold.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{latin_keys, same_greek_word};
|
||||
|
||||
/// One Greek word compares equal in any case, accent or final sigma; another word, or a Latin
|
||||
/// one, does not.
|
||||
#[test]
|
||||
fn one_greek_word_is_the_same_in_any_case_accent_or_final_sigma() {
|
||||
for (a, b) in [("ΣΟΛΙΔ", "σολιδ"), ("ΣΟΛΙΔ", "σολίδ"), ("ΛΑΣ", "λας"), ("ΛΑΣ", "λασ"), ("ΛΙ\u{301}ΝΕ", "λινε")]
|
||||
{
|
||||
assert!(same_greek_word(a, b), "{a} {b}");
|
||||
}
|
||||
for (a, b) in [("ΣΟΛΙΔ", "ΣΟΛΙ"), ("ΣΟΛΙΔ", "solid"), ("solid", "SOLID")] {
|
||||
assert!(!same_greek_word(a, b), "{a} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A character the layout does not type is never taken for the Greek letter Unicode upper-cases
|
||||
/// it to: the micro sign is not μ, the long s and the sharp s are not σ, an alpha with an iota
|
||||
/// subscript is not αι, the theta symbol is not θ.
|
||||
#[test]
|
||||
fn a_character_the_layout_does_not_type_is_no_spelling_of_a_greek_letter() {
|
||||
let pairs = [("\u{b5}", "μ"), ("Λ\u{17f}", "ΛΣ"), ("Λ\u{df}", "ΛΣΣ"), ("\u{1fb3}", "αι"), ("\u{3d1}", "θ")];
|
||||
let same: Vec<_> = pairs.iter().filter(|(a, b)| same_greek_word(a, b) || same_greek_word(b, a)).collect();
|
||||
assert!(same.is_empty(), "{same:?}");
|
||||
}
|
||||
|
||||
/// Greek-layout typing reads as its Latin keys: plain, accented and decomposed letters, the Q
|
||||
/// and W keys, ASCII prefixes.
|
||||
#[test]
|
||||
fn greek_layout_commands_read_as_their_latin_keys() {
|
||||
assert_eq!(latin_keys("ΛΙΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("λινε").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΣΤΡΕΤΨΗ").as_deref(), Some("stretch"));
|
||||
assert_eq!(latin_keys("ΜΙΡΡΟΡ").as_deref(), Some("mirror"));
|
||||
assert_eq!(latin_keys("ΘΝΔΟ").as_deref(), Some("undo"));
|
||||
// The Q key types ";", the W key the final sigma.
|
||||
assert_eq!(latin_keys(";ΣΑΩΕ").as_deref(), Some("qsave"));
|
||||
assert_eq!(latin_keys("ςΒΛΟΨΚ").as_deref(), Some("wblock"));
|
||||
// Accented letters (the layout's dead key) read as the plain letter.
|
||||
assert_eq!(latin_keys("ΛΊΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΆΡΡΑΥ").as_deref(), Some("array"));
|
||||
// The same accents written as a combining mark after the letter (decomposed text).
|
||||
assert_eq!(latin_keys("ΛΙ\u{301}ΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙΝΕ\u{301}").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙ\u{308}ΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙ\u{308}\u{301}ΝΕ").as_deref(), Some("line"), "dialytika and tonos on one letter");
|
||||
// Prefixes and digits are kept.
|
||||
assert_eq!(latin_keys("-ΛΑΥΕΡ").as_deref(), Some("-layer"));
|
||||
assert_eq!(latin_keys("_ΛΙΝΕ").as_deref(), Some("_line"));
|
||||
}
|
||||
|
||||
/// A word with no Greek letter, or with a character the layout does not type, is not read.
|
||||
#[test]
|
||||
fn words_that_are_not_greek_layout_typing_are_left_alone() {
|
||||
assert_eq!(latin_keys("line"), None, "no Greek letter");
|
||||
assert_eq!(latin_keys(""), None);
|
||||
assert_eq!(latin_keys(";"), None, "a semicolon alone is not a word");
|
||||
assert_eq!(latin_keys("ΛΙΝΕ Ж"), None, "a letter the layout does not type");
|
||||
assert_eq!(latin_keys("ΛΙΝΕ 日本"), None);
|
||||
assert_eq!(latin_keys("\u{387}"), None, "the ano teleia is punctuation, not a letter");
|
||||
}
|
||||
|
||||
/// A combining tonos or dialytika counts only straight after a Greek letter, and once each.
|
||||
#[test]
|
||||
fn a_combining_mark_must_follow_the_greek_letter_it_accents() {
|
||||
assert_eq!(latin_keys("\u{301}ΛΙΝΕ"), None, "no letter before the mark");
|
||||
assert_eq!(latin_keys("\u{308}ΛΙΝΕ"), None);
|
||||
assert_eq!(latin_keys("ΛΙΝΕ-\u{301}"), None, "after punctuation");
|
||||
assert_eq!(latin_keys("a\u{301}ΛΙΝΕ"), None, "after a Latin letter");
|
||||
assert_eq!(latin_keys("Λ\u{301}\u{301}ΙΝΕ"), None, "a second tonos on one letter");
|
||||
assert_eq!(latin_keys("Λ\u{308}\u{308}ΙΝΕ"), None, "a second dialytika on one letter");
|
||||
}
|
||||
|
||||
/// A 100 000-letter word and odd code points are read or refused; nothing panics.
|
||||
#[test]
|
||||
fn hostile_text_never_panics() {
|
||||
let long = "Ω".repeat(100_000);
|
||||
assert_eq!(latin_keys(&long).map(|s| s.len()), Some(100_000));
|
||||
for s in ["\u{0}", "\u{301}", "ΛΙΝΕ\u{301}", "\u{10ffff}", "ς\u{3c2}\u{3a3}"] {
|
||||
let _ = latin_keys(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ mod finite;
|
||||
pub mod grip_dims;
|
||||
pub mod grips;
|
||||
mod guard;
|
||||
pub mod keyboard;
|
||||
pub mod lisp;
|
||||
pub mod mtext_edit;
|
||||
pub mod pointmod;
|
||||
@@ -237,25 +236,11 @@ impl Session {
|
||||
pub fn space(&self) -> Space {
|
||||
self.state().map(DocState::edit_space).unwrap_or_default()
|
||||
}
|
||||
/// The layers frozen in the active MSPACE viewport ([`DocState::viewport_frozen`]): what a
|
||||
/// pick or a selection in [`Session::space`] leaves out. Empty with no drawing open.
|
||||
pub fn viewport_frozen(&self) -> &[String] {
|
||||
self.state().map(DocState::viewport_frozen).unwrap_or_default()
|
||||
}
|
||||
/// The tab being displayed: Model or a layout, regardless of MSPACE.
|
||||
pub fn layout_space(&self) -> Space {
|
||||
self.state().map(|s| s.space.clone()).unwrap_or_default()
|
||||
}
|
||||
/// The selected objects as the view being edited has them ([`DocState::shown_selection`]):
|
||||
/// inside an MSPACE viewport, without the objects on layers frozen in that viewport. Commands,
|
||||
/// grips and palettes start from this; [`DocState::selection`] holds every selected object.
|
||||
pub fn selection(&self) -> Vec<Handle> {
|
||||
self.state().map(DocState::shown_selection).unwrap_or_default()
|
||||
}
|
||||
/// Every selected object ([`DocState::selection`]), also the ones the active MSPACE viewport
|
||||
/// does not draw, which [`Session::selection`] leaves out. Adding to the selection or removing
|
||||
/// from it starts from this, so that it keeps them.
|
||||
pub(crate) fn stored_selection(&self) -> Vec<Handle> {
|
||||
self.state().map(|s| s.selection.clone()).unwrap_or_default()
|
||||
}
|
||||
pub fn set_selection(&mut self, sel: Vec<Handle>) {
|
||||
|
||||
@@ -651,19 +651,15 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
Some("X") => all(s, true),
|
||||
Some("L") => {
|
||||
let order = handles_in_order(ctx);
|
||||
// Inside an MSPACE viewport, the objects that viewport does not draw are passed over.
|
||||
let st = ctx.s.state().ok();
|
||||
let drawn = |h: &Handle| st.is_none_or(|st| st.doc.entity(*h).is_none_or(|e| !st.hidden_in_viewport(e)));
|
||||
order.iter().rev().copied().find(drawn).into_iter().collect()
|
||||
order.last().copied().into_iter().collect()
|
||||
}
|
||||
Some("P") => ctx.s.state().map(|st| st.drawn_in_viewport(&st.previous_selection)).unwrap_or_default(),
|
||||
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
|
||||
Some("I") => ctx.s.selection(),
|
||||
Some("W") | Some("C") => {
|
||||
let (p1, p2) = (point(0)?, point(1)?);
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let bx = cadcraft_geom::Bounds2::from_points([p1, p2]);
|
||||
crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, mode.as_deref() == Some("C"))
|
||||
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
|
||||
}
|
||||
Some("F") | Some("WP") | Some("CP") => {
|
||||
let poly: Vec<Vec2> =
|
||||
@@ -671,8 +667,8 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
match mode.as_deref() {
|
||||
Some("F") => crate::select::select_fence_in(d, &space, s.viewport_frozen(), &poly),
|
||||
m => crate::select::select_polygon_in(d, &space, s.viewport_frozen(), &poly, m == Some("CP")),
|
||||
Some("F") => crate::select::select_fence(d, &space, &poly),
|
||||
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
|
||||
}
|
||||
}
|
||||
Some(":S") | Some("A") | None => {
|
||||
@@ -688,7 +684,7 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let s = &*ctx.s;
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
crate::select::pick_in(d, &space, s.viewport_frozen(), p, ap).into_iter().collect()
|
||||
crate::select::pick(d, &space, p, ap).into_iter().collect()
|
||||
}
|
||||
}
|
||||
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
|
||||
|
||||
@@ -141,7 +141,7 @@ impl GetRequest {
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
|
||||
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
|
||||
match crate::select::pick_in(d, &space, s.viewport_frozen(), Vec2::new(p[0], p[1]), ap) {
|
||||
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
|
||||
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
|
||||
None => Err("Missed, try again.".into()),
|
||||
}
|
||||
|
||||
@@ -102,10 +102,6 @@ pub struct Prompt {
|
||||
/// the routine writes, so it cannot be read for the word "Select" (#1598).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub picks_one: bool,
|
||||
/// The prompt takes a block's name as well as its keywords: a Greek word that is an existing
|
||||
/// block's name is that block, not a keyword read from its Latin keys.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub takes_block: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
@@ -140,11 +136,6 @@ impl Prompt {
|
||||
self.picker = Some(p);
|
||||
self
|
||||
}
|
||||
/// Takes a block's name besides its keywords (the `takes_block` field).
|
||||
pub fn takes_block(mut self) -> Self {
|
||||
self.takes_block = true;
|
||||
self
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(ToString::to_string).collect();
|
||||
self
|
||||
@@ -224,24 +215,6 @@ impl Prompt {
|
||||
return None;
|
||||
}
|
||||
let tl = t.to_ascii_lowercase();
|
||||
// A keyword typed with the Greek keyboard layout active (ΘΝΔΟ for Undo) is its Latin keys, unless
|
||||
// the prompt offers that spelling itself (a Greek keyword in a LISP program's list): its word
|
||||
// or a prefix of it as typed (ΘΝ for ΘΝΔΟ, not UN for Undo) is read before the Latin keys.
|
||||
if let Some(latin) = crate::keyboard::latin_keys(t) {
|
||||
// Greek letters have case, accents and a final sigma, which the ASCII-only folding above does not see.
|
||||
let typed = crate::keyboard::greek_fold(t).unwrap_or_else(|| latin.clone());
|
||||
let greek = || self.keywords.iter().filter_map(|k| crate::keyboard::greek_fold(k).map(|fold| (k, fold)));
|
||||
return greek()
|
||||
.find(|(_, k)| *k == typed)
|
||||
.or_else(|| greek().find(|(_, k)| k.starts_with(&typed)))
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| self.match_latin_keyword(&latin));
|
||||
}
|
||||
self.match_latin_keyword(&tl)
|
||||
}
|
||||
|
||||
/// [`Prompt::match_keyword`] for text already read as Latin letters, in lower case.
|
||||
fn match_latin_keyword(&self, tl: &str) -> Option<String> {
|
||||
let keys = self.keywords.iter().map(|k| {
|
||||
let shortcut: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect::<String>().to_ascii_lowercase();
|
||||
let shortcut =
|
||||
@@ -254,8 +227,8 @@ impl Prompt {
|
||||
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
|
||||
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
|
||||
keys.clone()
|
||||
.find(|(_, kl, sc)| tl == kl.as_str() || tl == sc.as_str())
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(tl)))
|
||||
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
|
||||
.map(|(k, _, _)| k.clone())
|
||||
}
|
||||
}
|
||||
@@ -421,29 +394,6 @@ mod tests {
|
||||
assert_eq!(parse_point("1,2,3,4", last), None);
|
||||
}
|
||||
|
||||
/// A keyword the prompt offers in Greek is matched as spelled (any case, accent or final sigma,
|
||||
/// or a prefix) before the Latin reading of the same keys; without one, the keys answer the
|
||||
/// Latin keyword.
|
||||
#[test]
|
||||
fn a_greek_keyword_is_matched_as_spelled_before_its_latin_reading() {
|
||||
// ΘΝΔΟ is the keys U-N-D-O, so it answers Undo, unless the prompt offers the Greek word itself
|
||||
// (a LISP program's keyword list).
|
||||
let both = Prompt::new("", Accept::TEXT).kw(&["Undo", "ΘΝΔΟ"]);
|
||||
assert_eq!(both.match_keyword("ΘΝΔΟ").as_deref(), Some("ΘΝΔΟ"));
|
||||
assert_eq!(both.match_keyword("θνδο").as_deref(), Some("ΘΝΔΟ"), "Greek case is folded like Latin case");
|
||||
assert_eq!(both.match_keyword("Undo").as_deref(), Some("Undo"));
|
||||
// A prefix of the Greek word as typed (ΘΝ) is that keyword, not the Latin prefix UN of Undo.
|
||||
assert_eq!(both.match_keyword("ΘΝ").as_deref(), Some("ΘΝΔΟ"));
|
||||
assert_eq!(both.match_keyword("θν").as_deref(), Some("ΘΝΔΟ"), "in either case");
|
||||
assert_eq!(both.match_keyword("θνδό").as_deref(), Some("ΘΝΔΟ"), "with an accent");
|
||||
let sigma = Prompt::new("", Accept::TEXT).kw(&["Last", "ΛΑΣΤ"]);
|
||||
assert_eq!(sigma.match_keyword("λας").as_deref(), Some("ΛΑΣΤ"), "a final sigma folds too");
|
||||
let latin = Prompt::new("", Accept::TEXT).kw(&["Undo"]);
|
||||
assert_eq!(latin.match_keyword("ΘΝ").as_deref(), Some("Undo"));
|
||||
assert_eq!(latin.match_keyword("ΘΝΔΟ").as_deref(), Some("Undo"));
|
||||
assert_eq!(latin.match_keyword("Θ").as_deref(), Some("Undo"), "a Greek-layout shortcut still works");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords() {
|
||||
let p = Prompt::new("Specify next point", Accept::POINT).kw(&["Undo", "Close"]);
|
||||
|
||||
+18
-98
@@ -152,28 +152,8 @@ fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
|
||||
!b.is_empty() && b.contains(turn(p))
|
||||
}
|
||||
|
||||
/// Whether `e` is on one of the layers in `frozen`: the layers frozen in a layout viewport, which
|
||||
/// does not draw their objects. The names are compared as the renderer compares them.
|
||||
pub(crate) fn on_frozen_layer(frozen: &[String], e: &Entity) -> bool {
|
||||
frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer))
|
||||
}
|
||||
|
||||
/// The last object of `store`, in draw order, that is not on one of the layers in `frozen`, the
|
||||
/// layers frozen in the layout viewport the selection is made through: what the Last option
|
||||
/// takes there. With no such layers it is the last object, whether the drawing shows it or not.
|
||||
pub(crate) fn last_in(store: &cadcraft_doc::EntityStore, frozen: &[String]) -> Option<Handle> {
|
||||
if frozen.is_empty() {
|
||||
return store.last().map(|e| e.handle);
|
||||
}
|
||||
store.iter().filter(|e| !on_frozen_layer(frozen, e)).last().map(|e| e.handle)
|
||||
}
|
||||
|
||||
/// Whether a selection can take `e`: the drawing shows it ([`Drawing::is_visible`]) and it is not
|
||||
/// on one of the layers in `frozen`, the layers frozen in the layout viewport the selection is
|
||||
/// made through ([`crate::DocState::viewport_frozen`]; empty anywhere else). A viewport shows
|
||||
/// model space, so `frozen` goes with a selection in model space only.
|
||||
pub(crate) fn selectable(d: &Drawing, e: &Entity, frozen: &[String]) -> bool {
|
||||
d.is_visible(e) && !on_frozen_layer(frozen, e)
|
||||
fn selectable(d: &Drawing, e: &Entity) -> bool {
|
||||
d.is_visible(e)
|
||||
}
|
||||
|
||||
/// Entities to test, in the order to test them, each with its bounds when already known.
|
||||
@@ -216,28 +196,15 @@ pub(crate) fn candidates<'a>(
|
||||
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
|
||||
/// picked, and DRAWORDER changes which).
|
||||
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
pick_in(d, space, &[], p, aperture)
|
||||
}
|
||||
|
||||
/// [`pick`] through a layout viewport: the objects on the layers in `frozen`, the layers frozen
|
||||
/// in that viewport, are not drawn there and are not picked.
|
||||
pub fn pick_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, frozen, p, aperture)
|
||||
pick_with(d, space, &ix, p, aperture)
|
||||
}
|
||||
|
||||
pub(crate) fn pick_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
p: Vec2,
|
||||
aperture: f64,
|
||||
) -> Option<Handle> {
|
||||
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
// Candidates come topmost first: the first within the aperture is the one.
|
||||
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
|
||||
if !selectable(d, e, frozen) {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
}
|
||||
let infinite = is_infinite(e);
|
||||
@@ -254,16 +221,11 @@ pub(crate) fn pick_with(
|
||||
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
|
||||
/// the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
pick_all_in(d, space, &[], p, aperture)
|
||||
}
|
||||
|
||||
/// [`pick_all`] through a layout viewport that freezes the layers in `frozen` ([`pick_in`]).
|
||||
pub fn pick_all_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
cands
|
||||
.filter(|(e, known)| selectable(d, e, frozen) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
|
||||
.map(|(e, _)| e.handle)
|
||||
.collect()
|
||||
@@ -291,27 +253,14 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
|
||||
|
||||
/// Window selection (entirely inside) or crossing selection (inside or touching).
|
||||
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
select_window_in(d, space, &[], bx, crossing)
|
||||
}
|
||||
|
||||
/// [`select_window`] through a layout viewport: the objects on the layers in `frozen`, the layers
|
||||
/// frozen in that viewport, are not drawn there and are not selected.
|
||||
pub fn select_window_in(d: &Drawing, space: &Space, frozen: &[String], bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_window_with(d, space, &ix, frozen, bx, crossing)
|
||||
select_window_with(d, space, &ix, bx, crossing)
|
||||
}
|
||||
|
||||
pub(crate) fn select_window_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
bx: Bounds2,
|
||||
crossing: bool,
|
||||
) -> Vec<Handle> {
|
||||
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
|
||||
let tol = window_tol(&bx);
|
||||
cands.filter(|(e, known)| selectable(d, e, frozen) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The tessellation tolerance window and crossing tests use for a box.
|
||||
@@ -357,23 +306,11 @@ pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option<Bounds2>, bx: &Bo
|
||||
|
||||
/// Fence selection: entities crossed by the fence polyline.
|
||||
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
|
||||
select_fence_in(d, space, &[], fence)
|
||||
}
|
||||
|
||||
/// [`select_fence`] through a layout viewport that freezes the layers in `frozen`
|
||||
/// ([`select_window_in`]).
|
||||
pub fn select_fence_in(d: &Drawing, space: &Space, frozen: &[String], fence: &[Vec2]) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_fence_with(d, space, &ix, frozen, fence)
|
||||
select_fence_with(d, space, &ix, fence)
|
||||
}
|
||||
|
||||
pub(crate) fn select_fence_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
fence: &[Vec2],
|
||||
) -> Vec<Handle> {
|
||||
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
|
||||
let fb = Bounds2::from_points(fence.iter().copied());
|
||||
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
|
||||
let probe = fb.expand(tol);
|
||||
@@ -381,7 +318,7 @@ pub(crate) fn select_fence_with(
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
// Infinite objects have no useful bounds; like window selection, test their geometry directly.
|
||||
if !selectable(d, e, frozen) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
|
||||
if !selectable(d, e) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
|
||||
continue;
|
||||
}
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
@@ -402,12 +339,6 @@ pub(crate) fn select_fence_with(
|
||||
/// Window polygon selection (entities entirely inside `poly`) or crossing polygon selection
|
||||
/// (inside or crossing its edges). The polygon closes back to its first point.
|
||||
pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool) -> Vec<Handle> {
|
||||
select_polygon_in(d, space, &[], poly, crossing)
|
||||
}
|
||||
|
||||
/// [`select_polygon`] through a layout viewport that freezes the layers in `frozen`
|
||||
/// ([`select_window_in`]).
|
||||
pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[Vec2], crossing: bool) -> Vec<Handle> {
|
||||
let (Some(first), true) = (poly.first(), poly.len() >= 3) else { return Vec::new() };
|
||||
let ix = spatial::index(d, space);
|
||||
let pb = Bounds2::from_points(poly.iter().copied());
|
||||
@@ -418,7 +349,7 @@ pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
let infinite = is_infinite(e);
|
||||
if !selectable(d, e, frozen) || (infinite && !crossing) {
|
||||
if !selectable(d, e) || (infinite && !crossing) {
|
||||
continue;
|
||||
}
|
||||
let eb = bounds_of(d, e, known);
|
||||
@@ -447,15 +378,9 @@ pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[
|
||||
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
|
||||
/// drawing has no such group.
|
||||
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
|
||||
select_group_in(d, space, &[], name)
|
||||
}
|
||||
|
||||
/// [`select_group`] through a layout viewport that freezes the layers in `frozen`: the group's
|
||||
/// objects on those layers are left out.
|
||||
pub fn select_group_in(d: &Drawing, space: &Space, frozen: &[String], name: &str) -> Option<Vec<Handle>> {
|
||||
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
|
||||
let store = d.space(space)?;
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| selectable(d, e, frozen))).collect())
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
|
||||
}
|
||||
|
||||
/// A selection mode typed at "Select objects", collecting its points before it selects.
|
||||
@@ -529,19 +454,14 @@ impl SelectMode {
|
||||
}
|
||||
/// The objects the mode selects, or `None` while it has too few points.
|
||||
pub fn select(&self, d: &Drawing, space: &Space) -> Option<Vec<Handle>> {
|
||||
self.select_in(d, space, &[])
|
||||
}
|
||||
/// [`SelectMode::select`] through a layout viewport: the objects on the layers in `frozen`,
|
||||
/// the layers frozen in that viewport, are not drawn there and are not selected.
|
||||
pub fn select_in(&self, d: &Drawing, space: &Space, frozen: &[String]) -> Option<Vec<Handle>> {
|
||||
match (self.kind, self.pts.as_slice()) {
|
||||
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
|
||||
Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
}
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence_in(d, space, frozen, pts)),
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
|
||||
Some(select_polygon_in(d, space, frozen, pts, self.kind == SelectKind::CPolygon))
|
||||
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -116,26 +116,9 @@ impl Session {
|
||||
};
|
||||
}
|
||||
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`). A word typed with
|
||||
// the Greek keyboard layout active is read by its keys there too: Λ is Last at COUNT's prompt,
|
||||
// not List.
|
||||
let selection_mode = prompt.accept.select && {
|
||||
let word = tt.strip_prefix('_').unwrap_or(tt);
|
||||
let keys = crate::keyboard::latin_keys(word).unwrap_or_else(|| word.to_ascii_lowercase());
|
||||
matches!(keys.as_str(), "all" | "l" | "last" | "p" | "previous")
|
||||
};
|
||||
// A Greek word that is an existing name at a prompt that takes one is that name, in any case:
|
||||
// ΣΟΛΙΔ is also the keys of Solid.
|
||||
let existing = if matches!(prompt.picker, Some(crate::Picker::HatchPattern(_))) {
|
||||
crate::keyboard::greek_fold(tt).and_then(|_| cmd::hatch::pattern_named(tt))
|
||||
} else if prompt.takes_block {
|
||||
self.greek_block_name(tt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(name) = existing {
|
||||
return self.input(Input::Text(name));
|
||||
}
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
|
||||
let selection_mode = prompt.accept.select
|
||||
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
// A leading underscore asks for a keyword by its English name, whatever the interface
|
||||
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
|
||||
// overrides such as `_cen` were taken above.)
|
||||
|
||||
@@ -8,39 +8,6 @@ use super::{
|
||||
impl Session {
|
||||
// ---------------- interactive commands ----------------
|
||||
|
||||
/// Whether `name`, as typed, is a command, an alias or a LISP command: the lookups
|
||||
/// [`Session::start`] dispatches by, so only ASCII is lower-cased and a Greek alias stays as stored.
|
||||
fn names_a_command(&self, name: &str) -> bool {
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
let dashed = name.trim_start_matches(['_', '.']);
|
||||
find_command(&cmd::resolve_alias(dashed)).is_some()
|
||||
|| find_command(&cmd::resolve_alias(&lower)).is_some()
|
||||
|| self.lisp_command(&lower).is_some()
|
||||
}
|
||||
|
||||
/// The alias or LISP command that is the Greek word `name` in another case or accent (λινε for
|
||||
/// c:ΛΙΝΕ): those lookups fold ASCII only.
|
||||
fn greek_command_name(&self, name: &str) -> Option<String> {
|
||||
let bare = name.trim_start_matches(['_', '.', '-']);
|
||||
let same = crate::keyboard::same_greek_word_as(bare)?;
|
||||
let lisp = || self.lisp_commands().into_iter().find(|c| same(c) && self.lisp_command(c).is_some());
|
||||
cmd::file::greek_alias(bare).or_else(lisp)
|
||||
}
|
||||
|
||||
/// Whether the Greek word `name` is a command of its own, which [`Session::start`] runs instead
|
||||
/// of the Latin name its keys type.
|
||||
pub fn greek_word_is_a_command(&self, name: &str) -> bool {
|
||||
self.names_a_command(name) || self.greek_command_name(name).is_some()
|
||||
}
|
||||
|
||||
/// The existing block that is the Greek word `word`: as spelled, else in another case or accent
|
||||
/// (μ for Μ).
|
||||
pub(super) fn greek_block_name(&self, word: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(word)?;
|
||||
let blocks = &self.doc().ok()?.blocks;
|
||||
blocks.keys().find(|k| *k == word).or_else(|| blocks.keys().find(|k| same(k))).cloned()
|
||||
}
|
||||
|
||||
/// Start a command by name or alias as if typed at the command line.
|
||||
pub fn start(&mut self, name: &str) -> Result<()> {
|
||||
self.last_polar = None;
|
||||
@@ -50,12 +17,6 @@ impl Session {
|
||||
Some(n) => (true, n),
|
||||
None => (false, name),
|
||||
};
|
||||
// A name typed with the Greek keyboard layout active (ΛΙΝΕ for LINE) is the Latin name's keys,
|
||||
// unless it is a command, alias or LISP function of its own, in any case or accent.
|
||||
let typed = name;
|
||||
let latin =
|
||||
crate::keyboard::latin_keys(name).filter(|_| !self.names_a_command(name)).map(|latin| self.greek_command_name(name).unwrap_or(latin));
|
||||
let name = latin.as_deref().unwrap_or(name);
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
|
||||
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
|
||||
@@ -76,7 +37,7 @@ impl Session {
|
||||
self.start(if transparent { "'setvar" } else { "setvar" })?;
|
||||
return self.input(Input::Text(var));
|
||||
}
|
||||
return Err(EngineError::UnknownCommand(typed.to_string()));
|
||||
return Err(EngineError::UnknownCommand(name.to_string()));
|
||||
};
|
||||
if transparent && spec.transparent && self.running.is_some() {
|
||||
// Transparent commands (zoom, pan…) run without cancelling the active one.
|
||||
@@ -214,33 +175,17 @@ impl Session {
|
||||
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
|
||||
other => other,
|
||||
};
|
||||
// A selection word typed with the Greek keyboard layout active (ΑΛΛ for ALL) is looked up as its
|
||||
// Latin keys. Only the lookup is: text that is no selection word (a block name at BEDIT's prompt,
|
||||
// which also takes a selection) reaches the command as typed. The name of an existing block at
|
||||
// a prompt that takes a name reaches it as stored, in whatever case it was typed: a block
|
||||
// called Μ is that block there, not Multiple.
|
||||
let block = match &input {
|
||||
Input::Text(t) if prompt.accept.text => self.greek_block_name(t.trim()),
|
||||
_ => None,
|
||||
};
|
||||
let is_block = block.is_some();
|
||||
let input = block.map_or(input, Input::Text);
|
||||
let word = match &input {
|
||||
Input::Text(t) => Some(crate::keyboard::latin_keys(t.trim()).filter(|_| !is_block).unwrap_or_else(|| t.clone())),
|
||||
Input::Keyword(k) => Some(k.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(w) = &word
|
||||
&& let Some(m) = select::SelectMode::from_keyword(w)
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& let Some(m) = select::SelectMode::from_keyword(t)
|
||||
{
|
||||
self.pending_window = None;
|
||||
self.select_mode = Some(m);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(w) = &word
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(w.trim()))
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
|
||||
{
|
||||
match w.trim().to_ascii_lowercase().as_str() {
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
"si" | "single" => {
|
||||
self.select_single = true;
|
||||
return Ok(None);
|
||||
@@ -257,10 +202,10 @@ impl Session {
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
|
||||
}
|
||||
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))),
|
||||
None => {
|
||||
self.pending_window = Some(PendingWindow { corner: p, during_command: true });
|
||||
@@ -268,18 +213,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Text(_) | Input::Keyword(_) => {
|
||||
let tl = word.as_deref().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
Input::Text(ref t) | Input::Keyword(ref t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let d = self.doc()?;
|
||||
let store = d.space(&space);
|
||||
let frozen = self.viewport_frozen();
|
||||
let picked = match tl.as_str() {
|
||||
"all" => store.map(|s| s.iter().filter(|e| select::selectable(d, e, frozen)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| select::last_in(s, frozen)).map(|h| vec![h]),
|
||||
"p" | "previous" => {
|
||||
let st = self.state()?;
|
||||
Some(st.drawn_in_viewport(&st.previous_selection))
|
||||
}
|
||||
"all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]),
|
||||
"p" | "previous" => Some(self.state()?.previous_selection.clone()),
|
||||
_ => None,
|
||||
};
|
||||
match picked {
|
||||
@@ -300,9 +241,8 @@ impl Session {
|
||||
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
|
||||
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
|
||||
if mode.kind == select::SelectKind::Group {
|
||||
let space = self.space();
|
||||
return match input {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group_in(self.doc()?, &space, self.viewport_frozen(), &name) {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
|
||||
Some(hs) => Ok(Some(Input::Pick(hs))),
|
||||
None => {
|
||||
self.echo(format!("No group named \"{}\".", name.trim()));
|
||||
@@ -322,14 +262,14 @@ impl Session {
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
if mode.push(p) {
|
||||
return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick));
|
||||
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
mode.pts.pop();
|
||||
}
|
||||
// Enter ends a fence or polygon; with too few points the mode is dropped.
|
||||
Input::Enter => return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick)),
|
||||
Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),
|
||||
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
|
||||
_ => self.echo("Invalid point."),
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ impl Session {
|
||||
let pickadd = self.settings.pickadd;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = cmd::groups::expand(self, hs);
|
||||
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
if shift && pickadd {
|
||||
sel.retain(|h| !hs.contains(h));
|
||||
} else {
|
||||
@@ -44,7 +44,7 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => self.idle_pick(h, shift),
|
||||
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
|
||||
}
|
||||
@@ -56,7 +56,7 @@ impl Session {
|
||||
let pickadd = self.settings.pickadd;
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
@@ -93,7 +93,7 @@ impl Session {
|
||||
return Vec::new();
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
self.doc().map(|d| select::pick_all_in(d, &self.space(), self.viewport_frozen(), p, ap)).unwrap_or_default()
|
||||
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
|
||||
@@ -155,8 +155,7 @@ impl Session {
|
||||
|
||||
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
|
||||
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
|
||||
/// object under `p` within `aperture`. Inside an MSPACE viewport a pick in model space finds
|
||||
/// what that viewport draws: not the objects on the layers frozen in it.
|
||||
/// object under `p` within `aperture`.
|
||||
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
|
||||
let d = self.doc()?;
|
||||
if let Some((h, q)) = self.pick_hint
|
||||
@@ -164,9 +163,7 @@ impl Session {
|
||||
{
|
||||
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
|
||||
}
|
||||
// The viewport shows model space only: its frozen layers hide nothing on the sheet.
|
||||
let frozen = if *space == Space::Model { self.viewport_frozen() } else { &[] };
|
||||
Ok(select::pick_in(d, space, frozen, p, aperture))
|
||||
Ok(select::pick(d, space, p, aperture))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,8 +424,8 @@ mod tests {
|
||||
for k in 0..400 {
|
||||
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
|
||||
let ap = [0.05, 0.3, 1.5][k % 3];
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, &[], p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, &[], p, ap);
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
|
||||
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
|
||||
hits += usize::from(a.is_some());
|
||||
}
|
||||
@@ -444,15 +444,12 @@ mod tests {
|
||||
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
|
||||
let bx = Bounds2::new(a, b);
|
||||
for crossing in [false, true] {
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, &[], bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, &[], bx, crossing);
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
|
||||
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
|
||||
}
|
||||
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
|
||||
assert_eq!(
|
||||
select::select_fence_with(&d, &Space::Model, &ix, &[], &fence),
|
||||
select::select_fence_with(&d, &Space::Model, &None, &[], &fence)
|
||||
);
|
||||
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,13 +484,10 @@ mod tests {
|
||||
for (p, ap) in
|
||||
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
|
||||
{
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, &[], p, ap), select::pick_with(&d, &Space::Model, &None, &[], p, ap));
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
|
||||
}
|
||||
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
|
||||
assert_eq!(
|
||||
select::select_window_with(&d, &Space::Model, &ix, &[], bx, true),
|
||||
select::select_window_with(&d, &Space::Model, &None, &[], bx, true)
|
||||
);
|
||||
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
//! A word typed with the Greek keyboard layout that is an alias of its own (ALIASEDIT) is that
|
||||
//! alias, not the Latin name its keys type. The aliases are process-wide, so this file keeps to
|
||||
//! one test at a time.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// The alias store is process-wide: each test holds this while it changes the aliases.
|
||||
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// A Greek word the user made an alias runs the alias's command, not the command its keys type.
|
||||
#[test]
|
||||
fn a_greek_alias_starts_its_command_not_the_latin_keys_command() {
|
||||
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();
|
||||
// ΛΙΝΕ types LINE, but the user made it an alias of CIRCLE: aliases are stored as typed (only
|
||||
// ASCII is lower-cased), and the alias wins.
|
||||
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
|
||||
let ran = s.script(
|
||||
"ΛΙΝΕ 0,0 5
|
||||
",
|
||||
);
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
ran.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!((circles, lines), (1, 0), "the alias drew the circle, LINE was not started");
|
||||
}
|
||||
|
||||
/// The alias is found when it is typed in lower case or with an accent too.
|
||||
#[test]
|
||||
fn a_greek_alias_starts_its_command_in_any_case_or_accent() {
|
||||
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": { "ΛΙΝΕ": "circle" } })).unwrap();
|
||||
let ran: Vec<_> = ["λινε", "λίνε"].iter().map(|typed| s.script(&format!("{typed} 0,0 5\n"))).collect();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert!(ran.iter().all(Result::is_ok), "{ran:?}");
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!((circles, lines), (2, 0), "the alias drew both circles, LINE was not started");
|
||||
}
|
||||
|
||||
/// 5 000 Greek aliases and a pasted word of a million letters that is none of them: looking it up
|
||||
/// costs the word's length once, not once for every alias.
|
||||
#[test]
|
||||
fn a_long_greek_word_is_folded_once_however_many_greek_aliases_there_are() {
|
||||
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();
|
||||
let set: serde_json::Map<String, serde_json::Value> = (0..5_000).map(|i| (format!("ΞΞ{i}"), json!("line"))).collect();
|
||||
s.execute("aliasedit", &json!({ "set": set })).unwrap();
|
||||
let word = "Ω".repeat(1_000_000);
|
||||
let t = std::time::Instant::now();
|
||||
let started = s.start(&word);
|
||||
let took = t.elapsed();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert!(started.is_err(), "no command has those keys");
|
||||
assert!(took.as_secs() < 5, "{took:?}");
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! A command or keyword typed with the Greek keyboard layout active (ΛΙΝΕ for LINE, ΘΝΔΟ for
|
||||
//! Undo) works as its Latin name: the layout types each Greek letter on the Latin key it sounds
|
||||
//! like.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// How many lines the drawing's model space holds.
|
||||
fn lines(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count()
|
||||
}
|
||||
|
||||
/// ΛΙΝΕ draws a line like LINE, in lower case and with accents too; `;ΣΑΩΕ` starts QSAVE; a
|
||||
/// Greek word that is no command's keys is reported as typed.
|
||||
#[test]
|
||||
fn greek_layout_commands_start_like_the_latin_ones() {
|
||||
let mut s = Session::new();
|
||||
s.script("ΛΙΝΕ 0,0 10,0\n\n").unwrap();
|
||||
assert_eq!(lines(&s), 1, "{}", s.prompt_text());
|
||||
// Accents the layout's dead key adds are ignored; lower case works too.
|
||||
s.script("λίνε 0,5 10,5\n\n").unwrap();
|
||||
assert_eq!(lines(&s), 2);
|
||||
// The Q key types a semicolon: QSAVE is ";ΣΑΩΕ", which only starts the command (no file needed
|
||||
// here: it reports, it never fails as an unknown command).
|
||||
let err = s.start(";ΣΑΩΕ").map(|_| String::new()).unwrap_or_else(|e| e.to_string());
|
||||
assert!(!err.contains("unknown command"), "{err}");
|
||||
// A Greek word that is no command's keys is reported as typed, not as the Latin letters read from it.
|
||||
let err = s.start("ΧΥΖ").unwrap_err().to_string();
|
||||
assert!(err.contains("ΧΥΖ") && !err.contains("xyz"), "{err}");
|
||||
}
|
||||
|
||||
/// A LISP command with a Greek name runs as itself, not as the command its keys type.
|
||||
#[test]
|
||||
fn a_greek_lisp_command_runs_as_itself_not_as_the_latin_keys_command() {
|
||||
let mut s = Session::new();
|
||||
// ΛΙΝΕ types LINE, but the routine defines a command of that Greek name: it is the command typed.
|
||||
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
|
||||
s.cmdline("ΛΙΝΕ").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!((circles, lines(&s)), (1, 0), "the routine drew the circle, LINE was not started");
|
||||
}
|
||||
|
||||
/// ΑΛΛ at "Select objects" selects everything, as ALL does.
|
||||
#[test]
|
||||
fn greek_layout_selection_words_select_like_the_latin_ones() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [20, 0], "radius": 5 })).unwrap();
|
||||
// ΑΛΛ is the keys A-L-L: ALL at the "Select objects" prompt.
|
||||
s.script(
|
||||
"ERASE ΑΛΛ
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(s.doc().unwrap().model.is_empty(), "ΑΛΛ selected nothing");
|
||||
}
|
||||
|
||||
/// Ν and Υ answer MIRROR's "Erase source objects?" as N and Y do.
|
||||
#[test]
|
||||
fn greek_layout_keywords_match_prompt_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
s.script("ΜΙΡΡΟΡ ALL\n\n0,0 10,0\n").unwrap();
|
||||
// ... the last prompt asks whether to delete the sources: N (Greek layout: Ν) keeps them. Text
|
||||
// the prompt does not know keeps them too, so what Ν is read as is checked itself.
|
||||
let no = s.current_prompt().and_then(|p| p.match_keyword("Ν"));
|
||||
assert_eq!(no.as_deref(), Some("No"), "{}", s.prompt_text());
|
||||
s.script("Ν\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!(circles, 2, "the circle and its mirror image");
|
||||
|
||||
// Υ (the Y key) answers Yes, which erases the source: unmatched Greek text would count as No, so
|
||||
// only a recognised keyword gives this result.
|
||||
let mut yes = Session::new();
|
||||
yes.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
yes.script(
|
||||
"ΜΙΡΡΟΡ ALL
|
||||
|
||||
0,0 10,0
|
||||
Υ
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(yes.running.is_none(), "{}", yes.prompt_text());
|
||||
let circles = yes.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!(circles, 1, "Υ answered Yes and the source was erased");
|
||||
}
|
||||
|
||||
/// A Greek block name typed at BEDIT's "block name or select" opens that block: it is not read
|
||||
/// as a selection word.
|
||||
#[test]
|
||||
fn a_greek_block_name_typed_at_a_name_or_select_prompt_reaches_the_command_as_typed() {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "ΠΟΡΤΑ", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
// BEDIT asks for a block name or a selected reference. ΠΟΡΤΑ is also the keys P-O-R-T-A, which a
|
||||
// selection word would be read from, but no selection word is meant here: the name is typed as it is.
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline("ΠΟΡΤΑ").unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some("ΠΟΡΤΑ"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// Μ is the M key, Multiple at a selection prompt; once a block has that name, typing it at
|
||||
/// BEDIT opens the block.
|
||||
#[test]
|
||||
fn a_block_named_like_a_greek_selection_word_opens_at_a_name_or_select_prompt() {
|
||||
// The Greek capital Mu (U+039C) is the M key: Multiple at a selection prompt.
|
||||
let mu = "\u{39c}";
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
// With no block of that name it stays the selection word, which BEDIT's prompt waits through.
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(mu).unwrap();
|
||||
assert!(s.running.is_some(), "Multiple keeps the prompt: {}", s.prompt_text());
|
||||
assert!(s.state().unwrap().block_editor().is_none());
|
||||
s.cancel();
|
||||
// Once a block has that name, typing it opens that block.
|
||||
s.execute("block", &json!({ "name": mu, "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(mu).unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some(mu), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// ΣΟΛΙΔ is the keys of Solid at HATCH's pattern prompt; once a loaded pattern has that name,
|
||||
/// typing it in any case or accent chooses that pattern.
|
||||
#[test]
|
||||
fn a_loaded_pattern_named_like_the_greek_keys_of_solid_is_that_pattern_at_the_pattern_prompt() {
|
||||
// ΣΟΛΙΔ is the keys S-O-L-I-D: Solid at HATCH's pattern prompt, which takes no scale or angle.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.start("hatch").unwrap();
|
||||
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
|
||||
s.cmdline("ΣΟΛΙΔ").unwrap();
|
||||
assert!(!s.prompt_text().contains("scale"), "Solid: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
// A pattern file that defines a pattern of that name: typing the name chooses that pattern, which asks its scale.
|
||||
s.execute("hatch.patterns", &json!({ "text": "*ΣΟΛΙΔ, test\n0,0,0,0,1\n" })).unwrap();
|
||||
let asked = ["ΣΟΛΙΔ", "σολιδ", "σολίδ"].map(|typed| {
|
||||
s.start("hatch").unwrap();
|
||||
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let asked = s.prompt_text();
|
||||
s.cancel();
|
||||
asked
|
||||
});
|
||||
s.execute("hatch.patterns", &json!({ "clear": true })).unwrap();
|
||||
assert!(asked.iter().all(|a| a.contains("scale")), "the loaded pattern, in any spelling: {asked:?}");
|
||||
}
|
||||
|
||||
/// A LISP command with a Greek name runs when typed in upper case, lower case or with an accent.
|
||||
#[test]
|
||||
fn a_greek_lisp_command_runs_in_any_case_or_accent() {
|
||||
for typed in ["ΛΙΝΕ", "λινε", "λίνε"] {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!((circles, lines(&s)), (1, 0), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// A block called Μ opens at BEDIT when its name is typed in either case.
|
||||
#[test]
|
||||
fn a_block_opens_in_any_case_or_accent_at_a_prompt_that_also_takes_a_selection() {
|
||||
for typed in ["\u{39c}", "\u{3bc}"] {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "\u{39c}", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some("\u{39c}"), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// Σ is the S key, Select at BLOCKREPLACE; once a block has that name, typing it in either case
|
||||
/// names the block.
|
||||
#[test]
|
||||
fn a_block_named_like_the_keys_of_select_is_that_block_at_blockreplace() {
|
||||
let mut s = Session::new();
|
||||
// Σ is the S key: the Select option of BLOCKREPLACE, while no block has that name.
|
||||
s.start("BLOCKREPLACE").unwrap();
|
||||
s.cmdline("Σ").unwrap();
|
||||
assert!(s.prompt_text().contains("Select a block reference"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Σ", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "Σ", "at": [0, 0] })).unwrap();
|
||||
for typed in ["Σ", "σ"] {
|
||||
s.start("BLOCKREPLACE").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let asked = s.prompt_text();
|
||||
s.cancel();
|
||||
assert!(asked.contains("replacement block for Σ"), "{typed}: {asked}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Λ is the L key. At COUNT's "Select object to count or [List/Spaces]" the Latin L is the selection
|
||||
/// word Last, not the List option: the Greek key is read the same way, and so is the whole word.
|
||||
#[test]
|
||||
fn a_greek_selection_word_wins_over_a_keyword_on_the_same_key() {
|
||||
let answer = |typed: &str| {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap();
|
||||
s.start("COUNT").unwrap();
|
||||
let from = s.log.len();
|
||||
s.cmdline(typed).unwrap();
|
||||
s.log[from..].join("\n")
|
||||
};
|
||||
let last = answer("L");
|
||||
assert!(last.contains("like the selected one"), "L counts the objects like the last one: {last}");
|
||||
assert_ne!(last, answer("List"));
|
||||
for typed in ["Λ", "λ", "ΛΑΣΤ"] {
|
||||
assert_eq!(answer(typed), last, "{typed}");
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ mod extend_polyline_arc_end;
|
||||
mod fillet_chamfer;
|
||||
mod fix_holds_grips;
|
||||
mod followups_626;
|
||||
mod greek_keyboard;
|
||||
mod grip_dim_input;
|
||||
mod grip_options;
|
||||
mod grip_selection;
|
||||
@@ -167,7 +166,6 @@ mod trim_extend_options;
|
||||
mod undo_count;
|
||||
mod undo_layout_tab;
|
||||
mod viewport_clip;
|
||||
mod viewport_frozen_selection;
|
||||
mod vplayer_prompts;
|
||||
mod vports;
|
||||
mod wipeout_grips;
|
||||
|
||||
@@ -1,783 +0,0 @@
|
||||
//! Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, "VP Freeze")
|
||||
//! is not drawn there, so the selections made through that viewport leave it out: picks, windows,
|
||||
//! the typed selection modes, the commands that select for themselves, and, for an object that is
|
||||
//! already selected, its grips and the commands that start from the selection. In a viewport that
|
||||
//! does not freeze the layer, in paper space and on the Model tab everything is as before.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle, Space};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Draws a line from `a` to `b` on the current layer and returns its handle.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Puts `h` on the layer "Hidden", which [`viewports`] freezes in its first viewport.
|
||||
fn hide(s: &mut Session, h: Handle) {
|
||||
if s.doc().unwrap().layer("Hidden").is_none() {
|
||||
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
}
|
||||
s.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
|
||||
}
|
||||
|
||||
/// Two viewports on Layout1: the first freezes the layer "Hidden", the second freezes nothing.
|
||||
/// The session is left in paper space.
|
||||
fn viewports(s: &mut Session) -> (String, String) {
|
||||
if s.doc().unwrap().layer("Hidden").is_none() {
|
||||
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
}
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let r = s.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let (a, b) = (r["viewports"][0].as_str().unwrap().to_string(), r["viewports"][1].as_str().unwrap().to_string());
|
||||
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Makes `viewport` the active MSPACE viewport.
|
||||
fn enter(s: &mut Session, viewport: &str) {
|
||||
s.execute("mspace", &json!({ "handle": viewport })).unwrap();
|
||||
assert_eq!(s.space(), Space::Model, "inside a viewport the edit space is model space");
|
||||
}
|
||||
|
||||
/// What most tests draw: `shown` on layer 0 and, drawn after it and so on top of it, `hidden` on
|
||||
/// the layer "Hidden", both from (0, 0) to (10, 0); and the two viewports of [`viewports`].
|
||||
struct Sheet {
|
||||
s: Session,
|
||||
shown: Handle,
|
||||
hidden: Handle,
|
||||
/// The viewport that freezes "Hidden".
|
||||
a: String,
|
||||
/// The viewport that freezes nothing.
|
||||
b: String,
|
||||
}
|
||||
|
||||
/// The drawing of [`Sheet`], in paper space.
|
||||
fn sheet() -> Sheet {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
hide(&mut s, hidden);
|
||||
assert_eq!(s.doc().unwrap().entity(shown).unwrap().common.layer, "0");
|
||||
let (a, b) = viewports(&mut s);
|
||||
Sheet { s, shown, hidden, a, b }
|
||||
}
|
||||
|
||||
/// The two ends of the line `h`.
|
||||
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the line `h` still runs from (0, 0) to (10, 0).
|
||||
fn untouched(s: &Session, h: Handle) -> bool {
|
||||
let (a, b) = ends(s, h);
|
||||
a.near(Vec2::new(0.0, 0.0), 1e-9) && b.near(Vec2::new(10.0, 0.0), 1e-9)
|
||||
}
|
||||
|
||||
/// Whether the drawing still has the object `h`.
|
||||
fn exists(s: &Session, h: Handle) -> bool {
|
||||
s.doc().unwrap().entity(h).is_some()
|
||||
}
|
||||
|
||||
/// Every selected object, in handle order: the stored selection, which also holds the objects
|
||||
/// the active viewport does not draw ([`Session::selection`] leaves those out).
|
||||
fn selected(s: &Session) -> Vec<Handle> {
|
||||
let mut v = s.state().unwrap().selection.clone();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether the line `h` was erased or no longer runs from (0, 0) to (10, 0).
|
||||
fn edited(s: &Session, h: Handle) -> bool {
|
||||
!exists(s, h) || !untouched(s, h)
|
||||
}
|
||||
|
||||
/// The index of the grip of `h` at the origin.
|
||||
fn origin_grip(s: &Session, h: Handle) -> usize {
|
||||
s.grips_of(h).unwrap().iter().position(|g| g.near(Vec2::new(0.0, 0.0), 1e-9)).unwrap()
|
||||
}
|
||||
|
||||
/// `hs` in handle order.
|
||||
fn sorted(mut hs: Vec<Handle>) -> Vec<Handle> {
|
||||
hs.sort();
|
||||
hs
|
||||
}
|
||||
|
||||
/// Runs `text` as a script and ends whatever prompt it leaves open with Enter.
|
||||
fn run(s: &mut Session, text: &str) {
|
||||
s.script(text).unwrap();
|
||||
for _ in 0..4 {
|
||||
if s.running.is_none() {
|
||||
break;
|
||||
}
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "`{text}` left a prompt open: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// The JSON `select` command replaces the selection with what `how` finds.
|
||||
fn select(s: &mut Session, how: Value) -> Vec<Handle> {
|
||||
s.set_selection(Vec::new());
|
||||
s.execute("select", &how).unwrap();
|
||||
selected(s)
|
||||
}
|
||||
|
||||
/// A pick takes the topmost object the viewport draws: the frozen line lies on top of the other
|
||||
/// one, and a pick at the same point finds the line under it. Where the layer is not frozen the
|
||||
/// pick finds the line on top, as it always did.
|
||||
#[test]
|
||||
fn a_pick_finds_the_topmost_object_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let at = json!({ "at": [5, 0], "aperture": 0.5 });
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(select(&mut s, at.clone()), [shown], "the viewport freezes the layer of the line on top");
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(select(&mut s, at.clone()), [hidden], "this viewport draws both: the one on top is picked");
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
assert_eq!(select(&mut s, at), [hidden], "the Model tab draws both");
|
||||
}
|
||||
|
||||
/// Window, crossing and fence selection through the viewport leave the frozen line out, and take
|
||||
/// both lines through the viewport that draws both.
|
||||
#[test]
|
||||
fn a_window_a_crossing_and_a_fence_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let ways =
|
||||
[json!({ "window": [[-1, -1], [11, 1]] }), json!({ "window": [[4, -1], [6, 1]], "crossing": true }), json!({ "fence": [[5, -1], [5, 1]] })];
|
||||
for how in &ways {
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(select(&mut s, how.clone()), [shown], "{how}");
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(select(&mut s, how.clone()), sorted(vec![shown, hidden]), "{how}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks with no command running: a click on the two lines selects the one the viewport draws,
|
||||
/// and a window dragged around them takes only that one.
|
||||
#[test]
|
||||
fn idle_clicks_and_windows_select_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
s.set_selection(Vec::new());
|
||||
// A click on nothing opens a window; the next click closes it (left to right: a window).
|
||||
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some(), "the first click must be on nothing");
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
|
||||
enter(&mut s, &b);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [hidden], "this viewport draws both: the one on top is picked");
|
||||
s.set_selection(Vec::new());
|
||||
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// Selection cycling lists the objects under the pick box that the viewport draws.
|
||||
#[test]
|
||||
fn selection_cycling_lists_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [shown]);
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [hidden, shown], "topmost first");
|
||||
}
|
||||
|
||||
/// Every way of answering "Select objects" leaves the frozen line out: ERASE through the
|
||||
/// viewport that freezes its layer erases the other line only, and through the other viewport
|
||||
/// both.
|
||||
#[test]
|
||||
fn the_answers_to_select_objects_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
|
||||
// Group selection off: a pick takes the object alone, so each answer is tested by itself.
|
||||
s.settings.pickstyle = 0;
|
||||
let answers = [
|
||||
"5,0",
|
||||
"-1,-1 11,1",
|
||||
"ALL",
|
||||
"W -1,-1 11,1",
|
||||
"C 6,1 4,-1",
|
||||
"BOX 6,1 4,-1",
|
||||
"F 5,-1 5,1\n",
|
||||
"WP -1,-1 11,-1 11,1 -1,1\n",
|
||||
"CP 4,-1 6,-1 5,1\n",
|
||||
"G PAIR",
|
||||
];
|
||||
for answer in answers {
|
||||
enter(&mut s, &a);
|
||||
run(&mut s, &format!("ERASE {answer}\n"));
|
||||
assert!(!exists(&s, shown), "`{answer}` must erase the line the viewport draws");
|
||||
assert!(exists(&s, hidden), "`{answer}` erased a line the viewport does not draw");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
|
||||
enter(&mut s, &b);
|
||||
run(&mut s, &format!("ERASE {answer}\n"));
|
||||
// A pick takes one object, the one on top; every other answer takes both.
|
||||
assert_eq!((exists(&s, shown), exists(&s, hidden)), (answer == "5,0", false), "`{answer}` where both are drawn");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// With group selection on, a pick brings the members of its group that the viewport draws.
|
||||
#[test]
|
||||
fn a_pick_brings_the_group_members_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
|
||||
s.settings.pickstyle = 1;
|
||||
s.set_selection(Vec::new());
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
enter(&mut s, &b);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// Select All and Select Similar take what the viewport draws.
|
||||
#[test]
|
||||
fn select_all_and_select_similar_take_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// Mode 0: any object of the same type is similar, whatever its layer.
|
||||
let similar = json!({ "handles": [shown.hex()], "mode": 0 });
|
||||
enter(&mut s, &a);
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
s.execute("selectsimilar", &similar).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
enter(&mut s, &b);
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
s.execute("selectsimilar", &similar).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// STRETCH selects for itself: its crossing window, polygon and fence, called with JSON or
|
||||
/// typed, and its typed All, Last and Previous, move the line the viewport draws and leave the
|
||||
/// frozen one where it was.
|
||||
#[test]
|
||||
fn stretch_moves_what_the_viewport_draws() {
|
||||
let calls = [
|
||||
json!({ "window": [[9, -1], [11, 1]], "delta": [0, 5] }),
|
||||
json!({ "polygon": [[9, -1], [11, -1], [11, 1], [9, 1]], "delta": [0, 5] }),
|
||||
json!({ "fence": [[5, -1], [5, 1]], "delta": [0, 5] }),
|
||||
];
|
||||
for call in &calls {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("stretch", call).unwrap();
|
||||
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must move");
|
||||
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.execute("stretch", call).unwrap();
|
||||
assert!(!untouched(&s, hidden), "{call}: where both are drawn both move");
|
||||
}
|
||||
// Typed: a crossing window, named and implied, All, a pick, Last, and Previous (both lines).
|
||||
for objects in ["C 11,1 9,-1", "11,1 9,-1", "ALL", "5,0", "L", "P"] {
|
||||
let typed = format!("STRETCH {objects}\n\n0,0 0,5\n");
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &typed);
|
||||
assert!(!untouched(&s, shown), "{typed:?}: the line the viewport draws must move");
|
||||
assert!(untouched(&s, hidden), "{typed:?}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &typed);
|
||||
assert!(!untouched(&s, hidden), "{typed:?}: where both are drawn the one on top moves");
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline whose control vertex is inside STRETCH's crossing window is stretched although
|
||||
/// the window misses its drawn lines, but not when the viewport does not draw it.
|
||||
#[test]
|
||||
fn stretch_leaves_a_multiline_the_viewport_does_not_draw() {
|
||||
/// The control vertices of the multiline `h`.
|
||||
fn vertices(s: &Session, h: Handle) -> Vec<Vec2> {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::MLine(m) => m.vertices.iter().map(|v| v.p.xy()).collect(),
|
||||
k => panic!("not a multiline: {k:?}"),
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// Zero justification at scale 2: the element lines run one unit either side of the vertices.
|
||||
let r = s.execute("mline", &json!({ "points": [[0, 0], [10, 0]], "scale": 2, "justification": "zero" })).unwrap();
|
||||
let mline = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap();
|
||||
hide(&mut s, mline);
|
||||
let (a, b) = viewports(&mut s);
|
||||
let call = json!({ "window": [[9.9, -0.1], [10.1, 0.1]], "delta": [0, 5] });
|
||||
enter(&mut s, &a);
|
||||
s.execute("stretch", &call).unwrap();
|
||||
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 0.0), 1e-9), "a multiline the viewport does not draw was stretched");
|
||||
enter(&mut s, &b);
|
||||
s.execute("stretch", &call).unwrap();
|
||||
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 5.0), 1e-9), "{:?}", vertices(&s, mline));
|
||||
}
|
||||
|
||||
/// TRIM's fence and crossing window trim the line the viewport draws and leave the frozen one.
|
||||
#[test]
|
||||
fn trim_by_fence_and_crossing_leaves_what_the_viewport_does_not_draw() {
|
||||
for call in [json!({ "fence": [[8, -1], [8, 1]] }), json!({ "crossing": [[7.5, -1], [8.5, 1]] })] {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// The cutting edge, on a layer every viewport draws.
|
||||
line(&mut s, [5.0, -5.0], [5.0, 5.0]);
|
||||
hide(&mut s, hidden);
|
||||
let (a, b) = viewports(&mut s);
|
||||
enter(&mut s, &a);
|
||||
s.execute("trim", &call).unwrap();
|
||||
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must be trimmed");
|
||||
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw was trimmed: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.execute("trim", &call).unwrap();
|
||||
assert!(!untouched(&s, hidden), "{call}: where both are drawn both are trimmed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A prompt that picks one object (BREAK's "Select object") finds the one the viewport draws.
|
||||
#[test]
|
||||
fn a_prompt_for_one_object_picks_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
run(&mut s, "BREAK 4,0 6,0\n");
|
||||
assert!(untouched(&s, hidden), "a line the viewport does not draw was broken: {:?}", ends(&s, hidden));
|
||||
assert!(!untouched(&s, shown), "the line the viewport draws must be broken");
|
||||
enter(&mut s, &b);
|
||||
run(&mut s, "BREAK 4,0 6,0\n");
|
||||
assert!(!untouched(&s, hidden), "where both are drawn the one on top is broken");
|
||||
}
|
||||
|
||||
/// LEADER's Copy option picks the text to copy: at a point where two texts lie, the one the
|
||||
/// viewport draws.
|
||||
#[test]
|
||||
fn leader_copies_the_text_the_viewport_draws() {
|
||||
/// The contents of the multiline texts in model space: LEADER's copies.
|
||||
fn copies(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect()
|
||||
}
|
||||
let mut s = Session::new();
|
||||
for text in ["SHOWN", "HIDDEN"] {
|
||||
s.execute("text", &json!({ "at": [0, 0], "text": text, "height": 2.5 })).unwrap();
|
||||
}
|
||||
// The text drawn last lies on top.
|
||||
let hidden = *s.doc().unwrap().model.handles().last().unwrap();
|
||||
hide(&mut s, hidden);
|
||||
let (a, b) = viewports(&mut s);
|
||||
let leader = "(command \"_.LEADER\" '(20.0 20.0) '(30.0 25.0) \"\" \"\" \"_C\" '(1.0 1.0))";
|
||||
enter(&mut s, &a);
|
||||
s.cmdline(leader).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(copies(&s), ["SHOWN"], "the viewport freezes the layer of the text on top");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(copies(&s).is_empty());
|
||||
enter(&mut s, &b);
|
||||
s.cmdline(leader).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(copies(&s), ["HIDDEN"], "this viewport draws both: the one on top is picked");
|
||||
}
|
||||
|
||||
/// AutoLISP selects through the viewport too: `ssget` with a window, a crossing window, a fence,
|
||||
/// a polygon, a crossing polygon or a point leaves the frozen line out, and `entsel` picks the
|
||||
/// line the viewport draws. The sets made earlier (the implied selection, `ssgetfirst`, Previous)
|
||||
/// come without the frozen line, and Last is the last object the viewport draws.
|
||||
#[test]
|
||||
fn lisp_selects_what_the_viewport_draws() {
|
||||
use cadcraft_engine::lisp::value::Value as Lisp;
|
||||
/// The objects of the selection set that `code` returns (none for nil), in handle order.
|
||||
fn set(s: &mut Session, code: &str) -> Vec<Handle> {
|
||||
match s.lisp_eval(code, false, true, Vec::new()).unwrap() {
|
||||
Some(Ok(Lisp::PickSet(set))) => sorted(set.items.lock().unwrap().clone()),
|
||||
Some(Ok(Lisp::Nil)) => Vec::new(),
|
||||
other => panic!("`{code}` returned {other:?}"),
|
||||
}
|
||||
}
|
||||
/// The object `entsel` returns for a pick at (5, 0).
|
||||
fn entsel(s: &mut Session) -> Handle {
|
||||
s.cmdline("(setq picked (car (entsel)))").unwrap();
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 0.0))).unwrap();
|
||||
match s.lisp_var("picked") {
|
||||
Lisp::Ename(h) => h,
|
||||
other => panic!("entsel returned {other:?}: {}", s.prompt_text()),
|
||||
}
|
||||
}
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let areas = [
|
||||
"(ssget \"W\" '(-1 -1) '(11 1))",
|
||||
"(ssget \"C\" '(4 -1) '(6 1))",
|
||||
"(ssget \"F\" '((5 -1) (5 1)))",
|
||||
"(ssget \"WP\" '((-1 -1) (11 -1) (11 1) (-1 1)))",
|
||||
"(ssget \"CP\" '((4 -1) (6 -1) (5 1)))",
|
||||
];
|
||||
enter(&mut s, &a);
|
||||
for code in areas {
|
||||
assert_eq!(set(&mut s, code), [shown], "{code}");
|
||||
}
|
||||
assert_eq!(set(&mut s, "(ssget '(5 0))"), [shown], "a point picks the object the viewport draws");
|
||||
assert_eq!(entsel(&mut s), shown);
|
||||
enter(&mut s, &b);
|
||||
for code in areas {
|
||||
assert_eq!(set(&mut s, code), sorted(vec![shown, hidden]), "{code}");
|
||||
}
|
||||
assert_eq!(set(&mut s, "(ssget '(5 0))"), [hidden], "this viewport draws both: the one on top is picked");
|
||||
assert_eq!(entsel(&mut s), hidden);
|
||||
|
||||
let earlier = ["(ssget \"I\")", "(ssget)", "(cadr (ssgetfirst))", "(ssget \"P\")"];
|
||||
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
|
||||
enter(&mut s, viewport);
|
||||
for code in earlier {
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
assert_eq!(set(&mut s, code), expected, "{code}");
|
||||
}
|
||||
}
|
||||
// Last: of two lines made after everything else, the later one is on the layer the first
|
||||
// viewport freezes, so through that viewport Last is the earlier one.
|
||||
s.set_selection(Vec::new());
|
||||
enter(&mut s, &b);
|
||||
let drawn = line(&mut s, [0.0, 2.0], [10.0, 2.0]);
|
||||
let last = line(&mut s, [0.0, 3.0], [10.0, 3.0]);
|
||||
hide(&mut s, last);
|
||||
assert_eq!(set(&mut s, "(ssget \"L\")"), [last]);
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(set(&mut s, "(ssget \"L\")"), [drawn], "Last must pass over an object the viewport does not draw");
|
||||
}
|
||||
|
||||
/// The grips of a selected object the viewport does not draw take no part in a grip edit: a
|
||||
/// shared grip stretches only the line that is drawn. Through the other viewport both stretch.
|
||||
#[test]
|
||||
fn a_selected_line_the_viewport_does_not_draw_is_not_stretched_with_a_shared_grip() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
let i = origin_grip(&s, shown);
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
|
||||
assert!(untouched(&s, hidden), "a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
// Its own grip starts no edit there.
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let j = origin_grip(&s, hidden);
|
||||
assert!(s.start_grip_edit(&[(hidden, j)]).is_err());
|
||||
assert!(s.running.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
|
||||
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
|
||||
assert!(ends(&s, hidden).0.near(Vec2::new(3.0, 4.0), 1e-9), "where both are drawn both stretch: {:?}", ends(&s, hidden));
|
||||
}
|
||||
|
||||
/// A layer frozen in the viewport while its object is selected: from then on the object's grips
|
||||
/// are not there, and thawing the layer in the viewport brings them back.
|
||||
#[test]
|
||||
fn freezing_a_layer_in_the_viewport_removes_the_grips_of_a_selected_object() {
|
||||
let Sheet { mut s, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![hidden]);
|
||||
// The typed GRIP command finds a grip of the selected line at its start.
|
||||
let grip_there = |s: &mut Session| {
|
||||
s.cmdline("grip").unwrap();
|
||||
s.cmdline("0,0").unwrap();
|
||||
let none = s.log.iter().any(|l| l.contains("No grip of a selected object there"));
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
s.log.clear();
|
||||
!none
|
||||
};
|
||||
assert!(grip_there(&mut s), "the viewport draws the line: its grip is there");
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(!grip_there(&mut s), "the viewport no longer draws the line");
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(grip_there(&mut s), "thawed in the viewport: the grip is back");
|
||||
}
|
||||
|
||||
/// What the edited view shows: the frozen layer hides its model-space objects inside that
|
||||
/// viewport only, and never an object on the sheet.
|
||||
#[test]
|
||||
fn only_the_viewport_that_freezes_a_layer_hides_its_model_objects() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// A line on the sheet, on the layer the first viewport freezes.
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
assert_eq!(s.doc().unwrap().space_of(on_sheet), Some(Space::Paper("Layout1".into())));
|
||||
let shows = |s: &Session, h: Handle| {
|
||||
let st = s.state().unwrap();
|
||||
st.shows(st.doc.entity(h).unwrap())
|
||||
};
|
||||
assert!(s.viewport_frozen().is_empty(), "paper space: no viewport is active");
|
||||
assert!(shows(&s, hidden) && shows(&s, shown) && shows(&s, on_sheet));
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(s.viewport_frozen(), ["Hidden".to_string()]);
|
||||
assert!(!shows(&s, hidden), "frozen in the active viewport");
|
||||
assert!(shows(&s, shown));
|
||||
assert!(shows(&s, on_sheet), "the viewport draws model space: its frozen layers hide nothing on the sheet");
|
||||
// A prompt that picks one object on the sheet while the viewport is active finds it.
|
||||
let sheet_space = Space::Paper("Layout1".into());
|
||||
assert_eq!(s.pick_object(&sheet_space, Vec2::new(1.5, 1.0), 0.5).unwrap(), Some(on_sheet));
|
||||
assert_eq!(s.pick_object(&Space::Model, Vec2::new(5.0, 0.0), 0.5).unwrap(), Some(shown), "in model space the frozen line is left out");
|
||||
enter(&mut s, &b);
|
||||
assert!(s.viewport_frozen().is_empty());
|
||||
assert!(shows(&s, hidden));
|
||||
// Layer names are not case sensitive: frozen under another spelling, the line is hidden too.
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["HIDDEN"] })).unwrap();
|
||||
assert_eq!(s.viewport_frozen(), ["HIDDEN".to_string()]);
|
||||
assert!(!shows(&s, hidden));
|
||||
assert_eq!(select(&mut s, json!({ "at": [5, 0], "aperture": 0.5 })), [shown]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["hidden"] })).unwrap();
|
||||
assert!(shows(&s, hidden));
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
assert!(s.viewport_frozen().is_empty(), "the Model tab has no layout viewport");
|
||||
assert!(shows(&s, hidden));
|
||||
}
|
||||
|
||||
/// An object that is selected when its layer is frozen in the active viewport stays selected, but
|
||||
/// nothing that starts from the selection takes it there: a typed command with the selection
|
||||
/// made before it (PICKFIRST), a JSON command that names no objects, and Delete.
|
||||
#[test]
|
||||
fn a_command_leaves_out_a_selected_object_the_viewport_does_not_draw() {
|
||||
/// One way of editing the selection.
|
||||
type Edit = fn(&mut Session);
|
||||
let edits: [(&str, Edit); 5] = [
|
||||
("typed ERASE", |s: &mut Session| run(s, "ERASE\n")),
|
||||
("typed MOVE", |s: &mut Session| run(s, "MOVE 0,0 0,5\n")),
|
||||
("JSON erase", |s: &mut Session| drop(s.execute("erase", &json!({})).unwrap())),
|
||||
("JSON move", |s: &mut Session| drop(s.execute("move", &json!({ "delta": [0, 5] })).unwrap())),
|
||||
("Delete", |s: &mut Session| drop(s.execute("erase.selection", &json!({})).unwrap())),
|
||||
];
|
||||
for (name, edit) in edits {
|
||||
// Where both lines are drawn, both are edited.
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
edit(&mut s);
|
||||
assert!(edited(&s, shown) && edited(&s, hidden), "{name}: where both are drawn both are edited");
|
||||
|
||||
// The layer is frozen in the active viewport while both lines are selected.
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "{name}: both lines must still be selected");
|
||||
assert_eq!(s.selection(), [shown], "{name}: the selection as the viewport has it");
|
||||
edit(&mut s);
|
||||
assert!(edited(&s, shown), "{name}: the line the viewport draws must be edited");
|
||||
assert!(!edited(&s, hidden), "{name}: a line the viewport does not draw was edited: {:?}", ends(&s, hidden));
|
||||
}
|
||||
|
||||
// With only that line selected there is nothing to start from.
|
||||
let Sheet { mut s, hidden, a, b, .. } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(s.selection().is_empty());
|
||||
assert!(s.execute("erase.selection", &json!({})).is_err(), "Delete must find nothing selected");
|
||||
s.script("ERASE\n").unwrap();
|
||||
assert!(s.running.is_some() && s.prompt_text().contains("Select objects"), "ERASE must ask for objects: {}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
assert!(!edited(&s, hidden));
|
||||
// The line is still selected, and a viewport that draws it has it again.
|
||||
s.set_selection(vec![hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [a], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(s.selection(), [hidden], "thawed in the viewport: selected as before");
|
||||
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
assert!(s.selection().is_empty());
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![hidden]);
|
||||
run(&mut s, "ERASE\n");
|
||||
assert!(!exists(&s, hidden), "where the line is drawn the selection made before ERASE is erased");
|
||||
}
|
||||
|
||||
/// The Previous and Last options of "Select objects" take what the viewport draws: of the set a
|
||||
/// command used before, and the last object that is not frozen there.
|
||||
#[test]
|
||||
fn previous_and_last_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
for option in ["P", "L"] {
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(Vec::new());
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &format!("ERASE {option}\n"));
|
||||
assert!(!exists(&s, shown), "`{option}` must erase the line the viewport draws");
|
||||
assert!(exists(&s, hidden), "`{option}` erased a line the viewport does not draw");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(Vec::new());
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &format!("ERASE {option}\n"));
|
||||
// Previous is both lines; Last is the one made last, the line on the layer "Hidden".
|
||||
assert_eq!((exists(&s, shown), exists(&s, hidden)), (option == "L", false), "`{option}` where both are drawn");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON grip edits that cover the selection (`grip.stretch` with hot grips, and
|
||||
/// [`Session::grip_edit`]) drag the grips of the other selected objects on the same point, but
|
||||
/// not the grip of a selected line the viewport does not draw.
|
||||
#[test]
|
||||
fn the_grip_edits_that_cover_the_selection_leave_out_what_the_viewport_does_not_draw() {
|
||||
use cadcraft_engine::grips::GripMode;
|
||||
/// Drags grip `i` of the line `h` to (3, 4).
|
||||
type Edit = fn(&mut Session, Handle, usize);
|
||||
let edits: [(&str, Edit); 2] = [
|
||||
("grip.stretch", |s: &mut Session, h: Handle, i: usize| {
|
||||
drop(s.execute("grip.stretch", &json!({ "grips": [[h.hex(), i]], "to": [3, 4] })).unwrap())
|
||||
}),
|
||||
("grip_edit", |s: &mut Session, h: Handle, i: usize| s.grip_edit(h, i, Vec2::new(3.0, 4.0), GripMode::Stretch).unwrap()),
|
||||
];
|
||||
for (name, edit) in edits {
|
||||
for frozen in [true, false] {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, if frozen { &a } else { &b });
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
let i = origin_grip(&s, shown);
|
||||
edit(&mut s, shown, i);
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{name}: {:?}", ends(&s, shown));
|
||||
assert_eq!(untouched(&s, hidden), frozen, "{name}, layer frozen in the viewport: {frozen}: {:?}", ends(&s, hidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session reports as the selection is the selection as the viewport has it, while
|
||||
/// every selected object stays in the stored selection.
|
||||
#[test]
|
||||
fn the_reported_selection_leaves_out_what_the_viewport_does_not_draw() {
|
||||
/// The handles in `list`, a JSON array of hex strings, in handle order.
|
||||
fn handles(list: &Value) -> Vec<Handle> {
|
||||
sorted(list.as_array().unwrap().iter().map(|h| Handle::parse_hex(h.as_str().unwrap()).unwrap()).collect())
|
||||
}
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
|
||||
enter(&mut s, viewport);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "both lines are selected");
|
||||
assert_eq!(sorted(s.selection()), expected);
|
||||
assert_eq!(handles(&s.summary()["selection"]), expected);
|
||||
let inspect = s.execute("drawing.inspect", &json!({})).unwrap();
|
||||
assert_eq!(handles(&inspect["selection"]), expected);
|
||||
}
|
||||
// A selected object on the sheet is never left out, whatever its layer.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden, on_sheet]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, on_sheet]));
|
||||
// Where no viewport is active nothing is left out: in paper space and on the Model tab.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
s.set_selection(vec![shown, hidden, on_sheet]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, on_sheet]), "paper space");
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden]), "the Model tab");
|
||||
}
|
||||
|
||||
/// Adding to the selection or removing from it inside the viewport keeps a selected object the
|
||||
/// viewport does not draw: nothing drops it behind the user's back, and it counts again once its
|
||||
/// layer is thawed there.
|
||||
#[test]
|
||||
fn changing_the_selection_keeps_a_selected_object_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
// A third line, on layer 0 like `shown`, away from the other two.
|
||||
let other = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
// Selects `hidden` where it is drawn, then freezes its layer in the active viewport.
|
||||
let freeze = |s: &mut Session| {
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(selected(s), [hidden], "the line must still be selected");
|
||||
assert!(s.selection().is_empty(), "the viewport does not draw it");
|
||||
};
|
||||
|
||||
// A click adds (PICKADD), Shift+click removes.
|
||||
freeze(&mut s);
|
||||
s.idle_click(Vec2::new(5.0, 5.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a click dropped the selected line the viewport does not draw");
|
||||
assert_eq!(s.selection(), [other]);
|
||||
s.idle_click(Vec2::new(5.0, 5.0), true).unwrap();
|
||||
assert_eq!(selected(&s), [hidden], "Shift+click must remove only the line clicked");
|
||||
|
||||
// A window adds.
|
||||
freeze(&mut s);
|
||||
s.idle_click(Vec2::new(-1.0, 4.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some(), "the first click must be on nothing");
|
||||
s.idle_click(Vec2::new(11.0, 6.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a window dropped the selected line the viewport does not draw");
|
||||
|
||||
// The JSON `select` with `add`, and QSELECT appending the objects on layer 0.
|
||||
freeze(&mut s);
|
||||
s.execute("select", &json!({ "at": [5, 5], "aperture": 0.5, "add": true })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with `add`");
|
||||
// Given nothing to select with, `select` leaves the selection as it is.
|
||||
s.execute("select", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with nothing to select with");
|
||||
freeze(&mut s);
|
||||
s.execute("qselect", &json!({ "layer": "0", "mode": "append" })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden, other]), "QSELECT appending");
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, other]));
|
||||
|
||||
// Thawed in the viewport, the line counts as selected again.
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, other]));
|
||||
}
|
||||
|
||||
/// [`Session::selection`] looks a few selected objects up one by one and, when there are many,
|
||||
/// passes over model space once. Both leave out the same objects and keep the order of the
|
||||
/// selection: in a drawing of some hundreds of lines whose draw order is not the order of their
|
||||
/// handles, a selection of a few lines and the selection of every line.
|
||||
#[test]
|
||||
fn a_small_and_a_large_selection_leave_out_the_same_objects() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// A line on the sheet, on the layer the first viewport freezes: no viewport hides it.
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
assert!(!s.doc().unwrap().model.contains(on_sheet), "the line must be on the sheet");
|
||||
enter(&mut s, &b);
|
||||
let more: Vec<Handle> = (0..400).map(|i| line(&mut s, [f64::from(i), 5.0], [f64::from(i), 6.0])).collect();
|
||||
// Every second one of them goes on the layer the first viewport freezes.
|
||||
let frozen: Vec<String> = more.iter().step_by(2).map(|h| h.hex()).collect();
|
||||
s.execute("properties.set", &json!({ "handles": frozen, "layer": "Hidden" })).unwrap();
|
||||
// The frozen line made last goes to the back: it is now the first object in draw order.
|
||||
s.execute("draworder", &json!({ "handles": [more[398].hex()], "order": "back" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.iter().next().map(|e| e.handle), Some(more[398]));
|
||||
let all: Vec<Handle> = [shown, hidden].into_iter().chain(more.iter().copied()).collect();
|
||||
let drawn: Vec<Handle> = std::iter::once(shown).chain(more.iter().copied().skip(1).step_by(2)).collect();
|
||||
assert_eq!(s.doc().unwrap().model.len(), all.len());
|
||||
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(s.selection(), [shown], "two selected lines of some hundreds");
|
||||
s.set_selection(vec![hidden, on_sheet, more[0], more[1]]);
|
||||
assert_eq!(s.selection(), [on_sheet, more[1]], "a line on the sheet is never left out");
|
||||
s.set_selection(all.clone());
|
||||
assert_eq!(s.selection(), drawn, "every line selected");
|
||||
assert_eq!(s.state().unwrap().selection, all, "every line must still be selected");
|
||||
|
||||
// The viewport that freezes nothing has them all, few or many.
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(s.selection(), [shown, hidden]);
|
||||
s.set_selection(all.clone());
|
||||
assert_eq!(s.selection(), all);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-mcp = { workspace = true }
|
||||
craft-jsonrpc = { workspace = true }
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
|
||||
+48
-37
@@ -22,24 +22,25 @@ Verify with inspect_drawing / query_entities and look with render.";
|
||||
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
session: craft_mcp::Session,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
session: craft_mcp::Session::new("cadcraft", env!("CARGO_PKG_VERSION"), craft_mcp::Policy::Compatible)
|
||||
.with_capabilities(json!({"tools": {"listChanged": false}, "resources": {"listChanged": false}}))
|
||||
.unwrap_or_else(|_| craft_mcp::Session::new("cadcraft", env!("CARGO_PKG_VERSION"), craft_mcp::Policy::Compatible))
|
||||
.with_legacy_initialize(SUPPORTED, json!({"name": "cadcraft", "title": "CADCraft", "version": env!("CARGO_PKG_VERSION")})),
|
||||
}
|
||||
Self { backend, initialized: false }
|
||||
}
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
self.backend.as_mut()
|
||||
}
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.session.is_initialized()
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes (logs to stderr only).
|
||||
@@ -61,43 +62,53 @@ impl Server {
|
||||
return None;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(msg) => self.handle_message(&msg),
|
||||
Err(e) => Some(craft_jsonrpc::error_response(Value::Null, craft_jsonrpc::RpcError::new(PARSE_ERROR, format!("parse error: {e}")))),
|
||||
// JSON-RPC 2.0: an empty batch is one Invalid Request, not silence.
|
||||
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
|
||||
Ok(Value::Array(batch)) => {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
Ok(msg) => self.handle(msg),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
// `handle` historically routes only one envelope; batches belong to `handle_line`.
|
||||
if msg.is_array() {
|
||||
return Some(craft_jsonrpc::error_response(Value::Null, craft_jsonrpc::RpcError::new(INVALID_REQUEST, "message must be an object")));
|
||||
}
|
||||
self.handle_message(&msg)
|
||||
}
|
||||
|
||||
fn handle_message(&mut self, msg: &Value) -> Option<Value> {
|
||||
let server = std::cell::RefCell::new(self);
|
||||
craft_jsonrpc::process_legacy(
|
||||
msg,
|
||||
|request| {
|
||||
let mut server = server.borrow_mut();
|
||||
if request.method == "initialize" {
|
||||
let instructions = format!("{INSTRUCTIONS} Backend: {}.", server.backend.describe());
|
||||
server.session.set_instructions(instructions);
|
||||
}
|
||||
if let Some(result) = server.session.request(request, msg.is_array()) {
|
||||
return result;
|
||||
}
|
||||
server
|
||||
.request(request.method, request.params.unwrap_or(&Value::Null))
|
||||
.map_err(|(code, message)| craft_jsonrpc::RpcError::new(code, message))
|
||||
},
|
||||
|request| server.borrow_mut().session.notification(request),
|
||||
)
|
||||
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
||||
let id = o.get("id").cloned();
|
||||
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
||||
if o.contains_key("result") || o.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
||||
};
|
||||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||||
let Some(id) = id else {
|
||||
if method == "notifications/initialized" {
|
||||
self.initialized = true;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
Some(match self.request(method, ¶ms) {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
|
||||
"serverInfo": {"name": "cadcraft", "title": "CADCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(tool_definitions(self.backend.has_ui())),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
|
||||
@@ -391,24 +391,3 @@ fn render_refuses_unknown_backgrounds_and_plot_styles() {
|
||||
assert!(crate::Backend::call(&mut h, "ui.render", json!({"background": "purple"})).is_err());
|
||||
assert!(crate::Backend::call(&mut h, "ui.render", json!({"plot_style": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_protocol_routing_and_version_negotiation() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
assert!(s.handle_line(r#"{"method":"notifications/initialized"}"#).is_none());
|
||||
assert!(s.is_initialized());
|
||||
for (params, expected) in
|
||||
[(json!(null), "2025-06-18"), (json!({"protocolVersion":"2024-11-05"}), "2024-11-05"), (json!({"protocolVersion":"other"}), "2025-06-18")]
|
||||
{
|
||||
let response = call(&mut s, 1, "initialize", params);
|
||||
assert_eq!(response["result"]["protocolVersion"], expected);
|
||||
assert_eq!(response["result"]["serverInfo"]["title"], "CADCraft");
|
||||
assert!(response["result"]["instructions"].as_str().unwrap().contains("Backend:"));
|
||||
}
|
||||
let response: Value =
|
||||
serde_json::from_str(&s.handle_line(r#"[{"id":[1],"method":"ping","params":5},{"method":"ignored"},{"result":1},{}]"#).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
response,
|
||||
json!([{"jsonrpc":"2.0","id":[1],"result":{}},{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"missing `method`"}}])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,21 +402,13 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The alias store is process-wide. A test of this crate that changes it holds this guard while
|
||||
/// it does, so such tests take turns.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_alias_store() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::MutexGuard;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -427,7 +419,8 @@ mod tests {
|
||||
|
||||
/// The alias store is process-wide, so the tests that load files take turns and clear it.
|
||||
fn alone() -> MutexGuard<'static, ()> {
|
||||
super::lock_alias_store()
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
|
||||
@@ -96,9 +96,8 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
return None;
|
||||
}
|
||||
let st = app.session.state().ok()?;
|
||||
let selection = st.shown_selection();
|
||||
let refs = selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count();
|
||||
if refs == 1 && selection.len() == 1 {
|
||||
let refs = st.selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count();
|
||||
if refs == 1 && st.selection.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
// Start on the block last edited if it is still there, else the first one.
|
||||
|
||||
@@ -176,9 +176,6 @@ pub struct CanvasState {
|
||||
pub build_ms: f64,
|
||||
pub draw_ms: f64,
|
||||
hover_at: Option<Pos2>,
|
||||
/// The layers frozen in the active viewport when the object and the cycling badge under the
|
||||
/// cursor were last worked out ([`cadcraft_engine::DocState::viewport_frozen`]).
|
||||
hover_frozen: Vec<String>,
|
||||
/// Constraint glyphs for the parametric overlay (cached per drawing revision).
|
||||
pub param: crate::parametric::Cache,
|
||||
/// A selection window opened by a press-and-drag, to close where the button is let go.
|
||||
@@ -698,22 +695,12 @@ fn update_hover(app: &mut CadApp, ui: &egui::Ui, inside: bool, hover_pos: Option
|
||||
// The cursor picks objects (pick box, rollover highlight): "Select objects" and single-object
|
||||
// prompts, or no command with PICKFIRST on; SELECTIONPREVIEW says in which of those it shows.
|
||||
let selecting = app.session.previews_selection();
|
||||
// What the cursor is over also depends on the layers frozen in the active viewport, and those
|
||||
// can change while the pointer rests (VPLAYER, undo, another viewport made the active one).
|
||||
let refrozen = match app.session.state() {
|
||||
Ok(st) if st.viewport_frozen() != app.canvas.hover_frozen.as_slice() => {
|
||||
app.canvas.hover_frozen = st.viewport_frozen().to_vec();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if inside && selecting && app.session.pending_window.is_none() {
|
||||
if (hover_pos != app.canvas.hover_at || refrozen)
|
||||
if hover_pos != app.canvas.hover_at
|
||||
&& let (Some(w), Ok(st)) = (raw_world, app.session.state())
|
||||
{
|
||||
let filter = app.session.settings.previewfilter;
|
||||
let ap = app.session.settings.pickbox.max(1.0) * 1.5 / scale;
|
||||
app.canvas.hover = cadcraft_engine::select::pick_in(&st.doc, &st.edit_space(), st.viewport_frozen(), w, ap)
|
||||
app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, app.session.settings.pickbox.max(1.0) * 1.5 / scale)
|
||||
.filter(|h| st.doc.entity(*h).is_none_or(|e| !cadcraft_engine::select::preview_filtered(&st.doc, e, filter)));
|
||||
app.canvas.hover_at = hover_pos;
|
||||
}
|
||||
@@ -730,7 +717,7 @@ fn update_hover(app: &mut CadApp, ui: &egui::Ui, inside: bool, hover_pos: Option
|
||||
app.canvas.hover_at = None;
|
||||
}
|
||||
shift_space_cycle(app, ui, hover_pos.filter(|_| inside), raw_world);
|
||||
if inside && (hover_pos != app.canvas.cycle_badge_at || refrozen) {
|
||||
if inside && hover_pos != app.canvas.cycle_badge_at {
|
||||
app.canvas.cycle_badge = raw_world.is_some_and(|w| app.session.cycling_badge(w));
|
||||
app.canvas.cycle_badge_at = hover_pos;
|
||||
} else if !inside {
|
||||
@@ -1117,7 +1104,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
|
||||
let gs = app.session.settings.gripsize as f32;
|
||||
for h in sel.iter().take(200) {
|
||||
if let Some(e) = st.doc.entity(*h)
|
||||
&& st.shows(e)
|
||||
&& st.doc.is_visible(e)
|
||||
{
|
||||
let grips = e.kind.grips();
|
||||
let count = grips.len();
|
||||
|
||||
@@ -3,11 +3,10 @@ use super::{CadApp, Handle, Input, Pos2, Rect, Xf, vec2};
|
||||
/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts
|
||||
/// editing it, with the other hot grips when it is one of them.
|
||||
pub(super) fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) {
|
||||
// A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS, a layer frozen
|
||||
// in the active viewport) is no longer painted, so it must not be stretched along with the
|
||||
// visible ones.
|
||||
// A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS) is no longer
|
||||
// painted, so it must not be stretched along with the visible ones.
|
||||
let Ok(st) = app.session.state() else { return };
|
||||
app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.shows(e)));
|
||||
app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.doc.is_visible(e)));
|
||||
let hot = &mut app.canvas.hot_grips;
|
||||
if shift {
|
||||
match hot.iter().position(|x| *x == g) {
|
||||
@@ -90,7 +89,7 @@ pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Han
|
||||
}
|
||||
for h in sel.iter().take(200) {
|
||||
let e = st.doc.entity(*h)?;
|
||||
if !st.shows(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
if !st.doc.is_visible(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
continue;
|
||||
}
|
||||
let grips = e.kind.grips();
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {
|
||||
pub(super) fn hatch_at(app: &CadApp, w: Vec2, scale: f64) -> Option<Handle> {
|
||||
let st = app.session.state().ok()?;
|
||||
let aperture = app.session.settings.pickbox.max(1.0) * 1.5 / scale.max(1e-12);
|
||||
let h = cadcraft_engine::select::pick_in(&st.doc, &st.edit_space(), st.viewport_frozen(), w, aperture)?;
|
||||
let h = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, aperture)?;
|
||||
matches!(st.doc.entity(h)?.kind, cadcraft_doc::EntityKind::Hatch(_)).then_some(h)
|
||||
}
|
||||
|
||||
|
||||
@@ -248,150 +248,3 @@ fn clicking_a_hot_grip_leaves_hidden_objects_out_of_the_stretch() {
|
||||
click_grip(&mut app, (shown, 0), false);
|
||||
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the hidden object's grip must leave the hot set");
|
||||
}
|
||||
|
||||
/// Makes the layer "Hidden" and, on Layout1, two viewports over the model: the first freezes
|
||||
/// "Hidden", the second freezes nothing. The app is left in paper space.
|
||||
fn viewports_freezing_hidden(app: &mut CadApp) -> (String, String) {
|
||||
app.session.execute("layer.new", &serde_json::json!({ "name": "Hidden" })).unwrap();
|
||||
app.session.execute("layout.set", &serde_json::json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &serde_json::json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// An app with two model-space lines, the second on the layer "Hidden", and the two viewports of
|
||||
/// [`viewports_freezing_hidden`]: (app, the first line, the second line, the viewport that
|
||||
/// freezes "Hidden", the viewport that freezes nothing).
|
||||
fn lines_in_two_viewports() -> (CadApp, Handle, Handle, String, String) {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
for y in [0, 30] {
|
||||
app.run("line", serde_json::json!({ "points": [[0, y], [100, y]] })).unwrap();
|
||||
}
|
||||
let handles = app.session.doc().unwrap().model.handles();
|
||||
let (shown, hidden) = (handles[0], handles[1]);
|
||||
let (a, b) = viewports_freezing_hidden(&mut app);
|
||||
app.session.execute("properties.set", &serde_json::json!({ "handles": [hidden.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().entity(hidden).unwrap().common.layer, "Hidden");
|
||||
(app, shown, hidden, a, b)
|
||||
}
|
||||
|
||||
/// A hot grip whose object is on a layer frozen in the active viewport is not painted there, so
|
||||
/// clicking another hot grip must not stretch that object along with it. Through a viewport that
|
||||
/// draws the layer both grips stay hot.
|
||||
#[test]
|
||||
fn clicking_a_hot_grip_leaves_out_objects_the_viewport_does_not_draw() {
|
||||
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
|
||||
for (viewport, hot) in [(&a, vec![(shown, 0)]), (&b, vec![(shown, 0), (hidden, 0)])] {
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": viewport })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
|
||||
click_grip(&mut app, (shown, 0), false);
|
||||
assert_eq!(app.canvas.hot_grips, hot);
|
||||
app.session.cancel();
|
||||
}
|
||||
// Frozen in the active viewport while both grips are hot.
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
click_grip(&mut app, (shown, 0), false);
|
||||
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the grip of the line the viewport no longer draws must leave the hot set");
|
||||
}
|
||||
|
||||
/// The cursor finds no grip of a selected object the viewport does not draw; it finds the grips
|
||||
/// of the other selected object there, and both through a viewport that draws both.
|
||||
#[test]
|
||||
fn the_cursor_finds_no_grip_of_an_object_the_viewport_does_not_draw() {
|
||||
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||||
let xf = Xf { rect, center: Vec2::new(50.0, 15.0), scale: 2.0, twist: 0.0 };
|
||||
// The start of each line, where its first grip is.
|
||||
let (on_shown, on_hidden) = (Some(xf.to_screen(Vec2::new(0.0, 0.0))), Some(xf.to_screen(Vec2::new(0.0, 30.0))));
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(grip_at(&app, &xf, on_shown), Some((shown, 0)));
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), None, "a grip of a line the viewport does not draw was hit");
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), Some((hidden, 0)));
|
||||
// Frozen in the active viewport while the line is selected, then thawed again.
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [shown, hidden], "both lines must still be selected");
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), None);
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(grip_at(&app, &xf, on_hidden), Some((hidden, 0)));
|
||||
}
|
||||
|
||||
/// The hatch under the cursor, which a double-click edits, is one the viewport draws.
|
||||
#[test]
|
||||
fn the_cursor_finds_no_hatch_the_viewport_does_not_draw() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
let handle = |v: &serde_json::Value| Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap();
|
||||
let square = app.session.execute("pline", &serde_json::json!({ "vertices": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true })).unwrap();
|
||||
let hatch = handle(&app.session.execute("hatch", &serde_json::json!({ "handles": [handle(&square).hex()], "pattern": "SOLID" })).unwrap());
|
||||
let (a, b) = viewports_freezing_hidden(&mut app);
|
||||
app.session.execute("properties.set", &serde_json::json!({ "handles": [hatch.hex()], "layer": "Hidden" })).unwrap();
|
||||
let inside = Vec2::new(10.0, 10.0);
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
|
||||
assert_eq!(hatch_at(&app, inside, 1.0), None, "a hatch the viewport does not draw was found");
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
assert_eq!(hatch_at(&app, inside, 1.0), Some(hatch));
|
||||
}
|
||||
|
||||
/// The highlight of the selection inside the active viewport leaves out a selected object on a
|
||||
/// layer frozen in that viewport, also when the layer is frozen while the object is selected,
|
||||
/// and is built again for another viewport although the selection and the drawing are the same.
|
||||
#[test]
|
||||
fn the_viewport_highlight_leaves_out_objects_the_viewport_does_not_draw() {
|
||||
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
|
||||
/// The objects `highlight` outlines for the selection of both lines.
|
||||
fn outlined(app: &CadApp, highlight: &mut VpHighlight, both: [Handle; 2]) -> Vec<Handle> {
|
||||
highlight.update(app.session.state().unwrap(), &both, 10.0);
|
||||
highlight.list.prims.iter().map(|p| p.handle).collect::<std::collections::BTreeSet<_>>().into_iter().collect()
|
||||
}
|
||||
let both = [shown, hidden];
|
||||
let mut highlight = VpHighlight::default();
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), both, "this viewport draws both lines");
|
||||
let revision = app.session.state().unwrap().revision;
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().revision, revision, "entering a viewport must not look like a change of the drawing");
|
||||
assert_eq!(outlined(&app, &mut highlight, both), [shown], "a line the viewport does not draw was outlined");
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), both, "the list built for the other viewport was reused");
|
||||
// Frozen in the active viewport while the line is selected, then thawed again.
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), [shown]);
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(outlined(&app, &mut highlight, both), both);
|
||||
}
|
||||
|
||||
/// Selection cycling's badge beside a resting pointer follows the layers frozen in the active
|
||||
/// viewport: over two overlapping lines it shows while the viewport draws both, goes when the
|
||||
/// layer of one of them is frozen there, and comes back when the layer is thawed.
|
||||
#[test]
|
||||
fn the_cycling_badge_follows_the_frozen_layers_under_a_resting_pointer() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
|
||||
for _ in 0..2 {
|
||||
app.run("line", serde_json::json!({ "points": [[0, 0], [100, 0]] })).unwrap();
|
||||
}
|
||||
let on_top = app.session.doc().unwrap().model.handles()[1];
|
||||
let (_, b) = viewports_freezing_hidden(&mut app);
|
||||
app.session.execute("properties.set", &serde_json::json!({ "handles": [on_top.hex()], "layer": "Hidden" })).unwrap();
|
||||
app.session.execute("setvar", &serde_json::json!({ "name": "SELECTIONCYCLING", "value": 1 })).unwrap();
|
||||
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
/// Whether the badge shows after a frame with the pointer resting on the two lines.
|
||||
fn badge(app: &mut CadApp, ctx: &egui::Context) -> bool {
|
||||
let (at, on_lines) = (Some(Pos2::new(200.0, 150.0)), Some(Vec2::new(50.0, 0.0)));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| update_hover(app, ui, true, at, on_lines, 2.0));
|
||||
output.textures_delta.clear();
|
||||
app.canvas.cycle_badge
|
||||
}
|
||||
assert!(badge(&mut app, &ctx), "this viewport draws both lines: a click would choose between them");
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert!(!badge(&mut app, &ctx), "the badge stayed for a line the viewport no longer draws");
|
||||
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert!(badge(&mut app, &ctx), "thawed in the viewport: two lines are under the cursor again");
|
||||
}
|
||||
|
||||
@@ -271,9 +271,7 @@ pub(super) fn viewport_highlight_tolerance(scale: f64) -> f64 {
|
||||
/// The selected or the hovered objects tessellated in model units for the active MSPACE
|
||||
/// viewport, whose contents can't be looked up by handle in the layout's lists. The selection and
|
||||
/// the hover are kept apart, so moving the cursor over objects doesn't tessellate the selection
|
||||
/// again; each is rebuilt only when the drawing, the objects, the zoom band or the layers frozen
|
||||
/// in the viewport change. An object on one of those layers is not drawn in the viewport, so it
|
||||
/// is not highlighted there either.
|
||||
/// again; each is rebuilt only when the drawing, the objects or the zoom band change.
|
||||
#[derive(Default)]
|
||||
pub(super) struct VpHighlight {
|
||||
key: Option<u64>,
|
||||
@@ -288,14 +286,12 @@ impl VpHighlight {
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
// Entering another viewport changes what is drawn without changing the drawing.
|
||||
let frozen = st.viewport_frozen();
|
||||
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, frozen, super::list::font_generation())
|
||||
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, super::list::font_generation())
|
||||
.hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
if self.key != Some(key) {
|
||||
let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).filter(|e| !st.hidden_in_viewport(e)).map(|e| e.as_ref());
|
||||
let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref());
|
||||
self.list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() });
|
||||
self.key = Some(key);
|
||||
self.list_gen = self.list_gen.wrapping_add(1);
|
||||
|
||||
@@ -489,25 +489,4 @@ mod tests {
|
||||
assert!(app.closing.is_none());
|
||||
assert!(app.session.docs.is_empty());
|
||||
}
|
||||
|
||||
/// CLOSE typed with the Greek keyboard layout active (ΨΛΟΣΕ) is the interface's CLOSE, which
|
||||
/// asks about unsaved changes, not the engine's, which closes at once. A Greek word that is a
|
||||
/// command of its own stays that command.
|
||||
#[test]
|
||||
fn close_typed_with_the_greek_layout_asks_before_it_closes() {
|
||||
let mut app = app_with(&["A", "B"]);
|
||||
dirty(&mut app, 1);
|
||||
app.cmdline("ΨΛΟΣΕ");
|
||||
assert_eq!(app.close_prompt(), Some("B"));
|
||||
assert_eq!(titles(&app), ["A", "B"]);
|
||||
app.answer_close(Choice::Cancel);
|
||||
assert!(app.closing.is_none());
|
||||
|
||||
// The drawing's LISP defines a command of that Greek name: typing it runs that command.
|
||||
app.cmdline("(defun c:ΨΛΟΣΕ () (princ))");
|
||||
app.cmdline("ΨΛΟΣΕ");
|
||||
assert!(app.closing.is_none());
|
||||
assert_eq!(titles(&app), ["A", "B"]);
|
||||
assert!(app.session.docs[1].is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ fn tab(app: &mut CadApp) {
|
||||
{
|
||||
// Both values typed: Tab locks the second one as well.
|
||||
app.cmd.locked_second = Some(app.cmd.buffer.clone());
|
||||
} else if let Some(s) = suggestions_in(&app.session, &app.cmd.buffer).first() {
|
||||
} else if let Some(s) = suggestions(&app.cmd.buffer).first() {
|
||||
app.cmd.buffer = s.id.clone();
|
||||
}
|
||||
}
|
||||
@@ -118,26 +118,12 @@ pub struct Suggestion {
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
/// [`suggestions`] for what `session` can run: a Greek word that is a command of its own there (an
|
||||
/// alias, a command the drawing's LISP defines) stays as typed.
|
||||
fn suggestions_in(session: &cadcraft_engine::Session, prefix: &str) -> Vec<Suggestion> {
|
||||
suggestions(prefix, &|word| session.greek_word_is_a_command(word))
|
||||
}
|
||||
|
||||
/// Matching command names/aliases for AutoComplete. `is_own_command` tells whether a Greek-layout
|
||||
/// word is a command of its own rather than the Latin name its keys type.
|
||||
fn suggestions(prefix: &str, is_own_command: &dyn Fn(&str) -> bool) -> Vec<Suggestion> {
|
||||
/// Matching command names/aliases for AutoComplete.
|
||||
fn suggestions(prefix: &str) -> Vec<Suggestion> {
|
||||
if prefix.is_empty() || prefix.contains(' ') {
|
||||
return Vec::new();
|
||||
}
|
||||
// Typed with the Greek keyboard layout (ΛΙΝ), it completes as the Latin name (LINE), unless the word
|
||||
// as typed already names a command, alias or LISP command: that is what Enter runs.
|
||||
let typed = prefix.to_ascii_lowercase();
|
||||
let p = if cadcraft_engine::cmd::find_command(&cadcraft_engine::cmd::resolve_alias(&typed)).is_some() {
|
||||
typed
|
||||
} else {
|
||||
cadcraft_engine::keyboard::latin_keys(prefix).filter(|_| !is_own_command(prefix)).unwrap_or(typed)
|
||||
};
|
||||
let p = prefix.to_ascii_lowercase();
|
||||
|
||||
let mut out: Vec<Suggestion> = Vec::new();
|
||||
let mut seen_ids: HashSet<&'static str> = HashSet::new();
|
||||
@@ -458,7 +444,7 @@ pub fn submit(app: &mut CadApp) {
|
||||
return;
|
||||
}
|
||||
// A partly typed command name runs the highlighted (first) AutoComplete suggestion.
|
||||
let completion = if app.session.prompting() { None } else { suggestions_in(&app.session, text.trim()).into_iter().next() };
|
||||
let completion = if app.session.prompting() { None } else { suggestions(text.trim()).into_iter().next() };
|
||||
app.cmdline_or(&text, completion.map(|s| s.id.to_ascii_lowercase()).as_deref());
|
||||
}
|
||||
|
||||
@@ -599,7 +585,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
);
|
||||
// AutoComplete list.
|
||||
if !app.session.prompting() {
|
||||
let sug = suggestions_in(&app.session, &app.cmd.buffer);
|
||||
let sug = suggestions(&app.cmd.buffer);
|
||||
if !sug.is_empty() {
|
||||
let rh = 20.0;
|
||||
let lr = Rect::from_min_size(
|
||||
@@ -661,7 +647,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones {
|
||||
let grip = Rect::from_min_max(pos2(bar.left(), top - 3.0), pos2(bar.right(), top + 3.0));
|
||||
let mut all = bar.union(grip);
|
||||
if !app.session.prompting() {
|
||||
let k = suggestions_in(&app.session, &app.cmd.buffer).len();
|
||||
let k = suggestions(&app.cmd.buffer).len();
|
||||
if k > 0 {
|
||||
let above = if n == 0 { 0.0 } else { LINE_H * n as f32 + 6.0 };
|
||||
all = all.union(Rect::from_min_size(pos2(bar.left(), bar.top() - 6.0 - 20.0 * k as f32 - above), vec2(bar.width(), 1.0)));
|
||||
@@ -771,52 +757,6 @@ mod tests {
|
||||
frame(app, ctx, vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }]);
|
||||
}
|
||||
|
||||
/// A Greek-layout prefix completes like its Latin keys; a prefix in another script is matched
|
||||
/// as it is.
|
||||
#[test]
|
||||
fn autocomplete_reads_a_greek_layout_prefix_as_its_latin_keys() {
|
||||
// ΛΙΝ is the keys L-I-N on the Greek layout: it completes like the Latin prefix does.
|
||||
let latin: Vec<String> = suggestions("lin", &|_| false).into_iter().map(|s| s.id).collect();
|
||||
assert!(latin.iter().any(|id| id == "LINE"), "{latin:?}");
|
||||
let greek: Vec<String> = suggestions("ΛΙΝ", &|_| false).into_iter().map(|s| s.id).collect();
|
||||
assert_eq!(greek, latin);
|
||||
// A prefix that is not Greek-layout typing is matched as it is.
|
||||
assert!(suggestions("ЖЖЖ", &|_| false).is_empty());
|
||||
}
|
||||
|
||||
/// A Greek word the user made an alias is offered as that alias's command, which is what Enter
|
||||
/// runs.
|
||||
#[test]
|
||||
fn autocomplete_offers_the_greek_alias_the_user_defined_that_enter_would_run() {
|
||||
// Enter runs a word that is an alias as typed, so AutoComplete must not read it as Latin keys first:
|
||||
// Ξ is the J key (JOIN's alias), but the user made Ξ an alias of QSAVE. The alias store is
|
||||
// process-wide and the alias dialog's tests clear it, so this test takes its turn with them.
|
||||
let _alone = crate::aliasdlg::lock_alias_store();
|
||||
let mut session = Session::empty();
|
||||
session.execute("aliasedit", &serde_json::json!({ "set": { "Ξ": "qsave" } })).unwrap();
|
||||
let first = suggestions("Ξ", &|_| false).into_iter().next().map(|s| s.id);
|
||||
session.execute("aliasedit", &serde_json::json!({ "remove": ["Ξ"] })).unwrap();
|
||||
assert_eq!(first.as_deref(), Some("QSAVE"));
|
||||
}
|
||||
|
||||
/// A Greek word the drawing's LISP defines as a command is not completed to the Latin name of
|
||||
/// its keys.
|
||||
#[test]
|
||||
fn autocomplete_leaves_a_greek_word_the_drawings_lisp_defines_as_typed() {
|
||||
// ΛΙΝΕ is the keys of LINE, but the drawing defines a command of that Greek name: Enter runs that
|
||||
// command, so Tab must not replace the word with LINE.
|
||||
let mut session = Session::new();
|
||||
session.cmdline("(defun c:ΛΙΝΕ () (princ))").unwrap();
|
||||
for word in ["ΛΙΝΕ", "λινε", "λίνε"] {
|
||||
assert!(suggestions_in(&session, word).is_empty(), "{word}: {:?}", suggestions_in(&session, word));
|
||||
}
|
||||
// Another word of the same keys is still read as Latin: Enter would start LINE for it too.
|
||||
assert!(suggestions_in(&session, "ΛΙΝ").iter().any(|s| s.id == "LINE"));
|
||||
// Without the definition ΛΙΝΕ completes as LINE.
|
||||
let plain = Session::new();
|
||||
assert!(suggestions_in(&plain, "ΛΙΝΕ").iter().any(|s| s.id == "LINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_preedit_is_shown_and_its_commit_is_inserted_once() {
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
@@ -950,7 +890,7 @@ mod autocomplete_tests {
|
||||
|
||||
#[test]
|
||||
fn exact_alias_is_first_suggestion_with_alias_display() {
|
||||
let rec = suggestions("rec", &|_| false);
|
||||
let rec = suggestions("rec");
|
||||
assert!(!rec.is_empty());
|
||||
assert_eq!(rec[0].id, "RECTANG");
|
||||
assert_eq!(rec[0].display, "REC (RECTANG)");
|
||||
@@ -960,26 +900,26 @@ mod autocomplete_tests {
|
||||
assert_eq!(rec[1].id, "RECOVER");
|
||||
assert_eq!(rec[1].display, "RECOVER");
|
||||
|
||||
let l = suggestions("l", &|_| false);
|
||||
let l = suggestions("l");
|
||||
assert_eq!(l[0].id, "LINE");
|
||||
assert_eq!(l[0].display, "L (LINE)");
|
||||
|
||||
let c = suggestions("c", &|_| false);
|
||||
let c = suggestions("c");
|
||||
assert_eq!(c[0].id, "CIRCLE");
|
||||
assert_eq!(c[0].display, "C (CIRCLE)");
|
||||
|
||||
let co = suggestions("co", &|_| false);
|
||||
let co = suggestions("co");
|
||||
assert_eq!(co[0].id, "COPY");
|
||||
assert_eq!(co[0].display, "CO (COPY)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_without_alias_shows_command_id() {
|
||||
let rect = suggestions("rect", &|_| false);
|
||||
let rect = suggestions("rect");
|
||||
assert_eq!(rect[0].id, "RECTANG");
|
||||
assert_eq!(rect[0].display, "RECTANG");
|
||||
|
||||
let line = suggestions("line", &|_| false);
|
||||
let line = suggestions("line");
|
||||
assert_eq!(line[0].id, "LINE");
|
||||
assert_eq!(line[0].display, "LINE");
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@ use cadcraft_engine::doc::dynblock::{DynGrip, GripShape};
|
||||
/// Most selected references whose grips are shown.
|
||||
const MAX_REFERENCES: usize = 200;
|
||||
|
||||
/// The dynamic grips of the selection, worked out once per drawing revision, selection and set
|
||||
/// of layers frozen in the active viewport: entering another viewport changes which references
|
||||
/// are shown without changing the drawing.
|
||||
/// The dynamic grips of the selection, worked out once per drawing revision and selection.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Cache {
|
||||
key: (u64, u64, Vec<Handle>, Vec<String>),
|
||||
key: (u64, u64, Vec<Handle>),
|
||||
grips: Arc<Vec<(Handle, DynGrip)>>,
|
||||
}
|
||||
|
||||
@@ -31,12 +29,12 @@ pub struct Cache {
|
||||
pub(crate) fn of_selection(app: &mut CadApp) -> Arc<Vec<(Handle, DynGrip)>> {
|
||||
let sel = app.session.selection();
|
||||
let Ok(st) = app.session.state() else { return Arc::default() };
|
||||
let key = (st.uid, st.revision, sel.clone(), st.viewport_frozen().to_vec());
|
||||
let key = (st.uid, st.revision, sel.clone());
|
||||
if app.canvas.dyn_grips.key != key {
|
||||
let grips: Vec<(Handle, DynGrip)> = sel
|
||||
.iter()
|
||||
.filter(|h| matches!(st.doc.entity(**h).map(|e| &e.kind), Some(cadcraft_engine::doc::EntityKind::Insert(_))))
|
||||
.filter(|h| st.doc.entity(**h).is_some_and(|e| st.shows(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
|
||||
.filter(|h| st.doc.entity(**h).is_some_and(|e| st.doc.is_visible(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
|
||||
.take(MAX_REFERENCES)
|
||||
.flat_map(|h| cadcraft_engine::doc::dynblock::grips(&st.doc, *h).into_iter().map(move |g| (*h, g)))
|
||||
.collect();
|
||||
@@ -197,62 +195,6 @@ mod tests {
|
||||
(s, h)
|
||||
}
|
||||
|
||||
/// An app with the reference of [`door`] on the layer "Hidden", and two viewports on Layout1
|
||||
/// over the model: the first freezes "Hidden", the second freezes nothing. Returns the app,
|
||||
/// the reference and the two viewports, in paper space.
|
||||
fn door_in_two_viewports() -> (CadApp, Handle, String, String) {
|
||||
let (session, h) = door();
|
||||
let mut app = CadApp::new(session, Services::default());
|
||||
app.session.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
app.session.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
|
||||
app.session.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.session.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(app, h, a, b)
|
||||
}
|
||||
|
||||
/// Makes `viewport` the active MSPACE viewport and selects `h` there (entering a viewport
|
||||
/// clears the selection; Undo or another selection source can put the reference back).
|
||||
fn enter_and_select(app: &mut CadApp, viewport: &str, h: Handle) {
|
||||
app.session.execute("mspace", &json!({ "handle": viewport })).unwrap();
|
||||
app.session.set_selection(vec![h]);
|
||||
}
|
||||
|
||||
/// A selected reference shows its dynamic grips through a viewport that draws its layer. Once
|
||||
/// the layer is frozen in that viewport the grips are gone although the reference is still
|
||||
/// selected, and thawing the layer there brings them back.
|
||||
#[test]
|
||||
fn a_reference_the_viewport_does_not_draw_has_no_dynamic_grips() {
|
||||
let (mut app, h, _, b) = door_in_two_viewports();
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "this viewport draws the reference: its two grips");
|
||||
app.session.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [h], "the reference must still be selected");
|
||||
assert!(of_selection(&mut app).is_empty(), "a reference the viewport no longer draws kept its dynamic grips");
|
||||
app.session.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [h]);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "thawed in the viewport: the grips are back");
|
||||
}
|
||||
|
||||
/// Entering another viewport changes which references are drawn without changing the drawing,
|
||||
/// so the cached grips of the same selection must not be reused from one viewport to the next.
|
||||
#[test]
|
||||
fn the_dynamic_grip_cache_follows_the_active_viewport() {
|
||||
let (mut app, h, a, b) = door_in_two_viewports();
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert_eq!(of_selection(&mut app).len(), 2);
|
||||
let revision = app.session.state().unwrap().revision;
|
||||
enter_and_select(&mut app, &a, h);
|
||||
assert_eq!(app.session.state().unwrap().revision, revision, "entering a viewport must not look like a change of the drawing");
|
||||
assert!(of_selection(&mut app).is_empty(), "the grips cached in the other viewport were reused where the reference is not drawn");
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert_eq!(app.session.state().unwrap().revision, revision);
|
||||
assert_eq!(of_selection(&mut app).len(), 2, "the empty result cached in the other viewport was reused where the reference is drawn");
|
||||
}
|
||||
|
||||
/// A hidden reference restored to the selection (Undo, or another selection source) shows
|
||||
/// and takes no dynamic grips, as ordinary grips are not shown for it either.
|
||||
#[test]
|
||||
@@ -267,17 +209,4 @@ mod tests {
|
||||
assert!(!st.doc.is_visible(st.doc.entity(h).unwrap()), "the reference must be hidden");
|
||||
assert!(of_selection(&mut app).is_empty(), "a hidden reference must not offer dynamic grips");
|
||||
}
|
||||
|
||||
/// BEDIT without a block name opens the editor on the one selected reference, and its dialog
|
||||
/// when there is none. A selected reference the viewport does not draw is none to start from.
|
||||
#[test]
|
||||
fn bedit_opens_its_dialog_when_the_viewport_does_not_draw_the_selected_reference() {
|
||||
let (mut app, h, a, b) = door_in_two_viewports();
|
||||
enter_and_select(&mut app, &b, h);
|
||||
assert!(crate::bedit::route(&mut app, "bedit", &serde_json::Value::Null).is_none(), "one reference is selected: no dialog");
|
||||
assert_eq!(app.ui.dialog, None);
|
||||
enter_and_select(&mut app, &a, h);
|
||||
assert!(crate::bedit::route(&mut app, "bedit", &serde_json::Value::Null).is_some(), "the dialog must open");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("bedit"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ pub fn open_create(app: &mut CadApp, gradient: bool) {
|
||||
/// pick a hatch first.
|
||||
pub fn open_edit(app: &mut CadApp) {
|
||||
let Ok(st) = app.session.state() else { return };
|
||||
let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.shown_selection()), create_state(&st.doc, st.uid, false));
|
||||
let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.selection.clone()), create_state(&st.doc, st.uid, false));
|
||||
create.space = st.edit_space();
|
||||
if let Some(e) = edit.as_mut() {
|
||||
e.space = st.edit_space();
|
||||
|
||||
@@ -416,35 +416,3 @@ fn a_gradient_hatch_offers_the_current_pattern_on_the_pattern_tab() {
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit mode opens on the selected hatches. A selected hatch on a layer frozen in the active
|
||||
/// viewport is not drawn there, so it is not one of them and the command asks for a hatch.
|
||||
#[test]
|
||||
fn edit_mode_leaves_out_a_selected_hatch_the_viewport_does_not_draw() {
|
||||
let (mut app, _) = app_with_rect();
|
||||
let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string();
|
||||
let hatch = Handle::parse_hex(&hx).unwrap();
|
||||
app.session.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
app.session.execute("properties.set", &json!({ "handles": [hx], "layer": "Hidden" })).unwrap();
|
||||
app.session.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.session.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
|
||||
// Through the viewport that draws the hatch, edit mode opens on it.
|
||||
app.session.execute("mspace", &json!({ "handle": b })).unwrap();
|
||||
app.session.set_selection(vec![hatch]);
|
||||
open_edit(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("hatch"));
|
||||
assert_eq!(app.hatch_dlg.as_ref().map(|d| d.edit.clone()), Some(vec![hatch]));
|
||||
|
||||
// Through the viewport that freezes its layer there is no hatch to edit yet.
|
||||
(app.ui.dialog, app.hatch_dlg) = (None, None);
|
||||
app.session.execute("mspace", &json!({ "handle": a })).unwrap();
|
||||
app.session.set_selection(vec![hatch]);
|
||||
open_edit(&mut app);
|
||||
assert_eq!(app.ui.dialog, None, "no dialog: the command asks for a hatch first");
|
||||
assert_eq!(app.hatch_dlg.as_ref().map(|d| d.edit.clone()), Some(Vec::new()), "a hatch the viewport does not draw must not be edited");
|
||||
assert!(app.session.running.is_some(), "HATCHPICK must be asking for a hatch");
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ pub enum Preference {
|
||||
Greek,
|
||||
#[serde(rename = "uk")]
|
||||
Ukrainian,
|
||||
#[serde(rename = "pl")]
|
||||
Polish,
|
||||
#[serde(rename = "pt-BR")]
|
||||
BrazilianPortuguese,
|
||||
#[serde(rename = "de")]
|
||||
@@ -54,13 +52,13 @@ pub enum Preference {
|
||||
#[serde(rename = "vi")]
|
||||
Vietnamese,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
pub const ALL: [Self; 21] = [
|
||||
pub const ALL: [Self; 20] = [
|
||||
Self::Auto,
|
||||
Self::English,
|
||||
Self::Greek,
|
||||
Self::Ukrainian,
|
||||
Self::Polish,
|
||||
Self::BrazilianPortuguese,
|
||||
Self::German,
|
||||
Self::Spanish,
|
||||
@@ -111,7 +109,6 @@ impl Preference {
|
||||
Self::English => "en",
|
||||
Self::Greek => "el",
|
||||
Self::Ukrainian => "uk",
|
||||
Self::Polish => "pl",
|
||||
Self::BrazilianPortuguese => "pt-BR",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
@@ -137,7 +134,6 @@ impl Preference {
|
||||
Self::English => "English",
|
||||
Self::Greek => "Ελληνικά",
|
||||
Self::Ukrainian => "Українська",
|
||||
Self::Polish => "Polski",
|
||||
Self::BrazilianPortuguese => "Português (Brasil)",
|
||||
Self::German => "Deutsch",
|
||||
Self::Spanish => "Español",
|
||||
@@ -192,7 +188,6 @@ impl Preference {
|
||||
Self::English => "en",
|
||||
Self::Greek => "el",
|
||||
Self::Ukrainian => "uk",
|
||||
Self::Polish => "pl",
|
||||
Self::BrazilianPortuguese => "pt-BR",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
@@ -225,9 +220,8 @@ pub struct Language {
|
||||
catalog: OnceLock<Catalog<'static>>,
|
||||
}
|
||||
|
||||
pub static LANGUAGES: [Language; 20] = [
|
||||
pub static LANGUAGES: [Language; 19] = [
|
||||
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
Language { code: "pl", name: "Polski", source: include_str!("i18n/pl.tsv"), plural: plural_pl, plural_forms: 3, catalog: OnceLock::new() },
|
||||
// Greek: one for exactly 1; other for zero and all other integer counts.
|
||||
Language {
|
||||
code: "el", name: "Ελληνικά", source: include_str!("i18n/el.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new()
|
||||
@@ -381,17 +375,6 @@ pub fn format(source: &str, values: &[(&str, String)]) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Polish plurals: one (1), few (2–4, 22–24), many (0, 5–21, 25–31).
|
||||
pub fn plural_pl(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
0
|
||||
} else if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukrainian integer plurals: one (1, 21), few (2–4, 22–24), many (0, 5–20, 25…).
|
||||
pub fn plural_uk(n: u64) -> usize {
|
||||
match (n % 10, n % 100) {
|
||||
|
||||
@@ -47,9 +47,6 @@
|
||||
Add: Pick points Προσθήκη: επιλέξτε σημεία
|
||||
Add: Select objects Προσθήκη: επιλέξτε αντικείμενα
|
||||
Additional angles Πρόσθετες γωνίες
|
||||
Add… Προσθήκη…
|
||||
Alias Ψευδώνυμο
|
||||
Alias file: {file} Αρχείο ψευδωνύμων: {file}
|
||||
Align Στοίχιση
|
||||
Align Multileaders Στοίχιση πολλαπλών γραμμών υπόδειξης (Multileaders)
|
||||
Align Text Στοίχιση κειμένου
|
||||
@@ -60,8 +57,6 @@
|
||||
All Scales Όλες οι κλίμακες
|
||||
All Used Layers Όλα τα χρησιμοποιούμενα επίπεδα (Layers)
|
||||
An array that would create more objects than this is refused. Απορρίπτεται η διάταξη επειδή θα δημιουργούσε περισσότερα αντικείμενα από το επιτρεπτό όριο.
|
||||
Analyze Shapes Ανάλυση σχημάτων
|
||||
Analyze Usage Ανάλυση χρήσης
|
||||
Angle Γωνία
|
||||
Angular Γωνιακό
|
||||
Animation Effects Εφέ κίνησης
|
||||
@@ -94,7 +89,6 @@
|
||||
Automatic save file location Θέση αρχείων αυτόματης αποθήκευσης
|
||||
Axis, End Άξονας, τέλος
|
||||
B B
|
||||
Backup copy Αντίγραφο ασφαλείας
|
||||
Base Βάση
|
||||
Baseline Γραμμή βάσης (Baseline)
|
||||
Batch Publish Μαζική δημοσίευση
|
||||
@@ -124,7 +118,6 @@
|
||||
Box Κουτί (Box)
|
||||
Break Διακοπή (Break)
|
||||
Break At Point Διακοπή σε σημείο (Break at Point)
|
||||
Break-line Symbol Σύμβολο γραμμής διακοπής (Break-line Symbol)
|
||||
Bring Above Objects Μεταφορά πάνω από τα αντικείμενα
|
||||
Bring Annotations to Front Μεταφορά σχολιασμών στο προσκήνιο
|
||||
Bring to Front Μεταφορά στο προσκήνιο
|
||||
@@ -152,13 +145,10 @@
|
||||
Center, Start, Length Κέντρο, αρχή, μήκος
|
||||
Centered Κεντραρισμένο
|
||||
Chamfer Λοξότμηση (Chamfer)
|
||||
Change Αλλαγή
|
||||
Change Properties Αλλαγή ιδιοτήτων (Change Properties)
|
||||
Change Space Αλλαγή χώρου σχεδίασης
|
||||
Change to Current Layer Μεταφορά στο τρέχον επίπεδο (Layer)
|
||||
Changed: {n} Αλλαγές: {n}
|
||||
Changes apply at once and are kept for next time. Οι αλλαγές εφαρμόζονται αμέσως και διατηρούνται για την επόμενη φορά.
|
||||
Choose the command the alias starts. Επιλέξτε την εντολή που εκκινεί το ψευδώνυμο.
|
||||
Circle Κύκλος
|
||||
Circumference Περιφέρεια
|
||||
Clear Εκκαθάριση
|
||||
@@ -176,16 +166,6 @@
|
||||
Close Array Editing Κλείσιμο επεξεργασίας διάταξης
|
||||
Close Block Editor Κλείσιμο επεξεργαστή μπλοκ
|
||||
Close Comparison Κλείσιμο σύγκρισης
|
||||
Command Εντολή
|
||||
Convert Text to Mtext Μετατροπή κειμένου σε πολυγραμμικό κείμενο (MText)
|
||||
Create backup copy with each save Δημιουργία αντιγράφου ασφαλείας σε κάθε αποθήκευση
|
||||
Customize Προσαρμογή
|
||||
Define each corner of the selection window with a separate click. Ορίστε κάθε γωνία του παραθύρου επιλογής με ξεχωριστό κλικ.
|
||||
Discard Απόρριψη
|
||||
Don't Save Να μην αποθηκευτεί
|
||||
Edit Command Aliases (PGP)... Επεξεργασία ψευδωνύμων εντολών (PGP)...
|
||||
Edit… Επεξεργασία…
|
||||
Explode Attributes to Text Διάσπαση ιδιοτήτων (Attributes) σε κείμενο
|
||||
Export Comparison Εξαγωγή σύγκρισης
|
||||
Closed Κλειστό
|
||||
Coincident Σύμπτωση (Coincident)
|
||||
@@ -217,29 +197,6 @@
|
||||
Constraint Bars Γραμμές περιορισμών
|
||||
Constraint Settings Ρυθμίσεις περιορισμών
|
||||
Construction Line Βοηθητική ευθεία (Construction Line)
|
||||
Express Tools Εργαλεία Express (Express Tools)
|
||||
Font Γραμματοσειρά
|
||||
Imperial (inches) Αγγλοσαξονικό (ίντσες)
|
||||
Implied Loops Έμμεσοι βρόχοι
|
||||
Interface scale Κλίμακα περιβάλλοντος
|
||||
Island Νησίδα
|
||||
Metric (millimetres) Μετρικό (χιλιοστά)
|
||||
More than {n} aliases. Περισσότερα από {n} ψευδώνυμα.
|
||||
Move/Copy/Rotate Μετακίνηση/Αντιγραφή/Περιστροφή
|
||||
Multiple Insert Πολλαπλή εισαγωγή (Multiple Insert)
|
||||
New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Τα νέα σχέδια και το πρώτο σχέδιο κατά την εκκίνηση χρησιμοποιούν αυτές τις μονάδες, εκτός αν έχει οριστεί προεπιλεγμένο πρότυπο (QNEWTEMPLATE).
|
||||
No alias file loaded. REINIT replaces these aliases with a file's. Δεν έχει φορτωθεί αρχείο ψευδωνύμων. Η εντολή REINIT αντικαθιστά αυτά τα ψευδώνυμα με αυτά ενός αρχείου.
|
||||
Save Layout as Template Αποθήκευση διάταξης ως πρότυπο (Save Layout as Template)
|
||||
Save alias Αποθήκευση ψευδωνύμου
|
||||
Save changes to block "{name}"? Να αποθηκευτούν οι αλλαγές στο μπλοκ «{name}»;
|
||||
Save changes to {name}? Να αποθηκευτούν οι αλλαγές στο {name};
|
||||
Select boundaries to remove Επιλέξτε περιγράμματα για αφαίρεση
|
||||
Select using two clicks only Επιλογή μόνο με δύο κλικ
|
||||
Type an alias name. Πληκτρολογήστε ένα όνομα ψευδωνύμου.
|
||||
Units Μονάδες
|
||||
Units of new drawings Μονάδες νέων σχεδίων
|
||||
Your changes will be lost if you don't save them. Οι αλλαγές σας θα χαθούν αν δεν τις αποθηκεύσετε.
|
||||
built-in ενσωματωμένο
|
||||
contains authoring elements. Open in Block Editor? περιέχει στοιχεία δημιουργίας. Άνοιγμα στον επεξεργαστή μπλοκ;
|
||||
Contents Περιεχόμενα
|
||||
Continue Συνέχεια
|
||||
@@ -1360,9 +1317,6 @@
|
||||
Zoom Realtime Μεγέθυνση σε πραγματικό χρόνο
|
||||
Zoom sensitivity Ευαισθησία μεγέθυνσης
|
||||
Zoom Window Μεγέθυνση περιοχής παραθύρου
|
||||
overrides built-in υπερισχύει του ενσωματωμένου
|
||||
{alias} is already defined; select it and use Edit. Το {alias} είναι ήδη ορισμένο· επιλέξτε το και χρησιμοποιήστε την Επεξεργασία.
|
||||
{command} is an unknown command. Το {command} δεν είναι γνωστή εντολή.
|
||||
{contributors} · {commits} {contributors} · {commits}
|
||||
{kind} (constraint {id}) {kind} (περιορισμός {id})
|
||||
{kind} constraint {name} = {expression} (double-click to edit) {kind} περιορισμός {name} = {expression} (διπλό κλικ για επεξεργασία)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 234 and column 24.
|
File diff suppressed because it is too large
Load Diff
@@ -195,20 +195,12 @@ fn assert_primary_locale_aliases() {
|
||||
for (tag, code) in [("ru", "ru"), ("ru-RU", "ru"), ("ru_RU.UTF-8", "ru"), ("fr", "fr"), ("fr-FR", "fr"), ("fr_CA.UTF-8", "fr")] {
|
||||
assert_eq!(locale_language(tag), Some(code), "{tag}");
|
||||
}
|
||||
|
||||
for tag in ["ua", "ukulele", "xx-XX", "", "garbage", "💙"] {
|
||||
assert_eq!(locale_language(tag), None, "{tag}");
|
||||
}
|
||||
assert_eq!(locale_language("pl-PL"), Some("pl"));
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "pt-BR".into()]), "pl");
|
||||
assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "fr");
|
||||
}
|
||||
|
||||
fn assert_portuguese_and_spanish_aliases() {
|
||||
for tag in ["pt", "pt-BR", "pt_BR.UTF-8", "PT-br", "pt-PT"] {
|
||||
assert_eq!(locale_language(tag), Some("pt-BR"), "{tag}");
|
||||
}
|
||||
|
||||
for tag in ["es", "es-ES", "es_MX.UTF-8", "ES-ar"] {
|
||||
assert_eq!(locale_language(tag), Some("es"), "{tag}");
|
||||
}
|
||||
@@ -236,7 +228,7 @@ fn assert_additional_locale_aliases() {
|
||||
|
||||
fn assert_unrecognized_locale_tags() {
|
||||
// Look-alike codes, three-letter codes and endonyms pick nothing: `se` is Northern Sami.
|
||||
let look_alikes = ["ua", "ukulele", "nl-NL", "", "garbage", "💙", "ptolemy", "cz", "csharp", "csomething", "italic"];
|
||||
let look_alikes = ["ua", "ukulele", "pl-PL", "", "garbage", "💙", "ptolemy", "cz", "csharp", "csomething", "italic"];
|
||||
let others = ["kr", "korean", "kotlin", "se", "swe", "svenska", "hun", "magyar", "tur", "türkçe", "elvish", "kat", "geo", "kab", "kartuli"];
|
||||
for tag in look_alikes.into_iter().chain(others) {
|
||||
assert_eq!(locale_language(tag), None, "{tag}");
|
||||
@@ -244,11 +236,10 @@ fn assert_unrecognized_locale_tags() {
|
||||
}
|
||||
|
||||
fn assert_auto_locale_precedence() {
|
||||
assert_eq!(Preference::Auto.resolve(&["nl-NL".into(), "pt-BR".into()]), "pt-BR");
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "pt-BR".into()]), "pl");
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "pt-BR".into()]), "pt-BR");
|
||||
assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "fr");
|
||||
assert_eq!(Preference::Auto.resolve(&["es-ES".into(), "pt-BR".into()]), "es");
|
||||
let locales = vec!["nl-NL".into(), "uk-UA".into(), "en-US".into()];
|
||||
let locales = vec!["pl-PL".into(), "uk-UA".into(), "en-US".into()];
|
||||
assert_eq!(Preference::Auto.resolve(&locales), "uk");
|
||||
assert_eq!(Preference::English.resolve(&locales), "en");
|
||||
assert_eq!(Preference::Auto.resolve(&["en-US".into(), "uk-UA".into()]), "en");
|
||||
@@ -271,7 +262,7 @@ fn assert_preference_resolution(preference: Preference) {
|
||||
// A chosen language wins over the system's; Auto takes the first system language there is.
|
||||
assert_eq!(preference.resolve(&["uk-UA".into()]), preference.code());
|
||||
if preference != Preference::English {
|
||||
assert_eq!(Preference::Auto.resolve(&["nl-NL".into(), preference.code().into(), "en-US".into()]), preference.code());
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), preference.code().into(), "en-US".into()]), preference.code());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,8 +279,6 @@ fn assert_saved_preference_persistence(preference: Preference) {
|
||||
#[test]
|
||||
fn locale_and_saved_preference_resolution() {
|
||||
assert_primary_locale_aliases();
|
||||
assert_eq!(locale_language("pl-PL"), Some("pl"));
|
||||
assert_eq!(Preference::parse("pl"), Some(Preference::Polish));
|
||||
assert_portuguese_and_spanish_aliases();
|
||||
assert_additional_locale_aliases();
|
||||
assert_unrecognized_locale_tags();
|
||||
@@ -679,11 +668,3 @@ fn vietnamese_takes_one_form_and_needs_a_font_with_its_letters() {
|
||||
assert_eq!(Preference::Vietnamese.name(), "Vietnamese");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_plural_forms() {
|
||||
for (n, expected) in [(0, 2), (1, 0), (2, 1), (4, 1), (5, 2), (12, 2), (14, 2), (21, 2), (22, 1), (24, 1), (25, 2), (101, 2), (102, 1), (112, 2)]
|
||||
{
|
||||
assert_eq!(plural_pl(n), expected, "n={n}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,13 +624,7 @@ impl CadApp {
|
||||
let first = text.split_whitespace().next().unwrap_or("");
|
||||
// `cmd {json}` is a programmatic call: the engine runs it with those parameters.
|
||||
let json_form = text.trim_start().get(first.len()..).is_some_and(|rest| rest.trim_start().starts_with('{'));
|
||||
// A name typed with the Greek keyboard layout active (ΨΛΟΣΕ for CLOSE) is the Latin name's
|
||||
// keys, unless it is a command of its own, as it is when the engine starts it: the
|
||||
// interface's own commands answer to it too (CLOSE asks about unsaved changes).
|
||||
let name = cadcraft_engine::keyboard::latin_keys(first)
|
||||
.filter(|_| !self.session.greek_word_is_a_command(first))
|
||||
.unwrap_or_else(|| first.to_ascii_lowercase());
|
||||
if !first.is_empty() && !json_form && menus::run_ui_command(self, &name, &Value::Null).is_some() {
|
||||
if !first.is_empty() && !json_form && menus::run_ui_command(self, &first.to_ascii_lowercase(), &Value::Null).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,23 +67,6 @@ fn json_file_commands_never_open_pickers() {
|
||||
assert_eq!(picks.get(), 5);
|
||||
}
|
||||
|
||||
/// A command typed with the Greek keyboard layout active is the Latin name's keys for the
|
||||
/// interface's own commands too: ΛΑΥΕΡ opens the Layers dialog and `;ΣΑΩΕ` (the Q key types a
|
||||
/// semicolon) asks where to save a drawing that has no file, as LAYER and QSAVE do.
|
||||
#[test]
|
||||
fn greek_layout_names_open_the_dialogs_and_pickers() {
|
||||
let (mut app, picks) = app();
|
||||
app.cmdline("ΛΑΥΕΡ");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("layers"));
|
||||
app.ui.dialog = None;
|
||||
app.cmdline("λάυερ");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("layers"), "lower case and an accent");
|
||||
app.ui.dialog = None;
|
||||
assert_eq!(picks.get(), 0);
|
||||
app.cmdline(";ΣΑΩΕ");
|
||||
assert_eq!(picks.get(), 1, "the Save picker opens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_form_runs_the_command() {
|
||||
let (mut app, _) = app();
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
//! Inside a layout viewport an object on a layer frozen in that viewport is not drawn: selected,
|
||||
//! it paints no grips there, and the cursor does not highlight it, also when the layer is frozen
|
||||
//! while the pointer rests over the object.
|
||||
|
||||
use cadcraft_engine::doc::Handle;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Session, View};
|
||||
use cadcraft_ui_egui::{CadApp, Services, theme::Tokens};
|
||||
use serde_json::json;
|
||||
|
||||
/// What the tests draw: two model-space lines from (0, 0) to (100, 0), `shown` on layer 0 and,
|
||||
/// drawn after it and so on top of it, `hidden` on the layer "Hidden"; and two viewports on
|
||||
/// Layout1, `a` freezing "Hidden" and `b` freezing nothing.
|
||||
struct Sheet {
|
||||
app: CadApp,
|
||||
shown: Handle,
|
||||
hidden: Handle,
|
||||
/// The viewport that freezes "Hidden".
|
||||
a: String,
|
||||
/// The viewport that freezes nothing.
|
||||
b: String,
|
||||
}
|
||||
|
||||
/// The drawing of [`Sheet`], in paper space with nothing selected.
|
||||
fn sheet() -> Sheet {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.start_tab = false;
|
||||
app.ui.in_window_menu = false;
|
||||
app.integrated_titlebar = true;
|
||||
// A third line gives the model a height for the viewports to fit.
|
||||
for points in [[[0, 0], [100, 0]], [[0, 0], [100, 0]], [[0, 40], [100, 40]]] {
|
||||
app.run("line", json!({ "points": points })).unwrap();
|
||||
}
|
||||
let handles = app.session.doc().unwrap().model.handles();
|
||||
assert_eq!(handles.len(), 3);
|
||||
let (shown, hidden) = (handles[0], handles[1]);
|
||||
app.run("layer.new", json!({ "name": "Hidden" })).unwrap();
|
||||
app.run("properties.set", json!({ "handles": [hidden.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().entity(hidden).unwrap().common.layer, "Hidden");
|
||||
app.run("layout.set", json!({ "name": "Layout1" })).unwrap();
|
||||
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
|
||||
let (a, b) = (viewport(0), viewport(1));
|
||||
app.run("vplayer", json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
// Show the part of the sheet that holds both viewports, with room around them.
|
||||
assert!(app.session.state_mut().unwrap().set_view(View { center: Vec2::new(120.0, 70.0), height: 300.0, twist: 0.0 }));
|
||||
app.session.set_selection(Vec::new());
|
||||
Sheet { app, shown, hidden, a, b }
|
||||
}
|
||||
|
||||
/// Makes `viewport` the active MSPACE viewport. That clears the selection.
|
||||
fn enter(app: &mut CadApp, viewport: &str) {
|
||||
app.run("mspace", json!({ "handle": viewport })).unwrap();
|
||||
}
|
||||
|
||||
/// The grip rectangles (filled with `color`) inside `canvas` among the painted shapes.
|
||||
fn count_grips(shape: &egui::Shape, canvas: egui::Rect, color: egui::Color32) -> usize {
|
||||
match shape {
|
||||
egui::Shape::Rect(rect) => usize::from(rect.fill == color && canvas.contains(rect.rect.center())),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count_grips(shape, canvas, color)).sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one frame of the app with the pointer at `pointer` (none: away from the window) and
|
||||
/// counts the grip rectangles painted on its canvas.
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context, pointer: Option<egui::Pos2>) -> usize {
|
||||
app.logic(ctx);
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
|
||||
events: vec![pointer.map_or(egui::Event::PointerGone, egui::Event::PointerMoved)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = ctx.run_ui(input, |ui| app.ui(ui));
|
||||
output.textures_delta.clear();
|
||||
let canvas = app.canvas.rect.unwrap();
|
||||
let color = Tokens::get().grip;
|
||||
output.shapes.iter().map(|shape| count_grips(&shape.shape, canvas, color)).sum()
|
||||
}
|
||||
|
||||
/// The grip rectangles painted once the frame has settled, with the pointer away.
|
||||
fn painted_grips(app: &mut CadApp, ctx: &egui::Context) -> usize {
|
||||
frame(app, ctx, None);
|
||||
frame(app, ctx, None)
|
||||
}
|
||||
|
||||
/// Whether every grip of `h` is on the canvas, where it would be painted.
|
||||
fn grips_on_canvas(app: &CadApp, h: Handle) -> bool {
|
||||
let xf = app.canvas.xf.unwrap();
|
||||
let state = app.session.state().unwrap();
|
||||
state.doc.entity(h).unwrap().kind.grips().iter().all(|point| xf.rect.contains(xf.to_screen(*point)))
|
||||
}
|
||||
|
||||
/// A selected line paints its grips through a viewport that draws its layer. Once the layer is
|
||||
/// frozen in that viewport the grips are gone although the line is still selected, the grips of
|
||||
/// the other selected line stay, and thawing the layer brings them back. The same holds from one
|
||||
/// viewport to the other.
|
||||
#[test]
|
||||
fn a_selected_object_the_viewport_does_not_draw_paints_no_grips() {
|
||||
let Sheet { mut app, shown, hidden, a, b } = sheet();
|
||||
let ctx = egui::Context::default();
|
||||
// Settle the dock layout.
|
||||
painted_grips(&mut app, &ctx);
|
||||
enter(&mut app, &b);
|
||||
app.session.set_selection(vec![shown]);
|
||||
let one = painted_grips(&mut app, &ctx);
|
||||
assert!(grips_on_canvas(&app, shown) && grips_on_canvas(&app, hidden), "the viewport must show both lines whole");
|
||||
assert!(one >= 2, "the counter must find the grips of one selected line, got {one}");
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(painted_grips(&mut app, &ctx), 2 * one, "this viewport draws both lines");
|
||||
|
||||
// The layer is frozen in the active viewport while its line is selected.
|
||||
app.run("vplayer", json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [shown, hidden], "both lines must still be selected");
|
||||
assert_eq!(painted_grips(&mut app, &ctx), one, "a line the viewport no longer draws kept its grips");
|
||||
app.run("vplayer", json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(app.session.state().unwrap().selection, [shown, hidden]);
|
||||
assert_eq!(painted_grips(&mut app, &ctx), 2 * one, "thawed in the viewport: the grips are back");
|
||||
|
||||
// The same selection through the viewport that freezes the layer, and back.
|
||||
enter(&mut app, &a);
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(painted_grips(&mut app, &ctx), one, "a line the viewport does not draw painted grips");
|
||||
assert!(grips_on_canvas(&app, hidden), "its grips would be on the canvas");
|
||||
enter(&mut app, &b);
|
||||
app.session.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(painted_grips(&mut app, &ctx), 2 * one);
|
||||
}
|
||||
|
||||
/// The cursor highlights the object a click would select: over the two lines, the one the
|
||||
/// viewport draws.
|
||||
#[test]
|
||||
fn the_cursor_highlights_the_object_the_viewport_draws() {
|
||||
let Sheet { mut app, shown, hidden, a, b } = sheet();
|
||||
let ctx = egui::Context::default();
|
||||
painted_grips(&mut app, &ctx);
|
||||
for (viewport, under_cursor) in [(&a, shown), (&b, hidden)] {
|
||||
enter(&mut app, viewport);
|
||||
painted_grips(&mut app, &ctx);
|
||||
let on_lines = app.canvas.xf.unwrap().to_screen(Vec2::new(50.0, 0.0));
|
||||
assert!(app.canvas.rect.unwrap().contains(on_lines), "the viewport must show the lines");
|
||||
frame(&mut app, &ctx, Some(on_lines));
|
||||
frame(&mut app, &ctx, Some(on_lines));
|
||||
assert_eq!(app.canvas.hover, Some(under_cursor));
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer of the line under a resting pointer is frozen in the active viewport, then thawed:
|
||||
/// the highlight goes to the line the viewport still draws and comes back, without the pointer
|
||||
/// moving in between.
|
||||
#[test]
|
||||
fn the_highlight_follows_the_frozen_layers_under_a_resting_pointer() {
|
||||
let Sheet { mut app, shown, hidden, a: _, b } = sheet();
|
||||
let ctx = egui::Context::default();
|
||||
painted_grips(&mut app, &ctx);
|
||||
enter(&mut app, &b);
|
||||
painted_grips(&mut app, &ctx);
|
||||
let on_lines = app.canvas.xf.unwrap().to_screen(Vec2::new(50.0, 0.0));
|
||||
assert!(app.canvas.rect.unwrap().contains(on_lines), "the viewport must show the lines");
|
||||
frame(&mut app, &ctx, Some(on_lines));
|
||||
frame(&mut app, &ctx, Some(on_lines));
|
||||
assert_eq!(app.canvas.hover, Some(hidden), "this viewport draws both lines, the later one on top");
|
||||
|
||||
app.run("vplayer", json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
frame(&mut app, &ctx, Some(on_lines));
|
||||
assert_eq!(app.canvas.hover, Some(shown), "the cursor kept the line the viewport no longer draws");
|
||||
app.run("vplayer", json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
frame(&mut app, &ctx, Some(on_lines));
|
||||
assert_eq!(app.canvas.hover, Some(hidden), "thawed in the viewport: the line on top is under the cursor again");
|
||||
}
|
||||
Reference in New Issue
Block a user