feat(hatch): add visual pattern picker

Closes #566
This commit is contained in:
Hakan Seven 2026-07-29 15:46:53 +03:00
commit 6686141d3b
6 changed files with 448 additions and 51 deletions

View file

@ -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,

View file

@ -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(),
),

View file

@ -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(&current))
.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()
}

View file

@ -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<Message> {
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 {

View file

@ -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<String> {
catalog().iter().map(|e| e.name.clone()).collect()
}
// ── DXF export ────────────────────────────────────────────────────────────
pub fn build_dxf_pattern(entry: &PatternEntry) -> DxfPattern {

View file

@ -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<Message> for HatchPatternPreview {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
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<LwItem> {
[
@ -131,7 +255,12 @@ pub struct PropertiesPanel {
pub layer_combo: combo_box::State<String>,
pub lineweight_combo: combo_box::State<LwItem>,
pub linetype_combo: combo_box::State<LinetypeItem>,
pub hatch_pattern_combo: combo_box::State<String>,
/// 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<String, String>,
/// 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));
}
}