diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 36db792..341e6eb 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -1,6 +1,7 @@ //! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects. mod patfile; +pub use patfile::{PatternChoice, pattern_choices}; pub(super) use patfile::{lines_for, run_patterns}; use cadcraft_color::Color; @@ -10,7 +11,7 @@ use serde_json::{Value, json}; use super::helpers::lwpoly; use super::*; -use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step}; mod assoc; mod edit; @@ -582,7 +583,11 @@ impl Interactive for HatchM { fn prompt(&self, s: &Session) -> Prompt { // The sub-prompts offer the current value: Enter keeps it. match self.asking { - Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()), + Some(HatchAsk::Pattern) => { + return Prompt::new("Enter a pattern name", Accept::TEXT) + .default(self.settings.pattern.clone()) + .picker(Picker::HatchPattern(self.settings.pattern.clone())); + } Some(HatchAsk::Scale) => { return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale)); } @@ -596,8 +601,10 @@ impl Interactive for HatchM { Some(HatchAsk::Origin) => return Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"), None => {} } + // The pattern picker stays offered while boundaries are picked: a choice sets the pattern. + let picker = |p: Prompt| if self.gradient { p } else { p.picker(Picker::HatchPattern(self.settings.pattern.clone())) }; if self.selecting { - return Prompt::new("Select objects", Accept::SELECT).kw(&[ + return picker(Prompt::new("Select objects", Accept::SELECT).kw(&[ "picK internal point", "Undo", "seTtings", @@ -605,9 +612,9 @@ impl Interactive for HatchM { "Angle", "Origin", "sEparate", - ]); + ])); } - Prompt::new("Pick internal point", Accept::POINT).kw(&[ + picker(Prompt::new("Pick internal point", Accept::POINT).kw(&[ "Select objects", "Undo", "seTtings", @@ -616,7 +623,7 @@ impl Interactive for HatchM { "Angle", "Origin", "sEparate", - ]) + ])) } fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(a) = self.asking.take() { @@ -670,6 +677,11 @@ impl Interactive for HatchM { } Ok(Step::Continue) } + // A pattern chosen in the picker while boundaries are picked. + Input::Text(t) if !self.gradient => { + self.answer(s, HatchAsk::Pattern, &t)?; + Ok(Step::Continue) + } // Enter ends boundary picking in either mode and creates the hatch. Input::Enter => self.finish(s), _ => Ok(Step::Continue), @@ -738,6 +750,43 @@ mod tests { assert!(hatch.gradient.is_none()); } + #[test] + fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() { + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + s.start("hatch").unwrap(); + let picker = s.current_prompt().and_then(|p| p.picker); + assert_eq!(picker, Some(Picker::HatchPattern(HatchSettings::from(&s, &Value::Null, false).pattern))); + // A choice made in the picker while boundaries are picked. + s.input(Input::Text("AR-CONC".into())).unwrap(); + assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("AR-CONC".into()))); + // And at the pattern prompt. + s.input(Input::Keyword("Pattern".into())).unwrap(); + assert!(s.current_prompt().unwrap().picker.is_some()); + s.input(Input::Text("BRICK".into())).unwrap(); + s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + s.input(Input::Enter).unwrap(); + assert_eq!(only_hatch(&s).1.pattern, "BRICK"); + // An unknown name keeps the pattern. + s.start("hatch").unwrap(); + s.input(Input::Text("NO-SUCH".into())).unwrap(); + assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("BRICK".into()))); + // GRADIENT offers no pattern picker. + s.cancel(); + s.start("gradient").unwrap(); + assert!(s.current_prompt().unwrap().picker.is_none()); + } + + #[test] + fn pattern_choices_list_the_library_with_its_lines() { + let all = pattern_choices(); + assert_eq!(all.first().map(|c| c.name.as_str()), Some("SOLID")); + assert!(all[0].lines.is_empty()); + let ansi31 = all.iter().find(|c| c.name == "ANSI31").unwrap(); + assert_eq!(ansi31.lines.len(), 1); + assert!(!ansi31.description.is_empty()); + } + #[test] fn repeated_picks_and_handles_in_one_region_do_not_produce_duplicate_loops() { // One internal point diff --git a/crates/engine/src/cmd/hatch/edit.rs b/crates/engine/src/cmd/hatch/edit.rs index a41783f..ce4e0d3 100644 --- a/crates/engine/src/cmd/hatch/edit.rs +++ b/crates/engine/src/cmd/hatch/edit.rs @@ -10,7 +10,7 @@ use super::assoc::{detach, same_shape}; use super::{ORIGIN_KW, OriginMode, closed_loop, island_style}; use crate::cmd::helpers::lwpoly; use crate::cmd::{bad, bool_or, point_param, str_param, targets}; -use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step}; /// Upper bound on hatches edited by one call. const MAX_EDITED: usize = 10_000; @@ -328,7 +328,10 @@ impl Interactive for HatchEditM { }; Prompt::new("Island detection style", Accept::TEXT).kw(&["Ignore", "Outer", "Normal"]).default(cur) } - Ask::Pattern => Prompt::new("Enter a pattern name", Accept::TEXT).default(h.map(|h| h.pattern).unwrap_or_default()), + Ask::Pattern => { + let current = h.map(|h| h.pattern).unwrap_or_default(); + Prompt::new("Enter a pattern name", Accept::TEXT).default(current.clone()).picker(Picker::HatchPattern(current)) + } Ask::Scale => Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", h.map_or(1.0, |h| h.scale))), Ask::Angle => Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER) .default(format!("{:.0}", h.map_or(0.0, |h| h.angle.to_degrees()))), diff --git a/crates/engine/src/cmd/hatch/patfile.rs b/crates/engine/src/cmd/hatch/patfile.rs index a1c8168..7ffe547 100644 --- a/crates/engine/src/cmd/hatch/patfile.rs +++ b/crates/engine/src/cmd/hatch/patfile.rs @@ -44,6 +44,25 @@ pub(crate) fn names() -> Vec { v } +/// A pattern offered for new hatches: a library pattern or one a `.pat` file loaded. +#[derive(Clone, Debug, PartialEq)] +pub struct PatternChoice { + pub name: String, + pub description: String, + /// The line families at unit scale (none for SOLID). + pub lines: Vec, +} + +/// Every pattern offered for new hatches, in the order of [`names`], for a pattern picker. +pub fn pattern_choices() -> Vec { + let mut v: Vec = standard_patterns() + .into_iter() + .map(|p| PatternChoice { name: p.name.to_string(), description: p.description.to_string(), lines: p.lines }) + .collect(); + v.extend(store().iter().map(|p| PatternChoice { name: p.name.clone(), description: p.description.clone(), lines: p.lines.clone() })); + v +} + /// Add the patterns of a `.pat` file's text (a pattern loaded before under the same name is /// replaced). Returns the names added and the file's problems. pub(crate) fn load(text: &str) -> (Vec, Vec) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c5bed2e..3d43e67 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,8 +37,9 @@ use serde_json::Value; pub use cadcraft_doc as doc; pub use cadcraft_geom as geom; pub use cadcraft_render as render; +pub use cmd::hatch::{PatternChoice, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; -pub use prompt::{Accept, Drag, Input, Interactive, Prompt, Step}; +pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step}; #[derive(Debug, thiserror::Error)] pub enum EngineError { diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 180d8f9..e531e3c 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -45,17 +45,34 @@ pub struct Prompt { /// index): Dynamic Input shows its dimensions ([`crate::grip_dims`]). #[serde(skip_serializing_if = "Option::is_none")] pub grip: Option<(Handle, usize)>, + /// A visual chooser the UI can show for this prompt; a choice made in it is entered as + /// [`Input::Text`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub picker: Option, +} + +/// A visual chooser for a prompt ([`Prompt::picker`]). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Picker { + /// A hatch pattern by name (see [`crate::pattern_choices`]); holds the current pattern. + HatchPattern(String), } impl Prompt { pub fn new(message: impl Into, accept: Accept) -> Self { - Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None } + Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None, picker: None } } /// The grip being stretched (the `grip` field). pub fn grip(mut self, g: (Handle, usize)) -> Self { self.grip = Some(g); self } + /// The visual chooser the UI can show (the `picker` field). + pub fn picker(mut self, p: Picker) -> Self { + self.picker = Some(p); + self + } pub fn kw(mut self, k: &[&str]) -> Self { self.keywords = k.iter().map(|s| s.to_string()).collect(); self diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index 9b1ca44..3590d6e 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -349,6 +349,41 @@ pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec], px: f64, view: out } +/// Repeats of a pattern across a swatch ([`pattern_swatch`]). +const SWATCH_REPEATS: f64 = 6.0; +/// Most runs a swatch holds. +const MAX_SWATCH_RUNS: usize = 4000; + +/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the +/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the +/// pattern (each family's line spacing or dash repeat, whichever is longer). Empty for a pattern +/// with no lines (SOLID). +pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec> { + let repeat = + lines.iter().map(|l| l.delta.1.abs().max(l.dashes.iter().map(|d| d.abs()).sum::())).filter(|r| r.is_finite()).fold(0.0, f64::max); + if !side.is_finite() || side <= 0.0 || repeat < 1e-9 { + return Vec::new(); + } + let h = Hatch { + pattern: String::new(), + solid: false, + loops: Vec::new(), + scale: side / (SWATCH_REPEATS * repeat), + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: lines.to_vec(), + }; + let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)]; + let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None); + runs.truncate(MAX_SWATCH_RUNS); + runs +} + /// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin` /// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last. struct Family<'a> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ce75c86..0f35765 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -31,6 +31,7 @@ use pen::Pen; pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, dimension_geometry, dimension_geometry_with, format_linear_value}; pub use fill::triangulate_evenodd; +pub use hatch::pattern_swatch; pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet}; pub use tolerance::{gdt_points, tolerance_in}; diff --git a/crates/render/tests/hatch_fill.rs b/crates/render/tests/hatch_fill.rs index 109efc1..ff61953 100644 --- a/crates/render/tests/hatch_fill.rs +++ b/crates/render/tests/hatch_fill.rs @@ -165,6 +165,23 @@ fn pattern_lines_from_a_file_are_drawn_for_unknown_names() { assert!(((p[1] - p[0]).angle().to_degrees().rem_euclid(180.0) - 45.0).abs() < 1e-6); } +#[test] +fn pattern_swatches_stay_in_their_square_and_show_a_few_repeats() { + for p in cadcraft_doc::library::standard_patterns() { + let runs = cadcraft_render::pattern_swatch(&p.lines, 1.0); + if p.lines.is_empty() { + assert!(runs.is_empty(), "{}", p.name); + continue; + } + assert!(runs.len() >= 3, "{} has {} runs", p.name, runs.len()); + assert!(runs.len() <= 4000, "{} has {} runs", p.name, runs.len()); + for v in runs.iter().flatten() { + assert!((-1e-9..=1.0 + 1e-9).contains(&v.x) && (-1e-9..=1.0 + 1e-9).contains(&v.y), "{} at {v:?}", p.name); + } + } + assert!(cadcraft_render::pattern_swatch(&[], 1.0).is_empty()); +} + #[test] fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() { let l = draw(hatch("BRICK", vec![square(0.0, 2.0)]), "0"); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 36df0c1..29d93ef 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1206,3 +1206,5 @@ menu View Zobrazení menu Draw Kreslení menu Dimension Kótování menu Modify Modifikace + Hatch Pattern Vzor šrafy + Search for pattern Hledat vzor diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f704314..89d0268 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1013,3 +1013,5 @@ Calibrate Kalibrieren Move sideways to pan; push/pull or twist to zoom. Seitlich bewegen zum Verschieben; drücken, ziehen oder drehen zum Zoomen. Left device button: zoom extents. Right device button: calibrate. Linke Gerätetaste: auf Grenzen zoomen. Rechte Gerätetaste: kalibrieren. + Hatch Pattern Schraffurmuster + Search for pattern Muster suchen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c3329b0..b20196b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1232,3 +1232,5 @@ Calibrate Calibrar Move sideways to pan; push/pull or twist to zoom. Mueve el mando lateralmente para encuadrar; empuja, tira o gira para hacer zoom. Left device button: zoom extents. Right device button: calibrate. Botón izquierdo del dispositivo: zoom a la extensión. Botón derecho: calibrar. + Hatch Pattern Patrón de sombreado + Search for pattern Buscar patrón diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1eb4517..82b2258 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -996,3 +996,5 @@ Import... Importer... Export... Exporter... Batch Publish... Publication par lots... + Hatch Pattern Motif de hachure + Search for pattern Rechercher un motif diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 18fc7bf..3d28984 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -995,3 +995,5 @@ Hide Others Ocultar outros Accessibility Acessibilidade Show System Cursor Exibir cursor do sistema + Hatch Pattern Padrão de hachura + Search for pattern Pesquisar padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4636510..90a60b0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -996,3 +996,5 @@ Import... Импорт... Export... Экспорт... Batch Publish... Пакетная публикация... + Hatch Pattern Образец штриховки + Search for pattern Поиск образца diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a340c6d..a187758 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -94,6 +94,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { include_str!("../cmdline.rs"), include_str!("../credits.rs"), include_str!("../palettes/quickcalc.rs"), + include_str!("../palettes/patterns.rs"), include_str!("../dialogs.rs"), include_str!("../layers.rs"), include_str!("../canvas/compare.rs"), diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9ad605c..41aab5c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1230,4 +1230,6 @@ Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження. Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню. + Hatch Pattern Зразок штрихування + Search for pattern Пошук зразка Run Script... Виконати сценарій... diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a019404..34ecc5e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -498,6 +498,7 @@ impl CadApp { } }); dialogs::show(self, ui.ctx()); + palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); autosave::show(self, ui.ctx()); autosave::gpu_notice(self, ui.ctx()); diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 9fa6838..e06dc0b 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -9,6 +9,8 @@ use crate::CadApp; use crate::icons::{self, Icon}; use crate::theme::Tokens; +/// The hatch pattern picker. +pub mod patterns; /// The QuickCalc palette. pub mod quickcalc; @@ -1014,11 +1016,9 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { EntityKind::Hatch(hh) => { prop_row(ui, "Pattern", |ui| { ui.menu_button(hh.pattern.clone(), |ui| { - for pat in cadcraft_doc::library::standard_patterns() { - if ui.button(pat.name).clicked() { - set = Some(json!({ "handles": ids, "pattern": pat.name })); - ui.close(); - } + if let Some(name) = patterns::menu(ui, &hh.pattern) { + set = Some(json!({ "handles": ids, "pattern": name })); + ui.close(); } }); }); diff --git a/crates/ui-egui/src/palettes/patterns.rs b/crates/ui-egui/src/palettes/patterns.rs new file mode 100644 index 0000000..6d628dc --- /dev/null +++ b/crates/ui-egui/src/palettes/patterns.rs @@ -0,0 +1,151 @@ +//! The hatch pattern picker: a swatch of every pattern offered for new hatches, beside HATCH's +//! and HATCHEDIT's prompts and in the Properties palette, so a pattern is chosen by its look +//! instead of by typing its name. + +use std::cell::RefCell; +use std::sync::Arc; + +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, PatternChoice, Picker, pattern_choices}; +use egui::{Align2, Rect, Sense, Stroke, pos2, vec2}; + +use crate::CadApp; +use crate::theme::Tokens; + +/// A swatch's side, in points. +const CELL: f32 = 56.0; +/// Room under a swatch for the pattern's name. +const LABEL_H: f32 = 16.0; +/// Space between swatches. +const GAP: f32 = 6.0; +/// Swatches per row in the Properties palette's menu. +const MENU_COLUMNS: f32 = 6.0; + +/// A pattern as the picker draws it: its runs in the unit square (y up), or a solid fill. +struct Swatch { + name: String, + description: String, + runs: Vec>, + solid: bool, +} + +/// The patterns' names and line family counts, and their swatches. +type Swatches = (Vec<(String, usize)>, Arc>); + +thread_local! { + static SWATCHES: RefCell = RefCell::new((Vec::new(), Arc::new(Vec::new()))); +} + +/// The swatches of the patterns offered now, made again only when the list changes (a `.pat` +/// file loaded more patterns). +fn swatches() -> Arc> { + let choices = pattern_choices(); + let key: Vec<(String, usize)> = choices.iter().map(|c| (c.name.clone(), c.lines.len())).collect(); + SWATCHES.with(|cell| { + let mut cache = cell.borrow_mut(); + if cache.0 != key { + *cache = (key, Arc::new(choices.into_iter().map(swatch).collect())); + } + cache.1.clone() + }) +} + +fn swatch(c: PatternChoice) -> Swatch { + let solid = c.lines.is_empty(); + Swatch { runs: cadcraft_engine::render::pattern_swatch(&c.lines, 1.0), name: c.name, description: c.description, solid } +} + +/// Draw one swatch in `r` (the square and the name under it). +fn paint(p: &egui::Painter, r: Rect, s: &Swatch, selected: bool, hovered: bool) { + let t = Tokens::get(); + let square = Rect::from_min_size(r.min, vec2(CELL, CELL)); + p.rect_filled(square, 3.0, t.field); + let inner = square.shrink(3.0); + if s.solid { + p.rect_filled(inner, 1.0, t.text); + } else { + let at = |v: &Vec2| pos2(inner.min.x + v.x as f32 * inner.width(), inner.max.y - v.y as f32 * inner.height()); + let stroke = Stroke::new(1.0, t.text); + for run in &s.runs { + match run.as_slice() { + [] => {} + [dot] => { + p.circle_filled(at(dot), 0.7, t.text); + } + pts => { + p.add(egui::Shape::line(pts.iter().map(at).collect(), stroke)); + } + } + } + } + let border = match (selected, hovered) { + (true, _) => Stroke::new(2.0, t.accent), + (false, true) => Stroke::new(1.0, t.accent), + _ => Stroke::new(1.0, t.border), + }; + p.rect_stroke(square, 3.0, border, egui::StrokeKind::Inside); + let label = Rect::from_min_max(pos2(r.min.x, square.max.y), r.max); + let color = if selected { t.accent } else { t.text_dim }; + p.with_clip_rect(label).text(label.center_top() + vec2(0.0, 2.0), Align2::CENTER_TOP, &s.name, crate::theme::small(), color); +} + +/// The swatches whose name or description holds `filter`, `current` marked; returns the +/// pattern clicked. +pub fn grid(ui: &mut egui::Ui, current: &str, filter: &str) -> Option { + let filter = filter.trim().to_lowercase(); + let mut chosen = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(GAP, GAP); + let shown = swatches(); + for s in + shown.iter().filter(|s| filter.is_empty() || s.name.to_lowercase().contains(&filter) || s.description.to_lowercase().contains(&filter)) + { + let (r, resp) = ui.allocate_exact_size(vec2(CELL, CELL + LABEL_H), Sense::click()); + if ui.is_rect_visible(r) { + paint(ui.painter(), r, s, s.name.eq_ignore_ascii_case(current), resp.hovered()); + } + let tip = if s.description.is_empty() { s.name.clone() } else { format!("{}\n{}", s.name, s.description) }; + if resp.on_hover_text(tip).clicked() { + chosen = Some(s.name.clone()); + } + } + }); + chosen +} + +/// The Properties palette's pattern menu: the swatch grid, scrolling; returns the pattern +/// clicked. +pub fn menu(ui: &mut egui::Ui, current: &str) -> Option { + ui.set_width(MENU_COLUMNS * (CELL + GAP)); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| grid(ui, current, "")).inner +} + +/// The picker window while the prompt offers one (HATCH's boundary and pattern prompts, +/// HATCHEDIT's pattern prompt): a click enters the pattern's name at the prompt. +pub fn prompt_picker(app: &mut CadApp, ctx: &egui::Context) { + let Some(Picker::HatchPattern(current)) = app.session.current_prompt().and_then(|p| p.picker) else { return }; + let filter_id = egui::Id::new("hatch_pattern_filter"); + let mut filter: String = ctx.data_mut(|d| d.get_temp(filter_id)).unwrap_or_default(); + let mut chosen = None; + let area = ctx.content_rect(); + egui::Window::new(crate::tl!("Hatch Pattern")) + .id(egui::Id::new("Hatch Pattern")) + .collapsible(true) + .resizable(true) + .default_size(vec2(5.0 * (CELL + GAP) + 12.0, 5.0 * (CELL + LABEL_H + GAP) + 40.0)) + .default_pos(pos2(area.max.x - 5.0 * (CELL + GAP) - 330.0, area.min.y + 120.0)) + .show(ctx, |ui| { + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)); + ui.add_space(4.0); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + chosen = grid(ui, ¤t, &filter); + }); + }); + ctx.data_mut(|d| d.insert_temp(filter_id, filter)); + if let Some(name) = chosen { + app.cmd.buffer.clear(); + if let Err(e) = app.session.input(Input::Text(name)) { + app.session.echo(e.to_string()); + } + } +}