Merge remote-tracking branch 'origin/main' into plugin/host-model-api

# Conflicts:
#	crates/ocs_plugin_api/src/process/v4.rs
#	src/app/update/mod.rs
This commit is contained in:
Felix Riestra committed 2026-09-19 23:58:01 +02:00
commit 8648225ac5
309 files changed
+81166 -4157

No files matched your search

+183 -28
View File
@@ -14,6 +14,9 @@ use std::ops::Range;
use crate::ui::style::common::accessible_accent_threshold;
/// Maximum entries kept for the context menu's "Recent Input" list.
pub const RECENT_INPUT_CAP: usize = 10;
pub const CMD_INPUT_ID: &str = "cmd_input";
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
@@ -68,15 +71,28 @@ fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) {
}
/// Drop a prompt's "[A / B / …]" option listing — used for the pinned line
/// when the same options render as clickable buttons beside it. Collapses the
/// leftover double spaces and the orphaned " :" the removal leaves behind.
fn strip_option_listing(s: &str) -> String {
/// when the same options render as clickable buttons beside it. Only a
/// bracket group that reads as a listing goes (several choices, or a single
/// choice that is one of `options`); an informational group such as
/// "[3 objects]" or "[base 1.0,2.0]" stays. Collapses the leftover double
/// spaces and the orphaned " :" the removal leaves behind.
fn strip_option_listing(s: &str, options: &[CmdOption]) -> String {
let Some(a) = s.find('[') else {
return s.to_string();
};
let Some(off) = s[a..].find(']') else {
return s.to_string();
};
let inner = s[a + 1..a + off].trim();
let is_listing = inner.contains('/')
|| inner.contains('|')
|| inner.contains('=')
|| options.iter().any(|o| {
o.label.eq_ignore_ascii_case(inner) || o.keyword.eq_ignore_ascii_case(inner)
});
if !is_listing {
return s.to_string();
}
let mut out = format!("{}{}", &s[..a], &s[a + off + 1..]);
while out.contains(" ") {
out = out.replace(" ", " ");
@@ -84,6 +100,23 @@ fn strip_option_listing(s: &str) -> String {
out.replace(" :", ":").trim().to_string()
}
// Font swaps must preserve U+276F, U+24D8, and U+2715.
const COMMAND_PREFIX: &str = "❯ ";
const INFO_PREFIX: &str = "ⓘ ";
const ERROR_PREFIX: &str = "✕ ";
fn format_info(msg: &str) -> String {
if msg.starts_with(INFO_PREFIX) {
msg.to_string()
} else {
format!("{INFO_PREFIX}{msg}")
}
}
fn format_error(msg: &str) -> String {
format!("{ERROR_PREFIX}{}: {msg}", t!("Invalid").to_uppercase())
}
const MAX_HISTORY: usize = 64;
#[derive(Clone)]
@@ -103,6 +136,10 @@ pub struct CommandLine {
/// context menu, shortcuts), newest last. Drives the right-click "Repeat"
/// menu so it reflects every command source, not only typed ones.
pub recent_commands: Vec<String>,
/// Tokens typed into a running command (points, distances, keywords),
/// newest first, de-duplicated. Drives the context menu's "Recent Input"
/// submenu so a value can be re-fed with one click. Not persisted.
pub recent_inputs: Vec<String>,
/// Current position in `cmd_recall` while navigating (None = not navigating).
recall_cursor: Option<usize>,
/// Saved draft input before the user started navigating history.
@@ -151,6 +188,7 @@ impl Default for CommandLine {
last_error: None,
cmd_recall: Vec::new(),
recent_commands: Vec::new(),
recent_inputs: Vec::new(),
recall_cursor: None,
recall_draft: String::new(),
history_open: false,
@@ -292,6 +330,22 @@ impl CommandLine {
self.cancel_history_navigation();
}
/// Remember a token fed to a running command for the "Recent Input"
/// submenu: newest first, case-insensitive move-to-front de-duplication,
/// capped at `RECENT_INPUT_CAP`. Empty, overly long and entity-handle
/// tokens are skipped (a handle is meaningless outside the pick it
/// answered).
pub fn record_recent_input(&mut self, token: &str) {
let token = token.trim();
if token.is_empty() || token.chars().count() > 40 {
return;
}
let upper = token.to_uppercase();
self.recent_inputs.retain(|t| t.to_uppercase() != upper);
self.recent_inputs.insert(0, token.to_string());
self.recent_inputs.truncate(RECENT_INPUT_CAP);
}
pub fn history_navigation_active(&self) -> bool {
self.recall_cursor.is_some()
}
@@ -335,7 +389,10 @@ impl CommandLine {
}
pub fn push_command(&mut self, cmd: &str) {
self.push(EntryKind::Command, format!("{} {cmd}", t!("Command:")));
self.push(
EntryKind::Command,
format!("{COMMAND_PREFIX}{} {cmd}", t!("Command:")),
);
}
pub fn push_output(&mut self, msg: &str) {
self.push(EntryKind::Output, msg.to_string());
@@ -343,7 +400,7 @@ impl CommandLine {
pub fn push_error(&mut self, msg: &str) {
self.error_revision = self.error_revision.wrapping_add(1);
self.last_error = Some(msg.to_owned());
self.push(EntryKind::Error, format!("*{}* {msg}", t!("Invalid")));
self.push(EntryKind::Error, format_error(msg));
}
/// Append an error unless it is already the latest history line. Repeated
/// retry failures should refresh the concise message, not flood history
@@ -352,7 +409,7 @@ impl CommandLine {
pub fn push_error_once(&mut self, msg: &str) {
self.error_revision = self.error_revision.wrapping_add(1);
self.last_error = Some(msg.to_owned());
let text = format!("*{}* {msg}", t!("Invalid"));
let text = format_error(msg);
if let Some(last) = self
.history
.last_mut()
@@ -372,12 +429,7 @@ impl CommandLine {
self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning")));
}
pub fn push_info(&mut self, msg: &str) {
let text = if msg.starts_with("i ") {
msg.to_string()
} else {
format!("i {msg}")
};
self.push(EntryKind::Info, text);
self.push(EntryKind::Info, format_info(msg));
}
fn push(&mut self, kind: EntryKind, text: String) {
self.history.push(HistoryEntry {
@@ -407,11 +459,7 @@ impl CommandLine {
e.created_at = Instant::now();
}
if let Some(p) = &prompt {
let formatted = if p.starts_with("i ") {
p.clone()
} else {
format!("i {p}")
};
let formatted = format_info(p);
// Reuse the prompt line dispatch/step-transition just pushed;
// otherwise add it.
if self.history.last().map(|e| &e.text) != Some(&formatted) {
@@ -452,6 +500,24 @@ impl CommandLine {
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
/// row (CLIPROMPTLINES, minus lines that have faded out), in pixels. Lets
/// cursor-anchored panels stay clear of the whole command area, not just
/// the input row.
pub fn overlay_lines_height(&self) -> f32 {
if self.cliprompt_lines == 0 || self.history_open {
return 0.0;
}
let visible = self
.history
.iter()
.filter(|e| self.entry_visible(e))
.count()
.min(self.cliprompt_lines as usize);
// Text size 12 (line height ≈ 15.6) plus the 1 px padding each side.
visible as f32 * 18.0
}
/// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests.
#[cfg(test)]
pub fn visible_history_count(&self) -> usize {
@@ -583,13 +649,18 @@ impl CommandLine {
.iter()
.fold(column![].spacing(0), |col, entry| {
let kind = entry.kind.clone();
let is_error = kind == EntryKind::Error;
let entry_text = |value: String| {
text(value).size(11).style({
let mut txt = text(value).size(11).style({
let kind = kind.clone();
move |theme: &Theme| iced::widget::text::Style {
color: Some(history_color(theme, &kind)),
}
})
});
if is_error {
txt = txt.font(iced::Font::DEFAULT.weight(iced::font::Weight::Bold));
}
txt
};
// The current step's prompt is the single pinned line. When the
// step offers options, render them as clickable buttons inline
@@ -598,7 +669,7 @@ impl CommandLine {
// already name every choice, so the prompt's own "[A / B / …]"
// listing is dropped here (the history log keeps the full text).
if entry.pinned && !self.step_options.is_empty() {
let shown = strip_option_listing(&entry.text);
let shown = strip_option_listing(&entry.text, &self.step_options);
let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center);
for opt in &self.step_options {
let btn = button(text(opt.label.to_uppercase()).size(11))
@@ -1041,13 +1112,19 @@ fn history_highlight_format(
) -> iced::advanced::text::highlighter::Format<iced::Font> {
iced::advanced::text::highlighter::Format {
color: Some(history_color(theme, kind)),
font: None,
font: match kind {
EntryKind::Error => Some(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)),
_ => None,
},
}
}
#[cfg(test)]
mod tests {
use super::{mcp_status, ranked_matches, CommandLine};
use super::{
format_error, history_highlight_format, mcp_status, ranked_matches, CommandLine, EntryKind,
ERROR_PREFIX, INFO_PREFIX,
};
use crate::t;
use rustc_hash::FxHashMap;
@@ -1121,13 +1198,81 @@ mod tests {
assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}");
}
#[test]
fn history_markers_and_copy_text_are_clean() {
let mut line = CommandLine::new();
line.clear_history();
let command = "LINE";
let info = "Object selected.";
let error = "Unable to save: file is in use.";
let output = "12.5, 7.0\nraw output";
line.push_command(command);
line.push_info(info);
line.push_error(error);
line.push_output(output);
let expected = vec![
format!("❯ {} {command}", t!("Command:")),
format!("{INFO_PREFIX}{info}"),
format!("{ERROR_PREFIX}{}: {error}", t!("Invalid").to_uppercase()),
output.to_string(),
];
let actual = line
.history
.iter()
.map(|entry| entry.text.as_str())
.collect::<Vec<_>>();
assert_eq!(
actual,
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
assert_eq!(
line.history
.iter()
.map(|entry| entry.kind.clone())
.collect::<Vec<_>>(),
vec![
EntryKind::Command,
EntryKind::Info,
EntryKind::Error,
EntryKind::Output,
]
);
let plain = line.history_plain_text();
assert!(!plain.contains('*'));
assert_eq!(plain, expected.join("\n"));
assert_eq!(line.history[3].text.as_bytes(), output.as_bytes());
}
#[test]
fn error_history_uses_bold_highlight_format() {
let theme = iced::Theme::Light;
let bold = iced::Font::DEFAULT.weight(iced::font::Weight::Bold);
assert_eq!(
history_highlight_format(&EntryKind::Error, &theme).font,
Some(bold)
);
for kind in [EntryKind::Command, EntryKind::Info, EntryKind::Output] {
assert_eq!(history_highlight_format(&kind, &theme).font, None);
}
}
#[test]
fn issue_498_repeated_save_error_is_not_duplicated() {
let mut line = CommandLine::new();
let initial_len = line.history.len();
line.push_error_once(t!("Unable to save: file is in use.").as_ref());
line.push_error_once(t!("Unable to save: file is in use.").as_ref());
let message = t!("Unable to save: file is in use.");
let expected_error = format_error(message.as_ref());
line.push_error_once(message.as_ref());
line.push_error_once(message.as_ref());
assert_eq!(line.history.len(), initial_len + 1);
assert_eq!(
line.history.last().map(|entry| entry.text.as_str()),
Some(expected_error.as_str())
);
assert!(!line.history_plain_text().contains('*'));
}
#[test]
@@ -1193,8 +1338,14 @@ mod tests {
fn info_entries_carry_prefix_marker() {
let mut line = CommandLine::new();
line.push_info("Object selected.");
assert_eq!(line.history.last().map(|e| e.text.as_str()), Some("i Object selected."));
assert_eq!(line.history.last().map(|e| &e.kind), Some(&super::EntryKind::Info));
assert_eq!(
line.history.last().map(|entry| entry.text.as_str()),
Some("ⓘ Object selected.")
);
assert_eq!(
line.history.last().map(|entry| &entry.kind),
Some(&EntryKind::Info)
);
}
#[test]
@@ -1204,9 +1355,13 @@ mod tests {
line.push_info("Specify first point:");
assert_eq!(line.history.len(), initial_len + 1);
line.set_step_prompt(Some("Specify first point:".to_string()));
assert_eq!(line.history.len(), initial_len + 1, "prompt must reuse push_info entry without duplicating");
assert_eq!(
line.history.len(),
initial_len + 1,
"prompt must reuse push_info entry without duplicating"
);
let last = line.history.last().unwrap();
assert!(last.pinned);
assert_eq!(last.text, "i Specify first point:");
assert_eq!(last.text, "ⓘ Specify first point:");
}
}
+38 -3
View File
@@ -41,6 +41,9 @@ pub enum DockMsg {
pub enum PanelId {
Properties,
BlockPalette,
ExternalReferences,
/// Outline of the drawing's origin planes, open sketch and solid bodies.
Browser,
}
impl PanelId {
@@ -49,6 +52,8 @@ impl PanelId {
match self {
PanelId::Properties => "Properties",
PanelId::BlockPalette => "Block Palette",
PanelId::ExternalReferences => "External References",
PanelId::Browser => "Browser",
}
}
@@ -57,6 +62,17 @@ impl PanelId {
match self {
PanelId::Properties => 250.0,
PanelId::BlockPalette => 260.0,
PanelId::ExternalReferences => 460.0,
PanelId::Browser => 230.0,
}
}
/// Widest a panel may be dragged or sized to. The references table is
/// column-rich, so it allows double the shared maximum.
fn max_width(self) -> f32 {
match self {
PanelId::ExternalReferences => DOCK_MAX_W * 2.0,
_ => DOCK_MAX_W,
}
}
}
@@ -131,7 +147,12 @@ impl DockState {
/// resize never hit a missing configuration. Also a cheap heal for configs
/// written by an older version.
pub fn ensure_settings(&mut self) {
for id in [PanelId::Properties, PanelId::BlockPalette] {
for id in [
PanelId::Properties,
PanelId::BlockPalette,
PanelId::ExternalReferences,
PanelId::Browser,
] {
self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id));
}
}
@@ -157,7 +178,7 @@ impl DockState {
pub fn width(&self, id: PanelId, win_w: f32) -> f32 {
self.settings(id)
.width
.clamp(DOCK_MIN_W, DOCK_MAX_W.min(win_w * 0.45).max(DOCK_MIN_W))
.clamp(DOCK_MIN_W, id.max_width().min(win_w * 0.45).max(DOCK_MIN_W))
}
pub fn auto_collapse(&self, id: PanelId) -> bool {
@@ -167,7 +188,7 @@ impl DockState {
/// Set the persisted width, clamped.
pub fn set_width(&mut self, id: PanelId, width: f32) {
let entry = self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id));
entry.width = width.clamp(DOCK_MIN_W, DOCK_MAX_W);
entry.width = width.clamp(DOCK_MIN_W, id.max_width());
}
/// Reset width to the panel's default.
@@ -223,6 +244,20 @@ pub const DOCK_MAX_W: f32 = 600.0;
mod tests {
use super::*;
#[test]
fn external_references_allows_double_max_width() {
let mut state = DockState::default();
state.ensure_settings();
// Wider default for the column-rich references table, double the maximum.
// (The 45%-of-window rule still dominates on narrow windows.)
assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), 460.0);
state.set_width(PanelId::ExternalReferences, 5000.0);
assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), DOCK_MAX_W * 2.0);
// Other panels keep the shared maximum.
state.set_width(PanelId::BlockPalette, 5000.0);
assert_eq!(state.width(PanelId::BlockPalette, 3000.0), DOCK_MAX_W);
}
#[test]
fn default_docks_each_known_panel_on_an_edge() {
let state = DockState::default();
+20
View File
@@ -44,6 +44,8 @@ static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent
static OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg");
static OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg");
static OSNAP_MTP: &[u8] = include_bytes!("../../assets/icons/osnap/mtp.svg");
static NAV_PAN: &[u8] = include_bytes!("../../assets/icons/pan.svg");
static NAV_ZOOM: &[u8] = include_bytes!("../../assets/icons/zoom_in.svg");
static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg");
static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg");
@@ -69,6 +71,7 @@ pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg");
pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");
pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg");
pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg");
pub static FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg");
pub static SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg");
pub static FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg");
@@ -626,6 +629,14 @@ pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] {
S::Grid => OSNAP_GRID,
// Not shown in the snap menu; fall back to a neutral marker.
S::ObjectPick => OSNAP_NEAREST,
// 3D modes have no dedicated menu glyphs yet; reuse the closest 2D
// shapes (hollow diamond ≈ vertex, hollow triangle ≈ edge midpoint).
S::Vertex => OSNAP_NODE,
S::EdgeMidpoint => OSNAP_MIDPOINT,
S::FaceCenter => OSNAP_CENTER,
S::Knot => OSNAP_NODE,
S::FacePerpendicular => OSNAP_PERPENDICULAR,
S::NearestFace => OSNAP_NEAREST,
}
}
@@ -634,6 +645,15 @@ pub fn mtp_icon() -> &'static [u8] {
OSNAP_MTP
}
/// Context-menu glyphs for the transparent navigation rows.
pub fn pan_icon() -> &'static [u8] {
NAV_PAN
}
pub fn zoom_icon() -> &'static [u8] {
NAV_ZOOM
}
/// Layer visibility icon bytes (on / off).
pub fn layer_visible(visible: bool) -> &'static [u8] {
if visible {
+54
View File
@@ -244,6 +244,60 @@ pub fn backdrop<'a>(
stack![base.into(), opaque(shield)].into()
}
/// A dialog's "unsaved changes" guard: `main` dimmed behind a small panel
/// that offers to discard the changes and close, or to keep editing. Clicking
/// the dim area keeps editing. Callers show it in place of the plain dialog
/// once the user asked to close with something to lose.
pub fn discard_guard<'a>(
main: impl Into<Element<'a, Message>>,
on_discard: Message,
on_keep: Message,
) -> Element<'a, Message> {
let shield = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme
.palette()
.background
.strongest
.color
.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(on_keep.clone());
let panel = container(
column![
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))
.on_press(on_discard)
.padding([5, 14])
.style(button::danger),
button(iced::widget::text(crate::t!("Keep editing")).size(12))
.on_press(on_keep)
.padding([5, 14])
.style(button::secondary),
]
.spacing(10),
]
.spacing(0),
)
.padding([18, 22])
.style(container::rounded_box);
stack![
main.into(),
shield,
container(panel).center_x(Length::Fill).center_y(Length::Fill)
]
.into()
}
/// Stack `content` over `base` behind a dimmed backdrop, framed with a title bar
/// (the ✕ close button at its right end). The backdrop only dims and blocks
/// clicks from reaching the view beneath — it does **not** dismiss the dialog;
+595 -101
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1,3 +1,4 @@
pub mod context_menu;
pub mod cycle_popup;
pub mod isolate_popup;
pub mod polar_popup;
+13
View File
@@ -854,6 +854,7 @@ impl PropertiesPanel {
PropValue::EditText(val) | PropValue::PlainText(val) => {
self.render_edit_row(label, prop.field, val)
}
PropValue::Hyperlink(val) => render_hyperlink_row(label, val),
PropValue::ReadOnly(val) if prop.field == "annotative_scale" => {
render_annotative_scale_row(label, val)
}
@@ -1892,6 +1893,18 @@ fn render_annotative_scale_row<'a>(
prop_row_widget(label, controls.into())
}
fn render_hyperlink_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill);
let manage = button(text("...").size(FONT_SZ))
.on_press(Message::PropHyperlinkOpen)
.style(button::secondary)
.padding([2, 7]);
let controls = row![field, manage, iced::widget::space().width(10)]
.spacing(2)
.align_y(iced::Center)
.width(Length::Fill);
prop_row_widget(label, controls.into())
}
fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
// A read-only value is shown as a non-editable but selectable field: no
// on_input means the caret never appears, but the text can be selected
+7
View File
@@ -1,6 +1,7 @@
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
pub mod status_menu;
pub(crate) mod spacemouse;
pub mod statusbar_config;
pub mod statusbar_menu;
@@ -135,6 +136,7 @@ impl StatusBar {
parametric_conflicts: usize,
// What is drawing the scene. Only a degraded verdict shows anything.
gpu_status: &'a crate::scene::pipeline::GpuStatus,
spacemouse: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
let StatusMenuData {
layout_names,
@@ -241,6 +243,11 @@ impl StatusBar {
// when the width can't hold them all on one line.
let vis = |p: StatusPill| config.is_visible(p);
let mut pills: Vec<Element<'_, Message>> = Vec::new();
if vis(StatusPill::SpaceMouse) {
if let Some(pill) = spacemouse {
pills.push(pill);
}
}
// Not a `StatusPill` and not hideable: the scene is on a software
// rasterizer or not drawn at all, and the popup that said so has been
// dismissed. This stays for the session and reopens it. Many people
+78
View File
@@ -0,0 +1,78 @@
use super::status_menu::{self, Entry};
use crate::app::Message;
use crate::input::spacemouse::{NavigationMode, Preferences, Status};
use iced::widget::{button, row, text};
use iced::{Element, Fill};
pub(crate) fn view(
preferences: Preferences,
status: Status,
paused: bool,
label: String,
sheet: bool,
) -> Element<'static, Message> {
let root = button(
row![
crate::ui::icons::themed(crate::ui::window::options::spacemouse::ICON, 15.),
text(label).size(11),
crate::ui::icons::themed_arrow_toggle(false, 8.)
]
.spacing(5)
.align_y(iced::Center),
)
.padding([3, 6])
.style(button::subtle);
let mut entries = vec![Entry::stay(
text(format!("SpaceMouse · {}", crate::t!(status.label()))).size(11),
)];
for mode in NavigationMode::ALL {
let label = format!(
"{} {}",
if mode == preferences.mode {
"●"
} else {
"○"
},
crate::t!(mode.label())
);
entries.push(Entry::close(
button(text(label).size(12))
.width(Fill)
.padding([7, 10])
.style(button::text)
.on_press(Message::SpaceMouseMode(mode)),
));
}
if sheet {
entries.push(Entry::stay(
text(crate::t!("Paper sheets keep rotation locked.")).size(11),
));
}
entries.push(Entry::close(
button(
text(crate::t!(if paused {
"Resume SpaceMouse"
} else {
"Pause SpaceMouse"
}))
.size(12),
)
.on_press(Message::SpaceMousePause)
.style(button::text)
.width(Fill)
.padding([7, 10]),
));
for (label, message) in [
("SpaceMouse preferences…", Message::SpaceMousePreferences),
("3Dconnexion settings…", Message::SpaceMouseDriverSettings),
] {
entries.push(Entry::close(
button(text(crate::t!(label)).size(12))
.on_press(message)
.style(button::text)
.width(Fill)
.padding([7, 10]),
));
}
status_menu::menu_bar(root, entries, 260.)
}
+4
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
/// Identifies a toggleable status-bar pill.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub enum StatusPill {
SpaceMouse,
Coords,
Ortho,
Lwt,
@@ -37,6 +38,7 @@ impl StatusPill {
/// Every pill, in status-bar display order. Drives both the bar layout and
/// the customization menu.
pub const ALL: &'static [StatusPill] = &[
StatusPill::SpaceMouse,
StatusPill::Coords,
StatusPill::Ortho,
StatusPill::Lwt,
@@ -62,6 +64,7 @@ impl StatusPill {
/// Stable identifier used for persistence.
pub fn id(self) -> &'static str {
match self {
StatusPill::SpaceMouse => "spacemouse",
StatusPill::Coords => "coords",
StatusPill::Ortho => "ortho",
StatusPill::Lwt => "lwt",
@@ -88,6 +91,7 @@ impl StatusPill {
/// Label shown in the customization menu.
pub fn label(self) -> &'static str {
match self {
StatusPill::SpaceMouse => "SpaceMouse",
StatusPill::Coords => "Coordinates",
StatusPill::Ortho => "Ortho Mode",
StatusPill::Lwt => "Show Lineweight",
+1 -13
View File
@@ -6,6 +6,7 @@ use iced::widget::{
};
use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme};
use crate::ui::style::common::muted_style;
use crate::ui::style::form::hdivider;
use crate::t;
use std::borrow::Cow;
use std::fmt;
@@ -263,19 +264,6 @@ impl canvas::Program<Message> for DimensionPreview {
}
}
fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
}
pub fn view_window<'a>(
styles: Vec<String>,
selected: &'a str,
+219
View File
@@ -0,0 +1,219 @@
//! Form building blocks shared by the in-canvas dialogs.
//!
//! The Plot dialog grew its own `label : control` rows, field and button
//! styles, and section dividers; every dialog that followed copied them. They
//! live here once so a new dialog reads like the existing ones and a theme
//! tweak lands everywhere.
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, container, pick_list, row, text, text_input, Space};
use iced::{Background, Border, Element, Length, Theme};
use std::borrow::Cow;
/// Width of the label column in a `label : control` row.
pub const LABEL_WIDTH: f32 = 92.0;
/// Dialog button: accent (primary action) or neutral.
pub fn button_style(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match (accent, status) {
(true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong,
(false, button::Status::Hovered | button::Status::Pressed) => palette.background.strong,
(true, _) => palette.primary.base,
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
}
}
/// Text field: base background, neutral border that turns primary on focus.
pub fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
let palette = theme.palette();
let border = match status {
text_input::Status::Focused { .. } => palette.primary.base.color,
_ => palette.background.neutral.color,
};
text_input::Style {
background: Background::Color(palette.background.base.color),
border: Border {
color: border,
width: 1.0,
radius: 3.0.into(),
},
icon: palette.background.base.text,
placeholder: palette.background.base.text.scale_alpha(0.48),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
}
/// One-pixel horizontal rule.
pub fn hdivider<'a>(width: Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
})
.into()
}
/// One-pixel vertical rule.
pub fn vseparator<'a>(height: Length) -> Element<'a, Message> {
container(Space::new().width(1).height(height))
.width(1)
.height(height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
})
.into()
}
/// Alias kept for call sites that historically used `vsep`.
/// Same implementation as [`vseparator`].
pub fn vsep<'a>(height: Length) -> Element<'a, Message> {
vseparator(height)
}
/// Muted section heading.
pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> {
text(label).size(11).style(muted_style).into()
}
fn row_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> {
text(label)
.size(11)
.style(muted_style)
.width(LABEL_WIDTH)
.into()
}
/// A `label : dropdown` row.
pub fn labeled_pick_list<'a, T>(
label: Cow<'static, str>,
options: Vec<T>,
selected: Option<T>,
on_select: impl Fn(T) -> Message + 'a,
width: Length,
) -> Element<'a, Message>
where
T: ToString + Clone + PartialEq + 'a,
{
let list = pick_list(selected, options, |value| value.to_string())
.on_select(on_select)
.text_size(12)
.padding([3, 6])
.width(width);
row![row_label(label), list]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A `label : dropdown` row that renders read-only when `enabled` is false.
pub fn labeled_pick_list_enabled<'a, T>(
label: Cow<'static, str>,
options: Vec<T>,
selected: Option<T>,
on_select: impl Fn(T) -> Message + 'a,
width: Length,
enabled: bool,
) -> Element<'a, Message>
where
T: ToString + Clone + PartialEq + 'a,
{
if enabled {
return labeled_pick_list(label, options, selected, on_select, width);
}
row![
row_label(label),
crate::ui::read_only::field(
selected
.map(|choice| choice.to_string())
.unwrap_or_default()
.as_str(),
12.0,
width,
),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A `label : text field` row.
pub fn labeled_field<'a>(
label: Cow<'static, str>,
value: &'a str,
on_input: impl Fn(String) -> Message + 'a,
width: f32,
) -> Element<'a, Message> {
row![
row_label(label),
text_input("", value)
.on_input(on_input)
.style(field_style)
.size(12)
.width(width),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A `label : text field` row that renders read-only when `enabled` is false.
pub fn labeled_field_enabled<'a>(
label: Cow<'static, str>,
value: &'a str,
on_input: impl Fn(String) -> Message + 'a,
width: f32,
enabled: bool,
) -> Element<'a, Message> {
if enabled {
return labeled_field(label, value, on_input, width);
}
row![
row_label(label),
crate::ui::read_only::field(value, 12.0, Length::Fixed(width)),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A `label : text field` pair with its own label width, for rows that put
/// several small values side by side.
pub fn labeled_field_compact<'a>(
label: Cow<'static, str>,
value: &'a str,
on_input: impl Fn(String) -> Message + 'a,
label_width: f32,
field_width: f32,
) -> Element<'a, Message> {
row![
text(label).size(11).style(muted_style).width(label_width),
text_input("", value)
.on_input(on_input)
.style(field_style)
.size(12)
.padding([3, 6])
.width(field_width),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
+144
View File
@@ -0,0 +1,144 @@
//! The Fusion theme pair: black chrome around a white canvas, and its
//! inverse.
//!
//! "Mixed black/white" is about the split, not about draining every colour
//! out of the UI. Ribbon, docks, status bar and command line sit at
//! near-black while model space stays paper-white, so the drawing is the
//! only bright thing on screen. One accent survives that split: selection
//! highlight defaults to the theme primary
//! (`ModelSpaceThemeConfig::selection_highlight_color` = 0), and a white
//! primary would be invisible against a white canvas. The accent is
//! therefore a saturated blue that reads against both halves.
//!
//! These are `iced::Theme::Custom` values, so they are absent from
//! `iced::Theme::ALL`. Anything enumerating themes must go through
//! [`crate::app::config::all_themes`] instead.
use iced::theme::palette::Seed;
use iced::{Color, Theme};
/// Display name of the black-chrome theme. Persisted verbatim in
/// `settings.json` as `theme.name`, so changing it orphans a user's choice.
pub const FUSION_BLACK: &str = "Fusion Black";
/// Display name of the light-chrome theme.
pub const FUSION_WHITE: &str = "Fusion White";
/// Model-space canvas for [`FUSION_BLACK`] — paper white against the
/// near-black chrome. Deliberately not pure #FFFFFF: a fraction below white
/// keeps the paper-space sheet distinguishable from the model canvas.
pub const FUSION_BLACK_CANVAS: [u8; 3] = [250, 250, 250];
/// Model-space canvas for [`FUSION_WHITE`].
pub const FUSION_WHITE_CANVAS: [u8; 3] = [255, 255, 255];
fn rgb(hex: u32) -> Color {
Color::from_rgb8(
((hex >> 16) & 0xFF) as u8,
((hex >> 8) & 0xFF) as u8,
(hex & 0xFF) as u8,
)
}
/// Near-black chrome, white text, blue accent.
#[must_use]
pub fn fusion_black() -> Theme {
Theme::custom(
FUSION_BLACK,
Seed {
background: rgb(0x1A_1A1A),
text: rgb(0xF2_F2F2),
primary: rgb(0x06_96D7),
success: rgb(0x4C_AF50),
warning: rgb(0xFF_B300),
danger: rgb(0xE5_3935),
},
)
}
/// The inverse: light chrome, near-black text. The accent darkens so it
/// keeps its contrast against a light surface.
#[must_use]
pub fn fusion_white() -> Theme {
Theme::custom(
FUSION_WHITE,
Seed {
background: rgb(0xFA_FAFA),
text: rgb(0x1A_1A1A),
primary: rgb(0x02_77BD),
success: rgb(0x2E_7D32),
warning: rgb(0xE6_5100),
danger: rgb(0xC6_2828),
},
)
}
/// Both Fusion themes, in the order the theme picker should show them.
#[must_use]
pub fn fusion_themes() -> Vec<Theme> {
vec![fusion_black(), fusion_white()]
}
/// The canvas colour a Fusion theme asks for, or `None` for any other theme.
/// Keyed by name because `Theme::Custom` carries no discriminant to match on.
#[must_use]
pub fn fusion_canvas(theme: &Theme) -> Option<[u8; 3]> {
match theme.to_string().as_str() {
FUSION_BLACK => Some(FUSION_BLACK_CANVAS),
FUSION_WHITE => Some(FUSION_WHITE_CANVAS),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::style::common::wcag_contrast;
/// The same bar `theme_accessibility_tests` holds the built-in themes to.
/// These themes are hand-written rather than generated, so nothing else
/// would catch a palette typo that drops text below AA.
#[test]
fn fusion_themes_meet_wcag_aa_on_every_surface() {
for theme in fusion_themes() {
let p = theme.palette();
for (name, pair) in [
("base", p.background.base),
("weak", p.background.weak),
("strong", p.background.strong),
("weakest", p.background.weakest),
] {
let contrast = wcag_contrast(pair.text, pair.color);
assert!(
contrast >= 4.5,
"{theme} {name} surface text is {contrast:.2}:1, below AA"
);
}
}
}
/// The accent exists so selection stays visible on the white canvas. If
/// it ever drifts toward white this catches it.
#[test]
fn fusion_accent_is_visible_on_the_canvas() {
for (theme, canvas) in [
(fusion_black(), FUSION_BLACK_CANVAS),
(fusion_white(), FUSION_WHITE_CANVAS),
] {
let canvas = Color::from_rgb8(canvas[0], canvas[1], canvas[2]);
let contrast = wcag_contrast(theme.palette().primary.base.color, canvas);
assert!(
contrast >= 3.0,
"{theme} accent is {contrast:.2}:1 on its canvas, too faint \
to show a selection"
);
}
}
#[test]
fn fusion_canvas_is_keyed_by_name() {
assert_eq!(fusion_canvas(&fusion_black()), Some(FUSION_BLACK_CANVAS));
assert_eq!(fusion_canvas(&fusion_white()), Some(FUSION_WHITE_CANVAS));
assert_eq!(fusion_canvas(&Theme::Dark), None);
}
}
+2
View File
@@ -10,6 +10,8 @@ pub mod scale_manager;
pub mod anno_object_scale;
pub mod style_manager;
pub mod common;
pub mod form;
pub mod fusion_theme;
#[cfg(test)]
mod common_tests {
+1 -26
View File
@@ -4,6 +4,7 @@ use crate::app::Message;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Element, Theme};
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{hdivider, vsep};
use crate::t;
use std::borrow::Cow;
use std::fmt;
@@ -83,32 +84,6 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
}
}
fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
}
fn vsep<'a>(height: iced::Length) -> Element<'a, Message> {
container(Space::new().width(1).height(height))
.width(1)
.height(height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
}
pub fn view_window<'a>(
document: &'a acadrust::CadDocument,
table: Option<&'a crate::io::plot_style::PlotStyleTable>,
+1 -25
View File
@@ -327,28 +327,4 @@ pub(crate) fn muted_text_style(theme: &Theme) -> iced::widget::text::Style {
crate::ui::style::common::muted_style(theme)
}
pub(crate) fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
}
pub(crate) fn vsep<'a>(height: iced::Length) -> Element<'a, Message> {
container(Space::new().width(1).height(height))
.width(1)
.height(height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
}
pub(crate) use crate::ui::style::form::{hdivider, vsep};
+173
View File
@@ -9,6 +9,8 @@
//! - Properties & Dock Panels (field labels, section headers, muted text)
//! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons)
//! - Dropdowns & Selection Overlays (visual style picker, item checkmarks)
//! - Block Palette (card labels in normal/hover/pressed/placing states,
//! header icon buttons, empty-state muted text)
use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast};
use iced::{Color, Theme};
@@ -462,3 +464,174 @@ fn test_viewport_controls_toggle_buttons_contrast() {
}
}
#[test]
fn test_block_palette_contrast() {
use crate::ui::window::block_palette::{
block_card_border, block_card_colors, block_icon_button_text_color,
};
use iced::widget::button::Status as BtnStatus;
for theme in Theme::ALL {
let p = theme.palette();
// ── 1. Card label pairs resolve to the theme's own text colors ──
// Regression guard for the light-theme bug where labels were hardcoded
// to `Color::WHITE` (unreadable white-on-light). If anyone reintroduces
// 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);
assert_eq!(
normal_bg, p.background.base.color,
"Theme {:?} block card normal background drifted from base pair",
theme
);
assert_eq!(
normal_fg, p.background.base.text,
"Theme {:?} block card normal label must use base text (was hardcoded WHITE)",
theme
);
let (_, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered);
assert_eq!(
hover_fg, p.background.strong.text,
"Theme {:?} block card hover label must use strong text (was hardcoded WHITE)",
theme
);
let (_, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed);
assert_eq!(
pressed_fg, p.background.strong.text,
"Theme {:?} block card pressed label must use strong text (was hardcoded WHITE)",
theme
);
// Default-branch statuses (Active/Disabled/Focused/...) share the base 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",
theme
);
// Placing (selected) cards always use the primary pair, regardless of
// hover — the background does not switch on hover while placing.
for status in [BtnStatus::Active, BtnStatus::Hovered, BtnStatus::Pressed] {
let (bg, fg) = block_card_colors(theme, true, status);
assert_eq!(bg, p.primary.base.color, "Theme {:?} placing card bg must be primary.base", theme);
assert_eq!(fg, p.primary.base.text, "Theme {:?} placing card label must be primary.base.text", theme);
}
// ── 2. Card label contrast: normal / hover / pressed ──
// 11px single-line labels are normal text → WCAG AA >= 4.5:1.
let normal_contrast = wcag_contrast(normal_fg, normal_bg);
assert!(
normal_contrast >= 4.5,
"Theme {:?} block card label ({:.2}:1) fails WCAG AA on card background",
theme, normal_contrast
);
let (hover_bg, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered);
let hover_contrast = wcag_contrast(hover_fg, hover_bg);
assert!(
hover_contrast >= 4.5,
"Theme {:?} block card hovered label ({:.2}:1) fails WCAG AA on hovered background",
theme, hover_contrast
);
let (pressed_bg, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed);
let pressed_contrast = wcag_contrast(pressed_fg, pressed_bg);
assert!(
pressed_contrast >= 4.5,
"Theme {:?} block card pressed label ({:.2}:1) fails WCAG AA on pressed background",
theme, pressed_contrast
);
// ── 3. Placing card contrast ──
// Selected-card label on `primary.base`: UI button component threshold
// (>= 3.0:1 per WCAG 1.4.11, matching the Primary-button test above).
let (placing_bg, placing_fg) = block_card_colors(theme, true, BtnStatus::Active);
let placing_contrast = wcag_contrast(placing_fg, placing_bg);
assert!(
placing_contrast >= 3.0,
"Theme {:?} placing block card label ({:.2}:1) fails button threshold (>=3.0:1)",
theme, placing_contrast
);
// Placing + hover must not silently drop contrast (bg is sticky).
let (placing_hover_bg, placing_hover_fg) =
block_card_colors(theme, true, BtnStatus::Hovered);
let placing_hover_contrast = wcag_contrast(placing_hover_fg, placing_hover_bg);
assert!(
placing_hover_contrast >= 3.0,
"Theme {:?} placing+hover block card label ({:.2}:1) fails button threshold (>=3.0:1)",
theme, placing_hover_contrast
);
// ── 4. Card border is theme-driven (no invisible borders) ──
assert_eq!(
block_card_border(theme, false),
p.background.neutral.color,
"Theme {:?} block card border must use neutral color",
theme
);
assert_eq!(
block_card_border(theme, true),
p.primary.base.color,
"Theme {:?} placing block card border must use primary color",
theme
);
// ── 5. Header icon buttons (glyphs inherit button text_color) ──
// Resting buttons are transparent over the dock (`base`) background.
let icon_rest = block_icon_button_text_color(theme, BtnStatus::Active);
assert_eq!(icon_rest, p.background.base.text);
let icon_rest_contrast = wcag_contrast(icon_rest, p.background.base.color);
assert!(
icon_rest_contrast >= 4.5,
"Theme {:?} block palette icon button ({:.2}:1) fails WCAG AA on dock background",
theme, icon_rest_contrast
);
// Hovered/pressed buttons sit on the `strong` surface with its text.
for status in [BtnStatus::Hovered, BtnStatus::Pressed] {
let icon_fg = block_icon_button_text_color(theme, status);
assert_eq!(
icon_fg, p.background.strong.text,
"Theme {:?} hovered icon button must use strong text",
theme
);
let c = wcag_contrast(icon_fg, p.background.strong.color);
assert!(
c >= 4.5,
"Theme {:?} hovered icon button ({:.2}:1) fails WCAG AA on hovered background",
theme, c
);
}
// ── 6. Empty-state muted text ("No blocks in this drawing") ──
// Rendered as `base.text` at 0.72 alpha over the dock `base`
// background → secondary text threshold >= 3.0:1. (The old hardcoded
// 0.55-gray failed this on light themes.)
let empty_text = composite_over(p.background.base.text.scale_alpha(0.72), p.background.base.color);
let empty_contrast = wcag_contrast(empty_text, p.background.base.color);
assert!(
empty_contrast >= 3.0,
"Theme {:?} block palette empty-state text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
theme, empty_contrast
);
// ── 7. Hardcoded-white regression probe ──
// The exact reported bug: pure white labels on the light card
// background. Every theme's card background must NOT be near-white
// with white text — i.e. white-on-card must fail while the real
// theme pair passes (proves the fix matters on light themes and the
// test would catch a WHITE hardcode).
let white_on_normal = wcag_contrast(Color::WHITE, normal_bg);
if white_on_normal < 4.5 {
assert!(
normal_contrast >= 4.5,
"Theme {:?} needs theme-aware labels: white-on-card is {:.2}:1 but theme pair is {:.2}:1",
theme, white_on_normal, normal_contrast
);
}
}
}
+1 -6
View File
@@ -1,14 +1,9 @@
use crate::app::Message;
use crate::t;
use crate::ui::style::common::muted_style;
use iced::widget::{button, column, container, row, svg, text, Space};
use iced::{Background, Border, Element, Fill, Length, Shrink, Theme};
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn primary_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
+250 -21
View File
@@ -1,10 +1,13 @@
//! Command-alias editor — an in-canvas modal (Plan B) for adding, remapping and
//! removing command-line aliases (the `ocad.pgp` table). Opened by ALIASEDIT.
//! Rows are `(alias, command)`; edits are buffered in `alias_editor_rows` and
//! committed to the alias table when the dialog closes. Mirrors the editable-row
//! pattern of the attribute editor and plugin manager.
//! committed to the alias table on Apply. Mirrors the shortcut editor:
//! transient draft row with explicit accept, duplicate/unknown validation,
//! reset-to-defaults with confirmation, apply-and-exit, alias count, and an
//! unsaved-changes guard on close.
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Element, Length, Theme};
use crate::t;
@@ -19,20 +22,54 @@ pub enum AliasField {
/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ column.
const GUTTER: f32 = 16.0;
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
fn danger_text_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
color: Some(theme.palette().danger.base.color),
}
}
/// Text-input variant flagging an invalid value: danger border and value
/// color on the theme's danger weak background.
fn danger_input_style(
theme: &Theme,
status: iced::widget::text_input::Status,
) -> iced::widget::text_input::Style {
let danger = theme.palette().danger.base;
iced::widget::text_input::Style {
background: Background::Color(theme.palette().danger.weak.color),
border: iced::border::rounded(4)
.color(danger.color)
.width(
if matches!(
status,
iced::widget::text_input::Status::Focused { is_hovered: _ }
) {
1.5
} else {
1.0
},
),
icon: danger.color,
placeholder: danger.color.scale_alpha(0.7),
value: danger.text,
selection: danger.color,
}
}
/// Build the alias editor content. `rows` is the live working buffer.
pub fn view_window(
rows: &[(String, String)],
pub fn view_window<'a>(
rows: &'a [(String, String)],
pending_add: bool,
reset_confirm: bool,
duplicate_aliases: &rustc_hash::FxHashSet<String>,
duplicate_conflicts: &[(String, String)],
unknown_commands: &rustc_hash::FxHashSet<String>,
close_confirm: bool,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'_, Message> {
) -> Element<'a, Message> {
let title = text(t!("Command Aliases")).size(15);
let hint = text(t!(
"Type an alias and the command it runs (e.g. L → LINE). Apply to save to ocad.pgp; closing discards unapplied edits."
"Click + Add alias, type the alias and command, accept the row, then Apply. Esc cancels the pending row."
))
.size(11)
.style(muted_style);
@@ -46,7 +83,7 @@ pub fn view_window(
row![
container(text(t!("Alias")).size(11).style(muted_style)).width(Length::Fixed(120.0)),
container(text(t!("Command")).size(11).style(muted_style)).width(sizing.width),
Space::new().width(Length::Fixed(30.0)),
Space::new().width(Length::Fixed(62.0)),
]
.spacing(8),
)
@@ -54,39 +91,167 @@ pub fn view_window(
let mut list = column![].spacing(3);
for (idx, (alias, cmd)) in rows.iter().enumerate() {
let alias_key = alias.trim().to_uppercase();
let duplicate = !alias_key.is_empty() && duplicate_aliases.contains(alias_key.as_str());
let alias_box = text_input(t!("alias").as_ref(), alias)
.on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Alias, value: v })
.size(13)
.padding([3, 6])
.width(Length::Fixed(120.0));
let alias_box = if duplicate {
alias_box.style(danger_input_style)
} else {
alias_box
};
let unknown_command =
!cmd.trim().is_empty() && unknown_commands.contains(cmd.trim());
let cmd_box = text_input(t!("command").as_ref(), cmd)
.on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Command, value: v })
.size(13)
.padding([3, 6])
.width(sizing.width);
let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0))
let cmd_box = if unknown_command {
cmd_box.style(danger_input_style)
} else {
cmd_box
};
// Draft rows get a check (finish the addition, without applying) and
// a cancel ✕; committed rows get the trash bin.
let remove = if pending_add && idx == 0 {
let done_ok = !alias.trim().is_empty()
&& !cmd.trim().is_empty()
&& !duplicate
&& !unknown_commands.contains(cmd.trim());
row![
button(crate::ui::icons::themed_success_text(
crate::ui::icons::CHECK,
12.0,
))
.on_press_maybe(done_ok.then_some(Message::AliasEditorDraftAccept))
.padding([2, 6])
.style(button::success),
button(crate::ui::icons::themed_danger_text(
crate::ui::icons::CLOSE,
12.0,
))
.on_press(Message::AliasEditorDraftCancel)
.padding([2, 6])
.style(button::danger),
]
.spacing(4)
.align_y(iced::Center)
} else {
row![button(crate::ui::icons::themed_danger_text(
crate::ui::icons::TRASH,
12.0,
))
.on_press(Message::AliasEditorRemove(idx))
.padding([2, 6])
.style(button::danger);
.style(button::danger)]
.align_y(iced::Center)
};
list = list.push(
row![alias_box, cmd_box, del]
row![alias_box, cmd_box, remove]
.spacing(8)
.align_y(iced::Center),
);
}
let add = button(text(t!("+ Add alias")).size(12))
.on_press(Message::AliasEditorAdd)
.padding([4, 10])
.style(button::secondary);
// While a draft row is pending, the add button doubles as the visible
// cancel affordance (Esc works too).
let add = if pending_add {
button(text(t!("Cancel add (Esc)")).size(12))
.on_press(Message::AliasEditorDraftCancel)
.padding([4, 10])
.style(button::danger)
} else {
button(text(t!("+ Add alias")).size(12))
.on_press(Message::AliasEditorAdd)
.padding([4, 10])
.style(button::secondary)
};
// The reset confirmation replaces the alias count so the warning
// gets the whole left side of the bar.
let stats = if reset_confirm {
row![Space::new().width(Length::Fixed(0.0))]
} else {
row![text(format!("{}: {}", t!("Number of aliases"), rows.len()))
.size(12)
.style(muted_style)]
};
// Reset asks for confirmation in place: the add/reset buttons are
// replaced by the question with Yes / No.
let (reset_area, add_area) = if reset_confirm {
(
row![
text(t!(
"Are you sure you want to reset? You will lose all your current aliases!"
))
.size(12)
.style(danger_text_style),
button(text(t!("Yes, reset")).size(12))
.on_press(Message::AliasEditorResetConfirm)
.padding([4, 10])
.style(button::danger),
button(text(t!("No")).size(12))
.on_press(Message::AliasEditorResetDeny)
.padding([4, 10])
.style(button::secondary),
]
.spacing(8)
.align_y(iced::Center),
row![Space::new().width(Length::Fixed(0.0))],
)
} else {
(
row![button(text(t!("Reset to default")).size(12))
.on_press(Message::AliasEditorResetAsk)
.padding([4, 10])
.style(button::secondary)]
.spacing(8)
.align_y(iced::Center),
row![add],
)
};
// Apply — primary action; commits the rows to ocad.pgp and stays open.
let apply = button(text(t!("Apply")).size(12))
.on_press(Message::AliasEditorApply)
.padding([4, 16])
.style(button::primary);
let apply_exit = button(text(t!("Apply && Exit")).size(12))
.on_press(Message::AliasEditorApplyExit)
.padding([4, 16])
.style(button::primary);
// The reset confirmation takes over the whole action bar.
let apply_area = if reset_confirm {
row![Space::new().width(Length::Fixed(0.0))]
} else {
row![apply, apply_exit].spacing(8).align_y(iced::Center)
};
container(
// Persistent validation warnings: shown until every conflicting alias is
// resolved (edited or a row removed) and every command is runnable.
let mut conflict_banner = column![].spacing(2);
for (alias, command) in duplicate_conflicts {
conflict_banner = conflict_banner.push(
text(crate::tf!(
"Alias already used for command: {} → {}",
alias,
command
))
.size(12)
.style(danger_text_style),
);
}
for command in unknown_commands {
conflict_banner = conflict_banner.push(
text(crate::tf!("Unknown command: {}", command))
.size(12)
.style(danger_text_style),
);
}
let content = container(
column![
title,
hint,
@@ -94,7 +259,11 @@ pub fn view_window(
head,
scrollable(container(list).padding(gutter)).height(sizing.height),
Space::new().height(6),
row![add, Space::new().width(sizing.width), apply].align_y(iced::Center),
conflict_banner,
Space::new().height(4),
row![stats, add_area, reset_area, Space::new().width(sizing.width), apply_area]
.spacing(8)
.align_y(iced::Center),
]
.spacing(6)
.width(sizing.width)
@@ -108,6 +277,66 @@ pub fn view_window(
theme.palette().background.base.color,
)),
..Default::default()
})
.into()
});
// Unsaved-changes guard: closing with un-applied rows stacks a dimmed
// shield and a confirmation panel on top of the editor.
if !close_confirm {
return content.into();
}
let shield = iced::widget::mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme
.palette()
.background
.strongest
.color
.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(Message::AliasEditorCloseKeep)
.interaction(iced::mouse::Interaction::Idle);
let panel = container(
column![
text(t!("Unsaved changes will be discarded.")).size(14),
Space::new().height(10),
row![
button(text(t!("Discard && close")).size(12))
.on_press(Message::AliasEditorCloseDiscard)
.padding([4, 12])
.style(button::danger),
button(text(t!("Keep editing")).size(12))
.on_press(Message::AliasEditorCloseKeep)
.padding([4, 12])
.style(button::secondary),
]
.spacing(8),
]
.spacing(4),
)
.padding(16)
.width(Length::Fixed(320.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
border: iced::Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
});
let centered = container(panel)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center);
iced::widget::stack![content, iced::widget::opaque(shield), centered].into()
}
+2 -20
View File
@@ -25,6 +25,8 @@ use std::borrow::Cow;
use std::fmt;
use crate::ui::properties::{lw_options, LwItem};
use crate::ui::style::common::muted_style;
use crate::ui::style::form::hdivider;
const LABEL_W: f32 = 120.0;
@@ -139,12 +141,6 @@ pub fn color_from_label(label: &str) -> Option<AcadColor> {
})
}
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
let palette = theme.palette();
let border = match status {
@@ -161,20 +157,6 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
}
}
/// Horizontal 1px divider in the shared border colour (matches style windows).
fn hdivider<'a>(width: Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
}
/// One `label : widget` row with a fixed-width label column.
fn field_row<'a>(
label: Cow<'static, str>,
+76 -17
View File
@@ -264,7 +264,20 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<
} else {
"No matches"
};
container(text(crate::t!(msg)).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }))
container(
text(crate::t!(msg))
.size(12)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(
theme
.palette()
.background
.base
.text
.scale_alpha(0.72),
),
}),
)
.center_x(Fill)
.center_y(Fill)
.width(Fill)
@@ -307,6 +320,56 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<
.into()
}
/// Theme-aware foreground/background pair for a block card.
///
/// Centralizes the card's text/background mapping so the contrast suite can
/// exercise the exact colors the UI renders (instead of re-implementing the
/// match). Normal cards use the `base` pair, hovered/pressed cards use the
/// `strong` pair, and the actively-placing card uses the `primary.base` pair.
pub(crate) fn block_card_colors(
theme: &Theme,
is_placing: bool,
status: button::Status,
) -> (Color, Color) {
let palette = theme.palette();
if is_placing {
(palette.primary.base.color, palette.primary.base.text)
} else {
match status {
button::Status::Hovered | button::Status::Pressed => {
(palette.background.strong.color, palette.background.strong.text)
}
_ => (
palette.background.base.color,
palette.background.base.text,
),
}
}
}
/// Border color for a block card in a given state (exposed for contrast tests).
pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color {
let palette = theme.palette();
if is_placing {
palette.primary.base.color
} else {
palette.background.neutral.color
}
}
/// Theme-aware foreground for the palette's header icon buttons.
///
/// Transparent resting state composites over the dock (`base`) background;
/// hovered/pressed state sits on the `strong` surface, so each state pairs
/// with its own background's text color to preserve contrast.
pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color {
let palette = theme.palette();
match status {
button::Status::Hovered | button::Status::Pressed => palette.background.strong.text,
_ => palette.background.base.text,
}
}
fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> {
let is_placing = palette.placing.as_deref() == Some(block.name.as_str());
let preview = canvas(BlockPreviewCanvas { wires: &block.wires })
@@ -315,9 +378,13 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
// Fixed height guarantees the label occupies exactly one line, so every
// cell in a row keeps the same height even for long names (the elide budget
// handles overflow, and this clips anything that still won't fit).
//
// NOTE: no explicit `.color(...)` here — the label inherits the button's
// `text_color` so normal/hover/placing states each get a theme-aware
// foreground with sufficient contrast on both light and dark themes.
// (A hardcoded `Color::WHITE` is unreadable on light themes.)
let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS))
.size(11)
.color(Color::WHITE)
.width(Fill)
.height(Length::Fixed(LABEL_LINE_H))
.center();
@@ -326,18 +393,8 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
.on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone())))
.width(Fill)
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let neutral = palette.background.neutral.color;
let base = palette.background.base.color;
let strong = palette.background.strong.color;
let (bg, border) = if is_placing {
(palette.primary.base.color, palette.primary.base.color)
} else {
match status {
button::Status::Hovered | button::Status::Pressed => (strong, neutral),
_ => (base, neutral),
}
};
let (bg, fg) = block_card_colors(theme, is_placing, status);
let border = block_card_border(theme, is_placing);
button::Style {
background: Some(Background::Color(bg)),
border: Border {
@@ -345,7 +402,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
width: 1.0,
radius: 3.0.into(),
},
text_color: Color::WHITE,
text_color: fg,
..Default::default()
}
})
@@ -354,7 +411,9 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> {
let icon_el: Element<'_, Message> = match icon {
IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(),
// No explicit color — inherits the button's theme-aware `text_color`
// so glyphs stay legible on light themes.
IconKind::Glyph(s) => text(s).size(15).into(),
IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H),
};
button(icon_el)
@@ -372,7 +431,7 @@ fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message>
radius: 3.0.into(),
..Default::default()
},
text_color: Color::WHITE,
text_color: block_icon_button_text_color(theme, status),
..Default::default()
})
.into()
+205
View File
@@ -0,0 +1,205 @@
//! The Browser: a standing outline of what the drawing is built from.
//!
//! Three sections, each read straight from the document rather than from a
//! parallel model the panel would have to keep in sync:
//!
//! * **Origin planes** — the three world planes, as somewhere to start a
//! sketch when the drawing is still empty and there is no face to pick.
//! * **Sketch** — the open sketch, if there is one.
//! * **Bodies** — every `Solid3D`, labelled with the operation that made it,
//! recovered from the DWG solid history.
//!
//! What this panel deliberately is *not* is a parametric feature tree. The
//! history records how each solid was built, but nothing re-runs it, so
//! editing an earlier feature cannot rebuild the ones after it. Showing the
//! list as if it were editable history would promise exactly that. It is a
//! 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};
/// The three world planes, with the argument `CREATESKETCH` takes for each.
const ORIGIN_PLANES: [(&str, &str); 3] = [("XY", "XY"), ("XZ", "XZ"), ("YZ", "YZ")];
/// Human name for the operation that produced a solid. `None` when the
/// drawing carries no history for it — a solid imported as plain geometry,
/// or one whose history was stripped on save.
fn operation_label(operation: &SolidHistoryOperation) -> &'static str {
match operation {
SolidHistoryOperation::Box(_) => "Box",
SolidHistoryOperation::Wedge(_) => "Wedge",
SolidHistoryOperation::Sphere(_) => "Sphere",
SolidHistoryOperation::Cone(_) => "Cone",
SolidHistoryOperation::Cylinder(_) => "Cylinder",
SolidHistoryOperation::Torus(_) => "Torus",
SolidHistoryOperation::Pyramid(_) => "Pyramid",
SolidHistoryOperation::Sweep(_) => "Sweep",
SolidHistoryOperation::Loft(_) => "Loft",
SolidHistoryOperation::Extrusion(_) => "Extrude",
SolidHistoryOperation::Revolve(_) => "Revolve",
SolidHistoryOperation::Fillet(_) => "Fillet",
SolidHistoryOperation::Chamfer(_) => "Chamfer",
_ => "Solid",
}
}
/// Every `Solid3D` in model space, with the label to show for it.
fn bodies(document: &CadDocument) -> Vec<(Handle, String)> {
document
.entities()
.filter(|entity| matches!(entity, EntityType::Solid3D(_)))
.map(|entity| {
let handle = entity.common().handle;
let label = crate::scene::model::solid_history::primitive_property_operation(
document, handle,
)
.as_ref()
.map_or("Solid", operation_label);
(handle, format!("{label} {:X}", handle.value()))
})
.collect()
}
fn section_header(label: String) -> Element<'static, Message> {
container(text(label).size(11))
.width(Fill)
.padding([3, 6])
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
..Default::default()
})
.into()
}
/// A clickable row that runs `command`.
fn entry_row(label: String, command: String) -> Element<'static, Message> {
button(text(label).size(12))
.on_press(Message::Command(command))
.width(Fill)
.padding([3, 10])
.style(button::subtle)
.into()
}
/// A row that states something rather than doing something.
fn note_row(label: String) -> Element<'static, Message> {
container(text(label).size(11).style(crate::ui::style::common::muted_style))
.width(Fill)
.padding([3, 10])
.into()
}
pub fn view<'a>(
document: &'a CadDocument,
open_sketch: Option<&'a str>,
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 mut tree = column![].spacing(1);
tree = tree.push(section_header(crate::t!("Origin planes").into_owned()));
for (label, argument) in ORIGIN_PLANES {
tree = tree.push(entry_row(
label.to_string(),
format!("CREATESKETCH {argument}"),
));
}
tree = tree.push(section_header(crate::t!("Sketch").into_owned()));
match open_sketch {
Some(name) => {
tree = tree.push(entry_row(name.to_string(), "FINISHSKETCH".to_string()));
}
None => {
tree = tree.push(note_row(crate::t!("No sketch open").into_owned()));
}
}
let bodies = bodies(document);
tree = tree.push(section_header(crate::tf!("Bodies ({})", bodies.len()).into_owned()));
if bodies.is_empty() {
tree = tree.push(note_row(crate::t!("No solids yet").into_owned()));
} else {
for (handle, label) in bodies {
// Selecting by handle is what SELECT already takes, so clicking a
// row reuses the ordinary selection path.
tree = tree.push(entry_row(label, format!("SELECT {:X}", handle.value())));
}
}
let body = scrollable(container(tree).padding(iced::Padding {
top: 0.0,
right: 8.0,
bottom: 6.0,
left: 0.0,
}))
.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()
}
+146 -98
View File
@@ -1,8 +1,8 @@
use crate::app::settings::IsoPlane;
use crate::app::Message;
use crate::snap::{SnapType, ALL_SNAP_MODES};
use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::snap::{SnapType, ALL_3D_SNAP_MODES, ALL_SNAP_MODES};
use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Element, Fill, Theme};
use std::borrow::Cow;
/// Active tab in the Drafting Settings dialog.
@@ -28,6 +28,14 @@ pub struct DraftingSettingsState {
// Snap and Grid
pub snap_on: bool,
pub grid_on: bool,
pub snap_x_input: String,
pub snap_y_input: String,
pub snap_equal: bool,
pub grid_x_input: String,
pub grid_y_input: String,
pub grid_major_input: String,
pub grid_adaptive: bool,
pub grid_beyond_limits: bool,
pub isometric: bool,
pub iso_plane: IsoPlane,
pub snap_angle_deg: f32,
@@ -41,6 +49,7 @@ pub struct DraftingSettingsState {
pub snap_modes: rustc_hash::FxHashSet<SnapType>,
// 3D Object Snap
pub osnap3d_on: bool,
pub snap3d_modes: rustc_hash::FxHashSet<SnapType>,
// Dynamic Input
pub dyn_input_on: bool,
// Quick Properties
@@ -55,6 +64,14 @@ impl DraftingSettingsState {
pub fn is_dirty(&self, saved: &Self) -> bool {
self.snap_on != saved.snap_on
|| self.grid_on != saved.grid_on
|| self.snap_x_input != saved.snap_x_input
|| self.snap_y_input != saved.snap_y_input
|| self.snap_equal != saved.snap_equal
|| self.grid_x_input != saved.grid_x_input
|| self.grid_y_input != saved.grid_y_input
|| self.grid_major_input != saved.grid_major_input
|| self.grid_adaptive != saved.grid_adaptive
|| self.grid_beyond_limits != saved.grid_beyond_limits
|| self.isometric != saved.isometric
|| self.iso_plane != saved.iso_plane
|| (self.snap_angle_deg - saved.snap_angle_deg).abs() > 0.001
@@ -65,12 +82,38 @@ impl DraftingSettingsState {
|| self.otrack_on != saved.otrack_on
|| self.snap_modes != saved.snap_modes
|| self.osnap3d_on != saved.osnap3d_on
|| self.snap3d_modes != saved.snap3d_modes
|| self.dyn_input_on != saved.dyn_input_on
|| self.quick_props_on != saved.quick_props_on
|| self.selection_cycling_on != saved.selection_cycling_on
}
}
/// Format a snap spacing value for the dialog's text inputs.
pub fn format_snap_spacing(v: f32) -> String {
if (v - v.round()).abs() < 1e-4 {
format!("{}", v.round() as i32)
} else {
format!("{v}")
}
}
/// Parse a snap spacing input. Spacings must be positive and finite.
pub fn parse_snap_spacing(s: &str) -> Option<f32> {
let v: f32 = s.trim().parse().ok()?;
if v.is_finite() && v > 0.0 && v <= 1e9 {
Some(v)
} else {
None
}
}
/// Parse a major-line interval. Must be an integer in 2..=100.
pub fn parse_grid_major(s: &str) -> Option<u32> {
let v: u32 = s.trim().parse().ok()?;
(2..=100).contains(&v).then_some(v)
}
/// Helper for grouped sub-panels with a light border and header title.
fn group<'a>(title: impl Into<String>, body: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(
@@ -111,19 +154,44 @@ pub fn view_window<'a>(
};
// ── Tab Bar ──────────────────────────────────────────────────────────
// Real tab strip: a single non-wrapping row of tab-shaped buttons with a
// divider underneath, matching the style-manager editor shell. The row
// never wraps — it clips on overflow so "Selection Cycling" can't spill
// onto a second row.
let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| {
let is_active = state.active_tab == tab;
button(text(label).size(11.5))
button(text(label).size(11))
.on_press(Message::DraftingSettingsTabChanged(tab))
.style(if is_active {
button::primary
} else {
button::secondary
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match (is_active, status) {
(true, _) => palette.primary.strong,
(
false,
button::Status::Hovered | button::Status::Pressed,
) => palette.background.strong,
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: iced::border::Radius {
top_left: 4.0,
top_right: 4.0,
bottom_right: 0.0,
bottom_left: 0.0,
},
},
..Default::default()
}
})
.padding([5, 9])
.padding([4, 8])
};
let tab_bar = row![
let tab_row = row![
tab_button(DraftingSettingsTab::SnapAndGrid, crate::t!("Snap and Grid")),
tab_button(DraftingSettingsTab::PolarTracking, crate::t!("Polar Tracking")),
tab_button(DraftingSettingsTab::ObjectSnap, crate::t!("Object Snap")),
@@ -132,8 +200,21 @@ pub fn view_window<'a>(
tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")),
tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")),
]
.spacing(4)
.wrap();
.spacing(2)
.width(Fill)
.clip(true);
let tab_divider = container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color,
)),
..Default::default()
});
let tab_bar = column![tab_row, tab_divider].spacing(0).width(Fill);
// ── Tab 1: Snap and Grid ─────────────────────────────────────────────
let snap_and_grid_view = {
@@ -149,18 +230,28 @@ pub fn view_window<'a>(
column![
row![
text(crate::t!("Snap X spacing:")).size(11).width(120),
text("10.00").size(11),
text_input("10", &state.snap_x_input)
.on_input(Message::DraftingSettingsSnapXChanged)
.size(11)
.padding([4, 7])
.width(100),
]
.spacing(8)
.align_y(iced::Center),
row![
text(crate::t!("Snap Y spacing:")).size(11).width(120),
text("10.00").size(11),
text_input("10", &state.snap_y_input)
.on_input(Message::DraftingSettingsSnapYChanged)
.size(11)
.padding([4, 7])
.width(100),
]
.spacing(8)
.align_y(iced::Center),
row![
checkbox(true).size(14),
checkbox(state.snap_equal)
.on_toggle(|_| Message::DraftingSettingsToggleEqualSnap)
.size(14),
text(crate::t!("Equal X and Y spacing")).size(11),
]
.spacing(6)
@@ -223,19 +314,31 @@ pub fn view_window<'a>(
column![
row![
text(crate::t!("Grid X spacing:")).size(11).width(120),
text("10.00").size(11),
text_input("10", &state.grid_x_input)
.on_input(Message::DraftingSettingsGridXChanged)
.size(11)
.padding([4, 7])
.width(100),
]
.spacing(8)
.align_y(iced::Center),
row![
text(crate::t!("Grid Y spacing:")).size(11).width(120),
text("10.00").size(11),
text_input("10", &state.grid_y_input)
.on_input(Message::DraftingSettingsGridYChanged)
.size(11)
.padding([4, 7])
.width(100),
]
.spacing(8)
.align_y(iced::Center),
row![
text(crate::t!("Major line every:")).size(11).width(120),
text("5").size(11),
text_input("5", &state.grid_major_input)
.on_input(Message::DraftingSettingsGridMajorChanged)
.size(11)
.padding([4, 7])
.width(100),
]
.spacing(8)
.align_y(iced::Center),
@@ -247,13 +350,17 @@ pub fn view_window<'a>(
crate::t!("Grid behavior"),
column![
row![
checkbox(true).size(14),
checkbox(state.grid_adaptive)
.on_toggle(|_| Message::DraftingSettingsToggleAdaptiveGrid)
.size(14),
text(crate::t!("Adaptive grid")).size(11),
]
.spacing(6)
.align_y(iced::Center),
row![
checkbox(true).size(14),
checkbox(state.grid_beyond_limits)
.on_toggle(|_| Message::DraftingSettingsToggleBeyondLimits)
.size(14),
text(crate::t!("Display grid beyond Limits")).size(11),
]
.spacing(6)
@@ -398,44 +505,26 @@ pub fn view_window<'a>(
Message::DraftingSettingsToggle3dOsnap,
);
let placeholder_group = group(
crate::t!("3D Object Snap modes"),
column![
row![checkbox(false).size(14), text(crate::t!("Vertex")).size(11)]
.spacing(7)
.align_y(iced::Center),
// Implemented 3D modes follow the same checkbox pattern as the
// Object Snap tab, driven by ALL_3D_SNAP_MODES.
let mut modes_col = column![].spacing(6);
for &(snap_type, _, label) in ALL_3D_SNAP_MODES {
let is_checked = state.snap3d_modes.contains(&snap_type);
modes_col = modes_col.push(
row![
checkbox(false).size(14),
text(crate::t!("Midpoint on edge")).size(11),
checkbox(is_checked)
.on_toggle(move |_| Message::DraftingSettingsToggleSnapMode3d(snap_type))
.size(14),
text(crate::t!(label)).size(11),
]
.spacing(7)
.align_y(iced::Center),
row![
checkbox(false).size(14),
text(crate::t!("Center of face")).size(11),
]
.spacing(7)
.align_y(iced::Center),
row![checkbox(false).size(14), text(crate::t!("Knot")).size(11)]
.spacing(7)
.align_y(iced::Center),
row![
checkbox(false).size(14),
text(crate::t!("Perpendicular to face")).size(11),
]
.spacing(7)
.align_y(iced::Center),
row![
checkbox(false).size(14),
text(crate::t!("Nearest to face")).size(11),
]
.spacing(7)
.align_y(iced::Center),
]
.spacing(6),
);
);
}
column![osnap3d_toggle, Space::new().height(4), placeholder_group]
let modes_group = group(crate::t!("3D Object Snap modes"), modes_col);
column![osnap3d_toggle, Space::new().height(4), modes_group]
.spacing(10)
.width(Fill)
};
@@ -628,51 +717,10 @@ pub fn view_window<'a>(
if !close_confirm {
return main_content.into();
}
let shield = iced::widget::mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme
.palette()
.background
.strongest
.color
.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(Message::DraftingSettingsCloseKeep);
let warning_panel = container(
column![
text(crate::t!("Unsaved changes will be discarded.")).size(13.5),
Space::new().height(12),
row![
button(text(crate::t!("Discard && close")).size(12))
.on_press(Message::DraftingSettingsCloseDiscard)
.padding([5, 14])
.style(button::danger),
button(text(crate::t!("Keep editing")).size(12))
.on_press(Message::DraftingSettingsCloseKeep)
.padding([5, 14])
.style(button::secondary),
]
.spacing(10),
]
.spacing(0),
)
.padding([18, 22])
.style(container::rounded_box);
iced::widget::stack![
crate::ui::modal::discard_guard(
main_content,
shield,
container(warning_panel).center_x(Fill).center_y(Fill)
]
.into()
Message::DraftingSettingsCloseDiscard,
Message::DraftingSettingsCloseKeep,
)
}
+1 -6
View File
@@ -2,6 +2,7 @@
use crate::app::Message;
use crate::ui::properties::{lw_options, LinetypeItem, LwItem};
use crate::ui::style::common::muted_style;
use crate::ui::ROW_H;
use acadrust::tables::layer::Layer as DocLayer;
use acadrust::tables::Table;
@@ -51,12 +52,6 @@ const LINETYPE_MENU_W: f32 = 220.0;
/// scrolled into view after it is added (#271).
pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll";
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn table_input_style(
theme: &Theme,
status: iced::widget::text_input::Status,
+2 -32
View File
@@ -1,16 +1,12 @@
//! Layout Manager window — fills the entire OS window.
use crate::app::Message;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{hdivider, vsep};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Element, Theme};
use crate::t;
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn primary_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
@@ -37,32 +33,6 @@ fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
}
}
fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color,
)),
..Default::default()
})
.into()
}
fn vsep<'a>(height: iced::Length) -> Element<'a, Message> {
container(Space::new().width(1).height(height))
.width(1)
.height(height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color,
)),
..Default::default()
})
.into()
}
pub fn view_window<'a>(
layouts: Vec<String>,
selected: &'a str,
+84
View File
@@ -0,0 +1,84 @@
// Missing-fonts prompt — shown after a drawing opens with `.shx` fonts this
// machine doesn't have. Offers to fetch them from the community repository
// (see `fonts/README.md` in the repo root) instead of rendering with a
// mismatched substitute.
use crate::app::Message;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Element, Fill, Length, Shrink, Theme};
fn muted_style(theme: &Theme) -> text::Style {
text::Style {
color: Some(theme.palette().background.weak.text),
}
}
/// The prompt: the missing-font list, a download action, and a skip.
pub fn view_window<'a>(
fonts: &'a [String],
font_source: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let content_width = if matches!(sizing.width, Length::Fill) {
Fill
} else {
Shrink
};
let content_height = if matches!(sizing.height, Length::Fill) {
Fill
} else {
Shrink
};
let listed: Vec<Element<'a, Message>> = fonts
.iter()
.map(|name| text(format!(" {name}")).size(12).into())
.collect();
let actions = row![
Space::new().width(content_width),
button(text(crate::t!("Skip")).size(12))
.on_press(Message::MissingFontsDismiss)
.style(button::secondary)
.padding([6, 14]),
button(text(crate::t!("Download")).size(12))
.on_press(Message::MissingFontsDownload)
.style(button::primary)
.padding([6, 14]),
]
.spacing(8);
container(
column![
text(crate::t!("Missing fonts")).size(20),
text(crate::t!(
"This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?"
))
.size(11)
.style(muted_style),
container(scrollable(column(listed).spacing(2)).height(content_height))
.padding([10, 12])
.width(content_width)
.height(content_height)
.style(container::bordered_box),
column![
text(crate::t!("Font source — leave empty for the community repository, or point at any folder (e.g. your company's font server):"))
.size(10)
.style(muted_style),
text_input(
crate::t!("https://server/fonts or a GitHub raw folder").as_ref(),
font_source,
)
.on_input(Message::MissingFontsSourceChanged)
.size(11)
.padding([4, 8])
.width(content_width),
]
.spacing(3),
actions,
]
.spacing(10)
.width(sizing.width)
.height(sizing.height),
)
.padding([12, 16])
.into()
}
+4
View File
@@ -1,5 +1,6 @@
pub mod about;
pub mod block_palette;
pub mod browser;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod drawing_units;
@@ -14,6 +15,7 @@ pub mod shortcuts;
pub mod layers;
pub mod update_notice;
pub mod open_progress;
pub mod missing_fonts;
pub mod recovery;
pub mod options;
pub mod attribute_editor;
@@ -21,3 +23,5 @@ pub mod annotation_data;
pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
pub mod xref_help;
pub mod xref_manager;
+1 -6
View File
@@ -17,6 +17,7 @@ use crate::app::Message;
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 iced::widget::tooltip::Position as TipPos;
@@ -113,12 +114,6 @@ pub struct ParamEditorRow {
/// column — same convention and value as `alias_editor::GUTTER`.
const GUTTER: f32 = 16.0;
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn danger_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
+145 -72
View File
@@ -1,5 +1,6 @@
pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType;
use crate::app::settings::{CursorType, RightClickMode};
use crate::app::Message;
use iced::widget::{
button, column, container, row, scrollable, slider, text, text_input, Space,
@@ -72,6 +73,12 @@ pub struct AppPrefs {
pub show_ucs_icon: bool,
/// UCSICON ORigin: draw it at the origin rather than the corner.
pub ucs_icon_at_origin: bool,
/// SHORTCUTMENU: what a right-click in the drawing area does.
pub right_click_mode: RightClickMode,
/// SHORTCUTMENUDURATION: time-sensitive hold threshold, ms.
pub right_click_hold_ms: i32,
/// Open the Plot / Page Setup dialog for every new layout.
pub page_setup_on_new_layout: bool,
}
/// The fixed locations the Files page lists.
@@ -141,6 +148,43 @@ impl<T> fmt::Display for Labelled<T> {
}
}
/// A background swatch that opens the colour wheel when clicked.
///
/// The wheel sits alongside the hex field rather than replacing it: typing
/// `#1E1E1E` stays the fastest way to reproduce an exact colour, while the
/// wheel is for choosing one by eye.
fn bg_swatch_picker<'a>(
target: crate::app::BgTarget,
rgb: [u8; 3],
open: Option<crate::app::BgTarget>,
) -> Element<'a, Message> {
let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]);
let swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(colour)),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let underlay = button(swatch)
.on_press(Message::BgPickerOpen(target))
.padding(0)
.style(button::text);
iced_aw::ColorPicker::new(
open == Some(target),
colour,
underlay,
Message::BgPickerCancel,
Message::BgPickerSubmit,
)
.into()
}
#[allow(clippy::too_many_arguments)]
pub fn view_window<'a>(
default_save_format: &'a str,
@@ -153,6 +197,7 @@ pub fn view_window<'a>(
cursor_size: i32,
selection: SelectionPrefs,
prefs: AppPrefs,
spacemouse: Element<'a, Message>,
snap_angle_input: &'a str,
drawing_prefs: DrawingPrefs,
folders: Folders,
@@ -166,6 +211,9 @@ pub fn view_window<'a>(
model_bg_input: &'a str,
paper_bg_input: &'a str,
desk_bg_input: &'a str,
bg_picker: Option<crate::app::BgTarget>,
dirty: bool,
close_confirm: bool,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let selected_format = crate::io::SAVE_FORMAT_OPTIONS
@@ -173,7 +221,7 @@ pub fn view_window<'a>(
.copied()
.find(|candidate| *candidate == default_save_format);
let theme_options = Theme::ALL
let theme_options = crate::app::config::all_themes()
.iter()
.map(ToString::to_string)
.chain(std::iter::once("Custom".to_string()))
@@ -216,6 +264,18 @@ pub fn view_window<'a>(
.find(|choice| choice.value == cursor_type)
.cloned();
let right_click_options = RightClickMode::ALL
.into_iter()
.map(|value: RightClickMode| Labelled {
label: crate::t!(value.label()).into_owned(),
value,
})
.collect::<Vec<_>>();
let selected_right_click = right_click_options
.iter()
.find(|choice| choice.value == prefs.right_click_mode)
.cloned();
let palette = ui_theme.palette.to_iced();
let colors = [
(crate::tr!("options", "color-background"), palette.background),
@@ -253,8 +313,18 @@ pub fn view_window<'a>(
);
}
// Changes show at once but are committed by OK / Apply; Close puts them
// back (asking first when there is something to lose).
let ok = button(text(crate::t!("OK")).size(12))
.on_press(Message::OptionsOk)
.padding([6, 18])
.style(button::primary);
let apply = button(text(crate::t!("Apply")).size(12))
.on_press_maybe(dirty.then_some(Message::OptionsApply))
.padding([6, 18])
.style(if dirty { button::secondary } else { button::text });
let close = button(text(crate::tr!("action", "close")).size(12))
.on_press(Message::CloseModal)
.on_press(Message::OptionsClose)
.padding([6, 18])
.style(button::secondary);
@@ -305,6 +375,29 @@ pub fn view_window<'a>(
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(22),
text(crate::t!("Plotting")).size(15),
Space::new().height(10),
row![
text(crate::t!("Plot device, paper, scale and plot styles"))
.size(12)
.width(Fill),
button(text(crate::t!("Plot and Page Setup…")).size(11))
.on_press(Message::PlotDialogOpen)
.padding([4, 10])
.style(button::secondary),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
iced::widget::checkbox(prefs.page_setup_on_new_layout)
.on_toggle(Message::PageSetupOnNewLayoutChanged)
.size(15),
text(crate::t!("Show the page setup for new layouts")).size(12),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(0)
.width(sizing.width);
@@ -400,20 +493,6 @@ pub fn view_window<'a>(
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(22),
text(crate::t!("Plotting")).size(15),
Space::new().height(10),
row![
text(crate::t!("Plot device, paper, scale and plot styles"))
.size(12)
.width(Fill),
button(text(crate::t!("Plot and Page Setup…")).size(11))
.on_press(Message::PlotDialogOpen)
.padding([4, 10])
.style(button::secondary),
]
.spacing(10)
.align_y(iced::Center),
]
.spacing(0)
.width(sizing.width);
@@ -437,58 +516,10 @@ pub fn view_window<'a>(
});
let model_bg_rgb = model_space.custom_bg.unwrap_or(crate::app::config::CLASSIC_CAD_DARK_BG);
let model_bg_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
model_bg_rgb[0],
model_bg_rgb[1],
model_bg_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let paper_bg_rgb = model_space.custom_paper_bg.unwrap_or(crate::app::config::DEFAULT_PAPER_BG);
let paper_bg_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
paper_bg_rgb[0],
paper_bg_rgb[1],
paper_bg_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let desk_bg_rgb = model_space.custom_desk_bg.unwrap_or(crate::app::config::DEFAULT_DESK_BG);
let desk_bg_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
desk_bg_rgb[0],
desk_bg_rgb[1],
desk_bg_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let mode_options = crate::app::config::ModelSpaceMode::ALL
.into_iter()
@@ -623,7 +654,7 @@ pub fn view_window<'a>(
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Model background")).size(12).width(140),
model_bg_swatch,
bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb, bg_picker),
text_input("#RRGGBB", model_bg_input)
.on_input(Message::ModelSpaceBgChanged)
.width(150),
@@ -636,7 +667,7 @@ pub fn view_window<'a>(
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Paper background")).size(12).width(140),
paper_bg_swatch,
bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb, bg_picker),
text_input("#RRGGBB", paper_bg_input)
.on_input(Message::PaperSpaceBgChanged)
.width(150),
@@ -648,7 +679,7 @@ pub fn view_window<'a>(
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Desk surround")).size(12).width(140),
desk_bg_swatch,
bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb, bg_picker),
text_input("#RRGGBB", desk_bg_input)
.on_input(Message::DeskSpaceBgChanged)
.width(150),
@@ -1135,6 +1166,8 @@ pub fn view_window<'a>(
let user_prefs = column![
text(crate::t!("User Preferences")).size(15),
Space::new().height(10),
spacemouse,
Space::new().height(12),
text(crate::t!("Zoom")).size(15),
Space::new().height(10),
row![
@@ -1217,6 +1250,43 @@ pub fn view_window<'a>(
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Right-click Customization")).size(15),
Space::new().height(10),
row![
text(crate::t!("Right-click in drawing area")).size(12).width(150),
iced::widget::pick_list(
selected_right_click,
right_click_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::RightClickModeChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(12),
row![
text(crate::t!("Hold duration")).size(12).width(150),
slider(
100..=1000,
prefs.right_click_hold_ms.clamp(100, 1000),
Message::RightClickHoldMsChanged
)
.step(50)
.width(Fill),
text(format!("{} ms", prefs.right_click_hold_ms.clamp(100, 1000)))
.size(11)
.width(52),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Shortcut menu: right-click always opens the menu. Time-sensitive: a quick click is Enter, holding longer opens the menu (SHORTCUTMENUDURATION)."
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Block Edit")).size(15),
Space::new().height(10),
row![
@@ -1610,7 +1680,7 @@ pub fn view_window<'a>(
// controls at the trailing edge of the Options content.
scrollable(content).spacing(8).height(Fill),
Space::new().height(12),
row![Space::new().width(Fill), close],
row![Space::new().width(Fill), ok, apply, close].spacing(8),
]
.width(Fill)
.height(sizing.height);
@@ -1620,10 +1690,13 @@ pub fn view_window<'a>(
.height(sizing.height);
let intrinsic = sizing.width == crate::ui::modal::ModalSizing::INTRINSIC.width;
container(body)
let main = container(body)
.style(container::rounded_box)
.padding([16, 18])
.width(if intrinsic { iced::Length::Fixed(DIALOG_WIDTH) } else { sizing.width })
.height(if intrinsic { iced::Length::Fixed(DIALOG_HEIGHT) } else { sizing.height })
.into()
.height(if intrinsic { iced::Length::Fixed(DIALOG_HEIGHT) } else { sizing.height });
if !close_confirm {
return main.into();
}
crate::ui::modal::discard_guard(main, Message::OptionsCloseDiscard, Message::OptionsCloseKeep)
}
+135
View File
@@ -0,0 +1,135 @@
use crate::app::Message;
use crate::input::spacemouse::{NavigationMode, Preferences, Status};
use iced::widget::{button, checkbox, column, container, pick_list, row, slider, text, Space};
use iced::{Element, Fill};
pub(crate) const ICON: &[u8] = include_bytes!("../../../../assets/icons/ui/spacemouse.svg");
pub(crate) fn view(
preferences: Preferences,
status: Status,
paused: bool,
details: bool,
) -> Element<'static, Message> {
let supported = cfg!(windows);
let mut enabled = checkbox(preferences.enabled).size(15);
if supported {
enabled = enabled.on_toggle(Message::SpaceMouseEnabled);
}
let mode = pick_list(Some(preferences.mode), NavigationMode::ALL, |mode| {
crate::t!(mode.label()).into_owned()
})
.width(220);
let mode = if supported {
mode.on_select(Message::SpaceMouseMode)
} else {
mode
};
let status_text = if !preferences.enabled {
crate::t!("Disabled").into_owned()
} else if paused {
crate::t!("Paused").into_owned()
} else {
crate::t!(status.label()).into_owned()
};
let mut content = column![
row![
crate::ui::icons::themed(ICON, 22.),
text(crate::t!("SpaceMouse")).size(15)
]
.spacing(8)
.align_y(iced::Center),
row![enabled, text(crate::t!("Enable SpaceMouse")).size(12)]
.spacing(8)
.align_y(iced::Center),
row![
text(crate::t!("Status")).size(12).width(150),
text(status_text).size(12)
]
.spacing(10),
row![text(crate::t!("Navigation")).size(12).width(150), mode]
.spacing(10)
.align_y(iced::Center),
text(crate::t!(preferences.mode.description()).into_owned())
.size(11)
.width(Fill),
]
.spacing(12);
if preferences.mode != NavigationMode::Full3D && supported {
content = content.push(
row![
checkbox(preferences.pan_reversed)
.size(15)
.on_toggle(Message::SpaceMousePanReversed),
text(crate::t!("Reverse pan direction")).size(12),
]
.spacing(8)
.align_y(iced::Center),
);
content = content.push(
row![
text(crate::t!("Pan speed")).size(12).width(150),
slider(
10..=300,
preferences.pan_speed.clamp(10, 300),
Message::SpaceMousePanSpeed
)
.step(10)
.width(160),
text(format!("{}%", preferences.pan_speed.clamp(10, 300))).size(11),
]
.spacing(10)
.align_y(iced::Center),
);
}
if paused {
content = content.push(
button(text(crate::t!("Resume SpaceMouse")).size(11))
.on_press(Message::SpaceMousePause)
.style(button::secondary),
);
}
content = content.push(
button(
text(crate::t!(if supported {
"Open 3Dconnexion settings…"
} else {
"SpaceMouse information…"
}))
.size(11),
)
.on_press(Message::SpaceMouseDriverSettings)
.style(button::secondary)
.padding([5, 10]),
);
content = content.push(
text(crate::t!(
"Configure 3D navigation, buttons, and radial menus in 3Dconnexion settings. Pan only and Pan and zoom share the push/tilt mapping and pan settings shown here."
))
.size(11)
.width(Fill),
);
content = content.push(
button(
row![
crate::ui::icons::themed_arrow_toggle(details, 9.),
text(crate::t!("Connection details")).size(11)
]
.spacing(6),
)
.on_press(Message::SpaceMouseDetails)
.style(button::text),
);
if details {
let detail = match status {
Status::Unavailable(reason) => reason,
Status::Unsupported => "This version supports SpaceMouse through 3DxWare on Windows. Other platforms do not yet have a device adapter.".into(),
Status::Disconnected => "3DxWare is running. Connect a SpaceMouse; Open CAD Studio reconnects automatically.".into(),
_ => "Uses the installed 3DxWare driver. Navigation pauses while a dialog is open. Your selected navigation mode is saved across drawings and sessions.".into(),
};
content = content.push(text(crate::t!(&detail).into_owned()).size(11).width(Fill));
}
container(content.push(Space::new().height(8)))
.width(Fill)
.into()
}
+823 -173
View File
File diff suppressed because it is too large. Load diff
+1 -6
View File
@@ -8,6 +8,7 @@
use crate::app::Message;
use crate::plugin::external::{ExternalPlugin, RegistryEntry, ReleaseInfo};
use crate::ui::style::common::muted_style;
use iced::widget::{
button, column, container, markdown, row, rule, scrollable, text, text_input, Space,
};
@@ -48,12 +49,6 @@ inventory::submit!(crate::command::CommandRegistration {
names: &["PLUGINS", "PLUGINMANAGER"]
});
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn primary_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
+1 -6
View File
@@ -1,5 +1,6 @@
use crate::app::Message;
use crate::t;
use crate::ui::style::common::muted_style as muted;
use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Element, Length, Theme};
@@ -13,12 +14,6 @@ fn button_style(primary: bool) -> impl Fn(&Theme, button::Status) -> button::Sty
}
}
fn muted(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
pub fn view_window<'a>(
layouts: &'a [(String, bool)],
printer: Option<&'a str>,
+1 -6
View File
@@ -1,14 +1,9 @@
use crate::app::Message;
use crate::io::recovery::{RecoveryReport, RecoveryStatus};
use crate::ui::style::common::muted_style;
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Element, Fill, Length, Shrink, Theme};
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn status_style(
status: RecoveryStatus,
) -> impl Fn(&Theme) -> iced::widget::text::Style + Copy {
+1 -6
View File
@@ -2,6 +2,7 @@
use crate::app::Message;
use crate::t;
use crate::ui::style::common::muted_style;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Element, Length, Theme};
@@ -13,12 +14,6 @@ pub enum ShortcutField {
const GUTTER: f32 = 16.0;
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn danger_text_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
+1 -6
View File
@@ -1,15 +1,10 @@
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Element, Theme};
use crate::t;
use std::borrow::Cow;
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn primary_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
+97
View File
@@ -0,0 +1,97 @@
//! Reference Manager help window: how the manager works on the web build
//! (read-only list, disabled mutations, no previews) and where to report
//! issues. Rendered as a modal like About/DSettings so the text gets a
//! proper window width — an anchored toolbar dropdown squeezed it into an
//! unreadable sliver.
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, column, container, row, text, Space};
use iced::{Background, Element, Fill, Length, Theme};
/// GitHub issues page linked from the help window, so web users can report
/// missing or broken behavior where it will be seen.
pub const XREF_ISSUES_URL: &str = "https://github.com/HakanSeven12/OpenCADStudio/issues";
pub fn view_window(sizing: crate::ui::modal::ModalSizing) -> Element<'static, Message> {
// Fixed window width: paragraphs wrap against this instead of the
// toolbar, which is what made the dropdown unreadable.
let body_w = Length::Fixed(460.0);
let para = |label: String| -> Element<'static, Message> {
text(label).size(12).width(Fill).into()
};
let intro = para(crate::t!("How the Reference Manager works on web").into_owned());
let list = para(
crate::t!(
"This panel lists the drawing's references read-only: status, type, size, date and saved path, plus the Details pane. Select rows to inspect them; List and Tree switch the presentation."
)
.into_owned(),
);
let limits = para(
crate::t!(
"Attach, Refresh and Change Path stay disabled here: the web build has no filesystem access and no native file pickers, so references can be neither added, reloaded nor repathed — and previews stay unavailable. Nested references are read-only on every platform, and relative paths need the drawing saved first."
)
.into_owned(),
);
let report_hint = para(
crate::t!(
"Hit something broken or missing? Report it on the GitHub issues page so it can be fixed."
)
.into_owned(),
);
let report_button = button(text(crate::t!("Report an issue")).size(12))
.on_press(Message::OpenUrl(XREF_ISSUES_URL.to_string()))
.style(button::primary)
.padding([6, 18]);
let close_button = button(text(crate::t!("Close")).size(12))
.on_press(Message::CloseModal)
.style(button::secondary)
.padding([6, 18]);
let action_row: Element<'static, Message> = row![
Space::new().width(Fill),
report_button,
close_button,
]
.spacing(8)
.align_y(iced::Center)
.into();
let content = column![
text(crate::t!("Reference Manager — Web").into_owned())
.size(14),
text(crate::t!("EXTERNALREFERENCES on the web build").into_owned())
.size(11)
.style(muted_style),
intro,
list,
limits,
report_hint,
Space::new().height(4),
action_row,
]
.spacing(10)
.padding(16)
.width(body_w);
container(content)
.width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
..Default::default()
})
.into()
}
#[cfg(test)]
mod tests {
#[test]
fn issues_url_points_at_github_issues() {
assert!(super::XREF_ISSUES_URL.starts_with("https://github.com/"));
assert!(super::XREF_ISSUES_URL.contains("/issues"));
}
}
File diff suppressed because it is too large. Load diff