From 6686141d3b621d09c78fdc68f11687da34ec20f4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 29 Jul 2026 15:46:53 +0300 Subject: [PATCH] feat(hatch): add visual pattern picker Closes #566 --- src/app/mod.rs | 10 + src/app/properties.rs | 12 -- src/app/update/mod.rs | 112 ++++++++++- src/app/view/mod.rs | 37 ++++ src/scene/model/hatch_patterns.rs | 4 - src/ui/properties.rs | 324 +++++++++++++++++++++++++++--- 6 files changed, 448 insertions(+), 51 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index ae20c4c8..3f41a001 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1926,6 +1926,16 @@ pub enum Message { PropVertexStep(i8), /// User selected a hatch pattern from the pattern pick_list in Properties. PropHatchPatternChanged(String), + /// Open or close the visual hatch-pattern picker. + PropHatchPatternPickerToggle(String), + /// Filter the visual hatch-pattern picker. + PropHatchPatternSearchChanged(String), + /// Move the visual pattern grid focus to a hovered card. + PropHatchPatternFocus(usize), + /// Move keyboard focus by one card or one two-column row. + PropHatchPatternNavigate(i8), + /// Select the keyboard-focused visual pattern card. + PropHatchPatternConfirm, /// User selected a generic choice field in the Properties panel. PropGeomChoiceChanged { field: &'static str, diff --git a/src/app/properties.rs b/src/app/properties.rs index 1f9c50bf..e67fa989 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -342,9 +342,6 @@ impl OpenCADStudio { lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), - hatch_pattern_combo: iced::widget::combo_box::State::new( - crate::scene::model::hatch_patterns::names(), - ), linetype_items, ..Default::default() } @@ -1464,9 +1461,6 @@ impl OpenCADStudio { title, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), - hatch_pattern_combo: iced::widget::combo_box::State::new( - crate::scene::model::hatch_patterns::names(), - ), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), @@ -1485,9 +1479,6 @@ impl OpenCADStudio { lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), - hatch_pattern_combo: iced::widget::combo_box::State::new( - crate::scene::model::hatch_patterns::names(), - ), linetype_items, ..Default::default() }, @@ -1528,9 +1519,6 @@ impl OpenCADStudio { selected_group: active_group, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), - hatch_pattern_combo: iced::widget::combo_box::State::new( - crate::scene::model::hatch_patterns::names(), - ), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b38fb60c..333bfb30 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1213,6 +1213,12 @@ impl OpenCADStudio { } Message::CommandHistoryPrev => { + if self.tabs[self.active_tab] + .properties + .hatch_pattern_picker_open + { + return self.update(Message::PropHatchPatternNavigate(-2)); + } // Grip popup wins first — arrow keys walk its items. if let Some(popup) = self.grip_popup.as_mut() { if !popup.items.is_empty() { @@ -1235,6 +1241,12 @@ impl OpenCADStudio { } Message::CommandHistoryNext => { + if self.tabs[self.active_tab] + .properties + .hatch_pattern_picker_open + { + return self.update(Message::PropHatchPatternNavigate(2)); + } if let Some(popup) = self.grip_popup.as_mut() { if !popup.items.is_empty() { popup.selected = (popup.selected + 1) % popup.items.len(); @@ -1349,7 +1361,17 @@ impl OpenCADStudio { } Message::CommandFinalize => self.on_command_finalize(), - Message::CommandEscape => self.on_command_escape(), + Message::CommandEscape => { + let panel = &mut self.tabs[self.active_tab].properties; + if panel.hatch_pattern_picker_open { + panel.hatch_pattern_picker_open = false; + panel.hatch_pattern_search.clear(); + panel.hatch_pattern_focus = 0; + Task::none() + } else { + self.on_command_escape() + } + } Message::Command(cmd) => { // Close viewport context menu if open. @@ -2764,6 +2786,12 @@ impl OpenCADStudio { Task::none() } Message::MTextCaretMove(d) => { + if self.tabs[self.active_tab] + .properties + .hatch_pattern_picker_open + { + return self.update(Message::PropHatchPatternNavigate(d as i8)); + } self.mtext_caret_move(d); Task::none() } @@ -3088,7 +3116,79 @@ impl OpenCADStudio { Task::none() } - Message::PropHatchPatternChanged(name) => self.on_prop_hatch_pattern_changed(name), + Message::PropHatchPatternChanged(name) => { + let panel = &mut self.tabs[self.active_tab].properties; + panel.hatch_pattern_picker_open = false; + panel.hatch_pattern_search.clear(); + self.on_prop_hatch_pattern_changed(name) + } + + Message::PropHatchPatternPickerToggle(current) => { + let panel = &mut self.tabs[self.active_tab].properties; + panel.hatch_pattern_picker_open = !panel.hatch_pattern_picker_open; + if panel.hatch_pattern_picker_open { + panel.color_picker_open = false; + panel.color_palette_open = false; + panel.open_color_field = None; + panel.edit_choice_open = false; + panel.hatch_pattern_focus = + crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(¤t)) + .unwrap_or(0); + return iced::widget::operation::focus(iced::widget::Id::new( + "hatch-pattern-search", + )); + } else { + panel.hatch_pattern_search.clear(); + panel.hatch_pattern_focus = 0; + } + Task::none() + } + + Message::PropHatchPatternSearchChanged(search) => { + let panel = &mut self.tabs[self.active_tab].properties; + panel.hatch_pattern_search = search; + panel.hatch_pattern_focus = 0; + Task::none() + } + + Message::PropHatchPatternFocus(index) => { + let panel = &mut self.tabs[self.active_tab].properties; + let len = + crate::ui::properties::filtered_hatch_patterns(&panel.hatch_pattern_search) + .len(); + if index < len { + panel.hatch_pattern_focus = index; + } + Task::none() + } + + Message::PropHatchPatternNavigate(delta) => { + let panel = &mut self.tabs[self.active_tab].properties; + let len = + crate::ui::properties::filtered_hatch_patterns(&panel.hatch_pattern_search) + .len(); + if len > 0 { + panel.hatch_pattern_focus = + (panel.hatch_pattern_focus as isize + delta as isize) + .rem_euclid(len as isize) as usize; + } + Task::none() + } + + Message::PropHatchPatternConfirm => { + let panel = &self.tabs[self.active_tab].properties; + let name = + crate::ui::properties::filtered_hatch_patterns(&panel.hatch_pattern_search) + .get(panel.hatch_pattern_focus) + .map(|entry| entry.name.clone()); + if let Some(name) = name { + self.update(Message::PropHatchPatternChanged(name)) + } else { + Task::none() + } + } Message::PropBoolToggle(field) => { let i = self.active_tab; @@ -3236,6 +3336,10 @@ impl OpenCADStudio { Message::PropEditChoiceToggle => { let panel = &mut self.tabs[self.active_tab].properties; panel.edit_choice_open = !panel.edit_choice_open; + if panel.edit_choice_open { + panel.hatch_pattern_picker_open = false; + panel.hatch_pattern_search.clear(); + } Task::none() } @@ -3255,6 +3359,8 @@ impl OpenCADStudio { !self.tabs[i].properties.color_picker_open; if self.tabs[i].properties.color_picker_open { self.tabs[i].properties.color_palette_open = false; + self.tabs[i].properties.hatch_pattern_picker_open = false; + self.tabs[i].properties.hatch_pattern_search.clear(); } Task::none() } @@ -3382,6 +3488,8 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].properties.color_picker_open = false; self.tabs[i].properties.color_palette_open = false; + self.tabs[i].properties.hatch_pattern_picker_open = false; + self.tabs[i].properties.hatch_pattern_search.clear(); Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 085f294e..e605ebae 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -45,6 +45,34 @@ fn viewcube_has_room(bar_w: f32, tile_w: f32) -> bool { bar_w + VIEWCUBE_GAP + VIEWCUBE_REGION_PX + VIEWCUBE_PAD <= tile_w } +fn hatch_pattern_key_event( + event: iced::Event, + status: iced::event::Status, + _window: window::Id, +) -> Option { + if !matches!(status, iced::event::Status::Captured) { + return None; + } + let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) = event else { + return None; + }; + match key { + keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => { + Some(Message::PropHatchPatternNavigate(-1)) + } + keyboard::Key::Named(keyboard::key::Named::ArrowRight) => { + Some(Message::PropHatchPatternNavigate(1)) + } + keyboard::Key::Named(keyboard::key::Named::ArrowUp) => { + Some(Message::PropHatchPatternNavigate(-2)) + } + keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { + Some(Message::PropHatchPatternNavigate(2)) + } + _ => None, + } +} + /// `ViewportRenderMode` enum carries the raw DXF integers, not a label, /// so wrap it locally with a friendly name renderer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1658,6 +1686,14 @@ impl OpenCADStudio { let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal); #[cfg(target_arch = "wasm32")] let single_instance = Subscription::none(); + let hatch_pattern_keys = if self.tabs[self.active_tab] + .properties + .hatch_pattern_picker_open + { + event::listen_with(hatch_pattern_key_event) + } else { + Subscription::none() + }; iced::Subscription::batch([ frames, history_tick, @@ -1668,6 +1704,7 @@ impl OpenCADStudio { web_fonts, autosave, single_instance, + hatch_pattern_keys, event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { diff --git a/src/scene/model/hatch_patterns.rs b/src/scene/model/hatch_patterns.rs index 3e9061b0..8122d625 100644 --- a/src/scene/model/hatch_patterns.rs +++ b/src/scene/model/hatch_patterns.rs @@ -34,10 +34,6 @@ pub fn find(name: &str) -> Option<&'static PatternEntry> { catalog().iter().find(|e| e.name.eq_ignore_ascii_case(name)) } -pub fn names() -> Vec { - catalog().iter().map(|e| e.name.clone()).collect() -} - // ── DXF export ──────────────────────────────────────────────────────────── pub fn build_dxf_pattern(entry: &PatternEntry) -> DxfPattern { diff --git a/src/ui/properties.rs b/src/ui/properties.rs index dadfdfda..feb5b3ae 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -8,18 +8,24 @@ //! • Geometry → text_input per coordinate / dimension field use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; -use std::fmt; +use std::{fmt, sync::Arc}; use crate::ui::ROW_H; use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::Handle; -use iced::widget::{button, column, combo_box, container, row, scrollable, text, text_input}; -use iced::{Background, Border, Color, Element, Length, Padding, Theme}; +use iced::widget::{ + button, canvas, column, combo_box, container, mouse_area, row, scrollable, text, text_input, +}; +use iced::{ + mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme, +}; // ── Row-height-derived constants ───────────────────────────────────────── const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; // fills combo to ROW_H const SWATCH_SZ: f32 = ROW_H * 0.54; // ≈14 px color swatch +const PATTERN_CARD_W: f32 = 158.0; +const PATTERN_PREVIEW_H: f32 = 58.0; use crate::app::Message; use crate::scene::model::object::{PropSection, PropValue}; @@ -72,6 +78,124 @@ impl fmt::Display for SelectionGroup { } } +#[derive(Clone)] +struct HatchPatternPreview { + pattern: crate::scene::model::hatch_model::HatchPattern, +} + +impl canvas::Program for HatchPatternPreview { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + use crate::scene::model::hatch_model::{HatchModel, HatchPattern}; + + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let palette = theme.extended_palette(); + let pad = 4.0; + let sample = canvas::Path::rectangle( + Point::new(pad, pad), + Size::new( + (bounds.width - pad * 2.0).max(0.0), + (bounds.height - pad * 2.0).max(0.0), + ), + ); + frame.fill(&sample, palette.background.base.color); + + match &self.pattern { + HatchPattern::Solid => { + frame.fill(&sample, palette.background.base.text.scale_alpha(0.72)); + } + HatchPattern::Gradient { .. } => { + frame.fill(&sample, palette.primary.weak.color); + } + HatchPattern::Pattern(_) => { + let model = HatchModel { + world_origin: [0.0, 0.0], + boundary: Arc::new(vec![ + [pad, pad], + [bounds.width - pad, pad], + [bounds.width - pad, bounds.height - pad], + [pad, bounds.height - pad], + ]), + boundary_wcs: None, + pattern: self.pattern.clone(), + name: String::new(), + color: [1.0; 4], + angle_offset: 0.0, + scale: hatch_preview_scale(&self.pattern), + draw_depth: 0.0, + }; + let stroke = canvas::Stroke::default() + .with_color(palette.background.base.text) + .with_width(1.0); + for segment in model.pattern_segments() { + frame.stroke( + &canvas::Path::line( + Point::new(segment[0][0] as f32, bounds.height - segment[0][1] as f32), + Point::new(segment[1][0] as f32, bounds.height - segment[1][1] as f32), + ), + stroke.clone(), + ); + } + } + } + + frame.stroke( + &sample, + canvas::Stroke::default() + .with_color(palette.background.neutral.color) + .with_width(1.0), + ); + vec![frame.into_geometry()] + } +} + +fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) -> f32 { + use crate::scene::model::hatch_model::HatchPattern; + + let HatchPattern::Pattern(families) = pattern else { + return 1.0; + }; + let spacing = families + .iter() + .filter_map(|family| { + let spacing = family.dy.abs(); + (spacing > 1.0e-4).then_some(spacing) + }) + .fold(f32::INFINITY, f32::min); + if spacing.is_finite() { + (8.0 / spacing).clamp(0.01, 100.0) + } else { + 1.0 + } +} + +fn hatch_pattern_matches( + entry: &crate::scene::model::hatch_patterns::PatternEntry, + search: &str, +) -> bool { + let query = search.trim(); + query.is_empty() + || entry.name.to_lowercase().contains(&query.to_lowercase()) + || entry.description.to_lowercase().contains(&query.to_lowercase()) +} + +pub(crate) fn filtered_hatch_patterns( + search: &str, +) -> Vec<&'static crate::scene::model::hatch_patterns::PatternEntry> { + crate::scene::model::hatch_patterns::catalog() + .iter() + .filter(|entry| hatch_pattern_matches(entry, search)) + .collect() +} + /// All standard CAD lineweight options for the combobox. pub fn lw_options() -> Vec { [ @@ -131,7 +255,12 @@ pub struct PropertiesPanel { pub layer_combo: combo_box::State, pub lineweight_combo: combo_box::State, pub linetype_combo: combo_box::State, - pub hatch_pattern_combo: combo_box::State, + /// Whether the visual hatch-pattern picker is open. + pub hatch_pattern_picker_open: bool, + /// Case-insensitive filter for the visual hatch-pattern picker. + pub hatch_pattern_search: String, + /// Keyboard/hover focus inside the filtered visual pattern grid. + pub hatch_pattern_focus: usize, /// In-progress text edits keyed by `field` name. pub edit_buf: HashMap, /// Entity handles this panel was built for. `refresh_properties` compares @@ -177,7 +306,9 @@ impl Default for PropertiesPanel { layer_combo: combo_box::State::new(vec![]), lineweight_combo: combo_box::State::new(lw_options()), linetype_combo: combo_box::State::new(vec![]), - hatch_pattern_combo: combo_box::State::new(crate::scene::model::hatch_patterns::names()), + hatch_pattern_picker_open: false, + hatch_pattern_search: String::new(), + hatch_pattern_focus: 0, edit_buf: HashMap::default(), source_handles: vec![], color_picker_open: false, @@ -865,38 +996,142 @@ impl PropertiesPanel { label: &'a str, current: &'a str, ) -> Element<'a, Message> { - let selected = if current == VARIES_LABEL { - None - } else { - Some(current.to_string()) - }; - // iced hides `selected` while the combo has focus and shows its - // placeholder instead. Use the real pattern name for a single hatch; - // a fixed VARIES placeholder made a correct ANSI31 value look mixed as - // soon as the dropdown opened (#476). - let placeholder = if current == VARIES_LABEL { - VARIES_LABEL - } else { - current - }; - let combo = combo_box( - &self.hatch_pattern_combo, - placeholder, - selected.as_ref(), - Message::PropHatchPatternChanged, + let head = button( + row![ + text(crate::ui::text_util::elide(current, 16)) + .size(FONT_SZ) + .width(Length::Fill), + if self.hatch_pattern_picker_open { + crate::ui::icons::themed_arrow_up(FONT_SZ) + } else { + crate::ui::icons::themed_arrow_down(FONT_SZ) + }, + ] + .align_y(iced::Center), ) - .size(FONT_SZ) - .padding(Padding { - top: COMBO_PAD_V, - bottom: COMBO_PAD_V, - left: 6.0, - right: 6.0, + .on_press(Message::PropHatchPatternPickerToggle(current.to_string())) + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + button::Style { + background: Some(Background::Color(if hovered { + palette.background.weak.color + } else { + palette.background.base.color + })), + text_color: palette.background.base.text, + border: Border { + color: if self.hatch_pattern_picker_open { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } }) - .input_style(combo_input_style) - .on_open(Message::PropColorPickerClose) + .padding([COMBO_PAD_V, 6.0]) .width(Length::Fill); - prop_row_widget(label, combo.into()) + if !self.hatch_pattern_picker_open { + return prop_row_widget(label, head.into()); + } + + let search = text_input("Search patterns…", &self.hatch_pattern_search) + .id(iced::widget::Id::new("hatch-pattern-search")) + .on_input(Message::PropHatchPatternSearchChanged) + .on_submit(Message::PropHatchPatternConfirm) + .size(FONT_SZ) + .padding([5, 7]) + .width(Length::Fill); + + let mut grid = column![].spacing(6); + let visible = filtered_hatch_patterns(&self.hatch_pattern_search); + for (row_index, pair) in visible.chunks(2).enumerate() { + let mut cards = row![].spacing(6); + for (column_index, entry) in pair.iter().enumerate() { + let index = row_index * 2 + column_index; + let selected = current.eq_ignore_ascii_case(&entry.name); + let focused = self.hatch_pattern_focus == index; + let name = entry.name.clone(); + let preview = canvas(HatchPatternPreview { + pattern: entry.gpu.clone(), + }) + .width(Length::Fill) + .height(PATTERN_PREVIEW_H); + let card = button( + column![ + preview, + container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ)) + .width(Length::Fill) + .align_x(iced::Center), + ] + .spacing(3), + ) + .on_press(Message::PropHatchPatternChanged(name)) + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + let hovered = + matches!(status, button::Status::Hovered | button::Status::Pressed); + let pair = if selected { + palette.primary.weak + } else if hovered || focused { + palette.background.strong + } else { + palette.background.weak + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: if selected || focused { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: if selected || focused { 2.0 } else { 1.0 }, + radius: 4.0.into(), + }, + ..Default::default() + } + }) + .padding(5) + .width(PATTERN_CARD_W); + cards = cards.push( + mouse_area(card).on_enter(Message::PropHatchPatternFocus(index)), + ); + } + grid = grid.push(cards); + } + + let results: Element<'_, Message> = if visible.is_empty() { + container( + text("No matching patterns") + .size(FONT_SZ) + .style(hint_text_style), + ) + .padding(12) + .width(Length::Fill) + .center_x(Length::Fill) + .into() + } else { + scrollable(grid) + .height(Length::Fixed(300.0)) + .width(Length::Fill) + .into() + }; + let popup = container(column![search, results].spacing(7)) + .style(container::bordered_box) + .padding(8) + .width(348) + .max_height(360.0); + + prop_row_widget( + label, + crate::ui::color_select::floating_below(head.into(), popup.into()), + ) } } @@ -1491,3 +1726,26 @@ fn hint_text_style(theme: &Theme) -> iced::widget::text::Style { color: Some(theme.extended_palette().background.base.text.scale_alpha(0.48)), } } + +#[cfg(test)] +mod tests { + use super::{hatch_pattern_matches, hatch_preview_scale}; + + #[test] + fn hatch_picker_filters_names_and_descriptions() { + let ansi31 = crate::scene::model::hatch_patterns::find("ANSI31").unwrap(); + + assert!(hatch_pattern_matches(ansi31, "ansi")); + assert!(hatch_pattern_matches(ansi31, &ansi31.description)); + assert!(!hatch_pattern_matches(ansi31, "definitely-not-a-pattern")); + } + + #[test] + fn hatch_preview_scale_is_finite_and_visible() { + let ansi31 = crate::scene::model::hatch_patterns::find("ANSI31").unwrap(); + let scale = hatch_preview_scale(&ansi31.gpu); + + assert!(scale.is_finite()); + assert!((0.01..=100.0).contains(&scale)); + } +}