forked from 1b-agent/opencadstudio-upstream
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:
625 files changed
+104397
-28053
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+221
-35
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+746
-281
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(®ions) || 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(®ions) {
|
||||
// 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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user