refactor: group ui/ style+popup dirs, rename cmd_result driver

- Group the flat src/ui/ dir: move the 9 style editors into ui/style/
  (dimstyle, mleaderstyle, mlstyle, plotstyle, textstyle, tablestyle,
  point_style, style_list, style_manager) and the 6 popups into
  ui/popup/ (cycle, isolate, scale, selection_filter, snap, units).
  Call sites repointed to crate::ui::style::/crate::ui::popup::; no
  compat re-exports. ui/ root drops from 36 to 21 files.
- Rename app/cmd_result.rs -> app/command_driver.rs: the file is the
  StepInput->feed_command driver, not the CmdResult type (which lives
  in command.rs). Name now matches contents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-26 23:34:10 +03:00
commit 9cd0479047
24 changed files with 40 additions and 38 deletions

View file

@ -0,0 +1,91 @@
//! Selection-cycling list box — shown at the cursor when a click lands on
//! two or more overlapping objects. Each row names a candidate; clicking it
//! adds that object to the current selection. Clicking outside dismisses it.
use iced::widget::{button, column, container, mouse_area, opaque, row, text, Space};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
use crate::app::Message;
/// Full-canvas overlay: the list box anchored at `anchor` (canvas
/// coordinates) plus a transparent click-catcher that cancels.
pub fn cycle_popup_overlay(
anchor: iced::Point,
items: Vec<(acadrust::Handle, String)>,
) -> Element<'static, Message> {
let rows: Vec<Element<'static, Message>> = items
.into_iter()
.map(|(handle, label)| item_row(handle, label))
.collect();
let panel = container(column(rows))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(150.0));
let positioned = column![
Space::new().height(Length::Fixed(anchor.y.max(0.0))),
row![
Space::new().width(Length::Fixed(anchor.x.max(0.0))),
opaque(panel),
],
]
.width(Fill)
.height(Fill);
mouse_area(positioned).on_press(Message::CycleCancel).into()
}
fn item_row(handle: acadrust::Handle, label: String) -> Element<'static, Message> {
let content = text(label).size(11).color(LABEL).align_y(iced::Center);
let btn = button(content)
.on_press(Message::CycleSelect(handle))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 10]);
// Highlight the underlying object while the cursor is over this row.
mouse_area(btn)
.on_enter(Message::CycleHover(Some(handle)))
.on_exit(Message::CycleHoverExit(handle))
.into()
}
// ── Colours ───────────────────────────────────────────────────────────────
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL_BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
};
const ROW_HOVER: Color = Color {
r: 0.22,
g: 0.45,
b: 0.62,
a: 1.0,
};
const LABEL: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};

View file

@ -0,0 +1,117 @@
//! Isolate action menu — Isolate / Hide / End Isolation. Opened from the
//! ISO status pill, rendered as a floating overlay above the status bar
//! (same pattern as the scale picker). The same actions are also offered
//! in the viewport right-click menu.
use iced::widget::{button, column, container, mouse_area, row, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
/// Full-screen overlay: transparent click-catcher + action list pinned
/// bottom-right, above the status bar.
///
/// - `has_selection`: enables Isolate / Hide (they act on the selection).
/// - `isolation_active`: enables End Isolation (something is hidden).
pub fn isolate_popup_overlay(has_selection: bool, isolation_active: bool) -> Element<'static, Message> {
let rows = column![
action_row(
"Isolate Objects",
has_selection,
Message::Command("ISOLATEOBJECTS".to_string()),
),
action_row(
"Hide Objects",
has_selection,
Message::Command("HIDEOBJECTS".to_string()),
),
action_row(
"End Isolation",
isolation_active,
Message::Command("UNISOLATEOBJECTS".to_string()),
),
];
let panel = container(rows)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(160.0));
let positioned = container(panel)
.align_right(Fill)
.align_bottom(Fill)
.padding(Padding {
bottom: 27.0,
right: 4.0,
top: 0.0,
left: 0.0,
})
.width(Fill)
.height(Fill);
mouse_area(positioned)
.on_press(Message::CloseIsolatePopup)
.into()
}
fn action_row(label: &'static str, enabled: bool, msg: Message) -> Element<'static, Message> {
let lbl = text(label)
.size(11)
.color(if enabled { LABEL_ON } else { LABEL_OFF });
let content = row![lbl].align_y(iced::Center);
let mut btn = button(content)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match (enabled, status) {
(true, button::Status::Hovered) => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 12]);
if enabled {
btn = btn.on_press(msg);
}
btn.into()
}
// ── Colours ───────────────────────────────────────────────────────────────
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL_BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
};
const ROW_HOVER: Color = Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
};
const LABEL_ON: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
const LABEL_OFF: Color = Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
};

6
src/ui/popup/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod cycle_popup;
pub mod isolate_popup;
pub mod scale_popup;
pub mod selection_filter_popup;
pub mod snap_popup;
pub mod units_popup;

132
src/ui/popup/scale_popup.rs Normal file
View file

@ -0,0 +1,132 @@
//! Scale picker dropdown — annotation scale (model space) or viewport scale (paper space).
//!
//! Rendered as a floating overlay above the status bar, same pattern as snap_popup.
use iced::widget::{button, column, container, mouse_area, row, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
/// Full-screen overlay: transparent click-catcher + scale list panel pinned bottom-right.
///
/// - `is_model`: true = model space (dispatches SetAnnotationScale), false = paper space (SetViewportScale).
/// - `current_anno_scale`: current annotation_scale from Scene (used to highlight active row in model space).
/// - `viewport_scale`: current effective vp scale, view_height-first (used to highlight in paper space).
/// - `file_scales`: scale list read from the drawing (`ACAD_SCALELIST`). Only
/// scales actually stored in the file are shown; the picker never injects
/// scales of its own.
pub fn scale_popup_overlay(
is_model: bool,
current_anno_scale: f32,
viewport_scale: Option<f64>,
file_scales: Vec<(String, f32, f64)>,
) -> Element<'static, Message> {
let rows: Vec<Element<'static, Message>> = file_scales
.into_iter()
.map(|(label, anno_scale, vp_scale)| {
let active = if is_model {
(current_anno_scale - anno_scale).abs() < 0.001 * current_anno_scale.max(0.001)
} else {
viewport_scale
.map(|vs| (vs - vp_scale).abs() < 0.001 * vp_scale.max(0.001))
.unwrap_or(false)
};
let msg = if is_model {
Message::SetAnnotationScale(anno_scale)
} else {
Message::SetViewportScale(vp_scale)
};
scale_row(label, active, msg)
})
.collect();
let panel = container(column(rows))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(120.0));
let positioned = container(panel)
.align_right(Fill)
.align_bottom(Fill)
.padding(Padding {
bottom: 27.0,
right: 4.0,
top: 0.0,
left: 0.0,
})
.width(Fill)
.height(Fill);
mouse_area(positioned)
.on_press(Message::CloseScalePopup)
.into()
}
fn scale_row(label: String, active: bool, msg: Message) -> Element<'static, Message> {
let check = crate::ui::icons::check_cell(active, CHECK_COLOR);
let lbl = text(label)
.size(11)
.color(if active { LABEL_ON } else { LABEL_OFF });
let content = row![check, lbl].spacing(6).align_y(iced::Center);
button(content)
.on_press(msg)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 10])
.into()
}
// ── Colours ───────────────────────────────────────────────────────────────
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL_BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
};
const ROW_HOVER: Color = Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
};
const CHECK_COLOR: Color = Color {
r: 0.35,
g: 0.75,
b: 1.00,
a: 1.0,
};
const LABEL_ON: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
const LABEL_OFF: Color = Color {
r: 0.65,
g: 0.65,
b: 0.65,
a: 1.0,
};

View file

@ -0,0 +1,128 @@
//! Selection-filter type picker — choose which entity types are selectable.
//! A checked row means that type can be picked; unchecking it excludes the
//! type from interactive selection. Opened from the FILTER status pill.
use rustc_hash::FxHashSet as HashSet;
use iced::widget::{button, column, container, mouse_area, row, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
/// Full-screen overlay: transparent click-catcher + type list pinned
/// bottom-right, above the status bar.
///
/// - `types`: entity-type names present in the current layout.
/// - `excluded`: types currently filtered out (unchecked).
pub fn selection_filter_popup_overlay(
types: Vec<String>,
excluded: &HashSet<String>,
) -> Element<'static, Message> {
let rows: Vec<Element<'static, Message>> = if types.is_empty() {
vec![empty_row()]
} else {
types
.into_iter()
.map(|name| {
let included = !excluded.contains(&name);
type_row(name, included)
})
.collect()
};
let panel = container(column(rows))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(160.0));
let positioned = container(panel)
.align_right(Fill)
.align_bottom(Fill)
.padding(Padding {
bottom: 27.0,
right: 4.0,
top: 0.0,
left: 0.0,
})
.width(Fill)
.height(Fill);
mouse_area(positioned)
.on_press(Message::CloseSelectionFilterPopup)
.into()
}
fn type_row(name: String, included: bool) -> Element<'static, Message> {
let check = crate::ui::icons::check_cell(included, CHECK_COLOR);
let lbl = text(name.clone())
.size(11)
.color(if included { LABEL_ON } else { LABEL_OFF });
let content = row![check, lbl].spacing(6).align_y(iced::Center);
button(content)
.on_press(Message::ToggleSelectionFilterType(name))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 10])
.into()
}
fn empty_row() -> Element<'static, Message> {
container(text("No objects").size(11).color(LABEL_OFF))
.padding([4, 10])
.into()
}
// ── Colours ───────────────────────────────────────────────────────────────
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL_BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
};
const ROW_HOVER: Color = Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
};
const CHECK_COLOR: Color = Color {
r: 0.35,
g: 0.75,
b: 1.00,
a: 1.0,
};
const LABEL_ON: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
const LABEL_OFF: Color = Color {
r: 0.6,
g: 0.6,
b: 0.6,
a: 1.0,
};

228
src/ui/popup/snap_popup.rs Normal file
View file

@ -0,0 +1,228 @@
//! OpenCADStudio-style OSNAP dropdown popup panel.
//!
//! Rendered as a floating overlay above the status bar. The popup is only
//! inserted into the view stack when `snap_popup_open` is true.
use iced::widget::{button, column, container, mouse_area, row, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES};
/// Returns a full-screen overlay element:
/// - a transparent click-catcher that closes the popup on outside click
/// - the popup panel pinned to the bottom-right (above the status bar)
pub fn snap_popup_overlay<'a>(snapper: &'a Snapper, right_offset: f32) -> Element<'a, Message> {
// ── Panel content ─────────────────────────────────────────────────────
let all_on = snapper.all_on();
let none_on = snapper.none_on();
// "Select All / Clear All" header row
let header = row![
header_btn("Select All", Message::SnapSelectAll, !all_on),
header_btn("Clear All", Message::SnapClearAll, !none_on),
]
.spacing(1)
.padding([4u16, 8]);
// Divider
let divider = container(iced::widget::Space::new().height(1))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(DIVIDER)),
..Default::default()
})
.width(Fill)
.padding([0, 4]);
// Snap mode rows
let mut rows: Vec<Element<'_, Message>> = Vec::new();
for &(snap_type, _glyph, label) in ALL_SNAP_MODES {
rows.push(snap_row(snap_type, label, snapper.is_on(snap_type)));
}
// "Object Snap Settings…" footer
let footer_divider = container(iced::widget::Space::new().height(1))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(DIVIDER)),
..Default::default()
})
.width(Fill)
.padding([0, 4]);
let footer = button(text("Object Snap Settings…").size(11).color(Color {
r: 0.75,
g: 0.75,
b: 0.75,
a: 1.0,
}))
.on_press(Message::Command("DSETTINGS".into()))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([5, 12]);
let panel_content = column![header, divider, column(rows), footer_divider, footer,];
let panel = container(panel_content)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(210.0));
// ── Position: bottom-right, above the status bar (26 px) ─────────────
let positioned = container(panel)
.align_right(Fill)
.align_bottom(Fill)
.padding(Padding {
bottom: 27.0,
right: right_offset,
top: 0.0,
left: 0.0,
})
.width(Fill)
.height(Fill);
// ── Click-catcher: closes popup on any outside click ──────────────────
mouse_area(positioned)
.on_press(Message::CloseSnapPopup)
.into()
}
// ── Individual snap row ───────────────────────────────────────────────────
fn snap_row<'a>(snap_type: SnapType, label: &'a str, active: bool) -> Element<'a, Message> {
let checkmark = crate::ui::icons::check_cell(active, CHECK_COLOR);
// SVG marker (not a Unicode glyph) so the symbols render on the web build,
// whose bundled font lacks them and showed tofu boxes. (#138)
let icon_el = container(crate::ui::icons::tinted::<Message>(
crate::ui::icons::osnap(snap_type),
13.0,
ICON_COLOR,
))
.width(Length::Fixed(16.0))
.align_x(iced::Center);
let label_el = text(label)
.size(11)
.color(if active { LABEL_ON } else { LABEL_OFF });
let content = row![checkmark, icon_el, label_el]
.spacing(4)
.align_y(iced::Center);
button(content)
.on_press(Message::ToggleSnap(snap_type))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([3, 8])
.into()
}
fn header_btn(label: &str, msg: Message, enabled: bool) -> Element<'_, Message> {
let b = button(text(label).size(10).color(if enabled {
Color {
r: 0.70,
g: 0.70,
b: 0.70,
a: 1.0,
}
} else {
Color {
r: 0.38,
g: 0.38,
b: 0.38,
a: 1.0,
}
}));
let b = if enabled { b.on_press(msg) } else { b };
b.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => BTN_BG,
})),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 2.0.into(),
},
..Default::default()
})
.padding([3, 8])
.into()
}
// ── Colours ───────────────────────────────────────────────────────────────
const PANEL_BG: Color = Color {
r: 0.16,
g: 0.16,
b: 0.16,
a: 0.98,
};
const PANEL_BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
};
const DIVIDER: Color = Color {
r: 0.28,
g: 0.28,
b: 0.28,
a: 1.0,
};
const ROW_HOVER: Color = Color {
r: 0.24,
g: 0.24,
b: 0.24,
a: 1.0,
};
const BTN_BG: Color = Color {
r: 0.20,
g: 0.20,
b: 0.20,
a: 1.0,
};
const CHECK_COLOR: Color = Color {
r: 0.20,
g: 0.75,
b: 0.35,
a: 1.0,
}; // green ✓
const ICON_COLOR: Color = Color {
r: 0.25,
g: 0.75,
b: 0.45,
a: 1.0,
}; // green icon
const LABEL_ON: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
const LABEL_OFF: Color = Color {
r: 0.52,
g: 0.52,
b: 0.52,
a: 1.0,
};

135
src/ui/popup/units_popup.rs Normal file
View file

@ -0,0 +1,135 @@
//! Drawing-units picker — sets the INSUNITS header value. Rendered as a
//! floating overlay above the status bar, same pattern as the scale picker.
use iced::widget::{button, column, container, mouse_area, row, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
/// Units offered in the picker: (INSUNITS code, menu label).
const UNITS: &[(i16, &str)] = &[
(0, "Unitless"),
(4, "Millimeters"),
(5, "Centimeters"),
(6, "Meters"),
(7, "Kilometers"),
(1, "Inches"),
(2, "Feet"),
(3, "Miles"),
(10, "Yards"),
];
/// Short label shown on the status-bar pill for an INSUNITS code.
pub fn unit_short(code: i16) -> &'static str {
match code {
1 => "in",
2 => "ft",
3 => "mi",
4 => "mm",
5 => "cm",
6 => "m",
7 => "km",
10 => "yd",
0 => "Unitless",
_ => "Unit",
}
}
/// Full-screen overlay: transparent click-catcher + units list pinned
/// bottom-right, above the status bar.
pub fn units_popup_overlay(current: i16) -> Element<'static, Message> {
let rows: Vec<Element<'static, Message>> = UNITS
.iter()
.map(|&(code, label)| unit_row(label, code == current, Message::SetDrawingUnits(code)))
.collect();
let panel = container(column(rows))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(140.0));
let positioned = container(panel)
.align_right(Fill)
.align_bottom(Fill)
.padding(Padding {
bottom: 27.0,
right: 4.0,
top: 0.0,
left: 0.0,
})
.width(Fill)
.height(Fill);
mouse_area(positioned)
.on_press(Message::CloseUnitsPopup)
.into()
}
fn unit_row(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> {
let check = crate::ui::icons::check_cell(active, CHECK_COLOR);
let lbl = text(label)
.size(11)
.color(if active { LABEL_ON } else { LABEL_OFF });
let content = row![check, lbl].spacing(6).align_y(iced::Center);
button(content)
.on_press(msg)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 10])
.into()
}
// ── Colours ───────────────────────────────────────────────────────────────
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL_BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
};
const ROW_HOVER: Color = Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
};
const CHECK_COLOR: Color = Color {
r: 0.35,
g: 0.75,
b: 1.00,
a: 1.0,
};
const LABEL_ON: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
const LABEL_OFF: Color = Color {
r: 0.65,
g: 0.65,
b: 0.65,
a: 1.0,
};