Merge main into codex/graphic-attributes-cleanup

Conflicts were additions on both sides, kept together: the Count palette
and Sheet Set toggles next to the Graphic Attributes toggle in the ribbon,
main's new locale groups and catalog entries next to graphic-attributes,
and module declarations. The palette code follows main's crate renames
(acadrust -> codec, cadkernel -> kernel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu
This commit is contained in:
Claude committed 2026-10-07 20:11:05 +00:00
commit 7a03e1c43c
625 files changed
+104397 -28053

No files matched your search

+9 -6
View File
@@ -6,7 +6,7 @@
use crate::app::Message;
use crate::ui::properties::acad_color_display;
use crate::ui::ROW_H;
use acadrust::types::Color as AcadColor;
use codec::types::Color as AcadColor;
use iced::widget::{button, column, container, row, text};
use iced::{Background, Border, Color, Element, Length, Theme};
use crate::t;
@@ -37,8 +37,11 @@ pub fn color_to_aci_string(c: AcadColor) -> String {
/// Convert an Iced colour chosen by `iced_aw::ColorPicker` into a DWG true
/// colour. ACI-only destinations map it to their closest indexed colour later.
pub fn iced_to_acad_color(color: Color) -> AcadColor {
let [r, g, b, _] = color.into_rgba8();
AcadColor::Rgb { r, g, b }
// Truncate the way the picker labels its channels, so the stored colour
// is the one the dialog showed; rounding drifted dragged values by one
// (#1621).
let byte = |c: f32| (c * 255.0) as u8;
AcadColor::Rgb { r: byte(color.r), g: byte(color.g), b: byte(color.b) }
}
/// Return the closest CAD Color Index for an RGB colour.
@@ -47,7 +50,7 @@ pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 {
let mut best_distance = u32::MAX;
for index in 1..=255 {
let Some((ar, ag, ab)) = acadrust::types::aci_table::aci_to_rgb(index) else {
let Some((ar, ag, ab)) = codec::types::aci_table::aci_to_rgb(index) else {
continue;
};
let dr = i32::from(r) - i32::from(ar);
@@ -83,7 +86,7 @@ pub fn color_display_name(c: AcadColor) -> String {
match c {
AcadColor::Index(i) => {
let (r, g, b) =
acadrust::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
codec::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
format!("{r},{g},{b}")
}
AcadColor::Rgb { r, g, b } => format!("{r},{g},{b}"),
@@ -464,7 +467,7 @@ pub fn index_color_page<'a>(
let selected_text = match current {
AcadColor::Index(i) => {
let (r, g, b) =
acadrust::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
codec::types::aci_table::aci_to_rgb(i).unwrap_or((128, 128, 128));
format!("ACI {i} RGB {r}, {g}, {b}")
}
AcadColor::Rgb { r, g, b } => {
+174 -37
View File
@@ -25,7 +25,7 @@ pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
/// Default for `COMMANDLINEFADETIME` (ms); the live value lives on
/// [`CommandLine::fade_ms`] so it can be changed via SETVAR.
pub const DEFAULT_COMMANDLINE_FADE_MS: u32 = 3000;
/// COMMANDLINEFADETIME bounds in milliseconds: 0 skips the overlay entirely,
/// COMMANDLINEFADETIME bounds in milliseconds: 0 means never fade,
/// 60000 keeps lines for a full minute.
pub const COMMANDLINE_FADE_MIN_MS: u32 = 0;
pub const COMMANDLINE_FADE_MAX_MS: u32 = 60000;
@@ -164,8 +164,9 @@ pub struct CommandLine {
pub history_open: bool,
/// Persisted height of the full-history editor in logical pixels.
pub history_height: f32,
/// Index of the currently-highlighted autocomplete suggestion, or
/// `None` before keyboard navigation begins. Reset when input changes.
/// Index of the currently-highlighted autocomplete suggestion. `None`
/// means the first match is pre-selected (highlighted) before keyboard
/// navigation begins. Reset when input changes.
pub autocomplete_cursor: Option<usize>,
/// Command names contributed by loaded plugins, refreshed whenever the
/// enabled-plugin set changes. Merged into autocomplete alongside the
@@ -189,8 +190,8 @@ pub struct CommandLine {
/// are displayed above the command window (0–50, default 3).
cliprompt_lines: u8,
/// COMMANDLINEFADETIME: how long overlay history lines stay visible,
/// in milliseconds (0–60000, default 3000). 0 skips drawing transient
/// lines entirely; the pinned step prompt still shows.
/// in milliseconds (0–60000, default 3000). 0 means history never fades;
/// the pinned step prompt still shows.
fade_ms: u32,
}
@@ -499,8 +500,8 @@ impl CommandLine {
self.cliprompt_lines = n.min(50);
}
/// COMMANDLINEFADETIME mirror (ms, 0–60000). `0` skips transient overlay
/// lines entirely; the pinned step prompt still shows.
/// COMMANDLINEFADETIME mirror (ms, 0–60000). `0` means overlay history
/// lines never fade; the pinned step prompt still shows.
pub fn set_commandline_fade_ms(&mut self, ms: u32) {
self.fade_ms = ms.min(COMMANDLINE_FADE_MAX_MS);
}
@@ -514,7 +515,7 @@ impl CommandLine {
}
fn entry_visible(&self, e: &HistoryEntry) -> bool {
e.pinned || (self.fade_ms > 0 && e.created_at.elapsed().as_secs_f32() < self.fade_secs())
e.pinned || self.fade_ms == 0 || e.created_at.elapsed().as_secs_f32() < self.fade_secs()
}
/// Height of the overlaid prompt lines currently shown above the input
@@ -546,10 +547,8 @@ impl CommandLine {
visible.len().min(self.cliprompt_lines as usize)
}
/// `true` while at least one history entry is still within the
/// visible window — the host app uses this to drive a low-frequency
/// tick subscription so the overlay re-renders and fades the entry
/// once it expires.
/// `true` while at least one history entry is currently visible in the
/// overlay area (subject to CLIPROMPTLINES).
pub fn has_visible_history(&self) -> bool {
if self.cliprompt_lines == 0 {
return false;
@@ -557,6 +556,19 @@ impl CommandLine {
self.history.iter().any(|e| self.entry_visible(e))
}
/// `true` while at least one visible history entry is waiting to fade out.
/// Used by the host app to drive an animation frame subscription only when
/// an entry actually needs to be removed after its visible window expires.
/// When `fade_ms == 0` (never fade) or entries are pinned, no tick is needed.
pub fn has_expiring_history(&self) -> bool {
if self.cliprompt_lines == 0 || self.fade_ms == 0 {
return false;
}
self.history
.iter()
.any(|e| !e.pinned && e.created_at.elapsed().as_secs_f32() < self.fade_secs())
}
pub fn toggle_history(&mut self) {
self.history_open = !self.history_open;
}
@@ -617,11 +629,20 @@ impl CommandLine {
true
}
/// The command name explicitly highlighted in the autocomplete popup.
/// The command name highlighted in the autocomplete popup. Before any
/// arrow-key navigation the first match is pre-selected (the popup renders
/// `autocomplete_cursor.unwrap_or(0)` as highlighted), so Enter runs that
/// entry — never a different command resolved through another path.
pub fn selected_suggestion(&self) -> Option<String> {
let matches = self.autocomplete_matches();
self.autocomplete_cursor
.and_then(|index| matches.get(index).cloned())
if matches.is_empty() {
return None;
}
let index = self
.autocomplete_cursor
.unwrap_or(0)
.min(matches.len() - 1);
matches.get(index).cloned()
}
/// Autocomplete suggestions for the current input — see
@@ -643,9 +664,10 @@ impl CommandLine {
control_enabled: bool,
control_busy: bool,
pick_pending: bool,
graph_open: bool,
) -> Element<'a, Message> {
// Only the most recent entries pushed within COMMANDLINEFADETIME
// show on the overlay (0 skips transient lines). The dropdown button
// show on the overlay (0 means never fade). The dropdown button
// keeps the full backlog reachable when the user actually wants it.
let mut visible: Vec<&HistoryEntry> =
self.history.iter().filter(|e| self.entry_visible(e)).collect();
@@ -779,12 +801,18 @@ impl CommandLine {
.on_input(Message::CommandInput)
.on_submit(Message::CommandSubmit);
}
let input = input.size(11).padding(Padding {
top: 4.0,
right: 30.0,
bottom: 4.0,
left: 6.0,
});
let input = input
.size(11)
.padding(Padding {
top: 4.0,
right: 30.0,
bottom: 4.0,
left: 6.0,
})
.style(|theme: &Theme, status| text_input::Style {
value: text_color(theme),
..text_input::default(theme, status)
});
// Autocomplete suggestions panel, shown above the input row
// when the user has typed a prefix that matches at least one
// command. Each row is a button — clicking it dispatches the
@@ -798,7 +826,16 @@ impl CommandLine {
let mut col = column![].spacing(0).width(Length::Fill);
for (idx, cmd) in matches.iter().enumerate() {
let is_selected = idx == cursor;
let row = button(text(cmd.clone()).size(11))
// Every row keeps the icon's width so names line up.
let icon: Element<'_, Message> =
match crate::modules::registry::command_icon(cmd) {
Some(bytes) => crate::ui::icons::semantic(bytes, 14.0),
None => Space::new().width(14.0).into(),
};
let label = row![icon, text(cmd.clone()).size(11)]
.spacing(6)
.align_y(iced::Center);
let row = button(label)
.on_press(Message::CommandSuggestionPick(cmd.clone()))
.width(Length::Fill)
.padding([2, 8])
@@ -876,7 +913,21 @@ impl CommandLine {
bottom: 0.0,
left: 0.0,
});
let input_row = row![prompt, literal_btn, input_with_history, mcp_btn]
let graph_btn = button(crate::ui::icons::themed(crate::ui::icons::NODE_GRAPH, 13.0))
.on_press(Message::Graph(crate::ui::node_graph::GraphMsg::Toggle))
.style(move |theme: &Theme, status| {
if graph_open {
button::primary(theme, status)
} else {
button::subtle(theme, status)
}
})
.padding([2, 6]);
let graph_tip = container(text(t!("Node graph")).size(11))
.padding([3, 6])
.style(container::bordered_box);
let graph_btn = tooltip(graph_btn, graph_tip, tooltip::Position::Top).gap(4);
let input_row = row![prompt, literal_btn, input_with_history, graph_btn, mcp_btn]
.spacing(4)
.align_y(iced::Center);
@@ -940,8 +991,13 @@ impl CommandLine {
.on_press(Message::CommandHistoryClear)
.style(header_btn_style)
.padding([2, 6]);
// Collapses the console back to the single input line (#1094).
let collapse_btn = button(crate::ui::icons::themed_arrow_down(11.0))
.on_press(Message::CommandHistoryToggle)
.style(header_btn_style)
.padding([2, 6]);
let header = container(
row![Space::new().width(Length::Fill), copy_btn, clear_btn]
row![Space::new().width(Length::Fill), copy_btn, clear_btn, collapse_btn]
.spacing(6)
.align_y(iced::Center),
)
@@ -1109,18 +1165,43 @@ fn header_btn_style(theme: &Theme, status: button::Status) -> button::Style {
}
}
/// The user's command-line text colour, packed `0x01RRGGBB`; 0 keeps the
/// theme's. Global because the history highlighter takes a plain `fn`.
static TEXT_COLOR: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
pub fn set_text_color(rgb: Option<[u8; 3]>) {
let packed = rgb.map_or(0, |[r, g, b]| 1 << 24 | u32::from_be_bytes([0, r, g, b]));
TEXT_COLOR.store(packed, std::sync::atomic::Ordering::Relaxed);
}
fn user_text_color() -> Option<Color> {
match TEXT_COLOR.load(std::sync::atomic::Ordering::Relaxed) {
0 => None,
packed => {
let [_, r, g, b] = packed.to_be_bytes();
Some(Color::from_rgb8(r, g, b))
}
}
}
fn text_color(theme: &Theme) -> Color {
user_text_color().unwrap_or(theme.palette().background.base.text)
}
fn history_color(theme: &Theme, kind: &EntryKind) -> Color {
let palette = theme.palette();
match kind {
EntryKind::Command => palette.background.base.text,
EntryKind::Output => palette.background.base.text.scale_alpha(0.72),
EntryKind::Command => text_color(theme),
EntryKind::Output => text_color(theme).scale_alpha(0.72),
EntryKind::Error => palette.danger.base.color,
EntryKind::Info => accessible_accent_threshold(
palette.primary.base.color,
palette.background.base.color,
palette.background.base.text,
4.5,
),
EntryKind::Info => user_text_color().unwrap_or_else(|| {
accessible_accent_threshold(
palette.primary.base.color,
palette.background.base.color,
palette.background.base.text,
4.5,
)
}),
}
}
@@ -1221,6 +1302,59 @@ mod tests {
assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}");
}
#[test]
fn preselected_top_suggestion_is_returned_without_navigation() {
// The popup highlights the first match before any arrow-key navigation
// (`unwrap_or(0)` in `view`); Enter must run that same entry. Typing
// `LT` with no alias table highlights `LTSCALE`, so the pre-selection
// must be `LTSCALE` — not `None` (which would fall through to alias /
// closest-match resolution and could run a different command).
let mut line = CommandLine::new();
line.clear_history();
line.command_aliases = FxHashMap::default();
line.input = "LT".to_string();
line.autocomplete_cursor = None;
let matches = line.autocomplete_matches();
assert_eq!(
matches.first().map(String::as_str),
Some("LTSCALE"),
"got {matches:?}"
);
assert_eq!(
line.selected_suggestion().as_deref(),
Some("LTSCALE"),
"Enter must run the highlighted pre-selection"
);
}
#[test]
fn preselected_alias_target_is_returned_without_navigation() {
// With the `LT` → `LINETYPE` alias, the forced top entry is `LINETYPE`
// and Enter must run it without requiring arrow-key navigation.
let mut line = CommandLine::new();
line.clear_history();
line.command_aliases = aliases(&[("LT", "LINETYPE"), ("LTS", "LTSCALE")]);
line.input = "LT".to_string();
line.autocomplete_cursor = None;
assert_eq!(
line.selected_suggestion().as_deref(),
Some("LINETYPE"),
"got {:?}",
line.autocomplete_matches()
);
}
#[test]
fn no_preselection_without_matches_or_input() {
let mut line = CommandLine::new();
line.clear_history();
line.input = String::new();
line.autocomplete_cursor = None;
assert_eq!(line.selected_suggestion(), None);
line.input = "ZZZ_NO_SUCH_COMMAND".to_string();
assert_eq!(line.selected_suggestion(), None);
}
#[test]
fn history_markers_and_copy_text_are_clean() {
let mut line = CommandLine::new();
@@ -1344,17 +1478,20 @@ mod tests {
}
#[test]
fn commandline_fade_zero_hides_unpinned_but_keeps_pinned() {
fn commandline_fade_zero_never_fades() {
let mut line = CommandLine::new();
line.push_info("transient");
assert!(line.has_visible_history());
assert!(line.has_expiring_history());
line.set_commandline_fade_ms(0);
// Non-pinned entries are skipped entirely at 0.
assert!(!line.has_visible_history());
assert_eq!(line.visible_history_count(), 0);
// At 0, entries never fade so history stays visible, but no timer tick is needed.
assert!(line.has_visible_history());
assert_eq!(line.visible_history_count(), 3);
assert!(!line.has_expiring_history());
// Pinned step prompt still shows at 0.
line.set_step_prompt(Some("Specify point:".to_string()));
assert!(line.has_visible_history());
assert!(!line.has_expiring_history());
}
#[test]
+125
View File
@@ -45,6 +45,14 @@ pub enum PanelId {
ExternalReferences,
/// Outline of the drawing's origin planes, open sketch and solid bodies.
Browser,
/// Node library of the node graph overlay.
NodeGraph,
/// Regions and scans of the drawing's point clouds.
PointCloudManager,
/// Block counts (COUNTLIST).
Count,
/// The open sheet sets: subsets and sheets.
SheetSetManager,
}
impl PanelId {
@@ -56,6 +64,10 @@ impl PanelId {
PanelId::BlockPalette => "Block Palette",
PanelId::ExternalReferences => "External References",
PanelId::Browser => "Browser",
PanelId::NodeGraph => "Node Graph",
PanelId::PointCloudManager => "Point Cloud Manager",
PanelId::Count => "Count",
PanelId::SheetSetManager => "Sheet Set Manager",
}
}
@@ -67,6 +79,10 @@ impl PanelId {
PanelId::BlockPalette => 260.0,
PanelId::ExternalReferences => 460.0,
PanelId::Browser => 230.0,
PanelId::NodeGraph => 220.0,
PanelId::PointCloudManager => 280.0,
PanelId::Count => 280.0,
PanelId::SheetSetManager => 280.0,
}
}
@@ -156,6 +172,10 @@ impl DockState {
PanelId::BlockPalette,
PanelId::ExternalReferences,
PanelId::Browser,
PanelId::NodeGraph,
PanelId::PointCloudManager,
PanelId::Count,
PanelId::SheetSetManager,
] {
self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id));
}
@@ -256,6 +276,111 @@ pub const DOCK_MIN_W: f32 = 200.0;
/// Largest docked width a panel may be dragged or sized to.
pub const DOCK_MAX_W: f32 = 600.0;
// ── Shared panel chrome ─────────────────────────────────────────────────
use crate::app::Message;
use iced::widget::{button, container, mouse_area, row, text, tooltip, Space};
use iced::{Background, Border, Color, Element, Length, Theme};
/// The row every docked panel starts with: its title, the auto-collapse pin
/// and close. Pressing the row starts re-docking the panel.
pub fn title_bar<'a>(id: PanelId, title: String, auto_collapse: bool) -> Element<'a, Message> {
let pin_icon = if auto_collapse {
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
} else {
crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0)
};
let pin = button(pin_icon)
.on_press(Message::Dock(DockMsg::AutoCollapseToggle(id)))
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
if auto_collapse {
let palette = theme.palette();
style.background = Some(Background::Color(palette.primary.weak.color));
style.text_color = palette.primary.weak.text;
style.border.color = palette.primary.base.color;
style.border.width = 1.0;
}
style
})
.padding([3, 5]);
let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4);
let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0))
.on_press(Message::Dock(DockMsg::Close(id)))
.style(button::subtle)
.padding([3, 5]);
let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4);
mouse_area(
container(
row![text(title).size(12), Space::new().width(Length::Fill), pin, close]
.spacing(3)
.align_y(iced::Center),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
..Default::default()
})
.width(Length::Fill)
.padding([3, 6]),
)
.on_press(Message::Dock(DockMsg::DockGrab(id)))
.interaction(iced::mouse::Interaction::Grab)
.into()
}
/// Side length of a panel toolbar button's icon.
pub const TOOL_H: f32 = 22.0;
/// A panel toolbar icon button, placed on the row under the title bar.
pub fn tool_button<'a>(
icon: Element<'a, Message>,
tip: String,
message: Message,
) -> Element<'a, Message> {
let button = button(icon)
.on_press(message)
.width(Length::Fixed(TOOL_H + 8.0))
.height(Length::Fixed(TOOL_H + 8.0))
.style(|theme: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => {
theme.palette().background.strong.color
}
_ => Color::TRANSPARENT,
})),
border: Border {
radius: 3.0.into(),
..Default::default()
},
text_color: crate::ui::window::block_palette::block_icon_button_text_color(
theme, status,
),
..Default::default()
});
tooltip(button, text(tip).size(10), tooltip::Position::Bottom)
.gap(4)
.into()
}
/// A docked panel's body: fixed width, full height, padded, on the base
/// background with a neutral edge.
pub fn frame<'a>(content: impl Into<Element<'a, Message>>, width: f32) -> Element<'a, Message> {
container(content)
.padding(6)
.width(Length::Fixed(width))
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}
#[cfg(test)]
mod tests {
use super::*;
+46 -1
View File
@@ -32,6 +32,8 @@ static REDO: &[u8] = include_bytes!("../../assets/icons/ui/redo.svg");
static OSNAP_ENDPOINT: &[u8] = include_bytes!("../../assets/icons/osnap/endpoint.svg");
static OSNAP_MIDPOINT: &[u8] = include_bytes!("../../assets/icons/osnap/midpoint.svg");
static OSNAP_CENTER: &[u8] = include_bytes!("../../assets/icons/osnap/center.svg");
static OSNAP_GEOMETRIC_CENTER: &[u8] =
include_bytes!("../../assets/icons/osnap/geometric_center.svg");
static OSNAP_NODE: &[u8] = include_bytes!("../../assets/icons/osnap/node.svg");
static OSNAP_QUADRANT: &[u8] = include_bytes!("../../assets/icons/osnap/quadrant.svg");
static OSNAP_INTERSECTION: &[u8] = include_bytes!("../../assets/icons/osnap/intersection.svg");
@@ -51,6 +53,8 @@ static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg");
static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg");
static LAY_FRZ: &[u8] = include_bytes!("../../assets/icons/layers/layfrz.svg");
static LAY_THW: &[u8] = include_bytes!("../../assets/icons/layers/laythw.svg");
static LAY_VP_FRZ: &[u8] = include_bytes!("../../assets/icons/layers/layvpfrz.svg");
static LAY_VP_THW: &[u8] = include_bytes!("../../assets/icons/layers/layvpthw.svg");
static LAY_LCK: &[u8] = include_bytes!("../../assets/icons/layers/laylck.svg");
static LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg");
@@ -59,8 +63,11 @@ static LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg");
pub static CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg");
pub static CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg");
pub static PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg");
pub static REFRESH: &[u8] = include_bytes!("../../assets/icons/ui/refresh.svg");
pub static SWAP: &[u8] = include_bytes!("../../assets/icons/ui/swap.svg");
pub static MINUS: &[u8] = include_bytes!("../../assets/icons/ui/minus.svg");
pub static TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg");
pub static NODE_GRAPH: &[u8] = include_bytes!("../../assets/icons/ui/node_graph.svg");
pub static COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg");
pub static MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg");
pub static MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg");
@@ -82,6 +89,8 @@ pub static GEAR: &[u8] = include_bytes!("../../assets/icons/ui/gear.svg");
pub static DOT: &[u8] = include_bytes!("../../assets/icons/ui/dot.svg");
pub static DIRTY_DOT: &[u8] = include_bytes!("../../assets/icons/ui/dirty_dot.svg");
pub static ARROW_LONG_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/arrow_long_right.svg");
pub static MORE: &[u8] = include_bytes!("../../assets/icons/ui/more.svg");
pub static INFO: &[u8] = include_bytes!("../../assets/icons/ui/info.svg");
// ── Status-bar toggle icons (issue #216) ──────────────────────────────────
pub static ST_ORTHO: &[u8] = include_bytes!("../../assets/icons/status/ortho.svg");
@@ -314,6 +323,15 @@ pub fn semantic<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
})
}
/// [`semantic`] faded like disabled menu text.
pub fn semantic_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
Element::new(SemanticIcon {
bytes,
size,
opacity: 0.42,
})
}
fn semantic_handle(bytes: &'static [u8], theme: &Theme) -> svg::Handle {
let key = SemanticCacheKey {
address: bytes.as_ptr() as usize,
@@ -616,6 +634,7 @@ pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] {
S::Endpoint => OSNAP_ENDPOINT,
S::Midpoint => OSNAP_MIDPOINT,
S::Center => OSNAP_CENTER,
S::GeometricCenter => OSNAP_GEOMETRIC_CENTER,
S::Node => OSNAP_NODE,
S::Quadrant => OSNAP_QUADRANT,
S::Intersection => OSNAP_INTERSECTION,
@@ -654,6 +673,14 @@ pub fn zoom_icon() -> &'static [u8] {
NAV_ZOOM
}
pub fn undo_icon() -> &'static [u8] {
UNDO
}
pub fn redo_icon() -> &'static [u8] {
REDO
}
/// Layer visibility icon bytes (on / off).
pub fn layer_visible(visible: bool) -> &'static [u8] {
if visible {
@@ -672,6 +699,15 @@ pub fn layer_freeze(frozen: bool) -> &'static [u8] {
}
}
/// Freeze-in-current-viewport icon bytes (frozen / thawed).
pub fn layer_vp_freeze(frozen: bool) -> &'static [u8] {
if frozen {
LAY_VP_FRZ
} else {
LAY_VP_THW
}
}
/// Layer lock icon bytes (locked / unlocked).
pub fn layer_lock(locked: bool) -> &'static [u8] {
if locked {
@@ -709,6 +745,10 @@ pub fn themed_disabled_arrow_down<'a, M: 'a>(size: f32) -> Element<'a, M> {
themed_disabled(TRI_DOWN, size)
}
pub fn themed_disabled_arrow_right<'a, M: 'a>(size: f32) -> Element<'a, M> {
themed_disabled(TRI_RIGHT, size)
}
pub fn themed_home<'a, M: 'a>(size: f32) -> Element<'a, M> {
themed(HOME, size)
}
@@ -1008,8 +1048,13 @@ mod themed_cache_tests {
/// on a different static.
///
/// Run with `--nocapture` to see the numbers:
/// `cargo test --lib themed_cache_speedup_over_uncached_handle_creation -- --nocapture`.
/// `cargo test --lib themed_cache_speedup_over_uncached_handle_creation -- --ignored --nocapture`.
///
/// Ignored by default: it compares wall-clock times, and under the
/// parallel suite's load the uncached loop can finish first. The cache's
/// behaviour is pinned by the entry-count tests above.
#[test]
#[ignore = "wall-clock benchmark; run with --ignored"]
fn themed_cache_speedup_over_uncached_handle_creation() {
use std::time::Instant;
+1
View File
@@ -7,6 +7,7 @@ pub mod command_line;
pub mod dock;
pub mod icons;
pub mod modal;
pub mod node_graph;
pub mod overlay;
pub mod popup;
pub mod properties;
+1 -1
View File
@@ -275,7 +275,7 @@ pub fn discard_guard<'a>(
iced::widget::text(crate::t!("Unsaved changes will be discarded.")).size(13.5),
Space::new().height(12),
row![
button(iced::widget::text(crate::t!("Discard && close")).size(12))
button(iced::widget::text(crate::t!("Discard & close")).size(12))
.on_press(on_discard)
.padding([5, 14])
.style(button::danger),
+1099
View File
File diff suppressed because it is too large. Load diff
+221 -35
View File
@@ -213,6 +213,18 @@ pub(crate) fn pick_box_aperture_px(value: i32) -> f32 {
aperture.max(1.0)
}
/// Which interactive navigation tool is armed. Each one owns the whole
/// viewport and hides the CAD crosshair, but they do not share a cursor: the
/// hand reads as "drag the sheet", which is wrong for a zoom or an orbit.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum NavCursor {
#[default]
None,
Pan,
Orbit,
Zoom,
}
#[derive(Clone, Copy)]
pub struct CrosshairOptions {
pub size_percent: i32,
@@ -227,6 +239,12 @@ pub struct CrosshairOptions {
/// the crosshair arms disappear and a blue pickbox remains — the
/// screen-level "the client wants YOU to pick" signal.
pub pick_pending: bool,
/// Draw only the pickbox: the active command previews a line that the
/// full-length arms would cover.
pub hide_arms: bool,
/// Explicit object-snap marker colour; `None` picks one that reads on
/// the canvas.
pub snap_color: Option<[u8; 3]>,
}
/// Rendering style for the viewport grid.
@@ -475,7 +493,7 @@ pub fn resolve_selection_base_color(
// ACI 0 (BYBLOCK) and 256 (BYLAYER) are not explicit overrides;
// the sysvar uses 0 as the unset sentinel; valid user picks are 1..=255.
if custom > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(custom) {
if let Some((r, g, b)) = codec::types::aci_table::aci_to_rgb(custom) {
return Color::from_rgb8(r, g, b);
}
}
@@ -852,7 +870,7 @@ pub fn selection_overlay<'a>(
dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>,
pane_drop_rect: Option<iced::Rectangle>,
pan_mode: bool,
nav: NavCursor,
suppressed: bool,
hover_locked: bool,
crosshair_bg: [f32; 4],
@@ -882,7 +900,7 @@ pub fn selection_overlay<'a>(
dividers,
pane_move_rect,
pane_drop_rect,
pan_mode,
nav,
suppressed,
hover_locked,
crosshair_bg,
@@ -936,9 +954,10 @@ struct SelectionCanvas {
pane_move_rect: Option<iced::Rectangle>,
/// The pane under the cursor during a pane move (drop target), highlighted.
pane_drop_rect: Option<iced::Rectangle>,
/// Interactive PAN mode: the crosshair is hidden and the cursor becomes a
/// hand so the viewport reads as a draggable surface.
pan_mode: bool,
/// Which interactive navigation tool is armed. Any of them hides the
/// crosshair — the viewport is being driven, not drawn on — and each
/// picks its own cursor in `mouse_interaction`.
nav: NavCursor,
/// A ribbon dropdown (or similar overlay) is open over the viewport. The
/// crosshair is not drawn and the OS cursor is shown normally so the panel
/// is usable instead of the cursor vanishing over it. (#227)
@@ -966,6 +985,80 @@ struct SelectionCanvas {
constraint_cursor_badge: Option<String>,
}
/// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the
/// centre grip and a square per axis pair; the hovered or dragged part is
/// yellow.
fn draw_move_gizmo(frame: &mut canvas::Frame, grips: &[GripMarker]) {
use crate::scene::pick::grip::{gizmo_plane_axes, GIZMO_AXIS_PX};
const COLORS: [Color; 3] = [
Color::from_rgb(0.90, 0.22, 0.20),
Color::from_rgb(0.27, 0.70, 0.29),
Color::from_rgb(0.18, 0.52, 0.93),
];
let active = Color::from_rgb(1.0, 0.84, 0.0);
// Unit screen direction (y down) and tip of each shown arrow.
let mut axes: [Option<(Point, iced::Vector)>; 3] = [None; 3];
for grip in grips {
if let (GripShape::GizmoAxis(k), Some([dx, dy])) = (grip.shape, grip.dir) {
axes[k as usize % 3] = Some((grip.pos, iced::Vector::new(dx, -dy)));
}
}
let Some(center) = axes
.iter()
.flatten()
.next()
.map(|(tip, d)| Point::new(tip.x - d.x * GIZMO_AXIS_PX, tip.y - d.y * GIZMO_AXIS_PX))
else {
return;
};
for grip in grips {
let GripShape::GizmoPlane(k) = grip.shape else {
continue;
};
let (a, b) = gizmo_plane_axes(k);
let (Some((_, a)), Some((_, b))) = (axes[a as usize], axes[b as usize]) else {
continue;
};
let corner = |u: f32, v: f32| {
Point::new(
center.x + (a.x * u + b.x * v) * GIZMO_AXIS_PX,
center.y + (a.y * u + b.y * v) * GIZMO_AXIS_PX,
)
};
let quad = canvas::Path::new(|p| {
p.move_to(corner(0.18, 0.18));
p.line_to(corner(0.42, 0.18));
p.line_to(corner(0.42, 0.42));
p.line_to(corner(0.18, 0.42));
p.close();
});
let lit = grip.is_hovered || grip.is_hot;
let color = if lit { active } else { Color::from_rgb(0.85, 0.85, 0.85) };
frame.fill(&quad, color.scale_alpha(if lit { 0.55 } else { 0.25 }));
frame.stroke(&quad, canvas::Stroke::default().with_width(1.0).with_color(color));
}
for grip in grips {
let (GripShape::GizmoAxis(k), Some((tip, d))) =
(grip.shape, grip.dir.map(|[dx, dy]| (grip.pos, iced::Vector::new(dx, -dy))))
else {
continue;
};
let color = if grip.is_hovered || grip.is_hot { active } else { COLORS[k as usize % 3] };
let base = Point::new(tip.x - d.x * 13.0, tip.y - d.y * 13.0);
frame.stroke(
&canvas::Path::line(center, base),
canvas::Stroke::default().with_width(2.5).with_color(color),
);
let head = canvas::Path::new(|p| {
p.move_to(tip);
p.line_to(Point::new(base.x - d.y * 6.0, base.y + d.x * 6.0));
p.line_to(Point::new(base.x + d.y * 6.0, base.y - d.x * 6.0));
p.close();
});
frame.fill(&head, color);
}
}
fn draw_grip_marker(
frame: &mut canvas::Frame,
grip: &GripMarker,
@@ -1019,6 +1112,7 @@ fn draw_grip_marker(
b.close();
}),
GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h),
GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_) => return,
GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - h, sp.y - h * 0.5));
b.line_to(Point::new(sp.x + h, sp.y - h * 0.5));
@@ -1029,7 +1123,7 @@ fn draw_grip_marker(
if grip.is_hot {
let hot_color = if visual.grip_hot > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_hot) {
if let Some((r, g, b)) = codec::types::aci_table::aci_to_rgb(visual.grip_hot) {
Color::from_rgb8(r, g, b)
} else {
theme.palette().danger.base.color
@@ -1041,7 +1135,7 @@ fn draw_grip_marker(
} else if grip.is_hovered {
let pair = theme.palette().primary.strong;
let hover_color = if visual.grip_hover > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_hover) {
if let Some((r, g, b)) = codec::types::aci_table::aci_to_rgb(visual.grip_hover) {
Color::from_rgb8(r, g, b)
} else {
pair.color
@@ -1061,7 +1155,7 @@ fn draw_grip_marker(
} else {
let palette = theme.palette();
let color = if visual.grip_color > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_color) {
if let Some((r, g, b)) = codec::types::aci_table::aci_to_rgb(visual.grip_color) {
Color::from_rgb8(r, g, b)
} else {
palette.primary.base.color
@@ -1122,13 +1216,30 @@ impl canvas::Program<Message> for SelectionCanvas {
if self.suppressed {
return mouse::Interaction::default();
}
// PAN mode owns the whole viewport: an open hand when hovering, a
// closed hand while dragging.
if self.pan_mode && cursor.is_over(bounds) {
return if self.selection.borrow().middle_down {
mouse::Interaction::Grabbing
} else {
mouse::Interaction::Grab
// An armed navigation tool owns the whole viewport, and the cursor
// says which one: a hand to drag the sheet, four-way arrows to swing
// the model, a magnifier that carries the sign of the zoom actually
// under way. Hovering before the drag starts shows the neutral "+"
// magnifier rather than guessing a direction.
if self.nav != NavCursor::None && cursor.is_over(bounds) {
let dragging = self.selection.borrow().input.middle_down;
return match self.nav {
NavCursor::Pan => {
if dragging {
mouse::Interaction::Grabbing
} else {
mouse::Interaction::Grab
}
}
NavCursor::Orbit => mouse::Interaction::AllScroll,
NavCursor::Zoom => {
if dragging && self.selection.borrow().input.zoom_dir_out {
mouse::Interaction::ZoomOut
} else {
mouse::Interaction::ZoomIn
}
}
NavCursor::None => unreachable!("guarded above"),
};
}
if self.show_viewcube {
@@ -1227,7 +1338,7 @@ impl canvas::Program<Message> for SelectionCanvas {
}
// Ghost card dragged under the cursor — a 0.32× preview of the
// source pane, centred on the cursor.
if let Some(c) = self.selection.borrow().last_move_pos {
if let Some(c) = self.selection.borrow().input.last_move_pos {
let gw = (src.width * 0.32).clamp(60.0, 280.0);
let gh = (src.height * 0.32).clamp(40.0, 200.0);
let g = canvas::Path::rectangle(
@@ -1292,26 +1403,32 @@ impl canvas::Program<Message> for SelectionCanvas {
);
}
if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) {
draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual, self.crosshair_bg);
// ZOOM Dynamic reuses `box_anchor` to remember where the drag began,
// so the marquee would otherwise rubber-band across the screen while
// the view zooms. Suppress the drawing, not the state — the anchor is
// still re-projected as the camera moves.
if self.nav != NavCursor::Zoom {
if let (Some(a), Some(b)) = (self.selection.borrow().gesture.box_anchor, self.selection.borrow().gesture.box_current) {
draw_marquee(&mut frame, a, b, self.selection.borrow().gesture.box_crossing, theme, &self.selection_visual, self.crosshair_bg);
}
}
// Preview marquee for point-picked windows (STRETCH) — same look, no pick.
if let Some((a, b, crossing)) = self.selection.borrow().preview_box {
if let Some((a, b, crossing)) = self.selection.borrow().gesture.preview_box {
draw_marquee(&mut frame, a, b, crossing, theme, &self.selection_visual, self.crosshair_bg);
}
if self.selection.borrow().poly_active && self.selection.borrow().poly_points.len() > 1 {
let crossing = self.selection.borrow().poly_crossing;
if self.selection.borrow().gesture.poly_active && self.selection.borrow().gesture.poly_points.len() > 1 {
let crossing = self.selection.borrow().gesture.poly_crossing;
let base = resolve_selection_base_color(crossing, theme, &self.selection_visual, self.crosshair_bg);
let canvas_light = crate::ui::style::common::canvas_is_light(self.crosshair_bg);
if self.selection_visual.area && self.selection_visual.opacity > 0 {
let alpha = selection_fill_alpha(self.selection_visual.opacity as f32, canvas_light);
let fill = base.scale_alpha(alpha);
if let Some(cur) = self.selection.borrow().last_move_pos {
let start = self.selection.borrow().poly_points[0];
if let Some(cur) = self.selection.borrow().input.last_move_pos {
let start = self.selection.borrow().gesture.poly_points[0];
let fill_path = canvas::Path::new(|p| {
p.move_to(start);
for pt in &self.selection.borrow().poly_points[1..] {
for pt in &self.selection.borrow().gesture.poly_points[1..] {
p.line_to(*pt);
}
p.line_to(cur);
@@ -1323,8 +1440,8 @@ impl canvas::Program<Message> for SelectionCanvas {
let stroke_alpha = if canvas_light { 0.95 } else { 0.90 };
let stroke = base.scale_alpha(stroke_alpha);
let path = canvas::Path::new(|p| {
p.move_to(self.selection.borrow().poly_points[0]);
for pt in &self.selection.borrow().poly_points[1..] {
p.move_to(self.selection.borrow().gesture.poly_points[0]);
for pt in &self.selection.borrow().gesture.poly_points[1..] {
p.line_to(*pt);
}
});
@@ -1342,9 +1459,9 @@ impl canvas::Program<Message> for SelectionCanvas {
..Default::default()
};
frame.stroke(&path, stroke_style.clone());
if let Some(cur) = self.selection.borrow().last_move_pos {
let start = self.selection.borrow().poly_points[0];
let last = *self.selection.borrow().poly_points.last().unwrap();
if let Some(cur) = self.selection.borrow().input.last_move_pos {
let start = self.selection.borrow().gesture.poly_points[0];
let last = *self.selection.borrow().gesture.poly_points.last().unwrap();
let preview = canvas::Path::new(|p| {
p.move_to(last);
p.line_to(cur);
@@ -1393,8 +1510,11 @@ impl canvas::Program<Message> for SelectionCanvas {
);
}
}
draw_move_gizmo(frame, &self.grips);
for grip in &self.grips {
draw_grip_marker(frame, grip, theme, &self.selection_visual);
if !matches!(grip.shape, GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_)) {
draw_grip_marker(frame, grip, theme, &self.selection_visual);
}
}
});
}
@@ -1403,6 +1523,10 @@ impl canvas::Program<Message> for SelectionCanvas {
if let Some((sp, snap_type)) = self.snap {
let (r, g, b) = if snap_type == SnapType::ObjectPick {
(0.95_f32, 0.50, 0.08) // orange object-snap marker
} else if let Some([r, g, b]) = self.crosshair.snap_color {
(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)
} else if crate::ui::style::common::canvas_is_light(self.crosshair_bg) {
(0.80, 0.45, 0.0) // dark amber: yellow vanishes on a light canvas
} else {
(1.0, 0.9, 0.1) // classic yellow OSNAP
};
@@ -1455,6 +1579,25 @@ impl canvas::Program<Message> for SelectionCanvas {
let path = canvas::Path::circle(sp, r);
frame.stroke(&path, stroke);
}
SnapType::GeometricCenter => {
// Pentagon around a centre dot: the area centroid.
let r = 6.5_f32;
let path = canvas::Path::new(|b| {
for k in 0..5 {
let a = -std::f32::consts::FRAC_PI_2
+ k as f32 * std::f32::consts::TAU / 5.0;
let p = Point::new(sp.x + r * a.cos(), sp.y + r * a.sin());
if k == 0 {
b.move_to(p);
} else {
b.line_to(p);
}
}
b.close();
});
frame.stroke(&path, stroke.clone());
frame.fill(&canvas::Path::circle(sp, 1.6), marker);
}
SnapType::Node => {
// Circle with an inscribed X.
let r = 5.5_f32;
@@ -1748,14 +1891,17 @@ impl canvas::Program<Message> for SelectionCanvas {
// arrow (see `mouse_interaction`); drawing the CAD crosshair on
// top of it would double up the visual feedback.
let over_divider = self.divider_under(cursor, bounds);
// PAN mode replaces the crosshair with a hand cursor.
// An armed navigation tool replaces the crosshair with its own cursor.
if !over_viewcube
&& !over_divider
&& !self.pan_mode
&& self.nav == NavCursor::None
&& !self.suppressed
&& self.crosshair.cursor_type == CursorType::Crosshair
{
if let Some(cp) = self.selection.borrow().last_move_pos {
// Over a grip the crosshair locks onto it, so the click lands on
// the grip exactly.
let hovered_grip = self.grips.iter().find(|grip| grip.is_hovered).map(|grip| grip.pos);
if let Some(cp) = hovered_grip.or(self.selection.borrow().input.last_move_pos) {
let [r, g, b, a] = self.crosshair.color.map_or_else(
|| {
crate::scene::view::render::adapt_to_bg(
@@ -1801,7 +1947,7 @@ impl canvas::Program<Message> for SelectionCanvas {
// The arms stay only for the normal (non-pending) cursor: a
// waiting pick shows the box alone, regardless of the UCS
// rotation, so the square is unmistakable.
if !pick_pending {
if !pick_pending && !self.crosshair.hide_arms {
for angle in base_angles {
let rad = (angle + self.crosshair.snap_angle_deg as f64).to_radians();
let dir = Point::new(rad.cos() as f32, -rad.sin() as f32);
@@ -3249,6 +3395,37 @@ impl DynInputCanvas {
(Self::box_content(b).len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0
}
/// Slide a box centred at `center` straight away from the crosshair until
/// it neither covers the aim point nor a box already placed (#1546).
fn clear_of(center: Point, w: f32, cursor: Point, placed: &[iced::Rectangle]) -> Point {
const KEEP: f32 = 18.0;
let hits = |c: Point| {
let r = iced::Rectangle {
x: c.x - w * 0.5,
y: c.y - DYN_BOX_H * 0.5,
width: w,
height: DYN_BOX_H,
};
r.expand(KEEP).contains(cursor) || placed.iter().any(|p| p.intersects(&r))
};
let (mut dx, mut dy) = (center.x - cursor.x, center.y - cursor.y);
let len = (dx * dx + dy * dy).sqrt();
if len < 1.0 {
(dx, dy) = (0.0, 1.0);
} else {
(dx, dy) = (dx / len, dy / len);
}
let mut c = center;
for _ in 0..60 {
if !hits(c) {
break;
}
c.x += dx * 4.0;
c.y += dy * 4.0;
}
c
}
/// Draw a value box centred at `center`, clamped inside `bounds`.
fn draw_box(
frame: &mut canvas::Frame,
@@ -3533,6 +3710,7 @@ impl DynInputCanvas {
}
// ── Box placement by role ──
let mut placed: Vec<iced::Rectangle> = Vec::with_capacity(self.boxes.len());
for b in &self.boxes {
let center = b.center.unwrap_or_else(|| match b.role {
DynRole::Angle => self.label_screen.unwrap_or_else(|| {
@@ -3576,6 +3754,14 @@ impl DynInputCanvas {
y: base.y + dy * len * 0.5 + ny * 16.0,
},
});
let w = Self::box_width(b);
let center = Self::clear_of(center, w, cursor, &placed);
placed.push(iced::Rectangle {
x: center.x - w * 0.5,
y: center.y - DYN_BOX_H * 0.5,
width: w,
height: DYN_BOX_H,
});
Self::draw_box(frame, b, center, bounds, theme);
}
// Keep the tracking hint near the crosshair in guided layouts.
+69 -21
View File
@@ -34,7 +34,7 @@ pub const MENU_WIDTH_WITH_HINTS: f32 = 236.0;
pub const RECENT_LIMIT: usize = 10;
/// Identifies an inline-expanding (accordion) submenu. Only one is open at a
/// time; the open one is tracked in `SelectionState::context_menu_ui`.
/// time; the open one is tracked in `SelectionMenu::ui`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SubmenuId {
RecentInput,
@@ -50,8 +50,10 @@ pub enum SubmenuId {
pub enum MenuIcon {
Snap(SnapType),
Mtp,
Pan,
Zoom,
/// Monochrome interface glyph, drawn in the text colour.
Chrome(&'static [u8]),
/// A command's ribbon icon, drawn in its own colours.
Tool(&'static [u8]),
}
/// What a grip-menu row does (the grip-mode shortcut menu of commercial solutions).
@@ -146,6 +148,12 @@ impl MenuItem {
self
}
/// The command's ribbon icon, when the ribbon has one.
fn command_icon(mut self, command: &str) -> Self {
self.icon = command_icon(command);
self
}
fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
@@ -179,11 +187,16 @@ pub enum MenuRow {
Submenu {
id: SubmenuId,
label: String,
icon: Option<MenuIcon>,
items: Vec<MenuItem>,
open: bool,
},
}
fn command_icon(command: &str) -> Option<MenuIcon> {
crate::modules::registry::command_icon(command).map(MenuIcon::Tool)
}
/// A keyboard-navigable row in display order: either an item or a submenu
/// header. Separators and the children of collapsed submenus are skipped.
#[derive(Clone, Debug, PartialEq)]
@@ -470,6 +483,7 @@ fn command_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::SnapOverrides,
label: t!("Snap Overrides").into_owned(),
icon: None,
items: snap_override_items(),
open: open_submenu == Some(SubmenuId::SnapOverrides),
});
@@ -486,6 +500,7 @@ fn recent_input_submenu(items: Vec<MenuItem>, open_submenu: Option<SubmenuId>) -
MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
icon: None,
items,
open: open_submenu == Some(SubmenuId::RecentInput),
}
@@ -506,6 +521,7 @@ fn snap_override_items() -> Vec<MenuItem> {
snap("Apparent Intersection", SnapType::ApparentIntersection),
snap("Extension", SnapType::Extension),
snap("Center", SnapType::Center),
snap("Geometric Center", SnapType::GeometricCenter),
snap("Quadrant", SnapType::Quadrant),
snap("Tangent", SnapType::Tangent),
snap("Perpendicular", SnapType::Perpendicular),
@@ -528,14 +544,14 @@ fn navigation_rows(transparent: bool) -> Vec<MenuRow> {
vec![
MenuRow::Item(
MenuItem::new(t!("Pan").into_owned(), MenuAction::Command(format!("{prefix}PAN")))
.icon(MenuIcon::Pan),
.icon(MenuIcon::Chrome(crate::ui::icons::pan_icon())),
),
MenuRow::Item(
MenuItem::new(
t!("Zoom").into_owned(),
MenuAction::Command(format!("{prefix}ZOOM DYNAMIC")),
)
.icon(MenuIcon::Zoom),
.icon(MenuIcon::Chrome(crate::ui::icons::zoom_icon())),
),
]
}
@@ -552,7 +568,9 @@ fn idle_rows(
props_open: bool,
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let cmd = |label: String, name: &str| MenuItem::new(label, MenuAction::Command(name.to_string()));
let cmd = |label: String, name: &str| {
MenuItem::new(label, MenuAction::Command(name.to_string())).command_icon(name)
};
let mut rows = Vec::new();
// ── Repeat / Recent Input ──────────────────────────────────────────
@@ -562,6 +580,7 @@ fn idle_rows(
t!("Repeat %{last}", last = last.clone()).into_owned(),
MenuAction::Command(last.to_uppercase()),
)
.command_icon(last)
.default(),
));
}
@@ -569,7 +588,9 @@ fn idle_rows(
recent_cmds
.iter()
.take(RECENT_LIMIT)
.map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())))
.map(|c| {
MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())).command_icon(c)
})
.collect(),
open_submenu,
));
@@ -579,6 +600,7 @@ fn idle_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::Clipboard,
label: t!("Clipboard").into_owned(),
icon: None,
items: vec![
cmd(t!("Cut").into_owned(), "CUTCLIP").enabled(has_selection),
cmd(t!("Copy").into_owned(), "COPYCLIP").enabled(has_selection),
@@ -602,19 +624,25 @@ fn idle_rows(
None => t!("Redo").into_owned(),
};
rows.push(MenuRow::Item(
MenuItem::new(undo, MenuAction::Undo).hint("Ctrl+Z").enabled(undo_label.is_some()),
MenuItem::new(undo, MenuAction::Undo)
.icon(MenuIcon::Chrome(crate::ui::icons::undo_icon()))
.hint("Ctrl+Z")
.enabled(undo_label.is_some()),
));
rows.push(MenuRow::Item(
MenuItem::new(redo, MenuAction::Redo).hint("Ctrl+Y").enabled(redo_label.is_some()),
MenuItem::new(redo, MenuAction::Redo)
.icon(MenuIcon::Chrome(crate::ui::icons::redo_icon()))
.hint("Ctrl+Y")
.enabled(redo_label.is_some()),
));
rows.push(MenuRow::Separator);
// ── Selection edit block (the "Edit" shortcut menu of commercial solutions) ──
if has_selection {
rows.push(MenuRow::Item(MenuItem::new(
t!("Erase").into_owned(),
MenuAction::DeleteSelected,
)));
rows.push(MenuRow::Item(
MenuItem::new(t!("Erase").into_owned(), MenuAction::DeleteSelected)
.command_icon("ERASE"),
));
rows.push(MenuRow::Item(cmd(t!("Move").into_owned(), "MOVE")));
rows.push(MenuRow::Item(cmd(t!("Copy Selection").into_owned(), "COPY")));
rows.push(MenuRow::Item(cmd(t!("Scale").into_owned(), "SCALE")));
@@ -623,6 +651,7 @@ fn idle_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::DrawOrder,
label: t!("Draw Order").into_owned(),
icon: command_icon("DRAWORDER_FRONT"),
items: vec![
cmd(t!("Bring to Front").into_owned(), "DRAWORDER F"),
cmd(t!("Send to Back").into_owned(), "DRAWORDER B"),
@@ -652,6 +681,7 @@ fn idle_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::Isolate,
label: t!("Isolate").into_owned(),
icon: Some(MenuIcon::Chrome(crate::ui::icons::ST_ISOLATE)),
items: vec![
cmd(t!("Isolate Objects").into_owned(), "ISOLATEOBJECTS").enabled(has_selection),
cmd(t!("Hide Objects").into_owned(), "HIDEOBJECTS").enabled(has_selection),
@@ -693,21 +723,39 @@ fn idle_rows(
t!("Quick Select...").into_owned(),
MenuAction::QuickSelect,
)));
// Count the selected objects, or start a count.
rows.push(MenuRow::Item(if has_selection {
cmd(t!("Count Selection").into_owned(), "COUNT _C")
} else {
cmd(t!("Count").into_owned(), "COUNT")
}));
rows.push(MenuRow::Separator);
// ── Panels ─────────────────────────────────────────────────────────
rows.push(MenuRow::Item(
MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties).checked(props_open),
MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties)
.command_icon("PROPERTIES")
.checked(props_open),
));
rows.push(MenuRow::Item(
MenuItem::new(t!("Options...").into_owned(), MenuAction::Options).command_icon("OPTIONS"),
));
rows.push(MenuRow::Item(MenuItem::new(
t!("Options...").into_owned(),
MenuAction::Options,
)));
rows
}
fn grip_rows(grip: &GripMenuContext) -> Vec<MenuRow> {
let g = |label: String, cmd: GripMenuCmd| MenuItem::new(label, MenuAction::Grip(cmd));
let g = |label: String, cmd: GripMenuCmd| {
let command = match cmd {
GripMenuCmd::Stretch => "STRETCH",
GripMenuCmd::Move => "MOVE",
GripMenuCmd::Rotate => "ROTATE",
GripMenuCmd::Scale => "SCALE",
GripMenuCmd::Mirror => "MIRROR",
GripMenuCmd::CopyToggle => "COPY",
GripMenuCmd::BasePoint | GripMenuCmd::Undo | GripMenuCmd::Exit => "",
};
MenuItem::new(label, MenuAction::Grip(cmd)).command_icon(command)
};
vec![
MenuRow::Item(MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint("⏎").default()),
MenuRow::Separator,
@@ -1029,8 +1077,8 @@ mod tests {
assert_eq!(menu.default_row_y(), MENU_PAD_TOP);
let menu = build_context_menu(&idle_ctx(false), None);
assert_eq!(menu.default_row_y(), MENU_PAD_TOP);
// 10 items + 3 submenu headers + 6 separators, plus panel padding.
let expected = MENU_PAD_TOP * 2.0 + 13.0 * MENU_ROW_H + 6.0 * MENU_SEP_H;
// 11 items + 3 submenu headers + 6 separators, plus panel padding.
let expected = MENU_PAD_TOP * 2.0 + 14.0 * MENU_ROW_H + 6.0 * MENU_SEP_H;
assert_eq!(menu.height(), expected);
}
+2 -2
View File
@@ -11,7 +11,7 @@ use crate::app::Message;
/// Row content for one overlapping candidate.
pub struct CycleCandidate {
pub handle: acadrust::Handle,
pub handle: codec::Handle,
pub type_name: String,
pub layer: String,
/// The color the object is drawn with (layer-inherited when ByLayer).
@@ -53,7 +53,7 @@ fn clamp_col_width(labels: impl Iterator<Item = usize>, glyph_w: f32, min: f32,
}
fn item_row(
handle: acadrust::Handle,
handle: codec::Handle,
type_name: String,
layer: String,
color: [f32; 4],
+8 -63
View File
@@ -11,11 +11,11 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::{fmt, sync::Arc};
use crate::ui::ROW_H;
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::Handle;
use codec::types::{Color as AcadColor, LineWeight};
use codec::Handle;
use iced::widget::{
button, canvas, column, combo_box, container, mouse_area, row, scrollable, text, text_input,
tooltip, Space,
tooltip,
};
use iced::{
mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme,
@@ -150,7 +150,7 @@ impl canvas::Program<Message> for HatchPatternPreview {
boundary_exterior: None,
boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
style: codec::entities::HatchStyleType::Normal,
pattern: self.pattern.clone(),
name: String::new(),
color: [1.0; 4],
@@ -679,65 +679,10 @@ impl PropertiesPanel {
}
pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> {
use crate::ui::dock::{DockMsg, PanelId};
use crate::ui::dock::PanelId;
// ── Header ──────────────────────────────────────────────────────────
let pin_icon = if auto_collapse {
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
} else {
crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0)
};
let pin = button(pin_icon)
.on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties)))
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
if auto_collapse {
let palette = theme.palette();
style.background = Some(Background::Color(palette.primary.weak.color));
style.text_color = palette.primary.weak.text;
style.border.color = palette.primary.base.color;
style.border.width = 1.0;
}
style
})
.padding([3, 5]);
let pin = tooltip(pin, text(t!("Auto")).size(10), tooltip::Position::Bottom).gap(4);
let close = button(crate::ui::icons::themed_secondary(
crate::ui::icons::CLOSE,
12.0,
))
.on_press(Message::Dock(DockMsg::Close(PanelId::Properties)))
.style(button::subtle)
.padding([3, 5]);
let close = tooltip(
close,
text(t!("Close")).size(10),
tooltip::Position::Bottom,
)
.gap(4);
let header = mouse_area(
container(
row![
text(t!("Properties")).size(12),
Space::new().width(Length::Fill),
pin,
close,
]
.spacing(3)
.align_y(iced::Center),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.weak.color,
)),
..Default::default()
})
.width(Length::Fill)
.padding([3, 6]),
)
.on_press(Message::Dock(DockMsg::DockGrab(PanelId::Properties)))
.interaction(iced::mouse::Interaction::Grab);
let header =
crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), auto_collapse);
// ── Title bar (entity type / "No Selection") ─────────────────────
let title_content: Element<'_, Message> = if self.selection_groups.is_empty() {
@@ -2190,7 +2135,7 @@ pub fn acad_color_display(c: AcadColor) -> (Color, &'static str) {
"ByBlock",
),
AcadColor::Index(i) => {
let (r, g, b) = acadrust::types::aci_table::aci_to_rgb(i).unwrap_or((200, 200, 200));
let (r, g, b) = codec::types::aci_table::aci_to_rgb(i).unwrap_or((200, 200, 200));
(
Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0),
aci_label(i),
+2 -2
View File
@@ -11,7 +11,7 @@ use super::widgets::{muted_text_style, popup_panel_style, popup_row_style};
use crate::app::Message;
use crate::t;
use crate::ui::properties::acad_color_display;
use acadrust::types::Color as AcadColor;
use codec::types::Color as AcadColor;
use iced::widget::{button, column, container, row, text, Space};
use iced::{Background, Border, Element, Length, Padding, Theme};
@@ -82,7 +82,7 @@ pub const QUICK_PICK_GRID: [[(u8, u8, u8); 9]; 5] = [
#[allow(dead_code)]
pub fn aci_1_9() -> [(u8, u8, u8); 9] {
std::array::from_fn(|i| {
acadrust::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128))
codec::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128))
})
}
+130
View File
@@ -0,0 +1,130 @@
//! Selection-driven tools: while only underlays of one kind (or only xrefs) are
//! selected their actions show on the right-edge toolbar; they go when the
//! selection changes. Contrast and fade stay in the Properties panel.
use super::*;
use crate::modules::{ModuleEvent, ToolDef};
/// The switches of the selected underlay (the first one) the toolbar shows
/// as on.
#[derive(Clone, Debug, PartialEq)]
pub struct UnderlayContext {
pub kind: codec::entities::UnderlayType,
pub monochrome: bool,
pub shown: bool,
pub snap: bool,
}
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
}
const CLIP: &[u8] = include_bytes!("../../../assets/icons/xclip.svg");
/// The clip icon with a small red cross at its lower right.
const UNCLIP: &[u8] = include_bytes!("../../../assets/icons/xclip_remove.svg");
/// Underlay tools: display, clipping, options, layers and (PDF only)
/// import.
pub fn pdf_underlay_tools(kind: codec::entities::UnderlayType) -> Vec<ToolDef> {
const FRAMES: &[u8] = include_bytes!("../../../assets/icons/underlay_frames.svg");
let mut tools = vec![
tool("_PDFULMONO", "Display in\nMonochrome", FRAMES),
tool("_PDFULCLIP", "Create Clipping\nBoundary", CLIP),
tool("_PDFULUNCLIP", "Remove\nClipping", UNCLIP),
tool("_PDFULSHOW", "Show\nUnderlay", FRAMES),
tool(
"_PDFULSNAP",
"Enable\nSnap",
include_bytes!("../../../assets/icons/snap_underlays.svg"),
),
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
tool(
"ULAYERS",
"Edit\nLayers",
include_bytes!("../../../assets/icons/underlay_layers.svg"),
),
];
if kind == codec::entities::UnderlayType::Pdf {
tools.push(tool(
"_PDFULIMPORT",
"Import As\nObjects",
include_bytes!("../../../assets/icons/cui_import.svg"),
));
}
tools
}
/// Point cloud tools: the three crops, showing, inverting and removing
/// them, and the palette.
pub fn point_cloud_tools() -> Vec<ToolDef> {
vec![
tool("_PCCROPRECT", "Rectangular\nCrop", CLIP),
tool("_PCCROPPOLY", "Polygonal\nCrop", CLIP),
tool("_PCCROPCIRC", "Circular\nCrop", CLIP),
tool(
"_PCCROPSHOW",
"Show\nCropping",
include_bytes!("../../../assets/icons/underlay_frames.svg"),
),
tool("_PCCROPINVERT", "Invert\nCropping", CLIP),
tool("_PCUNCROP", "Remove All\nCropping", UNCLIP),
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
]
}
/// External reference tools: edit, open, clipping and the palette.
pub fn xref_tools() -> Vec<ToolDef> {
vec![
tool(
"_XREFEDIT",
"Edit Reference\nIn-Place",
include_bytes!("../../../assets/icons/edit_block.svg"),
),
tool("_XREFOPEN", "Open\nReference", crate::ui::icons::FOLDER_OPEN),
tool("_XREFCLIP", "Create Clipping\nBoundary", CLIP),
tool("_XREFUNCLIP", "Remove\nClipping", UNCLIP),
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
]
}
impl Ribbon {
/// The selection context: the selected underlay's switches, or that
/// only xrefs are selected, or neither.
pub fn set_underlay_context(&mut self, context: Option<UnderlayContext>, xref: bool) {
self.underlay_ctx = context;
self.xref_ctx = xref;
}
pub fn underlay_context(&self) -> Option<&UnderlayContext> {
self.underlay_ctx.as_ref()
}
pub fn xref_context(&self) -> bool {
self.xref_ctx
}
/// Only point clouds are selected: whether the first shows its crops.
pub fn set_point_cloud_context(&mut self, context: Option<bool>) {
self.point_cloud_ctx = context;
}
pub fn point_cloud_context(&self) -> Option<bool> {
self.point_cloud_ctx
}
/// Select a tab by its module id.
pub fn select_by_id(&mut self, id: &str) -> bool {
match self.modules.iter().position(|m| m.id() == id) {
Some(index) => {
self.select(index);
true
}
None => false,
}
}
}
+201 -9
View File
@@ -106,26 +106,89 @@ const TOOLS: &[Tool] = &[
#[derive(Clone, Copy)]
struct Panel {
id: &'static str,
/// A command the owning ribbon group must hold, for titles more than one
/// tab uses ("Block" on Home and Insert).
anchor: Option<&'static str>,
title_id: &'static str,
title: &'static str,
tools: &'static [Tool],
}
/// The Reference panel's slide-out: Edit Reference, then the xref fading
/// switch and amount (drawn by `reference_overlay`).
const REFERENCE_TOOLS: &[Tool] = &[Tool {
command: "REFEDIT",
label: "Edit Reference",
icon: include_bytes!("../../../assets/icons/edit_block.svg"),
options: &[],
}];
pub(super) const REFERENCE_PANEL_ID: &str = "reference_extension";
const fn icon_tool(command: &'static str, label: &'static str, icon: &'static [u8]) -> Tool {
Tool { command, label, icon, options: &[] }
}
/// Home > Block slide-out.
const HOME_BLOCK_TOOLS: &[Tool] = &[
icon_tool("ATTDEF", "Define Attributes", include_bytes!("../../../assets/icons/attdef.svg")),
icon_tool("ATTMAN", "Manage Attributes", include_bytes!("../../../assets/icons/attman.svg")),
icon_tool("ATTSYNC", "Synchronize", include_bytes!("../../../assets/icons/attsync.svg")),
icon_tool("BASE", "Set Base Point", include_bytes!("../../../assets/icons/base_point.svg")),
];
/// Insert > Block Definition slide-out.
const BLOCK_DEFINITION_TOOLS: &[Tool] = &[
icon_tool("BASE", "Set Base Point", include_bytes!("../../../assets/icons/base_point.svg")),
icon_tool("ATTSYNC", "Synchronize", include_bytes!("../../../assets/icons/attsync.svg")),
];
const PANELS: &[Panel] = &[
Panel {
id: PANEL_ID,
anchor: None,
title_id: TITLE_ID,
title: "Draw",
tools: TOOLS,
},
Panel {
id: "modify_extension",
anchor: None,
title_id: "modify_extension_title",
title: "Modify",
tools: super::modify_panel::TOOLS,
},
Panel {
id: REFERENCE_PANEL_ID,
anchor: None,
title_id: "reference_extension_title",
title: "Reference",
tools: REFERENCE_TOOLS,
},
Panel {
id: "home_block_extension",
anchor: Some("BEDIT"),
title_id: "home_block_extension_title",
title: "Block",
tools: HOME_BLOCK_TOOLS,
},
Panel {
id: "block_definition_extension",
anchor: None,
title_id: "block_definition_extension_title",
title: "Block Definition",
tools: BLOCK_DEFINITION_TOOLS,
},
];
/// Command, label and icon of every tool in the expanded Draw and Modify
/// panels.
pub(crate) fn tools() -> impl Iterator<Item = (&'static str, &'static str, &'static [u8])> {
PANELS
.iter()
.flat_map(|panel| panel.tools)
.map(|tool| (tool.command, tool.label, tool.icon))
}
fn panel_for_dropdown(id: &str) -> Option<Panel> {
PANELS.iter().copied().find(|panel| {
panel.id == id
@@ -144,22 +207,58 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> {
panel_for_dropdown(id).map(|panel| panel.id)
}
pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Element<'a, Message> {
let Some(panel) = PANELS
.iter()
.find(|panel| panel.title == title && !panel.tools.is_empty())
else {
/// The slide-out under the title of the ribbon group `title`, whose items
/// are `group_ids`.
fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option<Panel> {
PANELS.iter().copied().find(|panel| {
panel.title == title
&& !panel.tools.is_empty()
&& panel.anchor.is_none_or(|anchor| group_ids.contains(&anchor))
})
}
/// Every command the slide-out of the group `title` offers: each tool and
/// each of its submenu options.
pub(super) fn slide_out_commands(
title: &str,
group_ids: &[&'static str],
) -> impl Iterator<Item = &'static str> {
panel_for_group(title, group_ids)
.into_iter()
.flat_map(|panel| panel.tools)
.flat_map(|tool| {
std::iter::once(tool.command).chain(tool.options.iter().map(|(cmd, _)| *cmd))
})
}
/// A slide-out tool is lit while it or one of its submenu options runs.
fn tool_is_active(tool: &Tool, active_tool: Option<&str>) -> bool {
active_tool.is_some_and(|active| {
tool.command == active || tool.options.iter().any(|(cmd, _)| *cmd == active)
})
}
pub(super) fn group_title<'a>(
title: &'static str,
group_ids: &[&'static str],
open: &Option<String>,
active_tool: Option<&str>,
) -> Element<'a, Message> {
let Some(panel) = panel_for_group(title, group_ids) else {
return container(text(t!(title)).size(9).style(muted_text_style))
.padding([1, 4])
.into();
};
let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id);
// A command running from the closed slide-out lights its title, the way a
// dropdown lights while one of its items runs.
let lit = expanded || panel.tools.iter().any(|tool| tool_is_active(tool, active_tool));
let arrow = if expanded {
icons::themed_arrow_up(GROUP_TITLE_ARROW_SIZE)
} else {
icons::themed_arrow_down(GROUP_TITLE_ARROW_SIZE)
};
PosReport::new(
let title_button: Element<'a, Message> = PosReport::new(
panel.id,
button(
row![
@@ -170,10 +269,41 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Ele
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(panel.id.to_string()))
.style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status))
.style(move |theme: &Theme, status| tool_btn_style(theme, lit, status))
.padding([1, 4]),
)
.into()
.into();
if panel.id != REFERENCE_PANEL_ID {
return title_button;
}
// The corner arrow opens the External References palette.
let launcher = tooltip(
// A right arrow turned to point into the corner.
button(
iced::widget::svg(icons::themed_handle(icons::ARROW_LONG_RIGHT))
.width(10)
.height(10)
.rotation(iced::Radians(std::f32::consts::FRAC_PI_4))
.style(|theme: &Theme, _| iced::widget::svg::Style {
color: Some(theme.palette().background.base.text),
}),
)
.on_press(Message::RibbonToolClick {
tool_id: "EXTERNALREFERENCES".to_string(),
event: crate::modules::ModuleEvent::Command("EXTERNALREFERENCES".to_string()),
})
.style(move |theme: &Theme, status| tool_btn_style(theme, false, status))
.padding([0, 3]),
make_tip(format!("{}\n{} EXTERNALREFERENCES", t!("External References"), t!("Command:"))),
tooltip::Position::Bottom,
)
.delay(Duration::from_millis(400))
.style(tip_style);
// Shrink-sized: a Fill here would make the panel claim the whole row.
row![title_button, launcher]
.spacing(6)
.align_y(iced::Center)
.into()
}
fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'static, Message> {
@@ -211,8 +341,70 @@ fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'st
}
}
/// The Reference slide-out: Edit Reference, and xref fading on/off with its
/// amount (XDWGFADECTL; the switch keeps the amount as a negative value).
fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Element<'a, Message> {
let width: f32 = 260.0_f32.min((win.0 - 8.0).max(120.0));
let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0);
let left = x.clamp(0.0, (win.0 - width).max(0.0));
let top = anchor_top.min((win.1 - 80.0).max(0.0));
let tool = &panel.tools[0];
let edit = button(
row![
make_icon(IconKind::Svg(tool.icon), 16.0),
text(t!(tool.label)).size(11)
]
.spacing(6)
.align_y(iced::Center),
)
.on_press(Message::DropdownSelectItem {
dropdown_id: panel.id,
cmd: tool.command,
})
.style(popup_row_style)
.width(Fill)
.padding([3, 6]);
let fade = ribbon.xref_fade;
let on = fade > 0;
let switch = button(make_icon(
IconKind::Svg(include_bytes!("../../../assets/icons/underlay_frames.svg")),
16.0,
))
.on_press(Message::XrefFadeToggle)
.style(move |theme: &Theme, status| tool_btn_style(theme, on, status))
.padding(3);
let amount = fade.unsigned_abs().min(90) as u8;
let fading = row![
tooltip(
switch,
make_tip(format!("{}\n{} XDWGFADECTL", t!("Xref fading"), t!("Command:"))),
tooltip::Position::Bottom
)
.delay(Duration::from_millis(400))
.style(tip_style),
text(t!("Xref fading")).size(11),
iced::widget::slider(0..=90u8, amount, Message::XrefFadeSlide)
.on_release(Message::XrefFadeCommit)
.width(Fill),
text(amount.to_string()).size(11).width(22),
]
.spacing(6)
.align_y(iced::Center);
let body = container(column![edit, fading].spacing(4).padding(6))
.width(width)
.style(|theme: &Theme| {
let mut style = popup_panel_style(theme);
style.border.color = theme.palette().primary.base.color;
style
});
dropdown_backdrop(position_ribbon_dropdown(body.into(), false, left, top))
}
pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> {
let panel = panel_for_dropdown(id).expect("registered ribbon extension");
if panel.id == REFERENCE_PANEL_ID {
return reference_overlay(ribbon, panel, win);
}
let tools = panel.tools;
let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0));
let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0);
@@ -259,7 +451,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element
.map(|tool| {
tool_button(
tool,
ribbon.active_tool.as_deref() == Some(tool.command),
tool_is_active(tool, ribbon.active_tool.as_deref()),
panel.id,
)
})
+283
View File
@@ -0,0 +1,283 @@
//! Where the ribbon offers a command, so a command started from the command
//! line, an alias, a shortcut or Repeat lights the same button a click on it
//! would. Seeing which button runs a typed command is how its place in the
//! ribbon is learned.
use crate::modules::{ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
use super::{draw_panel, item_id, Ribbon};
/// One button of a group: the id its highlight is keyed on, and the command
/// line it dispatches (`None` for a button that raises a non-command event).
type Entry<'a> = (&'static str, Option<&'a str>);
fn tool_entry(t: &ToolDef) -> Entry<'_> {
match &t.event {
ModuleEvent::Command(cmd) => (t.id, Some(cmd.as_str())),
_ => (t.id, None),
}
}
/// The buttons one ribbon item shows: itself, the items of a dropdown, or the
/// small tools under a combo. A dropdown item is keyed on the command it
/// dispatches, which is what the dropdown's highlight looks for.
fn item_entries(item: &RibbonItem) -> Vec<Entry<'_>> {
match item {
RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => {
vec![tool_entry(t)]
}
RibbonItem::PropertiesGroup { match_prop } => vec![tool_entry(match_prop)],
RibbonItem::Dropdown { items, .. }
| RibbonItem::LabeledDropdown { items, .. }
| RibbonItem::LargeDropdown { items, .. } => {
items.iter().map(|(cmd, _, _)| (*cmd, Some(*cmd))).collect()
}
RibbonItem::ToolGrid { columns } | RibbonItem::StyleComboGroup { rows: columns, .. } => {
columns.iter().flatten().map(tool_entry).collect()
}
RibbonItem::LayerComboGroup { row2, row3 } => {
row2.iter().chain(row3).map(tool_entry).collect()
}
}
}
/// Every button `group` offers: its items' buttons and the tools of its title
/// slide-out, keyed like dropdown items on the command they dispatch.
pub(super) fn group_entries(group: &RibbonGroup) -> Vec<Entry<'_>> {
let mut out: Vec<_> = group.tools.iter().flat_map(item_entries).collect();
let ids: Vec<_> = group.tools.iter().filter_map(item_id).collect();
out.extend(draw_panel::slide_out_commands(group.title, &ids).map(|cmd| (cmd, Some(cmd))));
out
}
/// Whether `item` shows the button keyed `id`, lit or as the item a lit
/// dropdown holds.
pub(super) fn item_holds(item: &RibbonItem, id: &str) -> bool {
item_entries(item).iter().any(|(entry, _)| *entry == id)
}
/// Whether `group` holds the button keyed `id`.
pub(super) fn group_holds(group: &RibbonGroup, id: &str) -> bool {
group_entries(group).iter().any(|(entry, _)| *entry == id)
}
impl Ribbon {
/// Every button of every tab, with the index of the tab it is on. The
/// tab on show comes first, so a command offered on two tabs lights the
/// button the user can already see.
fn entries(&self) -> impl Iterator<Item = (usize, Entry<'_>)> {
let order = std::iter::once(self.active)
.chain((0..self.modules.len()).filter(move |i| *i != self.active));
order
.filter_map(|i| self.modules.get(i).map(|m| (i, m)))
.flat_map(|(i, module)| {
module
.ribbon_groups()
.iter()
.flat_map(group_entries)
.map(move |entry| (i, entry))
})
}
/// The button id that runs the command line `cmd`: an exact match first,
/// then a button that runs its bare verb (`OFFSET 5` → Offset).
pub(super) fn tool_for_command(&self, cmd: &str) -> Option<&'static str> {
let cmd = cmd.trim();
let verb = cmd.split_whitespace().next().unwrap_or(cmd);
[cmd, verb].into_iter().find_map(|want| {
self.entries()
.find(|(_, (_, runs))| runs.is_some_and(|runs| runs.eq_ignore_ascii_case(want)))
.map(|(_, (id, _))| id)
})
}
/// Light the ribbon button of the command line `cmd` that is starting,
/// however it was started. A button the user just clicked to run `cmd`
/// stays lit, so a command offered on two tabs keeps the clicked one; a
/// command with no button clears the previous command's highlight.
pub fn show_command(&mut self, cmd: &str) {
let clicked = self.active_tool.as_deref().is_some_and(|current| {
self.entries().any(|(_, (id, runs))| {
id == current && runs.is_some_and(|runs| runs.eq_ignore_ascii_case(cmd.trim()))
})
});
if !clicked {
self.active_tool = self.tool_for_command(cmd).map(str::to_string);
}
}
/// The tab holding the lit button, when it is not the tab on show: its
/// header lights instead, pointing at where the button lives. The Layout
/// module has no tab (its tools are on the side toolbar).
pub(super) fn tab_holding_active_tool(&self) -> Option<usize> {
let id = self.active_tool.as_deref()?;
let holds = |i: usize| {
self.modules[i]
.ribbon_groups()
.iter()
.any(|group| group_holds(group, id))
};
if self
.modules
.get(self.active)
.is_some_and(|_| holds(self.active))
{
return None;
}
(0..self.modules.len()).find(|i| self.modules[*i].id() != "layout" && holds(*i))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modules::{CadModule, IconKind};
struct TestTab {
id: &'static str,
groups: Vec<RibbonGroup>,
}
impl CadModule for TestTab {
fn id(&self) -> &'static str {
self.id
}
fn title(&self) -> &'static str {
self.id
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
&self.groups
}
}
fn tool(id: &'static str, cmd: &str) -> ToolDef {
ToolDef {
id,
label: id,
icon: IconKind::Glyph("x"),
event: ModuleEvent::Command(cmd.to_string()),
}
}
/// Home: OFFSET (button id differs from its command), a CIRCLE dropdown,
/// LAYERS (a non-command event). View: ZOOM EXTENTS and a second OFFSET.
fn ribbon() -> Ribbon {
let mut layers = tool("LAYERS", "");
layers.event = ModuleEvent::ToggleLayers;
let home = TestTab {
id: "home",
groups: vec![RibbonGroup {
title: "Modify",
tools: vec![
RibbonItem::LargeTool(tool("OFFSET_TOOL", "OFFSET")),
RibbonItem::Dropdown {
id: "circle_dd",
icon: IconKind::Glyph("o"),
items: vec![
("CIRCLE", "Center, Radius", IconKind::Glyph("o")),
("CIRCLE_2P", "2-Point", IconKind::Glyph("o")),
],
default: "CIRCLE",
},
RibbonItem::Tool(layers),
],
}],
};
let view = TestTab {
id: "view",
groups: vec![RibbonGroup {
title: "Navigate",
tools: vec![
RibbonItem::Tool(tool("ZOOM_EXTENTS", "ZOOM EXTENTS")),
RibbonItem::Tool(tool("OFFSET_VIEW", "OFFSET")),
],
}],
};
let mut ribbon = Ribbon::default();
ribbon.set_modules(vec![Box::new(home), Box::new(view)]);
ribbon
}
#[test]
fn a_command_lights_the_button_that_runs_it_not_one_named_like_it() {
let mut r = ribbon();
r.show_command("OFFSET");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL"));
assert_eq!(r.tab_holding_active_tool(), None);
}
#[test]
fn a_dropdown_item_is_keyed_on_its_command_so_the_dropdown_lights() {
let mut r = ribbon();
r.show_command("circle_2p");
assert_eq!(r.active_tool.as_deref(), Some("CIRCLE_2P"));
}
#[test]
fn a_button_on_another_tab_lights_that_tab() {
let mut r = ribbon();
r.show_command("ZOOM EXTENTS");
assert_eq!(r.active_tool.as_deref(), Some("ZOOM_EXTENTS"));
assert_eq!(r.tab_holding_active_tool(), Some(1));
r.select(1);
assert_eq!(r.tab_holding_active_tool(), None);
}
#[test]
fn a_command_on_two_tabs_prefers_the_tab_on_show_then_the_clicked_one() {
let mut r = ribbon();
r.select(1);
r.show_command("OFFSET");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW"));
// A clicked button keeps its highlight when its command dispatches,
// even though another button for the same command comes first.
r.select(0);
r.activate_tool("OFFSET_VIEW");
r.show_command("OFFSET");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW"));
}
#[test]
fn arguments_fall_back_to_the_bare_verb() {
let mut r = ribbon();
r.show_command("OFFSET 5");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL"));
}
#[test]
fn a_command_without_a_button_clears_the_previous_highlight() {
let mut r = ribbon();
r.show_command("OFFSET");
r.show_command("DIST");
assert_eq!(r.active_tool, None);
assert_eq!(r.tab_holding_active_tool(), None);
// A non-command button has no command line to match.
r.show_command("LAYERS");
assert_eq!(r.active_tool, None);
}
#[test]
fn slide_out_tools_and_their_options_belong_to_their_group() {
// The Draw group's title slide-out holds SPLINE; the Modify one holds
// the draw-order submenu.
let draw = RibbonGroup {
title: "Draw",
tools: vec![],
};
assert!(group_holds(&draw, "SPLINE"));
let modify = RibbonGroup {
title: "Modify",
tools: vec![],
};
assert!(group_holds(&modify, "DRAWORDER_BACK"));
}
#[test]
fn the_shipped_ribbon_resolves_its_own_commands() {
let mut r = Ribbon::default();
for cmd in ["LINE", "OFFSET", "SPLINE", "ZOOM EXTENTS"] {
r.show_command(cmd);
assert!(r.active_tool.is_some(), "{cmd} has no ribbon button");
}
}
}
+174 -85
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use rustc_hash::FxHashMap as HashMap;
use acadrust::types::{Color as AcadColor, LineWeight};
use codec::types::{Color as AcadColor, LineWeight};
use iced::widget::{button, column, container, mouse_area, row, scrollable, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
@@ -20,13 +20,17 @@ use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem};
use crate::plugin::all_ribbon_modules;
use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem};
mod widgets;
pub(crate) mod widgets;
mod draw_panel;
pub(crate) use draw_panel::tools as panel_tools;
mod modify_panel;
mod color_dropdown;
mod context_tools;
pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext};
use widgets::{StyleContext, *};
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
mod collapse;
mod locate;
use collapse::{CollapsePanels, Panel};
pub use collapse::CollapseMode;
use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
@@ -54,6 +58,10 @@ pub struct Ribbon {
pub show_ucs_icon: bool,
/// Properties panel (PROPERTIES) visibility — drives the Properties button highlight.
pub show_properties: bool,
/// The Count palette is open (View › Palettes › Count).
pub show_count_palette: bool,
/// Sheet Set Manager palette shown (SSMSTATE).
pub show_sheet_set: bool,
/// Graphic Attributes panel visibility — drives its ribbon button highlight.
pub show_graphic_attributes: bool,
/// File tabs (FILETAB) visibility — drives the File Tabs button highlight.
@@ -104,6 +112,14 @@ pub struct Ribbon {
/// Set by `CollapsePanels` when the tool row is in its tight state; the mode
/// selector hides itself then to give the cramped tab row its space back.
collapse_tight: Arc<AtomicBool>,
/// The selected PDF underlay's switches while only underlays are selected.
underlay_ctx: Option<UnderlayContext>,
/// Only xrefs are selected.
xref_ctx: bool,
/// Only point clouds are selected: whether the first shows its crops.
point_cloud_ctx: Option<bool>,
/// XDWGFADECTL as the Reference slide-out shows it (negative = off).
pub xref_fade: i32,
}
/// Per-layer display data shown in the ribbon layer dropdown.
@@ -114,6 +130,8 @@ pub struct LayerInfo {
pub visible: bool,
pub frozen: bool,
pub locked: bool,
/// Layout with an active viewport: (its Layer Manager column, frozen there).
pub vp_frozen: Option<(usize, bool)>,
}
/// Full-screen backdrop for an open ribbon dropdown: closes the dropdown when
@@ -164,6 +182,8 @@ impl Ribbon {
show_viewcube: true,
show_ucs_icon: true,
show_properties: true,
show_count_palette: false,
show_sheet_set: false,
show_graphic_attributes: false,
show_file_tabs: true,
show_layout_tabs: true,
@@ -198,6 +218,10 @@ impl Ribbon {
tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())),
collapse_mode: CollapseMode::default(),
collapse_tight: Arc::new(AtomicBool::new(false)),
underlay_ctx: None,
xref_ctx: false,
point_cloud_ctx: None,
xref_fade: 50,
}
}
@@ -291,6 +315,10 @@ impl Ribbon {
pub fn activate_tool(&mut self, id: &str) {
self.active_tool = Some(id.to_string());
}
#[cfg(test)]
pub(crate) fn active_tool(&self) -> Option<&str> {
self.active_tool.as_deref()
}
pub fn deactivate_tool(&mut self) {
self.active_tool = None;
}
@@ -318,6 +346,12 @@ impl Ribbon {
pub fn set_properties(&mut self, on: bool) {
self.show_properties = on;
}
pub fn set_count_palette(&mut self, on: bool) {
self.show_count_palette = on;
}
pub fn set_sheet_set(&mut self, on: bool) {
self.show_sheet_set = on;
}
pub fn set_graphic_attributes(&mut self, on: bool) {
self.show_graphic_attributes = on;
}
@@ -338,13 +372,64 @@ impl Ribbon {
show_viewcube: self.show_viewcube,
show_ucs_icon: self.show_ucs_icon,
show_properties: self.show_properties,
show_count_palette: self.show_count_palette,
show_graphic_attributes: self.show_graphic_attributes,
show_block_palette,
show_sheet_set: self.show_sheet_set,
show_file_tabs: self.show_file_tabs,
show_layout_tabs: self.show_layout_tabs,
}
}
/// One panel in its four densities; `lead` adds controls before the
/// group's own tools.
fn panel<'a>(
&'a self,
g: &'a RibbonGroup,
ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>,
lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>,
) -> Panel<'a> {
let group = |compact: bool| {
render_group(
compact,
g,
lead(compact),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
style_ctx,
)
};
let button = |tight: bool| {
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
style_ctx,
tight,
)
};
Panel {
id: g.title.to_string(),
elements: [group(false), group(true), button(false), button(true)],
}
}
pub fn toggle_dropdown(&mut self, id: &str) {
if self.open_dropdown.as_deref() == Some(id) {
self.open_dropdown = None;
@@ -423,6 +508,14 @@ impl Ribbon {
self.close_dropdown();
}
/// Show `cmd` as the current item of a dropdown that mirrors a drawing
/// setting, without running it.
pub fn set_dropdown_current(&mut self, dropdown_id: &'static str, cmd: &'static str) {
if self.last_cmd.get(dropdown_id) != Some(&cmd) {
self.last_cmd.insert(dropdown_id, cmd);
}
}
// ── View ──────────────────────────────────────────────────────────────
pub fn view(
@@ -435,7 +528,7 @@ impl Ribbon {
) -> Element<'_, Message> {
// ── Quick-access file commands + undo/redo, one merged flow ────────
let lead = iced::widget::Row::with_children(vec![
quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW").into(),
quick_access_btn(crate::ui::icons::DOC_NEW, "New", "QNEW").into(),
quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN").into(),
quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE").into(),
quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS").into(),
@@ -451,6 +544,10 @@ impl Ribbon {
// The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks
// them so a wrapped tab never shares a row with a quick-access button.
// A running command whose button is on another tab tints that tab's
// header with a faint wash of the button's blue, pointing at where the
// button lives without competing with the selected tab's outline.
let holding_tab = self.tab_holding_active_tool();
let tab_items = self.modules.iter().enumerate().fold(
Vec::<Element<'_, Message>>::new(),
|mut acc, (i, module)| {
@@ -462,6 +559,7 @@ impl Ribbon {
let is_active = i == self.active;
let is_contextual = module.id() == "layout";
let holds_tool = holding_tab == Some(i);
let btn = container(
button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12))
.on_press(Message::RibbonSelectTab(i))
@@ -474,6 +572,7 @@ impl Ribbon {
};
let pair = match (is_active, status) {
(true, _) => palette.background.weakest,
(false, _) if holds_tool => palette.primary.weak,
(false, button::Status::Hovered) => {
if is_contextual {
palette.warning.weak
@@ -485,10 +584,17 @@ impl Ribbon {
};
button::Style {
background: (is_active
|| holds_tool
|| matches!(status, button::Status::Hovered))
.then_some(Background::Color(pair.color)),
.then_some(Background::Color(if holds_tool && !is_active {
pair.color.scale_alpha(0.35)
} else {
pair.color
})),
text_color: if is_active {
pair.text
} else if holds_tool {
palette.background.base.text
} else if is_contextual {
accent.color
} else {
@@ -530,6 +636,7 @@ impl Ribbon {
},
);
// Tabs may squeeze their gaps to fit before wrapping: from the normal 6px
// down to -12px on a narrow (e.g. phone) tab row, tucking neighbours into
// each other's 14px side padding without overlapping the labels. When the
@@ -609,73 +716,10 @@ impl Ribbon {
// fit they degrade from the right — a panel's large buttons first
// shrink to compact icon columns, then it collapses to a ▾ flyout
// button. See `CollapsePanels`.
let ts = self.toggle_state(show_block_palette);
let panels: Vec<Panel<'_>> = groups
.iter()
.map(|g| {
let ts = self.toggle_state(show_block_palette);
Panel {
id: g.title.to_string(),
elements: [render_group(
false,
g,
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
),
render_group(
true,
g,
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
),
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
false,
),
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
&style_ctx,
true,
),
],
}
})
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
.collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.report_height(self.tool_bar_h.clone())
@@ -742,6 +786,19 @@ impl Ribbon {
// ── Dropdown overlay ──────────────────────────────────────────────────
/// Place `panel` (`w` wide) under dropdown `id`, closing on a click
/// outside it.
pub fn place_dropdown<'a>(
&self,
id: &str,
panel: Element<'a, Message>,
w: f32,
win_w: f32,
) -> Element<'a, Message> {
let (align_right, h_pad, top) = self.dd_anchor(id, w, win_w);
dropdown_backdrop(position_ribbon_dropdown(panel, align_right, h_pad, top))
}
pub fn dropdown_overlay(
&self,
undo_labels: &[String],
@@ -881,6 +938,7 @@ impl Ribbon {
.iter()
.map(|(cmd, label, item_icon)| {
let is_current = *cmd == last_cmd;
let disabled = widgets::is_disabled_item(cmd);
let checkmark: Element<'_, Message> =
crate::ui::icons::themed_check_cell(is_current);
let icon_el: Element<Message> =
@@ -892,7 +950,7 @@ impl Ribbon {
.size(11)
.wrapping(iced::advanced::text::Wrapping::None)
.style(move |theme: &Theme| iced::widget::text::Style {
color: (!is_current).then_some(
color: (!is_current || disabled).then_some(
theme
.palette()
.background
@@ -907,10 +965,10 @@ impl Ribbon {
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::DropdownSelectItem {
.on_press_maybe((!disabled).then_some(Message::DropdownSelectItem {
dropdown_id: dd_id,
cmd: *cmd,
})
}))
.style(popup_row_style)
.width(Fill)
.padding([4, 10])
@@ -957,11 +1015,10 @@ impl Ribbon {
let rows: Vec<Element<Message>> = self
.layer_infos
.iter()
.enumerate()
.filter(|(_, info)| {
.filter(|info| {
filter.is_empty() || info.name.to_lowercase().contains(&filter)
})
.map(|(index, info)| {
.map(|info| {
let is_active = info.name == self.active_layer;
let lc = info.color;
let lv = info.visible;
@@ -984,16 +1041,23 @@ impl Ribbon {
let vis = icon_btn(
crate::ui::icons::layer_visible(lv),
Message::LayerToggleVisible(index),
Message::LayerToggleVisible(info.name.clone()),
);
let freeze = icon_btn(
crate::ui::icons::layer_freeze(lf),
Message::LayerToggleFreeze(index),
Message::LayerToggleFreeze(info.name.clone()),
);
let lock = icon_btn(
crate::ui::icons::layer_lock(ll),
Message::LayerToggleLock(index),
Message::LayerToggleLock(info.name.clone()),
);
// Freeze in the current viewport only (#1466).
let vp_freeze = info.vp_frozen.map(|(column, frozen)| {
icon_btn(
crate::ui::icons::layer_vp_freeze(frozen),
Message::LayerToggleVpFreeze(info.name.clone(), column),
)
});
let checkmark: Element<'_, Message> =
crate::ui::icons::themed_check_cell(is_active);
let label =
@@ -1019,7 +1083,10 @@ impl Ribbon {
.padding([4, 4]);
container(
row![checkmark, vis, freeze, lock, select]
row![checkmark, vis, freeze]
.push(vp_freeze)
.push(lock)
.push(select)
.spacing(5)
.align_y(iced::Center),
)
@@ -1328,6 +1395,7 @@ impl Ribbon {
fn render_group<'a>(
compact: bool,
group: &RibbonGroup,
lead: Vec<Element<'a, Message>>,
active_tool: &Option<String>,
open_dd: &Option<String>,
last_cmd: &HashMap<&'static str, &'static str>,
@@ -1339,7 +1407,7 @@ fn render_group<'a>(
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = Vec::new();
let mut items_row: Vec<Element<Message>> = lead;
let mut small_buf: Vec<Element<Message>> = Vec::new();
let ctx = widgets::RenderCtx {
@@ -1392,7 +1460,12 @@ fn render_group<'a>(
column![
tools_el,
draw_panel::group_title(group.title, open_dd),
draw_panel::group_title(
group.title,
&group.tools.iter().filter_map(item_id).collect::<Vec<_>>(),
open_dd,
active_tool.as_deref(),
),
]
.align_x(iced::Center)
.spacing(0)
@@ -1473,6 +1546,19 @@ fn collapse_button<'a>(
) -> Element<'a, Message> {
let title = group.title;
let localized_title = t!(title).into_owned();
// A collapsed panel lights its opener while one of its hidden tools runs,
// the way a dropdown lights while one of its items does.
let active = active_tool.as_deref();
let holds = active.is_some_and(|id| locate::group_holds(group, id));
let opener_style = move |lit: bool| {
move |theme: &Theme, status| {
if lit {
tool_btn_style(theme, true, status)
} else {
button::subtle(theme, status)
}
}
};
// Tightest form: one button = the panel's FIRST tool icon + its title + ▾.
// Clicking opens the flyout listing every tool; no tool runs directly at this
@@ -1490,7 +1576,7 @@ fn collapse_button<'a>(
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::WordOrGlyph)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
@@ -1503,7 +1589,7 @@ fn collapse_button<'a>(
.width(Fill),
)
.on_press(Message::ToggleRibbonPanel(title.to_string()))
.style(button::subtle)
.style(opener_style(holds))
.width(Fill)
.padding([3, 5]);
return automatic_large_button(localized_title, content.into());
@@ -1513,6 +1599,8 @@ fn collapse_button<'a>(
// that runs the last-used tool — above a title + ▾ opener for the full flyout.
// For a Properties panel the representative is its Match button.
let rep = representative(group, last_used);
// The face lights itself when it is the running tool.
let face_lit = rep.zip(active).is_some_and(|(item, id)| locate::item_holds(item, id));
let face: Element<'_, Message> = match rep {
Some(RibbonItem::PropertiesGroup { match_prop }) => {
render_large(
@@ -1559,7 +1647,7 @@ fn collapse_button<'a>(
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::WordOrGlyph)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
@@ -1568,7 +1656,7 @@ fn collapse_button<'a>(
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonPanel(title.to_string()))
.style(button::subtle)
.style(opener_style(holds && !face_lit))
.width(Fill)
.padding([1, 4]);
let opener = automatic_large_button(localized_title, opener.into());
@@ -1621,3 +1709,4 @@ mod tests {
assert_eq!(ribbon.open_dropdown, None);
}
}
+31 -9
View File
@@ -5,7 +5,7 @@ use rustc_hash::FxHashMap as HashMap;
use std::cell::RefCell;
use std::time::Duration;
use acadrust::types::{Color as AcadColor, LineWeight};
use codec::types::{Color as AcadColor, LineWeight};
use iced::advanced::{
layout, mouse, overlay, renderer, text as advanced_text, widget, Layout, Shell, Widget,
};
@@ -37,8 +37,10 @@ pub(super) struct ToggleState {
pub show_viewcube: bool,
pub show_ucs_icon: bool,
pub show_properties: bool,
pub show_count_palette: bool,
pub show_graphic_attributes: bool,
pub show_block_palette: bool,
pub show_sheet_set: bool,
pub show_file_tabs: bool,
pub show_layout_tabs: bool,
}
@@ -95,6 +97,11 @@ thread_local! {
RefCell::new(HashMap::default());
}
/// Dropdown items that report a state and cannot be picked.
pub(super) fn is_disabled_item(cmd: &str) -> bool {
cmd == "FRAMES3"
}
fn ribbon_label_bounds(
renderer: &iced::Renderer,
label: &str,
@@ -304,7 +311,7 @@ pub(super) const TOP_HIST_GAP: f32 = 4.0;
pub(crate) const UNDO_HISTORY_ID: &str = "UNDO_HISTORY";
pub(crate) const REDO_HISTORY_ID: &str = "REDO_HISTORY";
pub(super) const LAYER_COMBO_ID: &str = "LAYER_COMBO";
pub(crate) const LAYER_COMBO_ID: &str = "LAYER_COMBO";
/// Dropdown id for the tab-bar panel-density selector.
pub(super) const COLLAPSE_MODE_ID: &str = "COLLAPSE_MODE";
pub(super) const PROP_COLOR_ID: &str = "PROP_COLOR";
@@ -375,8 +382,10 @@ pub(super) fn is_active_tool(
"NAVVCUBE" => state.show_viewcube,
"UCSICON" => state.show_ucs_icon,
"PROPERTIES" => state.show_properties,
"COUNTLIST" => state.show_count_palette,
"GRAPHICATTRIBUTES" => state.show_graphic_attributes,
"BLOCKPALETTE" => state.show_block_palette,
"SHEETSET" => state.show_sheet_set,
"FILETAB" => state.show_file_tabs,
"LAYOUTTAB" => state.show_layout_tabs,
id => active_tool.as_deref() == Some(id),
@@ -443,7 +452,7 @@ pub(super) fn combo_btn_style(
}
}
pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style {
pub(crate) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
palette.background.weak
@@ -457,7 +466,7 @@ pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::
}
}
pub(super) fn popup_panel_style(theme: &Theme) -> container::Style {
pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
@@ -667,18 +676,31 @@ pub(super) fn render_small<'a>(
items.iter().find(|(candidate, _, _)| *candidate == cmd)
.map(|(_, _, item_icon)| *item_icon)
}).or_else(|| items.first().map(|(_, _, item_icon)| *item_icon)).unwrap_or(*icon);
let localized_label = t!(*label).into_owned();
let localized_label = if label.is_empty() {
items
.iter()
.find(|(cmd, _, _)| *cmd == last)
.map(|(_, item_label, _)| t!(*item_label).into_owned())
.unwrap_or_default()
} else {
t!(*label).into_owned()
};
let face = row![
container(make_icon(cur_icon, SMALL_ICON)).width(Length::Fixed(SMALL_W)),
text(localized_label.clone()).size(10).wrapping(advanced_text::Wrapping::None),
].spacing(3).align_y(iced::Center);
// A state that is only shown (not chosen) opens the list instead.
let face_btn = button(face)
.on_press(Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
.on_press(if is_disabled_item(last) {
Message::ToggleRibbonDropdown(id.to_string())
} else {
Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
}
})
.style(move |theme: &Theme, status| tool_btn_style(theme, active, status))
.width(Length::Fixed(LABELED_SMALL_W)).height(ROW_H).padding([3, 4]);
.width(Length::Shrink).height(ROW_H).padding([3, 4]);
let arrow = button(container(icons::themed_arrow_down(8.0))
.width(Fill).height(Fill).align_x(iced::Center).align_y(iced::Center))
.on_press(Message::ToggleRibbonDropdown(id.to_string()))
+23 -4
View File
@@ -17,6 +17,9 @@ const ICON_SIZE: f32 = 22.0;
const BTN_SIZE: f32 = 38.0;
/// Gap between the toolbar and the right edge of the canvas.
const EDGE_MARGIN: f32 = 8.0;
/// Room kept free at the top right for the view cube and the UCS list; the
/// toolbar is centred in the height below it.
const TOP_RESERVE: f32 = 200.0;
fn icon_el(icon: IconKind) -> Element<'static, Message> {
match icon {
@@ -26,6 +29,8 @@ fn icon_el(icon: IconKind) -> Element<'static, Message> {
}
fn tip_panel(label: &'static str) -> Element<'static, Message> {
// Ribbon labels break over two lines; a tooltip reads them on one.
let label = crate::t!(label).replace('\n', " ");
container(text(label).size(11))
.padding([2, 6])
.style(|theme: &Theme| {
@@ -48,12 +53,21 @@ fn tip_panel(label: &'static str) -> Element<'static, Message> {
/// centred over the canvas. Returns `None` when `tools` is empty so the caller
/// can skip pushing an overlay.
pub fn view(tools: &[ToolDef]) -> Option<Element<'static, Message>> {
view_with_active(tools, &|_| false)
}
/// As [`view`], with the tools `active` names drawn as switched on.
pub fn view_with_active(
tools: &[ToolDef],
active: &dyn Fn(&str) -> bool,
) -> Option<Element<'static, Message>> {
if tools.is_empty() {
return None;
}
let mut col = column![].spacing(4).align_x(iced::Center);
for t in tools {
let on = active(t.id);
let btn = button(icon_el(t.icon))
.on_press(Message::RibbonToolClick {
tool_id: t.id.to_string(),
@@ -61,18 +75,22 @@ pub fn view(tools: &[ToolDef]) -> Option<Element<'static, Message>> {
})
.width(Length::Fixed(BTN_SIZE))
.height(Length::Fixed(BTN_SIZE))
.style(|theme: &Theme, status| {
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let hovered = matches!(
status,
button::Status::Hovered | button::Status::Pressed
);
button::Style {
background: hovered
.then_some(Background::Color(palette.background.strong.color)),
background: if on {
Some(Background::Color(palette.primary.weak.color))
} else {
hovered.then_some(Background::Color(palette.background.strong.color))
},
border: Border {
radius: 3.0.into(),
..Default::default()
color: palette.primary.base.color,
width: if on { 1.0 } else { 0.0 },
},
text_color: palette.background.base.text,
..Default::default()
@@ -107,6 +125,7 @@ pub fn view(tools: &[ToolDef]) -> Option<Element<'static, Message>> {
.align_y(iced::Center)
.padding(iced::Padding {
right: EDGE_MARGIN,
top: TOP_RESERVE,
..iced::Padding::ZERO
})
.into(),
+180 -36
View File
@@ -1,6 +1,7 @@
//! Dimension Style Manager window — fills the entire OS window.
use crate::app::{ColorPickTarget, DsField, Message};
use crate::scene::convert::tessellate::{arrow_from_block_name, ArrowKind};
use iced::widget::{
button, canvas, checkbox, column, container, row, scrollable, text, text_input, Space,
};
@@ -171,11 +172,32 @@ struct DimensionPreview {
dim2: bool,
tick: bool,
arrow_size: f32,
arrow1: ArrowKind,
arrow2: ArrowKind,
text_above: bool,
basic: bool,
text: String,
}
/// Resolve the staged arrowhead block names to per-end preview kinds: the
/// shared `dimblk` when Dimsah is off, the per-end blocks when on — the same
/// selection the renderer makes. `dimasz` is the preview arrow size so
/// size-scaled kinds (SMALL, DOTSMALL) keep their proportions.
fn preview_arrow_kinds(
dimsah: bool,
dimblk: &str,
dimblk1: &str,
dimblk2: &str,
dimasz: f32,
) -> (ArrowKind, ArrowKind) {
let kind = |name: &str| arrow_from_block_name(Some(name), dimasz);
if dimsah {
(kind(dimblk1), kind(dimblk2))
} else {
(kind(dimblk), kind(dimblk))
}
}
impl canvas::Program<Message> for DimensionPreview {
type State = ();
@@ -218,23 +240,107 @@ impl canvas::Program<Message> for DimensionPreview {
}
let size = self.arrow_size.clamp(5.0, 14.0);
for (tip_x, sign) in [(x1, 1.0_f32), (x2, -1.0_f32)] {
// Per-end arrowheads: each end draws its own resolved kind so split
// (Dimsah) configurations read correctly. A global DIMTSZ tick still
// overrides both ends, matching AutoCAD precedence.
let tick_path = |tip_x: f32, tick_size: f32| {
canvas::Path::line(
Point::new(tip_x - tick_size * 0.55, y + tick_size * 0.75),
Point::new(tip_x + tick_size * 0.55, y - tick_size * 0.75),
)
};
for (kind, tip_x, sign) in [(&self.arrow1, x1, 1.0_f32), (&self.arrow2, x2, -1.0_f32)] {
if self.tick {
frame.stroke(
&line(
Point::new(tip_x - size * 0.55, y + size * 0.75),
Point::new(tip_x + size * 0.55, y - size * 0.75),
),
&tick_path(tip_x, size),
canvas::Stroke::default().with_color(guide).with_width(1.6),
);
} else {
let arrow = canvas::Path::new(|path| {
path.move_to(Point::new(tip_x, y));
path.line_to(Point::new(tip_x + sign * size, y - size * 0.42));
path.line_to(Point::new(tip_x + sign * size, y + size * 0.42));
path.close();
});
frame.fill(&arrow, guide);
continue;
}
match kind {
ArrowKind::None => {}
ArrowKind::Triangle { size, filled, size_mul } => {
let s = size * size_mul;
let arrow = canvas::Path::new(|path| {
path.move_to(Point::new(tip_x, y));
path.line_to(Point::new(tip_x + sign * s, y - s * 0.42));
path.line_to(Point::new(tip_x + sign * s, y + s * 0.42));
path.close();
});
if *filled {
frame.fill(&arrow, guide);
} else {
frame.stroke(&arrow, canvas::Stroke::default().with_color(guide).with_width(1.4));
}
}
ArrowKind::Open { size, half_angle } => {
let (sin, cos) = half_angle.sin_cos();
for side in [-1.0_f32, 1.0_f32] {
frame.stroke(
&line(
Point::new(tip_x, y),
Point::new(tip_x + sign * size * cos, y + side * size * sin),
),
canvas::Stroke::default().with_color(guide).with_width(1.4),
);
}
}
ArrowKind::Dot { size, filled } => {
let dot = canvas::Path::circle(Point::new(tip_x + sign * size * 0.5, y), size * 0.5);
if *filled {
frame.fill(&dot, guide);
} else {
frame.stroke(&dot, canvas::Stroke::default().with_color(guide).with_width(1.4));
}
}
ArrowKind::Tick { size } => {
frame.stroke(
&tick_path(tip_x, *size),
canvas::Stroke::default().with_color(guide).with_width(1.6),
);
}
ArrowKind::Box_ { size, filled } => {
let half = size * 0.45;
let square = canvas::Path::rectangle(
Point::new(tip_x + sign * half - half, y - half),
iced::Size::new(half * 2.0, half * 2.0),
);
if *filled {
frame.fill(&square, guide);
} else {
frame.stroke(&square, canvas::Stroke::default().with_color(guide).with_width(1.4));
}
}
// Datum/Origin have dedicated CAD geometry; the preview keeps
// the schematic triangle/dot respectively.
ArrowKind::Datum { size, filled } => {
let arrow = canvas::Path::new(|path| {
path.move_to(Point::new(tip_x, y));
path.line_to(Point::new(tip_x + sign * size, y - size * 0.42));
path.line_to(Point::new(tip_x + sign * size, y + size * 0.42));
path.close();
});
if *filled {
frame.fill(&arrow, guide);
} else {
frame.stroke(&arrow, canvas::Stroke::default().with_color(guide).with_width(1.4));
}
}
ArrowKind::Origin { size } => {
let dot = canvas::Path::circle(Point::new(tip_x + sign * size * 0.5, y), size * 0.4);
frame.stroke(&dot, canvas::Stroke::default().with_color(guide).with_width(1.4));
}
// Custom DWG block geometry cannot render in the schematic
// preview; fall back to the standard filled triangle.
ArrowKind::Custom { .. } => {
let arrow = canvas::Path::new(|path| {
path.move_to(Point::new(tip_x, y));
path.line_to(Point::new(tip_x + sign * size, y - size * 0.42));
path.line_to(Point::new(tip_x + sign * size, y + size * 0.42));
path.close();
});
frame.fill(&arrow, guide);
}
}
}
@@ -384,6 +490,29 @@ pub fn view_window<'a>(
};
// Linear unit formats shared by DIMLUNIT / DIMALTU.
// Precision reads as the number pattern it produces (#1426).
const OPT_PREC: &[(&str, &str)] = &[
("0", "0"),
("1", "0.0"),
("2", "0.00"),
("3", "0.000"),
("4", "0.0000"),
("5", "0.00000"),
("6", "0.000000"),
("7", "0.0000000"),
("8", "0.00000000"),
];
// A precision dropdown, or the read-only value while the option is off.
let prec_field = move |label: Cow<'static, str>, fld: DsField, val: &'a str, enabled: bool| -> Element<'a, Message> {
if enabled {
enum_field(label, fld, val, OPT_PREC)
} else {
row![lbl(label), mk_field_enabled(fld, val, false)]
.spacing(8)
.align_y(iced::Center)
.into()
}
};
const OPT_LUNIT: &[(&str, &str)] = &[
("1", "Scientific"),
("2", "Decimal"),
@@ -900,8 +1029,7 @@ pub fn view_window<'a>(
4 => column![
text(t!("Linear dimensions")).size(11).style(primary_style),
enum_field(t!("Unit format"), DsField::Dimlunit, vals.dimlunit, OPT_LUNIT),
row![lbl(t!("Precision")), mk_field(DsField::Dimdec, vals.dimdec)]
.spacing(8).align_y(iced::Center),
prec_field(t!("Precision"), DsField::Dimdec, vals.dimdec, true),
if matches!(vals.dimlunit.trim(), "4" | "5") {
enum_field(
t!("Fraction format"),
@@ -946,8 +1074,7 @@ pub fn view_window<'a>(
vals.dimaunit,
&[("0", "Decimal degrees"), ("1", "Degrees, minutes, seconds"), ("2", "Gradians"), ("3", "Radians"), ("4", "Surveyor's units")],
),
row![lbl(t!("Precision")), mk_field(DsField::Dimadec, vals.dimadec)]
.spacing(8).align_y(iced::Center),
prec_field(t!("Precision"), DsField::Dimadec, vals.dimadec, true),
enum_field(
t!("Zero suppression"),
DsField::Dimazin,
@@ -970,24 +1097,14 @@ pub fn view_window<'a>(
]
.spacing(8)
.align_y(iced::Center),
row![
lbl(t!("Precision")),
mk_field_enabled(DsField::Dimaltd, vals.dimaltd, vals.dimalt)
]
.spacing(8)
.align_y(iced::Center),
prec_field(t!("Precision"), DsField::Dimaltd, vals.dimaltd, vals.dimalt),
if vals.dimalt {
enum_field(t!("Unit format"), DsField::Dimaltu, vals.dimaltu, OPT_LUNIT)
} else {
row![lbl(t!("Unit format")), container(text(alternate_unit_name).size(11).style(muted_style)).padding([4, 7]).width(150)]
.spacing(8).align_y(iced::Center).into()
},
row![
lbl(t!("Tolerance precision")),
mk_field_enabled(DsField::Dimalttd, vals.dimalttd, vals.dimalt)
]
.spacing(8)
.align_y(iced::Center),
prec_field(t!("Tolerance precision"), DsField::Dimalttd, vals.dimalttd, vals.dimalt),
row![
lbl(t!("Round off")),
mk_field_enabled(DsField::Dimaltrnd, vals.dimaltrnd, vals.dimalt)
@@ -1042,12 +1159,7 @@ pub fn view_window<'a>(
]
.spacing(8)
.align_y(iced::Center),
row![
lbl(t!("Precision")),
mk_field_enabled(DsField::Dimtdec, vals.dimtdec, vals.dimtol || vals.dimlim)
]
.spacing(8)
.align_y(iced::Center),
prec_field(t!("Precision"), DsField::Dimtdec, vals.dimtdec, vals.dimtol || vals.dimlim),
row![
lbl(t!("Height scale")),
mk_field_enabled(DsField::Dimtfac, vals.dimtfac, vals.dimtol || vals.dimlim)
@@ -1121,13 +1233,23 @@ pub fn view_window<'a>(
preview_text.push_str(&format!(" +{} −{}", vals.dimtp.trim(), vals.dimtm.trim()));
}
}
let arrow_size = vals.dimasz.trim().parse::<f32>().unwrap_or(1.0).abs() * 5.0 + 5.0;
let (arrow1, arrow2) = preview_arrow_kinds(
vals.dimsah,
&vals.dimblk_name,
&vals.dimblk1_name,
&vals.dimblk2_name,
arrow_size.clamp(5.0, 14.0),
);
let preview = canvas(DimensionPreview {
ext1: !vals.dimse1,
ext2: !vals.dimse2,
dim1: !vals.dimsd1,
dim2: !vals.dimsd2,
tick: vals.dimtsz.trim().parse::<f32>().unwrap_or(0.0) > 0.0,
arrow_size: vals.dimasz.trim().parse::<f32>().unwrap_or(1.0).abs() * 5.0 + 5.0,
arrow_size,
arrow1,
arrow2,
text_above: vals.dimtad.trim() != "0",
basic: vals.dimgap.trim().starts_with('-'),
text: preview_text,
@@ -1225,3 +1347,25 @@ pub fn view_window<'a>(
editor: right_panel.into(),
})
}
#[cfg(test)]
mod preview_arrow_tests {
// Preview must resolve each end independently: shared block when Dimsah
// is off, per-end blocks when on.
use super::preview_arrow_kinds;
use crate::scene::convert::tessellate::ArrowKind;
#[test]
fn shared_block_feeds_both_ends_when_dimsah_off() {
let (first, second) = preview_arrow_kinds(false, "OPEN", "DOT", "NONE", 10.0);
assert!(matches!(first, ArrowKind::Open { .. }));
assert!(matches!(second, ArrowKind::Open { .. }));
}
#[test]
fn per_end_blocks_feed_each_end_when_dimsah_on() {
let (first, second) = preview_arrow_kinds(true, "OPEN", "DOT", "NONE", 10.0);
assert!(matches!(first, ArrowKind::Dot { .. }));
assert!(matches!(second, ArrowKind::None));
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ fn choices(values: &[(&str, &str)]) -> Vec<EnumChoice> {
pub struct MLeaderStyleView<'a> {
pub styles: Vec<String>,
pub selected: &'a str,
pub style: Option<&'a acadrust::objects::MultiLeaderStyle>,
pub style: Option<&'a codec::objects::MultiLeaderStyle>,
pub current: String,
pub tab: u8,
pub compare_name: String,
+1 -1
View File
@@ -10,7 +10,7 @@ use std::borrow::Cow;
pub struct MlStyleView<'a> {
pub styles: Vec<String>,
pub selected: &'a str,
pub style: Option<&'a acadrust::objects::MLineStyle>,
pub style: Option<&'a codec::objects::MLineStyle>,
pub current: String,
pub tab: u8,
pub compare_name: String,
+1
View File
@@ -10,6 +10,7 @@ pub mod scale_manager;
pub mod anno_object_scale;
pub mod style_manager;
pub mod common;
pub mod row_button;
pub mod form;
pub mod fusion_theme;
+14 -14
View File
@@ -92,11 +92,11 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
/// unit-tested and benched without building widgets. `pub` (not
/// `pub(crate)`) so the `cargo bench` harness (`benches/`, an external
/// crate) can measure it as `ui_plotstyle_layer_usage`.
pub fn build_layer_usage(document: &acadrust::CadDocument) -> Vec<Vec<String>> {
pub fn build_layer_usage(document: &codec::CadDocument) -> Vec<Vec<String>> {
let mut layer_usage = vec![Vec::<String>::new(); 256];
for layer in document.layers.iter() {
if let acadrust::types::Color::Index(aci) = &layer.color {
if let codec::types::Color::Index(aci) = &layer.color {
let index = *aci as usize;
if index > 0 && index < layer_usage.len() {
@@ -114,7 +114,7 @@ pub fn build_layer_usage(document: &acadrust::CadDocument) -> Vec<Vec<String>> {
}
pub fn view_window<'a>(
document: &'a acadrust::CadDocument,
document: &'a codec::CadDocument,
table: Option<&'a crate::io::plot_style::PlotStyleTable>,
selected_aci: u8,
color_buf: &'a str,
@@ -182,7 +182,7 @@ pub fn view_window<'a>(
String::new()
};
let (aci_color, _) = crate::ui::properties::acad_color_display(
acadrust::types::Color::Index(aci),
codec::types::Color::Index(aci),
);
let has_override = table
.and_then(|t| t.aci_entries.get(aci as usize))
@@ -379,14 +379,14 @@ pub fn view_window<'a>(
.strip_prefix('#')
.filter(|hex| hex.len() == 6)
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.map(|rgb| acadrust::types::Color::Rgb {
.map(|rgb| codec::types::Color::Rgb {
r: ((rgb >> 16) & 0xFF) as u8,
g: ((rgb >> 8) & 0xFF) as u8,
b: (rgb & 0xFF) as u8,
});
let display_color = selected_color
.unwrap_or(acadrust::types::Color::Index(selected_aci));
.unwrap_or(codec::types::Color::Index(selected_aci));
let (swatch_color, _) =
crate::ui::properties::acad_color_display(display_color);
@@ -559,19 +559,19 @@ mod layer_usage_tests {
use super::*;
fn indexed_layer(
doc: &mut acadrust::CadDocument,
doc: &mut codec::CadDocument,
name: &str,
aci: u8,
) -> acadrust::tables::Layer {
let mut layer = acadrust::tables::Layer::new(name);
) -> codec::tables::Layer {
let mut layer = codec::tables::Layer::new(name);
layer.handle = doc.allocate_handle();
layer.color = acadrust::types::Color::Index(aci);
layer.color = codec::types::Color::Index(aci);
layer
}
#[test]
fn build_layer_usage_buckets_index_colors() {
let mut doc = acadrust::CadDocument::new();
let mut doc = codec::CadDocument::new();
let a = indexed_layer(&mut doc, "WALLS", 1);
let b = indexed_layer(&mut doc, "doors", 1);
let c = indexed_layer(&mut doc, "ROOF", 3);
@@ -589,10 +589,10 @@ mod layer_usage_tests {
#[test]
fn build_layer_usage_ignores_non_index_colors() {
let mut doc = acadrust::CadDocument::new();
let mut truecolor = acadrust::tables::Layer::new("TRUECOLOR");
let mut doc = codec::CadDocument::new();
let mut truecolor = codec::tables::Layer::new("TRUECOLOR");
truecolor.handle = doc.allocate_handle();
truecolor.color = acadrust::types::Color::Rgb {
truecolor.color = codec::types::Color::Rgb {
r: 10,
g: 20,
b: 30,
+57
View File
@@ -0,0 +1,57 @@
//! Row/header button styles shared by the layers and xref list panels.
//!
//! Centralizes the zebra/selected row-button coloring previously duplicated
//! in `src/ui/window/layers.rs` (`layer_cell_button_style` +
//! `layer_header_button_style`) and `src/ui/window/xref_manager.rs`
//! (`row_button_style`). The two cell copies were functionally byte-identical
//! (Hovered → `background.strong`, selected → `primary.weak`, even →
//! `background.base`, else → `background.weak`); a single source keeps hover,
//! selection, and zebra colors consistent across both panels.
use iced::widget::button;
use iced::{Background, Theme};
/// Cell (row) button style — closure form so both `.style(...)` call sites
/// (`layers.rs`, `xref_manager.rs`) keep their existing shape.
pub(crate) fn cell_button_style(
selected: bool,
index: usize,
) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status: button::Status| {
let palette = theme.palette();
let highlighted = matches!(status, button::Status::Hovered);
let pair = if highlighted {
palette.background.strong
} else if selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
button::Style {
background: highlighted.then_some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
}
/// Column-header button style (layers panel).
pub(crate) fn header_button_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let highlighted = matches!(
status,
button::Status::Hovered | button::Status::Pressed
);
let pair = if highlighted {
palette.background.strong
} else {
palette.background.weak
};
button::Style {
background: highlighted.then_some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub struct TableStyleView<'a> {
pub styles: Vec<String>,
pub selected: &'a str,
pub current: &'a str,
pub style: Option<&'a acadrust::objects::TableStyle>,
pub style: Option<&'a codec::objects::TableStyle>,
pub tab: u8,
pub compare_name: String,
pub compare_opts: Vec<String>,
+9 -7
View File
@@ -480,14 +480,16 @@ fn test_block_palette_contrast() {
// a hardcoded foreground, these equality checks fail on every theme
// whose surface text is not pure white.
let (normal_bg, normal_fg) = block_card_colors(theme, false, BtnStatus::Active);
// Cards sit on the dock's base surface, so they take the weak pair
// to read as cards.
assert_eq!(
normal_bg, p.background.base.color,
"Theme {:?} block card normal background drifted from base pair",
normal_bg, p.background.weak.color,
"Theme {:?} block card normal background drifted from weak pair",
theme
);
assert_eq!(
normal_fg, p.background.base.text,
"Theme {:?} block card normal label must use base text (was hardcoded WHITE)",
normal_fg, p.background.weak.text,
"Theme {:?} block card normal label must use weak text (was hardcoded WHITE)",
theme
);
@@ -505,11 +507,11 @@ fn test_block_palette_contrast() {
theme
);
// Default-branch statuses (Active/Disabled/Focused/...) share the base pair.
// Default-branch statuses (Active/Disabled/Focused/...) share the weak pair.
let (_, disabled_fg) = block_card_colors(theme, false, BtnStatus::Disabled);
assert_eq!(
disabled_fg, p.background.base.text,
"Theme {:?} block card default-state label must use base text",
disabled_fg, p.background.weak.text,
"Theme {:?} block card default-state label must use weak text",
theme
);
+104
View File
@@ -44,6 +44,39 @@ fn info_card<'a>(
.into()
}
/// A full-width label/value line. CPU and GPU names are too long for the
/// fixed-width cards above.
fn info_row<'a>(
label: std::borrow::Cow<'static, str>,
value: impl iced::widget::text::IntoFragment<'a>,
width: Length,
) -> Element<'a, Message> {
container(
row![
text(label).size(11).style(muted_style).width(Length::Fixed(110.0)),
text(value).size(13).width(Fill),
]
.spacing(8)
.align_y(iced::Center),
)
.padding([8, 12])
.width(width)
.style(surface_style)
.into()
}
/// `Adapter (backend)` for the GPU row.
fn gpu_summary() -> String {
#[cfg(target_arch = "wasm32")]
{
"WebGPU / WebGL".to_string()
}
#[cfg(not(target_arch = "wasm32"))]
{
crate::gpu_backend::active_gpu_summary()
}
}
pub(crate) fn platform_name() -> &'static str {
#[cfg(target_arch = "wasm32")]
{
@@ -69,6 +102,63 @@ pub(crate) fn architecture_name() -> &'static str {
}
}
/// The text `Copy Info` puts on the clipboard: everything a bug report needs
/// about the build and the machine, including which graphics backend the
/// resolver picked and why.
pub(crate) fn report() -> String {
use crate::sysinfo::{format_bytes, system_info};
let sys = system_info();
#[allow(unused_mut)]
let mut out = format!(
"Open CAD Studio v{}\nRevision: {}\nCommit date: {}\nProfile: {}\nFeatures: {}\n\n\
[System]\nOS: {} ({} {})\nCPU: {}\nLogical processors: {}\nRAM: {} total, {} available\n",
env!("OCS_FULL_VERSION"),
env!("OCS_GIT_REV"),
env!("OCS_COMMIT_DATE"),
env!("OCS_BUILD_PROFILE"),
env!("OCS_BUILD_FEATURES"),
sys.os,
platform_name(),
architecture_name(),
sys.cpu,
sys.logical_cores,
format_bytes(sys.ram_total),
format_bytes(sys.ram_available),
);
#[cfg(not(target_arch = "wasm32"))]
{
use crate::gpu_backend::{active_gpu, load_prefs};
let gpu = active_gpu();
let prefs = load_prefs();
let or_unknown = |s: &str| {
if s.is_empty() {
"Unknown".to_string()
} else {
s.to_string()
}
};
out.push_str(&format!(
"\n[Graphics]\nGPU: {}\nDriver: {}\nBackend: {}\nSelected by: {}\n\
Below WebGPU baseline: {}\nWGPU_BACKEND: {}\n\
Saved options: backend={}, compat renderer={}, OpenGL on older GPUs={}\n",
or_unknown(gpu.adapter.as_deref().unwrap_or("")),
or_unknown(&gpu.driver),
or_unknown(gpu.backend.as_deref().unwrap_or("")),
or_unknown(&gpu.origin),
if gpu.legacy { "yes" } else { "no" },
std::env::var("WGPU_BACKEND").unwrap_or_else(|_| "(unset)".into()),
prefs.backend.as_str().unwrap_or("auto"),
prefs.compat_renderer,
prefs.legacy_gl,
));
}
#[cfg(target_arch = "wasm32")]
{
out.push_str("\n[Graphics]\nBackend: WebGPU / WebGL\n");
}
out
}
/// Build card text: the metadata suffix without its leading `+`
/// (`194.gef189d77`, `gef189d77.dirty`), or "Release" for a clean build on
/// the release tag.
@@ -137,6 +227,19 @@ pub fn view_window(
.spacing(8)
.width(content_width);
let row_width = if matches!(sizing.width, Length::Fill) {
Fill
} else {
Length::Fixed(148.0 * 3.0 + 16.0)
};
let system = column![
info_row(t!("CPU"), crate::sysinfo::cpu_summary(), row_width),
info_row(t!("RAM"), crate::sysinfo::ram_summary(), row_width),
info_row(t!("GPU (backend)"), gpu_summary(), row_width),
]
.spacing(6)
.width(content_width);
let copy = dialog_button(t!("Copy Info"), Message::AboutCopyInfo, true);
container(
@@ -144,6 +247,7 @@ pub fn view_window(
hero,
metadata,
build_info,
system,
row![Space::new().width(content_width), copy]
.width(sizing.width)
.align_y(iced::Center),
+2 -2
View File
@@ -218,7 +218,7 @@ pub fn view_window<'a>(
.on_press(Message::AliasEditorApply)
.padding([4, 16])
.style(button::primary);
let apply_exit = button(text(t!("Apply && Exit")).size(12))
let apply_exit = button(text(t!("Apply & Exit")).size(12))
.on_press(Message::AliasEditorApplyExit)
.padding([4, 16])
.style(button::primary);
@@ -307,7 +307,7 @@ pub fn view_window<'a>(
text(t!("Unsaved changes will be discarded.")).size(14),
Space::new().height(10),
row![
button(text(t!("Discard && close")).size(12))
button(text(t!("Discard & close")).size(12))
.on_press(Message::AliasEditorCloseDiscard)
.padding([4, 12])
.style(button::danger),
+1 -1
View File
@@ -7,7 +7,7 @@
use std::collections::BTreeSet;
use std::fmt;
use acadrust::types::Handle;
use codec::types::Handle;
use iced::widget::{button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space};
use iced::{Border, Element, Fill, Length, Theme};
+420
View File
@@ -0,0 +1,420 @@
//! Attribute Definition (ATTDEF) and Edit Attribute Definition dialogs:
//! cards with accent headings, a help button on the left and the named
//! action on the right, like the other drawing dialogs.
use iced::widget::{button, checkbox, column, container, pick_list, row, text, text_input, Space};
use iced::{Element, Fill, Length};
use crate::app::Message;
use crate::modules::draw::draw::attdef::{
Justify, AFLAG_CONSTANT, AFLAG_INVISIBLE, AFLAG_LOCK, AFLAG_MULTILINE, AFLAG_PRESET,
AFLAG_VERIFY,
};
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, dialog_button, field_style};
use crate::ui::window::pdf_dialogs::{accent_text, card_style};
/// One edit in either attribute definition dialog.
#[derive(Debug, Clone)]
pub enum AttdefDialogMsg {
Mode(u8, bool),
Annotative(bool),
Tag(String),
Prompt(String),
Default(String),
Justify(JustifyChoice),
Style(String),
Height(String),
Rotation(String),
Width(String),
OnScreen(bool),
Coord(usize, String),
AlignBelow(bool),
PickHeight,
PickRotation,
PickWidth,
/// The ƒ button: pick a field for the Default value.
InsertField,
EditInsertField,
/// Edit the default value in the multi-line text editor.
EditValue,
DismissError,
Ok,
Help,
EditTag(String),
EditPrompt(String),
EditDefault(String),
EditOk,
}
/// A justification as the dialog list shows it (translated label).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JustifyChoice(pub Justify);
impl std::fmt::Display for JustifyChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&t!(self.0.label()))
}
}
/// The ATTDEF dialog's fields.
#[derive(Debug, Clone)]
pub struct AttdefDialogState {
pub aflags: u8,
pub annotative: bool,
pub tag: String,
pub prompt: String,
pub default: String,
pub justify: Justify,
pub style: String,
pub styles: Vec<String>,
pub height: String,
pub rotation: String,
pub width: String,
pub on_screen: bool,
pub coords: [String; 3],
pub align_below: bool,
/// A definition was placed before in this session.
pub can_align_below: bool,
/// Validation message shown above the buttons.
pub error: Option<String>,
/// The field the Default value shows, when one was inserted.
pub field: Option<(String, Vec<codec::Handle>)>,
}
/// The Edit Attribute Definition dialog's fields.
#[derive(Debug, Clone)]
pub struct AttdefEditState {
pub handle: codec::Handle,
pub tag: String,
pub prompt: String,
pub default: String,
pub constant: bool,
pub error: Option<String>,
pub field: Option<(String, Vec<codec::Handle>)>,
/// The Default was retyped or a field inserted: the stored field is
/// replaced on Apply.
pub field_changed: bool,
}
fn msg(m: AttdefDialogMsg) -> Message {
Message::AttdefDialog(m)
}
fn card<'a>(title: String, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(column![text(title.to_uppercase()).size(10).style(accent_text), content.into()].spacing(8))
.padding([10, 12])
.width(Fill)
.style(card_style)
.into()
}
fn labelled<'a>(label: String, field: Element<'a, Message>, enabled: bool) -> Element<'a, Message> {
let label = text(label).size(12).width(Length::Fixed(118.0));
let label = if enabled { label } else { label.style(muted_style) };
row![label, field].spacing(8).align_y(iced::Center).into()
}
fn input<'a>(value: &str, enabled: bool, on: fn(String) -> AttdefDialogMsg) -> Element<'a, Message> {
let mut field = text_input("", value).size(12).padding([5, 8]).width(Fill).style(field_style);
if enabled {
field = field.on_input(move |v| msg(on(v)));
}
field.into()
}
/// The small button beside a field that takes the value from the drawing.
fn pick<'a>(
face: Element<'a, Message>,
tip: String,
message: Option<AttdefDialogMsg>,
) -> Element<'a, Message> {
let b = button(container(face).center_x(Fill))
.style(button_style(false))
.padding([4, 0])
.width(Length::Fixed(28.0));
let b = match message {
Some(m) => b.on_press(msg(m)),
None => b,
};
iced::widget::tooltip(b, text(tip).size(11), iced::widget::tooltip::Position::Bottom).into()
}
fn mode<'a>(label: String, on: bool, bit: u8) -> Element<'a, Message> {
checkbox(on)
.label(label)
.text_size(12)
.size(14)
.on_toggle(move |v| msg(AttdefDialogMsg::Mode(bit, v)))
.into()
}
/// The validation message band, as in the Write Block dialog.
fn error_band<'a>(error: &Option<String>) -> Option<Element<'a, Message>> {
let error = error.clone()?;
Some(
container(
row![
crate::ui::icons::semantic(ICON_WARN, 14.0),
text(error).size(11),
Space::new().width(Fill),
button(crate::ui::icons::themed(crate::ui::icons::CLOSE, 10.0))
.on_press(msg(AttdefDialogMsg::DismissError))
.style(button_style(false))
.padding([1, 6]),
]
.spacing(8)
.align_y(iced::Center),
)
.padding([6, 10])
.width(Fill)
.style(|theme: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme.palette().danger.base.color.scale_alpha(0.18))),
border: iced::Border { radius: 4.0.into(), ..Default::default() },
..Default::default()
})
.into(),
)
}
/// The face of the buttons that take a value from the drawing.
fn screen_pick<'a>() -> Element<'a, Message> {
crate::ui::icons::themed(crate::ui::icons::SNAP, 14.0)
}
static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg");
fn footer<'a>(action: std::borrow::Cow<'static, str>, ok: AttdefDialogMsg) -> Element<'a, Message> {
row![
button(text("?").size(12))
.on_press(msg(AttdefDialogMsg::Help))
.style(button_style(false))
.padding([5, 11]),
Space::new().width(Fill),
dialog_button(t!("Cancel"), Message::CloseModal, false),
dialog_button(action, msg(ok), true),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> {
let f = state.aflags;
let constant = f & AFLAG_CONSTANT != 0;
let multiline = f & AFLAG_MULTILINE != 0;
let modes = card(
t!("Mode").into_owned(),
column![
mode(t!("Invisible").into_owned(), f & AFLAG_INVISIBLE != 0, AFLAG_INVISIBLE),
mode(t!("Constant").into_owned(), constant, AFLAG_CONSTANT),
mode(t!("Verify").into_owned(), f & AFLAG_VERIFY != 0, AFLAG_VERIFY),
mode(t!("Preset").into_owned(), f & AFLAG_PRESET != 0, AFLAG_PRESET),
mode(t!("Lock position").into_owned(), f & AFLAG_LOCK != 0, AFLAG_LOCK),
mode(t!("Multiple lines").into_owned(), multiline, AFLAG_MULTILINE),
]
.spacing(7),
);
let coords_on = !state.on_screen && !state.align_below;
let coord = |i: usize, axis: &'static str| -> Element<'a, Message> {
let mut field = text_input("", &state.coords[i]).size(12).padding([5, 8]).width(Fill).style(field_style);
if coords_on {
field = field.on_input(move |v| msg(AttdefDialogMsg::Coord(i, v)));
}
let label = text(axis).size(12).width(Length::Fixed(14.0));
let label = if coords_on { label } else { label.style(muted_style) };
row![label, field].spacing(8).align_y(iced::Center).into()
};
let mut on_screen = checkbox(state.on_screen)
.label(t!("Specify on-screen").into_owned())
.text_size(12)
.size(14);
if !state.align_below {
on_screen = on_screen.on_toggle(|v| msg(AttdefDialogMsg::OnScreen(v)));
}
let insertion = card(
t!("Insertion Point").into_owned(),
column![on_screen, coord(0, "X"), coord(1, "Y"), coord(2, "Z")].spacing(7),
);
let attribute = card(
t!("Attribute").into_owned(),
column![
labelled(t!("Tag").into_owned(), input(&state.tag, true, AttdefDialogMsg::Tag), true),
labelled(
t!("Prompt").into_owned(),
input(&state.prompt, !constant, AttdefDialogMsg::Prompt),
!constant
),
labelled(
if constant { t!("Value") } else { t!("Default") }.into_owned(),
{
let mut value = row![input(&state.default, true, AttdefDialogMsg::Default)].spacing(6);
if multiline {
value = value.push(pick(
text("...").size(13).into(),
t!("Multiline editor").into_owned(),
Some(AttdefDialogMsg::EditValue),
));
}
value.push(pick(text("fx").size(12).into(), t!("Insert field").into_owned(), Some(AttdefDialogMsg::InsertField)))
}
.spacing(6)
.into(),
true
),
]
.spacing(8),
);
let text_on = !state.align_below;
let justify: Element<'a, Message> = if text_on && !multiline {
pick_list(
Some(JustifyChoice(state.justify)),
Justify::ALL.map(JustifyChoice).to_vec(),
|c: &JustifyChoice| c.to_string(),
)
.on_select(|c| msg(AttdefDialogMsg::Justify(c)))
.text_size(12)
.padding([5, 8])
.width(Fill)
.into()
} else {
container(text(JustifyChoice(state.justify).to_string()).size(12).style(muted_style))
.padding([5, 8])
.width(Fill)
.into()
};
let style: Element<'a, Message> = if text_on {
pick_list(Some(state.style.clone()), state.styles.clone(), |s: &String| s.clone())
.on_select(|s| msg(AttdefDialogMsg::Style(s)))
.text_size(12)
.padding([5, 8])
.width(Fill)
.into()
} else {
container(text(state.style.clone()).size(12).style(muted_style)).padding([5, 8]).width(Fill).into()
};
let mut annotative = checkbox(state.annotative).label(t!("Annotative").into_owned()).text_size(12).size(14);
if text_on {
annotative = annotative.on_toggle(|v| msg(AttdefDialogMsg::Annotative(v)));
}
let size_on = text_on && state.justify != Justify::Align;
let rot_on = text_on && !state.justify.two_point();
let settings = card(
t!("Text Settings").into_owned(),
column![
labelled(t!("Justification").into_owned(), justify, text_on && !multiline),
labelled(t!("Text style").into_owned(), style, text_on),
row![Space::new().width(Length::Fixed(126.0)), annotative],
labelled(
t!("Text height").into_owned(),
row![
input(&state.height, size_on, AttdefDialogMsg::Height),
pick(screen_pick(), t!("Specify on-screen").into_owned(), size_on.then_some(AttdefDialogMsg::PickHeight)),
]
.spacing(6)
.into(),
size_on
),
labelled(
t!("Rotation").into_owned(),
row![
input(&state.rotation, rot_on, AttdefDialogMsg::Rotation),
pick(screen_pick(), t!("Specify on-screen").into_owned(), rot_on.then_some(AttdefDialogMsg::PickRotation)),
]
.spacing(6)
.into(),
rot_on
),
labelled(
t!("Boundary width").into_owned(),
row![
input(&state.width, text_on && multiline, AttdefDialogMsg::Width),
pick(
screen_pick(),
t!("Specify on-screen").into_owned(),
(text_on && multiline).then_some(AttdefDialogMsg::PickWidth)
),
]
.spacing(6)
.into(),
text_on && multiline
),
]
.spacing(8),
);
let mut align = checkbox(state.align_below)
.label(t!("Align below previous attribute definition").into_owned())
.text_size(12)
.size(14);
if state.can_align_below {
align = align.on_toggle(|v| msg(AttdefDialogMsg::AlignBelow(v)));
}
let body = row![
column![modes, insertion].spacing(10).width(Length::Fixed(200.0)),
column![attribute, settings].spacing(10).width(Fill),
]
.spacing(10);
let mut content = column![body, align];
if let Some(band) = error_band(&state.error) {
content = content.push(band);
}
content
.push(footer(t!("Define"), AttdefDialogMsg::Ok))
.spacing(10)
.padding([10, 12])
.width(sizing.width)
.into()
}
pub fn view_edit<'a>(state: &'a AttdefEditState, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> {
let field = |label: String, value: &'a str, enabled: bool, on: fn(String) -> AttdefDialogMsg| {
let mut input = text_input("", value).size(12).padding([5, 8]).width(Fill).style(field_style);
if enabled {
input = input.on_input(move |v| msg(on(v)));
}
let label = text(label).size(12).width(Length::Fixed(80.0));
let label = if enabled { label } else { label.style(muted_style) };
row![label, input].spacing(8).align_y(iced::Center)
};
let content = card(
t!("Attribute Definition").into_owned(),
column![
field(t!("Tag").into_owned(), &state.tag, true, AttdefDialogMsg::EditTag),
field(t!("Prompt").into_owned(), &state.prompt, !state.constant, AttdefDialogMsg::EditPrompt),
row![
field(
if state.constant { t!("Value") } else { t!("Default") }.into_owned(),
&state.default,
true,
AttdefDialogMsg::EditDefault
),
pick(text("fx").size(12).into(), t!("Insert field").into_owned(), Some(AttdefDialogMsg::EditInsertField)),
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(8),
);
let mut content = column![content];
if let Some(band) = error_band(&state.error) {
content = content.push(band);
}
content
.push(footer(t!("Apply"), AttdefDialogMsg::EditOk))
.spacing(10)
.padding([10, 12])
.width(sizing.width)
.into()
}
/// The tag messages the reference shows for the dialogs.
pub fn tag_error(tag: &str) -> Option<&'static str> {
if tag.trim().is_empty() {
Some("The tag cannot be empty.")
} else if tag.trim().contains(char::is_whitespace) {
Some("The tag may not contain spaces.")
} else {
None
}
}
+2 -2
View File
@@ -14,8 +14,8 @@
//! edits, undo and repaint live in the update handler (`Message::AttrEditorApply`).
use crate::app::Message;
use acadrust::entities::{HorizontalAlignment, VerticalAlignment};
use acadrust::types::{Color as AcadColor, LineWeight};
use codec::entities::{HorizontalAlignment, VerticalAlignment};
use codec::types::{Color as AcadColor, LineWeight};
use iced::widget::{
button, checkbox, column, container, row, scrollable, text, text_input, Space,
};
+1 -1
View File
@@ -3,7 +3,7 @@
use std::fmt;
use acadrust::Handle;
use codec::Handle;
use iced::widget::{
button, checkbox, column, combo_box, container, mouse_area, pick_list, row, stack, text,
text_editor, text_input, Space,
File diff suppressed because it is too large. Load diff
+8 -66
View File
@@ -16,11 +16,11 @@
//! table of contents, and it is labelled as one.
use crate::app::Message;
use crate::ui::dock::{DockMsg, PanelId};
use acadrust::objects::SolidHistoryOperation;
use acadrust::{CadDocument, EntityType, Handle};
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::ui::dock::PanelId;
use codec::objects::SolidHistoryOperation;
use codec::{CadDocument, EntityType, Handle};
use iced::widget::{button, column, container, scrollable, text};
use iced::{Background, Element, Fill, Theme};
/// The three world planes, with the argument `CREATESKETCH` takes for each.
const ORIGIN_PLANES: [(&str, &str); 3] = [("XY", "XY"), ("XZ", "XZ"), ("YZ", "YZ")];
@@ -99,54 +99,8 @@ pub fn view<'a>(
width: f32,
auto_collapse: bool,
) -> Element<'a, Message> {
// ── Dock chrome (title, pin, close) — matches the other panels ────────
let pin_icon = if auto_collapse {
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
} else {
crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0)
};
let pin = button(pin_icon)
.on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Browser)))
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
if auto_collapse {
let palette = theme.palette();
style.background = Some(Background::Color(palette.primary.weak.color));
style.text_color = palette.primary.weak.text;
style.border.color = palette.primary.base.color;
style.border.width = 1.0;
}
style
})
.padding([3, 5]);
let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4);
let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0))
.on_press(Message::Dock(DockMsg::Close(PanelId::Browser)))
.style(button::subtle)
.padding([3, 5]);
let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4);
let title_bar = mouse_area(
container(
row![
text(crate::t!("Browser")).size(12),
iced::widget::Space::new().width(Fill),
pin,
close,
]
.spacing(3)
.align_y(iced::Center),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
..Default::default()
})
.width(Fill)
.padding([3, 6]),
)
.on_press(Message::Dock(DockMsg::DockGrab(PanelId::Browser)))
.interaction(iced::mouse::Interaction::Grab);
let title_bar =
crate::ui::dock::title_bar(PanelId::Browser, crate::t!("Browser").into_owned(), auto_collapse);
let mut tree = column![].spacing(1);
@@ -189,17 +143,5 @@ pub fn view<'a>(
.width(Fill)
.height(Fill);
container(column![title_bar, body].spacing(6).padding(6))
.width(Length::Fixed(width))
.height(Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
crate::ui::dock::frame(column![title_bar, body].spacing(6), width)
}
+675
View File
@@ -0,0 +1,675 @@
//! The Count palette (COUNTLIST) and count mode (COUNT): the drawing's
//! blocks with how many references of each are counted, and — once a block
//! is chosen — its counted references coloured in the drawing with a
//! floating toolbar above it. Counting itself is `codec::count`.
use crate::app::Message;
use crate::ui::dock::PanelId;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
use codec::count::{BlockInstance, CountKey};
use codec::{CadDocument, Handle};
use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
const BLOCK_ICON: &[u8] = include_bytes!("../../../assets/icons/blocks/block.svg");
const AREA_ICON: &[u8] = include_bytes!("../../../assets/icons/shapes/rect.svg");
const SELECT_ICON: &[u8] = include_bytes!("../../../assets/icons/blocks/select_objects.svg");
const TABLE_ICON: &[u8] = include_bytes!("../../../assets/icons/table.svg");
const WARNING_ICON: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg");
const CHECK_ICON: &[u8] = include_bytes!("../../../assets/icons/ui/check.svg");
/// The name of the layer count areas are drawn on.
pub const AREA_LAYER: &str = "0-CountArea";
/// What a count counts.
#[derive(Debug, Clone, PartialEq)]
pub enum CountTarget {
/// The references of a block. `reference` is the instance the match
/// options compare with; `picked` when it came from a COUNT selection.
Block {
name: String,
reference: Option<Handle>,
matching: [bool; 3],
picked: bool,
},
/// Picked objects (several, or one that is not a reference): the copies
/// of the group are counted.
Group(Vec<Handle>),
}
/// Count mode of a drawing.
#[derive(Debug, Clone, Default)]
pub struct CountMode {
/// The count area (a closed WCS polygon); `None` = all of model space.
pub area: Option<Vec<[f64; 2]>>,
/// The closed polyline marking the area, and whether count mode drew it
/// (then COUNTCLOSE erases it unless a field refers to it).
pub boundary: Option<(Handle, bool)>,
pub target: Option<CountTarget>,
/// The counted reference ← / → last showed.
pub cursor: Option<usize>,
/// The scene geometry epoch the colouring was computed at.
pub epoch: u64,
/// The result and count at `epoch` (recomputed when the drawing changes).
pub cache: Option<(CountResult, usize)>,
}
/// A count mode's result: the counted references and the overlapping
/// duplicates (errors).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CountResult {
pub counted: Vec<Handle>,
pub errors: Vec<Handle>,
/// The counted references a duplicate overlaps (drawn in the error colour too).
pub overlapped: Vec<Handle>,
}
impl CountMode {
pub fn instances(&self, doc: &CadDocument) -> Vec<BlockInstance> {
codec::count::block_instances(doc, self.area.as_deref())
}
/// The key the match options build from the reference instance.
pub fn key(&self, instances: &[BlockInstance]) -> CountKey {
match &self.target {
Some(CountTarget::Block { reference: Some(r), matching, .. }) => instances
.iter()
.find(|i| i.handle == *r)
.map(|i| CountKey::of(i, matching[0], matching[1], matching[2]))
.unwrap_or_default(),
_ => CountKey::default(),
}
}
/// The cached result, or one worked out from the drawing when there is none.
pub fn result(&self, doc: &CadDocument) -> std::borrow::Cow<'_, CountResult> {
match &self.cache {
Some((result, _)) => std::borrow::Cow::Borrowed(result),
None => std::borrow::Cow::Owned(self.compute(doc).0),
}
}
/// What the count shows: the counted references, or the copies of a group.
pub fn count(&self, doc: &CadDocument) -> usize {
match &self.cache {
Some((_, n)) => *n,
None => self.compute(doc).1,
}
}
/// The result and the count, worked out from the drawing.
pub fn compute(&self, doc: &CadDocument) -> (CountResult, usize) {
if let Some(CountTarget::Group(handles)) = &self.target {
let groups = codec::count::group_matches(doc, handles, self.area.as_deref());
let result = CountResult { counted: groups.concat(), errors: Vec::new(), overlapped: Vec::new() };
return (result, groups.len());
}
let result = self.block_result(doc);
let n = result.counted.len();
(result, n)
}
fn block_result(&self, doc: &CadDocument) -> CountResult {
match &self.target {
None | Some(CountTarget::Group(_)) => CountResult::default(),
Some(CountTarget::Block { name, .. }) => {
let instances = self.instances(doc);
let key = self.key(&instances);
let mine = |i: &&BlockInstance| i.name.eq_ignore_ascii_case(name) && key.matches(i);
CountResult {
counted: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_none()).map(|i| i.handle).collect(),
errors: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_some()).map(|i| i.handle).collect(),
overlapped: instances.iter().filter(mine).filter_map(|i| i.duplicate_of).collect(),
}
}
}
}
/// The name the toolbar and the command line show for the target.
pub fn target_name(&self) -> Option<String> {
match &self.target {
Some(CountTarget::Block { name, .. }) => Some(name.clone()),
Some(CountTarget::Group(_)) => Some("group".into()),
None => None,
}
}
}
/// Palette state that is not in the drawing, and the profile variables
/// COUNTCOLOR, COUNTERRORCOLOR and COUNTSERVICE.
#[derive(Debug)]
pub struct CountPalette {
pub show: bool,
pub search: String,
/// Sorted by the Count column (else by Name), and descending.
pub by_count: bool,
pub descending: bool,
/// Expanded rows: block name (upper case) → expand by layer, scale, mirror state.
pub expanded: Vec<(String, [bool; 3])>,
/// Create Table selection mode: the checked block names (upper case).
pub table: Option<Vec<String>>,
pub details_closed: bool,
pub errors_closed: bool,
pub color: i16,
pub error_color: i16,
pub service: bool,
/// The Invalid Area dialog's "always" box while it is open.
pub invalid_always: bool,
/// What a lost count boundary does without asking: 0 ask, 1 undo, 2 go
/// on with all of model space (kept with the user settings).
pub invalid_choice: u8,
/// The list's references for (document tab id, geometry epoch, area).
pub list_cache: std::cell::RefCell<Option<(u64, u64, Option<Vec<[f64; 2]>>, std::rc::Rc<Vec<BlockInstance>>)>>,
}
impl Default for CountPalette {
fn default() -> Self {
Self {
show: false,
search: String::new(),
by_count: false,
descending: false,
expanded: Vec::new(),
table: None,
details_closed: false,
errors_closed: false,
color: 3,
error_color: 1,
service: true,
invalid_always: false,
invalid_choice: 0,
list_cache: Default::default(),
}
}
}
impl CountPalette {
pub fn expansion(&self, name: &str) -> Option<[bool; 3]> {
let key = name.to_ascii_uppercase();
self.expanded.iter().find(|(n, _)| *n == key).map(|(_, e)| *e)
}
}
/// What a row's context menu does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowAction {
Review,
Field,
/// Expand by layer (0), scale (1) or mirror state (2).
Expand(usize),
}
#[derive(Debug, Clone)]
pub enum CountMsg {
Search(String),
/// Sort by the Count column (`true`) or the Name column.
Sort(bool),
/// A row clicked: count mode on its block (and key, for an expanded row).
Open(String, CountKey),
Menu(String, CountKey, RowAction),
CreateTable,
TableCheck(String, bool),
TableAll(bool),
TableCancel,
TableInsert,
Back,
Match(usize, bool),
ToggleDetails,
ToggleErrors,
/// Show an overlapping duplicate.
ShowError(Handle),
Area,
Prev,
Next,
Select,
Field,
Close,
/// The Invalid Area dialog: undo the boundary change (also its ✕ and
/// Cancel), or go on counting all of model space; `InvalidAlways` is its
/// "always perform my current choice" box.
InvalidUndo,
InvalidContinue,
InvalidAlways(bool),
}
fn msg(m: CountMsg) -> Message {
Message::Count(m)
}
/// One block's row: counted references and duplicates.
pub struct Row {
pub name: String,
pub count: usize,
pub errors: usize,
}
/// The rows of the list: blocks whose name contains the search text
/// (case-insensitively), sorted by the chosen column.
pub fn rows(instances: &[BlockInstance], palette: &CountPalette) -> Vec<Row> {
let query = palette.search.trim().to_lowercase();
let mut rows: Vec<Row> = codec::count::block_counts(instances)
.into_iter()
.filter(|c| query.is_empty() || c.name.to_lowercase().contains(&query))
.map(|c| Row { count: c.counted.len(), errors: c.duplicates.len(), name: c.name })
.collect();
if palette.by_count {
rows.sort_by(|a, b| a.count.cmp(&b.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
}
if palette.descending {
rows.reverse();
}
rows
}
fn small_icon<'a>(bytes: &'static [u8], size: f32) -> Element<'a, Message> {
crate::ui::icons::semantic(bytes, size)
}
fn row_menu(name: String, key: CountKey, expandable: bool) -> Element<'static, Message> {
let entry = |label: String, action: RowAction| -> Element<'static, Message> {
button(text(label).size(12))
.width(Fill)
.padding([5, 12])
.on_press(msg(CountMsg::Menu(name.clone(), key.clone(), action)))
.style(|theme: &Theme, status| {
let mut s = button::text(theme, status);
if matches!(status, button::Status::Hovered) {
s.background = Some(Background::Color(theme.palette().primary.base.color));
s.text_color = theme.palette().primary.base.text;
}
s
})
.into()
};
let mut items = column![
entry(crate::t!("Review Count Details").into_owned(), RowAction::Review),
entry(crate::t!("Insert Count Field").into_owned(), RowAction::Field),
]
.spacing(1);
if expandable {
items = items
.push(container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
}))
.push(container(text(crate::t!("EXPAND BY")).size(10).style(muted_style)).padding([3, 12]))
.push(entry(crate::t!("Layer").into_owned(), RowAction::Expand(0)))
.push(entry(crate::t!("Scale").into_owned(), RowAction::Expand(1)))
.push(entry(crate::t!("Mirror State").into_owned(), RowAction::Expand(2)));
}
container(items)
.padding(4)
.width(Length::Fixed(200.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 4.0.into() },
..Default::default()
})
.into()
}
fn list_row<'a>(
palette: &CountPalette,
name: &str,
key: CountKey,
label: String,
count: Option<usize>,
errors: bool,
depth: u16,
expanded: Option<bool>,
) -> Element<'a, Message> {
let lead: Element<'a, Message> = if let Some(checked) = palette.table.as_ref().filter(|_| depth == 0) {
let on = checked.contains(&name.to_ascii_uppercase());
let n = name.to_string();
checkbox(on).size(14).on_toggle(move |v| msg(CountMsg::TableCheck(n.clone(), v))).into()
} else if expanded == Some(true) {
container(crate::ui::icons::themed(crate::ui::icons::MINUS, 12.0)).width(Length::Fixed(14.0)).into()
} else {
Space::new().width(Length::Fixed(14.0)).into()
};
let mut cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 18.0)), lead];
if depth == 0 {
cells = cells.push(small_icon(BLOCK_ICON, 14.0));
}
cells = cells.push(text(label).size(12).width(Fill));
if errors {
cells = cells.push(crate::ui::icons::themed_warning(WARNING_ICON, 12.0));
}
// An expanded row shows its counts on the rows below it.
cells = cells.push(match count {
Some(n) => Element::from(text(n.to_string()).size(12)),
None => crate::ui::icons::themed(crate::ui::icons::MORE, 12.0),
});
let body = container(cells.spacing(6).align_y(iced::Center)).width(Fill).padding([4, 6]);
let area = mouse_area(body)
.on_press(msg(CountMsg::Open(name.to_string(), key.clone())))
.interaction(iced::mouse::Interaction::Pointer);
let (n, expandable) = (name.to_string(), depth == 0);
iced_aw::ContextMenu::new(area, move || row_menu(n.clone(), key.clone(), expandable)).into()
}
fn header_button<'a>(label: String, active: bool, descending: bool, m: CountMsg) -> Element<'a, Message> {
let mut content = row![text(label).size(11)].spacing(3).align_y(iced::Center);
if active {
content = content.push(crate::ui::icons::themed_arrow_toggle(!descending, 8.0));
}
button(content)
.on_press(msg(m))
.style(button::text)
.padding([2, 4])
.into()
}
/// The list: search, area button, Name / Count columns, rows, Create Table.
fn list_view<'a>(palette: &'a CountPalette, instances: &[BlockInstance]) -> Element<'a, Message> {
let search = text_input(&crate::t!("Search"), &palette.search)
.on_input(|v| msg(CountMsg::Search(v)))
.padding([4, 8])
.size(12)
.style(field_style);
let top = row![
search.width(Fill),
crate::ui::dock::tool_button(
small_icon(AREA_ICON, crate::ui::dock::TOOL_H),
crate::t!("Count in a specified area").into_owned(),
msg(CountMsg::Area),
),
]
.spacing(4)
.align_y(iced::Center);
let header = row![
header_button(crate::t!("Name").into_owned(), !palette.by_count, palette.descending, CountMsg::Sort(false)),
Space::new().width(Fill),
header_button(crate::t!("Count").into_owned(), palette.by_count, palette.descending, CountMsg::Sort(true)),
]
.align_y(iced::Center);
let mut list = column![].spacing(1);
for r in rows(instances, palette) {
let expansion = palette.expansion(&r.name).filter(|e| e.iter().any(|v| *v));
let count = expansion.is_none().then_some(r.count);
list = list.push(list_row(
palette,
&r.name,
CountKey::default(),
r.name.clone(),
count,
r.errors > 0,
0,
Some(expansion.is_some()),
));
if let Some([l, s, m]) = expansion {
for (key, n) in codec::count::expanded_counts(instances, &r.name, l, s, m) {
let label = key.label();
list = list.push(list_row(palette, &r.name, key, label, Some(n), false, 1, None));
}
}
}
let footer: Element<'a, Message> = match &palette.table {
None => row![
Space::new().width(Fill),
button(text(crate::t!("Create Table")).size(12))
.on_press(msg(CountMsg::CreateTable))
.style(button_style(false))
.padding([5, 12]),
]
.into(),
Some(checked) => {
let all = rows(instances, palette);
let every = !all.is_empty() && all.iter().all(|r| checked.contains(&r.name.to_ascii_uppercase()));
let mut insert = button(text(crate::t!("Insert")).size(12)).style(button_style(true)).padding([5, 12]);
if !checked.is_empty() {
insert = insert.on_press(msg(CountMsg::TableInsert));
}
row![
checkbox(every).size(14).on_toggle(|v| msg(CountMsg::TableAll(v))),
Space::new().width(Fill),
button(text(crate::t!("Cancel")).size(12))
.on_press(msg(CountMsg::TableCancel))
.style(button_style(false))
.padding([5, 12]),
insert,
]
.spacing(6)
.align_y(iced::Center)
.into()
}
};
column![
top,
container(column![header, scrollable(list).height(Fill)].spacing(2))
.padding(4)
.height(Fill)
.style(card),
footer,
]
.spacing(6)
.into()
}
fn card(theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border { radius: 4.0.into(), ..Default::default() },
..Default::default()
}
}
/// Count mode in the palette: back to the list, the counted block with the
/// match options, and the error report.
fn mode_view<'a>(palette: &'a CountPalette, mode: &'a CountMode, doc: &CadDocument) -> Element<'a, Message> {
let back = button(row![crate::ui::icons::themed_arrow_left(10.0), text(crate::t!("Back to List")).size(12)].spacing(4).align_y(iced::Center))
.on_press(msg(CountMsg::Back))
.style(button::text)
.padding([2, 2]);
let result = mode.result(doc);
let mut body = column![back].spacing(8);
if let Some(name) = mode.target_name() {
let mut details = column![button(
row![
crate::ui::icons::themed_success(CHECK_ICON, 12.0),
text(match mode.target {
Some(CountTarget::Group(_)) => crate::tf!("Geometries: {}", mode.count(doc)).into_owned(),
_ => format!("{name}: {}", mode.count(doc)),
})
.size(12)
.width(Fill),
]
.spacing(6)
.align_y(iced::Center),
)
.on_press(msg(CountMsg::ToggleDetails))
.style(button::text)
.padding(0)]
.spacing(6);
if let (false, Some(CountTarget::Block { matching, .. })) = (palette.details_closed, &mode.target) {
for (k, label) in ["Match Layer", "Match Scale", "Match Mirror State"].iter().enumerate() {
details = details.push(
checkbox(matching[k])
.label(crate::t!(*label).into_owned())
.text_size(12)
.size(14)
.on_toggle(move |v| msg(CountMsg::Match(k, v))),
);
}
}
body = body.push(container(details).padding(8).width(Fill).style(card));
}
if !result.errors.is_empty() {
let mut report = column![button(
row![
crate::ui::icons::themed_warning(WARNING_ICON, 12.0),
text(crate::tf!("Count Error Report: {}", result.errors.len())).size(12).width(Fill),
]
.spacing(6)
.align_y(iced::Center),
)
.on_press(msg(CountMsg::ToggleErrors))
.style(button::text)
.padding(0)]
.spacing(4);
if !palette.errors_closed {
for h in &result.errors {
report = report.push(
button(text(crate::t!("Overlapping object")).size(12))
.on_press(msg(CountMsg::ShowError(*h)))
.style(button::text)
.padding([2, 18]),
);
}
}
body = body.push(container(report).padding(8).width(Fill).style(card));
}
body.into()
}
pub fn view<'a>(
palette: &'a CountPalette,
mode: Option<&'a CountMode>,
doc: &'a CadDocument,
doc_id: u64,
epoch: u64,
width: f32,
auto_collapse: bool,
) -> Element<'a, Message> {
let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), auto_collapse);
let body = match mode {
Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc),
_ => {
let area = mode.and_then(|m| m.area.clone());
let key = (doc_id, epoch);
let mut cache = palette.list_cache.borrow_mut();
let instances = match cache.as_ref() {
Some((d, e, a, list)) if (*d, *e) == key && *a == area => list.clone(),
_ => {
let list = std::rc::Rc::new(codec::count::block_instances(doc, area.as_deref()));
*cache = Some((key.0, key.1, area, list.clone()));
list
}
};
list_view(palette, &instances)
}
};
crate::ui::dock::frame(column![title_bar, body].spacing(6), width)
}
fn bar_button<'a>(icon: Element<'a, Message>, tip: String, m: Option<CountMsg>) -> Element<'a, Message> {
let mut b = button(icon).style(button::text).padding([3, 5]);
if let Some(m) = m {
b = b.on_press(msg(m));
}
tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into()
}
/// The floating toolbar over the drawing in count mode: the count, the
/// status, previous / next reference, area, select, field and close.
pub fn toolbar<'a>(mode: &CountMode, doc: &CadDocument) -> Element<'a, Message> {
let has_target = mode.target.is_some();
let result = mode.result(doc);
let count = if has_target { mode.count(doc).to_string() } else { "-".to_string() };
let status: Element<'a, Message> = if !result.errors.is_empty() {
crate::ui::icons::themed_warning(WARNING_ICON, 14.0)
} else if has_target {
crate::ui::icons::themed_primary(crate::ui::icons::INFO, 14.0)
} else {
crate::ui::icons::themed_disabled(WARNING_ICON, 14.0)
};
let on = |m: CountMsg| has_target.then_some(m);
let icon = |bytes: &'static [u8]| -> Element<'a, Message> {
if has_target {
crate::ui::icons::themed_success(bytes, 16.0)
} else {
crate::ui::icons::themed_disabled(bytes, 16.0)
}
};
let bar = row![
text(crate::tf!("Count: {}", count)).size(13),
status,
bar_button(crate::ui::icons::themed_arrow_left(12.0), crate::t!("Previous").into_owned(), on(CountMsg::Prev)),
bar_button(crate::ui::icons::themed_arrow_right(12.0), crate::t!("Next").into_owned(), on(CountMsg::Next)),
bar_button(crate::ui::icons::themed_success(AREA_ICON, 16.0), crate::t!("Count in a specified area").into_owned(), Some(CountMsg::Area)),
bar_button(crate::ui::icons::themed_success(SELECT_ICON, 16.0), crate::t!("Select objects").into_owned(), Some(CountMsg::Select)),
bar_button(icon(TABLE_ICON), crate::t!("Insert Count Field").into_owned(), on(CountMsg::Field)),
bar_button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0), crate::t!("Close").into_owned(), Some(CountMsg::Close)),
]
.spacing(6)
.align_y(iced::Center);
let bar = container(bar).padding([4, 10]).style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 4.0.into() },
..Default::default()
});
container(iced::widget::opaque(bar)).width(Fill).align_x(iced::Center).padding([10, 0]).into()
}
/// The blue frame around the drawing area in count mode.
pub fn border<'a>() -> Element<'a, Message> {
container(Space::new().width(Fill).height(Fill))
.width(Fill)
.height(Fill)
.style(|_: &Theme| container::Style {
border: Border { color: Color::from_rgb(0.18, 0.52, 0.95), width: 2.0, radius: 0.0.into() },
..Default::default()
})
.into()
}
/// A colour index as the display colour (`COUNTCOLOR` / `COUNTERRORCOLOR`).
pub fn aci_rgba(index: i16) -> [f32; 4] {
crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::from_index(index))
}
/// The Invalid Area dialog, shown when the count area's boundary is gone.
pub fn invalid_area_view<'a>(palette: &CountPalette, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> {
let action = |title: String, detail: String, m: CountMsg| -> Element<'a, Message> {
button(
row![
crate::ui::icons::themed_primary(crate::ui::icons::ARROW_LONG_RIGHT, 16.0),
column![text(title).size(13), text(detail).size(11).style(muted_style)].spacing(2),
]
.spacing(10)
.align_y(iced::Center),
)
.on_press(msg(m))
.width(Fill)
.padding([10, 12])
.style(button_style(false))
.into()
};
column![
row![
crate::ui::icons::themed_warning(WARNING_ICON, 20.0),
text(crate::t!("The changes to count boundary have resulted to an invalid count area. What do you want to do?"))
.size(14)
.width(Fill),
]
.spacing(12)
.align_y(iced::Center),
action(
crate::t!("Undo the changes to the count boundary").into_owned(),
crate::t!("The last change is undone; the boundary and the count area come back.").into_owned(),
CountMsg::InvalidUndo,
),
action(
crate::t!("Continue and count the entire model space").into_owned(),
crate::t!("The count area is dropped; counting goes on in all of model space.").into_owned(),
CountMsg::InvalidContinue,
),
row![
checkbox(palette.invalid_always)
.label(crate::t!("Always perform my current choice").into_owned())
.text_size(12)
.size(14)
.on_toggle(|v| msg(CountMsg::InvalidAlways(v))),
Space::new().width(Fill),
crate::ui::style::form::dialog_button(crate::t!("Cancel"), msg(CountMsg::InvalidUndo), false),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(10)
.padding([12, 14])
.width(sizing.width)
.into()
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -7,7 +7,7 @@
use std::fmt;
use acadrust::Handle;
use codec::Handle;
use iced::widget::{button, checkbox, column, container, row, text, text_input, Space};
use iced::{Border, Element, Fill, Length, Theme};
+8 -8
View File
@@ -3,7 +3,7 @@
use crate::app::Message;
use crate::scene::model::hatch_model::GradientKind;
use crate::ui::window::graphic_attributes::GraphicAttributesMsg;
use acadrust::types::Color as AcadColor;
use codec::types::Color as AcadColor;
use iced::widget::{
button, checkbox, column, container, image, mouse_area, row, slider, text, text_input, tooltip,
Space,
@@ -18,7 +18,7 @@ pub enum GradientColorMode {
#[derive(Debug, Clone)]
pub struct GradientEditorState {
pub handles: Vec<acadrust::Handle>,
pub handles: Vec<codec::Handle>,
pub color_mode: GradientColorMode,
pub color_1: AcadColor,
pub color_2: AcadColor,
@@ -55,8 +55,8 @@ fn msg(message: GradientMsg) -> Message {
impl GradientEditorState {
pub fn from_hatch(
handles: Vec<acadrust::Handle>,
hatch: &acadrust::entities::Hatch,
handles: Vec<codec::Handle>,
hatch: &codec::entities::Hatch,
) -> Self {
let color = |index: usize| {
hatch
@@ -125,7 +125,7 @@ fn rgb(color: &AcadColor) -> Color {
let (r, g, b) = match color {
AcadColor::Rgb { r, g, b } => (*r, *g, *b),
AcadColor::Index(i) => {
acadrust::types::aci_table::aci_to_rgb(*i).unwrap_or((128, 128, 128))
codec::types::aci_table::aci_to_rgb(*i).unwrap_or((128, 128, 128))
}
_ => (255, 255, 255),
};
@@ -155,11 +155,11 @@ fn preview_sized(
rgb(&state.color_2)
};
let angle = state.angle_radians();
let frame = cadkernel::geom2d::gradient_frame(
let frame = kernel::geom2d::gradient_frame(
&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
angle,
if state.centered { 0.0 } else { 1.0 },
cadkernel::geom2d::Tolerance::default(),
kernel::geom2d::Tolerance::default(),
)?;
let (sin, cos) = angle.sin_cos();
let radial = state.kind.radial();
@@ -214,7 +214,7 @@ fn preview(state: &GradientEditorState) -> Element<'static, Message> {
}
pub(crate) fn compact_preview(
hatch: &acadrust::entities::Hatch,
hatch: &codec::entities::Hatch,
) -> Option<iced::widget::image::Handle> {
preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26)
}
+3 -3
View File
@@ -6,9 +6,9 @@ use crate::app::{ColorPickTarget, Message};
use crate::scene::Scene;
use crate::ui::dock::{DockMsg, PanelId};
use crate::ui::window::gradient_editor::{GradientEditorState, GradientMsg};
use acadrust::entities::{EntityCommon, Hatch, HatchGradientPattern};
use acadrust::types::{Color as AcadColor, LineWeight, Transparency};
use acadrust::{CadDocument, EntityType, Handle};
use codec::entities::{EntityCommon, Hatch, HatchGradientPattern};
use codec::types::{Color as AcadColor, LineWeight, Transparency};
use codec::{CadDocument, EntityType, Handle};
use iced::widget::{
button, image, column, combo_box, container, mouse_area, row, slider, text, text_input, tooltip,
Space,
+5 -5
View File
@@ -2,7 +2,7 @@
use crate::app::{LayerStateLayerFlag, LayerStateProperty, Message};
use crate::ui::properties::{lw_options, LwItem};
use acadrust::{LayerState, LayerStateMask};
use codec::{LayerState, LayerStateMask};
use iced::widget::{
button, checkbox, column, container, row, scrollable, text, text_input, Space,
};
@@ -12,7 +12,7 @@ use std::borrow::Cow;
use std::fmt;
#[derive(Clone, PartialEq, Debug)]
struct TransparencyItem(Option<acadrust::types::Transparency>);
struct TransparencyItem(Option<codec::types::Transparency>);
impl fmt::Display for TransparencyItem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -23,11 +23,11 @@ impl fmt::Display for TransparencyItem {
}
}
fn transparency_options(current: Option<acadrust::types::Transparency>) -> Vec<TransparencyItem> {
fn transparency_options(current: Option<codec::types::Transparency>) -> Vec<TransparencyItem> {
let mut options = vec![TransparencyItem(None)];
for percent in (0..=90).step_by(10) {
options.push(TransparencyItem(Some(
acadrust::types::Transparency::from_percent(percent as f64 / 100.0),
codec::types::Transparency::from_percent(percent as f64 / 100.0),
)));
}
let current = TransparencyItem(current);
@@ -400,7 +400,7 @@ fn editor_header<'a>() -> Element<'a, Message> {
fn editor_layer_row<'a>(
index: usize,
layer: &'a acadrust::LayerStateLayer,
layer: &'a codec::LayerStateLayer,
color_open: bool,
linetypes: Vec<String>,
) -> Element<'a, Message> {
+24 -67
View File
@@ -3,12 +3,13 @@
use crate::app::Message;
use crate::ui::properties::{lw_options, LinetypeItem, LwItem};
use crate::ui::style::common::muted_style;
use crate::ui::style::row_button::{cell_button_style, header_button_style};
use crate::ui::ROW_H;
use acadrust::tables::layer::Layer as DocLayer;
use acadrust::tables::Table;
use acadrust::types::aci_table::aci_to_rgb;
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::Handle;
use codec::tables::layer::Layer as DocLayer;
use codec::tables::Table;
use codec::types::aci_table::aci_to_rgb;
use codec::types::{Color as AcadColor, LineWeight};
use codec::Handle;
use iced::widget::{
button, column, combo_box, container, mouse_area, row, scrollable, text, text_input, tooltip,
};
@@ -503,48 +504,6 @@ impl LayerPanel {
// ── Sorting helpers ─────────────────────────────────────────────────────────
fn layer_cell_button_style(
theme: &Theme,
status: button::Status,
is_selected: bool,
index: usize,
) -> button::Style {
let palette = theme.palette();
let highlighted = matches!(status, button::Status::Hovered);
let pair = if highlighted {
palette.background.strong
} else if is_selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
button::Style {
background: highlighted.then_some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
fn layer_header_button_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let highlighted = matches!(
status,
button::Status::Hovered | button::Status::Pressed
);
let pair = if highlighted {
palette.background.strong
} else {
palette.background.weak
};
button::Style {
background: highlighted.then_some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
/// Packed RGB key for ordering colours deterministically by hue-ish bytes.
fn color_sort_key(c: AcadColor) -> u32 {
let c = iced_color_from_acad(&c);
@@ -573,7 +532,7 @@ fn sortable_header<'a>(
}
button(content)
.on_press(Message::LayerSort(col))
.style(layer_header_button_style)
.style(header_button_style)
.padding(Padding {
top: 0.0,
bottom: 0.0,
@@ -714,12 +673,13 @@ fn layer_row<'a>(
vp_cols: &'a [VpCol],
name_col_w: f32,
) -> Element<'a, Message> {
// #22: messages carry the layer name (resolved at dispatch time) so a
// resort between view() and the handler cannot mistarget the row.
let lname = layer.name.clone();
let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> {
button(crate::ui::icons::semantic(bytes, ICON_SZ))
.on_press(on_press)
.style(move |theme: &Theme, status| {
layer_cell_button_style(theme, status, is_selected, index)
})
.style(cell_button_style(is_selected, index))
.padding(Padding {
top: COMBO_PAD_V,
bottom: COMBO_PAD_V,
@@ -746,10 +706,8 @@ fn layer_row<'a>(
};
let plot_btn: Element<'_, Message> = button(plot_icon)
.on_press(Message::LayerTogglePlot(index))
.style(move |theme: &Theme, status| {
layer_cell_button_style(theme, status, is_selected, index)
})
.on_press(Message::LayerTogglePlot(lname.clone()))
.style(cell_button_style(is_selected, index))
.padding(Padding {
top: COMBO_PAD_V,
bottom: COMBO_PAD_V,
@@ -787,10 +745,8 @@ fn layer_row<'a>(
text(crate::ui::text_util::elide(&layer.name, name_budget))
.size(FONT_SZ),
)
.on_press(Message::LayerRenameStart(index))
.style(move |theme: &Theme, status| {
layer_cell_button_style(theme, status, is_selected, index)
})
.on_press(Message::LayerRenameStart(lname.clone()))
.style(cell_button_style(is_selected, index))
.padding(Padding {
top: COMBO_PAD_V,
bottom: COMBO_PAD_V,
@@ -821,9 +777,9 @@ fn layer_row<'a>(
..Default::default()
},
Message::LayerColorSet,
Message::LayerColorPickerToggle(index),
Message::LayerColorPickerToggle(lname.clone()),
Message::OpenColorWindow(
crate::app::ColorPickTarget::Layer(index),
crate::app::ColorPickTarget::Layer(lname.clone()),
layer.color,
),
))
@@ -888,8 +844,9 @@ fn layer_row<'a>(
// Transparency cell
let trans_str = layer.transparency.to_string();
let lname_trans = lname.clone();
let trans_cell = text_input("0", &trans_str)
.on_input(move |s| Message::LayerTransparencyEdit(index, s))
.on_input(move |s| Message::LayerTransparencyEdit(lname_trans.clone(), s))
.size(FONT_SZ)
.padding(Padding {
top: COMBO_PAD_V,
@@ -911,13 +868,13 @@ fn layer_row<'a>(
.align_y(iced::Center),
name_cell,
iced::widget::Space::new().width(2),
container(svg_btn(vis_svg, Message::LayerToggleVisible(index)))
container(svg_btn(vis_svg, Message::LayerToggleVisible(lname.clone())))
.width(Length::Fixed(COL_ICON))
.align_x(iced::Center),
container(svg_btn(frz_svg, Message::LayerToggleFreeze(index)))
container(svg_btn(frz_svg, Message::LayerToggleFreeze(lname.clone())))
.width(Length::Fixed(COL_ICON))
.align_x(iced::Center),
container(svg_btn(lck_svg, Message::LayerToggleLock(index)))
container(svg_btn(lck_svg, Message::LayerToggleLock(lname.clone())))
.width(Length::Fixed(COL_ICON))
.align_x(iced::Center),
container(plot_btn)
@@ -939,7 +896,7 @@ fn layer_row<'a>(
row_content = row_content.push(
container(svg_btn(
vp_frz_svg,
Message::LayerToggleVpFreeze(index, vp_idx),
Message::LayerToggleVpFreeze(lname.clone(), vp_idx),
))
.width(Length::Fixed(COL_ICON))
.align_x(iced::Center),
@@ -972,7 +929,7 @@ fn layer_row<'a>(
.height(Length::Fixed(ROW_H))
.width(Fill),
)
.on_press(Message::LayerSelect(index))
.on_press(Message::LayerSelect(lname))
.into()
}
+6
View File
@@ -1,4 +1,6 @@
pub mod about;
pub mod attdef_dialog;
pub mod field_dialog;
pub mod block_definition;
pub mod block_palette;
pub mod browser;
@@ -26,6 +28,10 @@ pub mod annotation_data;
pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
pub mod pc_manager;
pub mod count_palette;
pub mod sheet_set;
pub mod pdf_dialogs;
pub mod xref_attach;
pub mod xref_help;
pub mod xref_manager;
+7 -7
View File
@@ -18,8 +18,8 @@ use crate::scene::named_parameters::ParameterTable;
use crate::scene::Scene;
use crate::t;
use crate::ui::style::common::muted_style;
use acadrust::types::Handle;
use acadrust::EntityType;
use codec::types::Handle;
use codec::EntityType;
use iced::widget::tooltip::Position as TipPos;
use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space};
use iced::{Background, Element, Length, Theme};
@@ -392,10 +392,10 @@ mod tests {
ConstraintKind, ParametricRef, ParametricScope,
};
let mut scene = Scene::new();
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
let line = scene.add_entity(codec::EntityType::Line(
codec::entities::Line::from_points(
codec::types::Vector3::new(0.0, 0.0, 0.0),
codec::types::Vector3::new(10.0, 0.0, 0.0),
),
));
scene
@@ -426,7 +426,7 @@ mod tests {
#[test]
fn entity_label_reports_erased_for_a_dangling_handle() {
let scene = Scene::new();
let label = entity_label(&scene, acadrust::types::Handle::new(999));
let label = entity_label(&scene, codec::types::Handle::new(999));
assert!(label.starts_with("(erased "), "got {label}");
}
}
+130 -9
View File
@@ -1,5 +1,8 @@
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod graphics;
pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig;
use crate::app::settings;
use crate::app::settings::{CursorType, RightClickMode};
use crate::app::Message;
use crate::ui::style::form::{dialog_button, dialog_button_styled_opt};
@@ -34,6 +37,7 @@ pub enum OptionsTab {
Modeling,
Selection,
UserPreferences,
Graphics,
}
/// Application preferences the dialog reads that are plain scalars on the app.
@@ -55,7 +59,8 @@ pub struct AppPrefs {
pub commandline_fade_ms: i32,
/// ZOOMWHEEL: reverse the mouse-wheel zoom direction.
pub zoom_wheel_reversed: bool,
/// ZOOMFACTOR, 3..=100.
/// ZOOMFACTOR. The slider sets the range the system variable has; the
/// field beside it reaches `settings::ZOOM_FACTOR_MAX`.
pub zoom_factor: i32,
/// TEXTEDITMODE: TEXTEDIT keeps prompting for the next object.
pub texteditmode: bool,
@@ -92,6 +97,9 @@ pub struct Folders {
pub plot_styles: Option<String>,
pub plugins: Option<String>,
pub autosave: Option<String>,
/// Where `.shx` / `.ttf` fonts dropped in by the user (or downloaded for
/// a drawing) are found.
pub fonts: Option<String>,
}
/// Values read from the current drawing's header rather than from preferences.
@@ -199,14 +207,21 @@ pub fn view_window<'a>(
selection: SelectionPrefs,
prefs: AppPrefs,
spacemouse: Element<'a, Message>,
graphics: Element<'a, Message>,
snap_angle_input: &'a str,
zoom_factor_input: &'a str,
drawing_prefs: DrawingPrefs,
folders: Folders,
qnew_template: &'a str,
double_click_block_refedit: bool,
double_click_block_attedit: bool,
cursor_type: CursorType,
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
snap_marker_color: Option<[u8; 3]>,
snap_marker_color_input: &'a str,
command_text_color: Option<[u8; 3]>,
command_text_color_input: &'a str,
lineweight_display_scale: i32,
model_space: &'a crate::app::config::ModelSpaceThemeConfig,
model_bg_input: &'a str,
@@ -415,7 +430,8 @@ pub fn view_window<'a>(
text(crate::tr!("options", "default-save-format-label")).size(12).width(150),
iced::widget::pick_list(
selected_format,
crate::io::SAVE_FORMAT_OPTIONS,
// Drawings, not the template entry (the list's last).
&crate::io::SAVE_FORMAT_OPTIONS[..crate::io::SAVE_FORMAT_OPTIONS.len() - 1],
|value| value.to_string(),
)
.on_select(|format: &str| Message::DefaultSaveFormatChanged(format.to_string()))
@@ -515,6 +531,42 @@ pub fn view_window<'a>(
..Default::default()
});
let command_rgb = command_text_color.unwrap_or([220, 220, 220]);
let command_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
command_rgb[0],
command_rgb[1],
command_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let snap_rgb = snap_marker_color.unwrap_or([255, 230, 26]);
let snap_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
snap_rgb[0],
snap_rgb[1],
snap_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let model_bg_rgb = model_space.custom_bg.unwrap_or(crate::app::config::CLASSIC_CAD_DARK_BG);
let paper_bg_rgb = model_space.custom_paper_bg.unwrap_or(crate::app::config::DEFAULT_PAPER_BG);
@@ -762,6 +814,30 @@ pub fn view_window<'a>(
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Snap marker color")).size(12).width(140),
snap_swatch,
text_input(crate::t!("#RRGGBB or blank").as_ref(), snap_marker_color_input)
.on_input(Message::SnapMarkerColorChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Command line text color")).size(12).width(140),
command_swatch,
text_input(crate::t!("#RRGGBB or blank").as_ref(), command_text_color_input)
.on_input(Message::CommandTextColorChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Leave the color blank to keep automatic viewport contrast."))
@@ -845,7 +921,7 @@ pub fn view_window<'a>(
.step(250)
.width(Fill),
text(if prefs.commandline_fade_ms <= 0 {
crate::t!("Off").into_owned()
crate::t!("Never").into_owned()
} else {
format!("{:.1} s", prefs.commandline_fade_ms as f32 / 1000.0)
})
@@ -858,7 +934,7 @@ pub fn view_window<'a>(
.push(Space::new().height(6))
.push(
text(crate::t!(
"How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME)."
"How long overlay history lines stay visible; 0 means never fade (COMMANDLINEFADETIME)."
))
.size(11)
.width(sizing.width),
@@ -1181,10 +1257,24 @@ pub fn view_window<'a>(
Space::new().height(12),
row![
text(crate::t!("Zoom factor")).size(12).width(150),
slider(3..=100, prefs.zoom_factor.clamp(3, 100), Message::ZoomFactorChanged)
.step(1)
.width(Fill),
text(prefs.zoom_factor.clamp(3, 100).to_string()).size(11).width(44),
slider(
settings::ZOOM_FACTOR_MIN..=settings::ZOOM_FACTOR_SYSVAR_MAX,
prefs
.zoom_factor
.clamp(settings::ZOOM_FACTOR_MIN, settings::ZOOM_FACTOR_SYSVAR_MAX),
Message::ZoomFactorChanged,
)
.step(1)
.width(Fill),
text_input("60", zoom_factor_input)
.on_input(Message::ZoomFactorInputChanged)
.width(52),
text(crate::tf!(
"{:.1}% a notch",
settings::zoom_notch_percent(prefs.zoom_factor)
))
.size(11)
.width(76),
]
.spacing(10)
.align_y(iced::Center),
@@ -1192,6 +1282,15 @@ pub fn view_window<'a>(
text(crate::t!("How far one wheel notch zooms (ZOOMFACTOR)."))
.size(11)
.width(sizing.width),
Space::new().height(4),
text(crate::tf!(
"The slider covers the system variable's {} to {}; the field takes up to {} for a faster wheel.",
settings::ZOOM_FACTOR_MIN,
settings::ZOOM_FACTOR_SYSVAR_MAX,
settings::ZOOM_FACTOR_MAX
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Text and Dimensions")).size(15),
Space::new().height(10),
@@ -1636,6 +1735,23 @@ pub fn view_window<'a>(
folder_row(crate::t!("Plugins"), folders.plugins.clone()),
Space::new().height(12),
folder_row(crate::t!("Autosave files"), folders.autosave.clone()),
Space::new().height(12),
folder_row(crate::t!("Fonts"), folders.fonts.clone()),
Space::new().height(16),
text(crate::t!("Default template for new drawings")).size(12),
Space::new().height(4),
row![
text_input(crate::t!("Blank drawing").as_ref(), qnew_template)
.on_input(Message::QnewTemplateChanged)
.size(11)
.width(Fill),
button(text(crate::t!("Browse...")).size(11))
.padding([4, 10])
.style(button::secondary)
.on_press(Message::QnewTemplateBrowse),
]
.spacing(10)
.align_y(iced::Center),
]
.spacing(0)
.width(sizing.width);
@@ -1649,6 +1765,7 @@ pub fn view_window<'a>(
OptionsTab::Drafting => drafting.into(),
OptionsTab::Modeling => modeling.into(),
OptionsTab::UserPreferences => user_prefs.into(),
OptionsTab::Graphics => graphics,
};
// A vertical rail rather than a horizontal strip: the tab names are
@@ -1671,7 +1788,11 @@ pub fn view_window<'a>(
tab_button(crate::t!("3D Modeling"), OptionsTab::Modeling),
tab_button(crate::t!("Selection"), OptionsTab::Selection),
tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences),
]
];
// The web build has no backend to choose, so it has no Graphics page.
#[cfg(not(target_arch = "wasm32"))]
let tabs = tabs.push(tab_button(crate::t!("Graphics"), OptionsTab::Graphics));
let tabs = tabs
.spacing(2)
.width(iced::Length::Fixed(TAB_RAIL_WIDTH));
+122
View File
@@ -0,0 +1,122 @@
//! Options > Graphics: which graphics backend the next launch uses.
//!
//! Everything here is read before the application boots (the backend is
//! chosen first), so changes are saved at once and apply on restart rather
//! than going through OK / Apply.
use crate::app::Message;
use crate::gpu_backend::{ActiveGpu, BackendChoice, GraphicsPrefs};
use iced::widget::{checkbox, column, pick_list, row, text, Space};
use iced::{Element, Fill};
fn current_row(label: std::borrow::Cow<'static, str>, value: String) -> Element<'static, Message> {
row![
text(label).size(12).width(150),
text(value).size(12).width(Fill),
]
.spacing(10)
.into()
}
fn unknown(s: &str) -> String {
if s.is_empty() {
crate::t!("Unknown").into_owned()
} else {
s.to_string()
}
}
pub(crate) fn view(prefs: GraphicsPrefs, active: ActiveGpu) -> Element<'static, Message> {
let backend = pick_list(
Some(prefs.backend),
BackendChoice::available(),
|choice: &BackendChoice| crate::t!(choice.label()).into_owned(),
)
.on_select(Message::GraphicsBackendChanged)
.width(260);
let mut col = column![
text(crate::t!("Graphics")).size(15),
Space::new().height(6),
text(crate::t!(
"Saved immediately. Changes take effect the next time Open CAD Studio starts."
))
.size(11)
.width(Fill),
Space::new().height(16),
text(crate::t!("This session")).size(13),
Space::new().height(8),
current_row(
crate::t!("GPU"),
unknown(active.adapter.as_deref().unwrap_or(""))
),
Space::new().height(4),
current_row(crate::t!("Driver"), unknown(&active.driver)),
Space::new().height(4),
current_row(
crate::t!("Backend"),
match active.backend.as_deref() {
Some("dx12") => "DirectX 12".to_string(),
Some("vulkan") => "Vulkan".to_string(),
Some("gl") => "OpenGL".to_string(),
Some("metal") => "Metal".to_string(),
Some(other) => other.to_string(),
None => {
if cfg!(target_os = "macos") {
"Metal".to_string()
} else {
unknown("")
}
}
},
),
Space::new().height(4),
current_row(crate::t!("Selected by"), unknown(&active.origin)),
Space::new().height(20),
text(crate::t!("Next launch")).size(13),
Space::new().height(8),
row![text(crate::t!("Graphics backend")).size(12).width(150), backend]
.spacing(10)
.align_y(iced::Center),
Space::new().height(12),
row![
checkbox(prefs.compat_renderer)
.on_toggle(Message::GraphicsCompatToggled)
.size(15),
text(crate::t!(
"Use the compatibility renderer (for GPUs without shader storage buffers)"
))
.size(12),
]
.spacing(8)
.align_y(iced::Center),
];
if cfg!(target_os = "windows") {
col = col.push(Space::new().height(10)).push(
row![
checkbox(prefs.legacy_gl)
.on_toggle(Message::GraphicsLegacyGlToggled)
.size(15),
text(crate::t!(
"Prefer OpenGL on older GPUs (automatic backend only)"
))
.size(12),
]
.spacing(8)
.align_y(iced::Center),
);
}
col.push(Space::new().height(16))
.push(
text(crate::t!(
"If a pinned backend fails to start, the next launch falls back to automatic selection. \
The --backend option and the WGPU_BACKEND variable override this page."
))
.size(11)
.width(Fill),
)
.spacing(0)
.into()
}
+358
View File
@@ -0,0 +1,358 @@
//! Point Cloud Manager (POINTCLOUDMANAGER): the regions and scans of the
//! drawing's point clouds as a tree, each with a switch that shows or hides
//! its points. The switches are the cloud's own hidden scans and regions, so
//! the tree is read straight from the document on every view.
use crate::app::Message;
use crate::scene::model::point_cloud::{self, UNASSIGNED_OFF};
use crate::ui::dock::PanelId;
use codec::entities::ExtendedEntityData;
use codec::{CadDocument, EntityType, Handle};
use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space};
use iced::{Background, Border, Element, Fill, Length, Theme};
use std::collections::HashSet;
const CLOUD_ICON: &[u8] = include_bytes!("../../../assets/icons/pc_attach.svg");
const REGION_ICON: &[u8] = include_bytes!("../../../assets/icons/region.svg");
const UNASSIGNED_ICON: &[u8] = include_bytes!("../../../assets/icons/revcloud.svg");
/// Palette state that is not in the drawing: shown, folded nodes, the
/// highlighted row and the search text.
#[derive(Debug, Default)]
pub struct PcManager {
pub show: bool,
pub collapsed: HashSet<String>,
pub selected: Option<String>,
pub search: String,
}
/// A row with a switch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Row {
Cloud,
Unassigned,
Scans,
/// A scan, by identifier.
Scan(String),
}
/// A switch's state: a parent is mixed while some children are on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Switch {
On,
Off,
Mixed,
}
impl Row {
/// `cloud`, `unassigned`, `scans` or `scan:<name or identifier>`
/// (automation).
pub fn parse(text: &str) -> Option<Self> {
match text {
"cloud" => Some(Self::Cloud),
"unassigned" => Some(Self::Unassigned),
"scans" => Some(Self::Scans),
_ => text.strip_prefix("scan:").map(|name| Self::Scan(name.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub enum PcManagerMsg {
Toggle(Handle, Row),
Expand(String),
Select(String),
Search(String),
CollapseAll,
ExpandAll,
}
/// One cloud of the tree.
pub struct Cloud {
pub handle: Handle,
pub name: String,
pub hidden: Vec<String>,
/// Each scan's name and identifier.
pub scans: Vec<(String, String)>,
}
/// The drawing's point clouds; only the selected ones when any is selected.
pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec<Cloud> {
let all: Vec<Cloud> = document
.entities()
.filter_map(|entity| {
let EntityType::Extended(extended) = entity else {
return None;
};
let ExtendedEntityData::PointCloudEx(data) = &extended.data else {
return None;
};
let common = entity.common();
let scans = point_cloud::resolve_source(document, data)
.and_then(|path| point_cloud::load(&path))
.map(|cloud| cloud.scan_ranges.iter().map(|s| (s.name.clone(), s.id.clone())).collect())
.unwrap_or_default();
Some(Cloud {
handle: common.handle,
name: if data.name.is_empty() { format!("{:X}", common.handle.value()) } else { data.name.clone() },
hidden: point_cloud::hidden(data),
scans,
})
})
.collect();
if all.iter().any(|cloud| selected.contains(&cloud.handle)) {
all.into_iter().filter(|cloud| selected.contains(&cloud.handle)).collect()
} else {
all
}
}
/// A row's switch. A parent is on with all its children on, off with all
/// off, mixed otherwise (the reference shows the cloud mixed with its only
/// scan off and its unassigned points on).
pub fn switch_state(hidden: &[String], scans: &[(String, String)], row: &Row) -> Switch {
let on = |id: &str| !hidden.iter().any(|h| h == id);
let of = |states: &[bool]| match (states.iter().all(|s| *s), states.iter().any(|s| *s)) {
(true, _) => Switch::On,
(false, false) => Switch::Off,
_ => Switch::Mixed,
};
let scan_states: Vec<bool> = scans.iter().map(|(_, id)| on(id)).collect();
match row {
Row::Scan(id) => of(&[on(id)]),
Row::Scans => of(&scan_states),
Row::Unassigned => of(&[on(UNASSIGNED_OFF)]),
Row::Cloud => {
let mut all = scan_states;
all.push(on(UNASSIGNED_OFF));
of(&all)
}
}
}
/// The hidden list after clicking `row`: an on switch turns its rows off,
/// an off or mixed one turns them all on.
pub fn toggled(hidden: &[String], scans: &[(String, String)], row: &Row) -> Vec<String> {
fn set(out: &mut Vec<String>, id: &str, off: bool) {
out.retain(|h| h != id);
if off {
out.push(id.to_string());
}
}
let off = switch_state(hidden, scans, row) == Switch::On;
let mut out = hidden.to_vec();
match row {
Row::Cloud => {
scans.iter().for_each(|(_, id)| set(&mut out, id, off));
set(&mut out, UNASSIGNED_OFF, off);
}
Row::Unassigned => set(&mut out, UNASSIGNED_OFF, off),
Row::Scans => scans.iter().for_each(|(_, id)| set(&mut out, id, off)),
Row::Scan(id) => set(&mut out, id, off),
}
out
}
/// Keys of the rows that fold: each cloud and its Scans.
pub fn folding_keys(clouds: &[Cloud]) -> Vec<String> {
clouds.iter().flat_map(|c| [key(c.handle), format!("{}/scans", key(c.handle))]).collect()
}
fn key(handle: Handle) -> String {
format!("{:X}", handle.value())
}
fn msg(m: PcManagerMsg) -> Message {
Message::PcManager(m)
}
/// One tree row: indent, fold arrow, icon, label and switch (`None` for a
/// row that has none to give, drawn off and disabled).
fn tree_row<'a>(
state: &PcManager,
key: String,
depth: u16,
fold: Option<bool>,
icon: Element<'a, Message>,
label: String,
switch: Option<(Switch, Message)>,
) -> Element<'a, Message> {
let arrow: Element<'a, Message> = match fold {
Some(open) => button(if open {
crate::ui::icons::themed_arrow_down(10.0)
} else {
crate::ui::icons::themed_arrow_right(10.0)
})
.on_press(msg(PcManagerMsg::Expand(key.clone())))
.style(button::text)
.padding(2)
.into(),
None => Space::new().width(14).into(),
};
let enabled = switch.is_some();
let label = text(label).size(12).width(Fill);
let label = if enabled { label } else { label.style(crate::ui::style::common::muted_style) };
let switch: Element<'a, Message> = match switch {
// Mixed: a dash in the box, as the reference's indeterminate state.
Some((Switch::Mixed, message)) => button(crate::ui::icons::themed_primary(crate::ui::icons::MINUS, 10.0))
.on_press(message)
.style(button::secondary)
.padding(1)
.width(Length::Fixed(16.0))
.height(Length::Fixed(16.0))
.into(),
Some((on, message)) => checkbox(on == Switch::On).on_toggle(move |_| message.clone()).size(14).into(),
None => checkbox(false).size(14).into(),
};
let selected = state.selected.as_deref() == Some(key.as_str());
let cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 16.0)), arrow, icon, label, switch]
.spacing(5)
.align_y(iced::Center);
mouse_area(
container(cells)
.width(Fill)
.padding([3, 6])
.style(move |theme: &Theme| container::Style {
background: selected.then(|| Background::Color(theme.palette().primary.weak.color)),
text_color: selected.then(|| theme.palette().primary.weak.text),
..Default::default()
}),
)
.on_press(msg(PcManagerMsg::Select(key)))
.into()
}
fn fold_button<'a>(icon: &'static [u8], tip: String, message: PcManagerMsg) -> Element<'a, Message> {
let b = button(crate::ui::icons::themed_secondary(icon, 12.0))
.on_press(msg(message))
.style(button::subtle)
.padding([2, 4]);
tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into()
}
pub fn view<'a>(
state: &'a PcManager,
document: &'a CadDocument,
selected: &[Handle],
width: f32,
auto_collapse: bool,
) -> Element<'a, Message> {
let title_bar =
crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), auto_collapse);
let query = state.search.trim().to_lowercase();
let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query);
// Searching opens every node so the matches show.
let open = |key: &str| !query.is_empty() || !state.collapsed.contains(key);
let regions = crate::t!("Regions").into_owned();
let unassigned = crate::t!("Unassigned Points").into_owned();
let scans_label = crate::t!("Scans").into_owned();
let mut tree = column![].spacing(1);
for cloud in clouds(document, selected) {
let root = key(cloud.handle);
let scans: Vec<&(String, String)> = cloud.scans.iter().filter(|(name, _)| matches(name)).collect();
let show_scans = matches(&scans_label) || !scans.is_empty();
let children = matches(&regions) || matches(&unassigned) || show_scans;
if !matches(&cloud.name) && !children {
continue;
}
let switch = |row: Row| {
let on = switch_state(&cloud.hidden, &cloud.scans, &row);
Some((on, msg(PcManagerMsg::Toggle(cloud.handle, row))))
};
let cloud_icon = || crate::ui::icons::semantic(CLOUD_ICON, 14.0);
tree = tree.push(tree_row(
state,
root.clone(),
0,
Some(open(&root)),
cloud_icon(),
cloud.name.clone(),
switch(Row::Cloud),
));
if !open(&root) {
continue;
}
if matches(&regions) {
// Region data is not read: the row is there, off and disabled.
tree = tree.push(tree_row(
state,
format!("{root}/regions"),
1,
None,
crate::ui::icons::semantic_disabled(REGION_ICON, 14.0),
regions.clone(),
None,
));
}
if matches(&unassigned) {
tree = tree.push(tree_row(
state,
format!("{root}/unassigned"),
1,
None,
crate::ui::icons::semantic(UNASSIGNED_ICON, 14.0),
unassigned.clone(),
switch(Row::Unassigned),
));
}
if show_scans {
let scans_key = format!("{root}/scans");
let scans_open = open(&scans_key);
tree = tree.push(tree_row(
state,
scans_key.clone(),
1,
Some(scans_open),
cloud_icon(),
scans_label.clone(),
switch(Row::Scans),
));
if scans_open {
for (name, id) in scans {
tree = tree.push(tree_row(
state,
format!("{scans_key}/{id}"),
2,
None,
cloud_icon(),
name.clone(),
switch(Row::Scan(id.clone())),
));
}
}
}
}
let heading = text(crate::t!("REGIONS AND SCANS").into_owned())
.size(11)
.font(iced::Font {
weight: iced::font::Weight::Bold,
style: iced::font::Style::Italic,
..iced::Font::DEFAULT
})
.style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) })
.width(Fill);
let header = row![
heading,
fold_button(crate::ui::icons::MINUS, crate::t!("Collapse all").into_owned(), PcManagerMsg::CollapseAll),
fold_button(crate::ui::icons::PLUS, crate::t!("Expand all").into_owned(), PcManagerMsg::ExpandAll),
]
.spacing(2)
.align_y(iced::Center);
let card = container(column![header, scrollable(tree).height(Fill)].spacing(6))
.padding(6)
.width(Fill)
.height(Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border { radius: 4.0.into(), ..Default::default() },
..Default::default()
});
let search = text_input(&crate::t!("Search"), &state.search)
.on_input(|v| msg(PcManagerMsg::Search(v)))
.padding([4, 8])
.size(12);
crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width)
}
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -158,13 +158,13 @@ pub struct PageSetupImportDraft {
pub file: String,
/// Each setup with whether it is ticked for import. The settings are
/// carried along so the source file is read once.
pub setups: Vec<(String, acadrust::objects::PlotSettings, bool)>,
pub setups: Vec<(String, codec::objects::PlotSettings, bool)>,
/// Why the file gave nothing, when it did not.
pub error: Option<String>,
}
impl PageSetupImportDraft {
pub fn selected(&self) -> impl Iterator<Item = (&str, &acadrust::objects::PlotSettings)> {
pub fn selected(&self) -> impl Iterator<Item = (&str, &codec::objects::PlotSettings)> {
self.setups
.iter()
.filter(|(_, _, on)| *on)
@@ -178,7 +178,7 @@ pub enum PageSetupImportMsg {
/// Ask for the drawing to import from.
Pick,
/// The drawing was read (or could not be): its name and page setups.
Loaded(Result<(String, Vec<(String, acadrust::objects::PlotSettings)>), String>),
Loaded(Result<(String, Vec<(String, codec::objects::PlotSettings)>), String>),
/// Tick or untick one setup.
Toggle(String),
/// Tick or untick every setup.
@@ -1553,7 +1553,7 @@ pub fn view_window(
paper_space_option(s.paperspace_last, t!("Paper space last"), PlotFlag::PaperspaceLast),
check(t!("Merge overlapping lines"), s.merge_lines, PlotFlag::MergeLines),
check(t!("Plot stamp"), s.stamp, PlotFlag::Stamp),
paper_space_option(s.save_to_layout, t!("Save changes to layout"), PlotFlag::SaveToLayout),
check(t!("Save changes to layout"), s.save_to_layout, PlotFlag::SaveToLayout),
]
.spacing(6)
.width(width),
+2 -2
View File
@@ -1033,7 +1033,7 @@ mod tests {
tag: "v1.4.0".to_string(),
api_version: 4,
acadrust_source: Some(
"git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(),
"git+https://github.com/HakanSeven12/opencadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(),
),
acadrust_declared: true,
acadrust_compatible: true,
@@ -1045,7 +1045,7 @@ mod tests {
tag: "v2.0.0".to_string(),
api_version: 4,
acadrust_source: Some(
"git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(),
"git+https://github.com/HakanSeven12/opencadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(),
),
acadrust_declared: true,
acadrust_compatible: false,
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -241,7 +241,7 @@ pub fn view_window<'a>(
.on_press(Message::ShortcutEditorApply)
.padding([4, 16])
.style(button::primary);
let apply_exit = button(text(t!("Apply && Exit")).size(12))
let apply_exit = button(text(t!("Apply & Exit")).size(12))
.on_press(Message::ShortcutEditorApplyExit)
.padding([4, 16])
.style(button::primary);
@@ -330,7 +330,7 @@ pub fn view_window<'a>(
text(t!("Unsaved changes will be discarded.")).size(14),
Space::new().height(10),
row![
button(text(t!("Discard && close")).size(12))
button(text(t!("Discard & close")).size(12))
.on_press(Message::ShortcutEditorCloseDiscard)
.padding([4, 12])
.style(button::danger),
+1 -1
View File
@@ -2,7 +2,7 @@
use std::fmt;
use acadrust::Handle;
use codec::Handle;
use iced::widget::{
button, column, combo_box, container, pick_list, row, text, text_input, Space,
};
+56 -104
View File
@@ -10,7 +10,8 @@ use crate::io::xref::collect_entries_with_prev;
use crate::io::xref_model::{normalize_lexical, Pathtype, RefKind, RefStatus, RefType, ReferenceEntry};
use crate::ui::ROW_H;
use crate::ui::style::common::muted_style;
use acadrust::CadDocument;
use crate::ui::style::row_button::cell_button_style;
use codec::CadDocument;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip};
use iced::Padding;
use iced::{Background, Border, Element, Fill, Length, Theme};
@@ -545,60 +546,12 @@ impl XrefManagerPanel {
missing: usize,
doc: &'a CadDocument,
) -> Element<'a, Message> {
use crate::ui::dock::{DockMsg, PanelId};
// ── Dock chrome (title, pin, close) — matches the block palette ──
let pin_icon = if auto_collapse {
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
} else {
crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0)
};
let pin = button(pin_icon)
.on_press(Message::Dock(DockMsg::AutoCollapseToggle(
PanelId::ExternalReferences,
)))
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
if auto_collapse {
let palette = theme.palette();
style.background = Some(Background::Color(palette.primary.weak.color));
style.text_color = palette.primary.weak.text;
style.border.color = palette.primary.base.color;
style.border.width = 1.0;
}
style
})
.padding([3, 5]);
let pin = tooltip(pin, text("Auto").size(10), tooltip::Position::Bottom).gap(4);
let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0))
.on_press(Message::Dock(DockMsg::Close(PanelId::ExternalReferences)))
.style(button::subtle)
.padding([3, 5]);
let close = tooltip(close, text("Close").size(10), tooltip::Position::Bottom).gap(4);
let title_bar = mouse_area(
container(
row![
text(format!(
"{} ({})",
crate::t!("External References").as_ref(),
self.entries.len()
))
.size(12),
iced::widget::Space::new().width(Fill),
pin,
close,
]
.spacing(3)
.align_y(iced::Center),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
..Default::default()
})
.width(Fill)
.padding([3, 6]),
)
.on_press(Message::Dock(DockMsg::DockGrab(PanelId::ExternalReferences)))
.interaction(iced::mouse::Interaction::Grab);
use crate::ui::dock::PanelId;
let title_bar = crate::ui::dock::title_bar(
PanelId::ExternalReferences,
format!("{} ({})", crate::t!("External References").as_ref(), self.entries.len()),
auto_collapse,
);
// Table content width: column widths plus gutters, stretched to the
// dock when wider so rows fill the panel; narrower docks sidescroll.
let table_w = (width - 16.0).max(self.table_content_width());
@@ -1135,8 +1088,7 @@ impl XrefManagerPanel {
/// directly — mirrors the three [`collect_entries`] scans so nested entries
/// (foreign handles) classify correctly even on handle collisions.
fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> {
use acadrust::entities::UnderlayType;
use acadrust::objects::ObjectType;
use codec::objects::ObjectType;
let mut ids: HashSet<(u64, String)> = HashSet::new();
for br in doc.block_records.iter() {
@@ -1150,8 +1102,11 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> {
ids.insert((handle.value(), def.file_name.clone()));
}
ObjectType::UnderlayDefinition(def) => {
if def.underlay_type == UnderlayType::Pdf {
ids.insert((handle.value(), def.file_path.clone()));
ids.insert((handle.value(), def.file_path.clone()));
}
ObjectType::ClassObject(object) => {
if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data {
ids.insert((handle.value(), def.source_filename.clone()));
}
}
_ => {}
@@ -1233,7 +1188,25 @@ fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> {
// Raster images display their file format (spec: type column shows
// the image format); unknown extensions fall back to Image.
RefKind::Image => image_format(&entry.saved_path),
RefKind::Pdf => crate::t!("PDF"),
RefKind::Underlay => {
let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/"))
.extension()
.map(|e| e.to_string_lossy().to_ascii_uppercase())
.unwrap_or_default();
match ext.as_str() {
"DWF" | "DWFX" => crate::t!("DWF"),
"DGN" => crate::t!("DGN"),
_ => crate::t!("PDF"),
}
}
// The file's own kind: RCP (project) or RCS (scan).
RefKind::PointCloud => {
let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/"))
.extension()
.map(|e| e.to_string_lossy().to_ascii_uppercase())
.unwrap_or_default();
if ext == "RCS" { "RCS".into() } else { "RCP".into() }
}
}
}
@@ -1287,27 +1260,6 @@ fn format_date(modified: Option<SystemTime>) -> String {
// ── Widget helpers (layers.rs conventions) ────────────────────────────────
fn row_button_style(selected: bool, index: usize) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status: button::Status| {
let palette = theme.palette();
let highlighted = matches!(status, button::Status::Hovered);
let pair = if highlighted {
palette.background.strong
} else if selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
button::Style {
background: highlighted.then_some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
}
fn toolbar_btn(label: String, msg: Option<Message>, fill: bool) -> Element<'static, Message> {
let mut b = button(text(label).size(11))
.style(|theme: &Theme, status| {
@@ -1836,7 +1788,7 @@ fn xref_row<'a>(
let key = entry.key;
let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0))
.on_press(Message::XrefManagerToggleExpand(key))
.style(row_button_style(is_selected, display.index))
.style(cell_button_style(is_selected, display.index))
.padding(Padding {
top: 6.0,
bottom: 6.0,
@@ -1921,7 +1873,7 @@ fn tree_row(
let key = entry.key;
let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0))
.on_press(Message::XrefManagerToggleExpand(key))
.style(row_button_style(is_selected, display.index))
.style(cell_button_style(is_selected, display.index))
.padding(Padding {
top: 6.0,
bottom: 6.0,
@@ -2166,7 +2118,7 @@ fn reference_preview(entry: &ReferenceEntry) -> Option<image::RgbaImage> {
let img = image::open(found).ok()?;
Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8())
}
RefKind::Pdf => None,
RefKind::Underlay | RefKind::PointCloud => None,
}
}
@@ -2175,17 +2127,17 @@ fn reference_preview(entry: &ReferenceEntry) -> Option<image::RgbaImage> {
fn find_image_def(
doc: &CadDocument,
key: u64,
) -> Option<&acadrust::objects::ImageDefinition> {
doc.objects.get(&acadrust::types::Handle::from(key)).and_then(|o| match o {
acadrust::objects::ObjectType::ImageDefinition(def) => Some(def),
) -> Option<&codec::objects::ImageDefinition> {
doc.objects.get(&codec::types::Handle::from(key)).and_then(|o| match o {
codec::objects::ObjectType::ImageDefinition(def) => Some(def),
_ => None,
})
}
fn find_image_display_size(doc: &CadDocument, key: u64) -> Option<(f64, f64)> {
let handle = acadrust::types::Handle::from(key);
let handle = codec::types::Handle::from(key);
for entity in doc.entities() {
if let acadrust::EntityType::RasterImage(img) = entity {
if let codec::EntityType::RasterImage(img) = entity {
if img.definition_handle == Some(handle) {
let w = (img.u_vector.x.powi(2) + img.u_vector.y.powi(2) + img.u_vector.z.powi(2)).sqrt();
let h = (img.v_vector.x.powi(2) + img.v_vector.y.powi(2) + img.v_vector.z.powi(2)).sqrt();
@@ -2442,10 +2394,10 @@ mod tests {
assert!(!panel.display_rows().iter().any(|r| r.is_nested));
}
fn preview_doc2(dir: &std::path::Path) -> acadrust::CadDocument {
fn preview_doc2(dir: &std::path::Path) -> codec::CadDocument {
// Two referenced images → genuine multi-select.
use acadrust::objects::{ImageDefinition, ObjectType};
let mut doc = acadrust::CadDocument::new();
use codec::objects::{ImageDefinition, ObjectType};
let mut doc = codec::CadDocument::new();
for (i, name) in ["a.png", "b.png"].iter().enumerate() {
let img = image::RgbaImage::from_pixel(8, 8, image::Rgba([9, 9, 9, 255]));
img.save(dir.join(name)).unwrap();
@@ -2454,37 +2406,37 @@ mod tests {
let mut def = ImageDefinition::with_dimensions(path.clone(), 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let mut ent = acadrust::entities::RasterImage::new(
let mut ent = codec::entities::RasterImage::new(
&path,
acadrust::types::Vector3::ZERO,
codec::types::Vector3::ZERO,
8.0 + i as f64,
8.0,
);
ent.definition_handle = Some(h);
doc.add_entity(acadrust::EntityType::RasterImage(ent)).unwrap();
doc.add_entity(codec::EntityType::RasterImage(ent)).unwrap();
}
doc
}
fn preview_doc(dir: &std::path::Path, name: &str) -> acadrust::CadDocument {
fn preview_doc(dir: &std::path::Path, name: &str) -> codec::CadDocument {
// Referenced 8x8 PNG under `dir`; the entry resolves Loaded.
use acadrust::objects::{ImageDefinition, ObjectType};
use codec::objects::{ImageDefinition, ObjectType};
let img = image::RgbaImage::from_pixel(8, 8, image::Rgba([9, 9, 9, 255]));
img.save(dir.join(name)).unwrap();
let img_path = dir.join(name).to_string_lossy().into_owned();
let mut doc = acadrust::CadDocument::new();
let mut doc = codec::CadDocument::new();
let h = doc.allocate_handle();
let mut def = ImageDefinition::with_dimensions(img_path.clone(), 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let mut ent = acadrust::entities::RasterImage::new(
let mut ent = codec::entities::RasterImage::new(
&img_path,
acadrust::types::Vector3::ZERO,
codec::types::Vector3::ZERO,
8.0,
8.0,
);
ent.definition_handle = Some(h);
doc.add_entity(acadrust::EntityType::RasterImage(ent)).unwrap();
doc.add_entity(codec::EntityType::RasterImage(ent)).unwrap();
doc
}
@@ -2558,8 +2510,8 @@ mod tests {
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("plan.dwg"), b"not-a-real-dwg").unwrap();
let mut doc = acadrust::CadDocument::new();
let mut br = acadrust::tables::BlockRecord::new("PLAN");
let mut doc = codec::CadDocument::new();
let mut br = codec::tables::BlockRecord::new("PLAN");
br.flags.is_xref = true;
br.xref_path = dir.join("plan.dwg").to_string_lossy().into_owned();
br.handle = doc.allocate_handle();
@@ -2596,7 +2548,7 @@ mod tests {
.objects
.iter()
.find_map(|(h, o)| match o {
acadrust::objects::ObjectType::ImageDefinition(_) => Some(h.value()),
codec::objects::ObjectType::ImageDefinition(_) => Some(h.value()),
_ => None,
})
.unwrap();