forked from 1b-agent/opencadstudio-upstream
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:
commit
8648225ac5
309 files changed
+81166
-4157
No files matched your search
+183
-28
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
pub mod context_menu;
|
||||
pub mod cycle_popup;
|
||||
pub mod isolate_popup;
|
||||
pub mod polar_popup;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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};
|
||||
@@ -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,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
@@ -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()
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,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,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 {
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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
Reference in new issue
Block a user