forked from 1b-agent/opencadstudio-upstream
first commit
This commit is contained in:
1 parent
d52c6440ff
commit
d2fdac7f1e
274 files changed
+89330
No files matched your search
@@ -0,0 +1,486 @@
|
||||
//! Application menu — H7CAD-style dropdown that opens when the H7 logo
|
||||
//! button is clicked. Overlays the entire viewport as a floating panel.
|
||||
//!
|
||||
//! Layout:
|
||||
//! ┌─────────────────────────────────────────────┐
|
||||
//! │ [Search bar] │
|
||||
//! ├──────────────────┬──────────────────────────┤
|
||||
//! │ Command list │ Recent files │
|
||||
//! │ (left column) │ (right column) │
|
||||
//! └──────────────────┴──────────────────────────┘
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::Message;
|
||||
|
||||
// ── Menu item definitions ─────────────────────────────────────────────────
|
||||
|
||||
struct MenuItem {
|
||||
icon: &'static str,
|
||||
label: &'static str,
|
||||
desc: &'static str,
|
||||
command: &'static str,
|
||||
}
|
||||
|
||||
const MENU_ITEMS: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
icon: "◻",
|
||||
label: "New",
|
||||
desc: "Create a new drawing",
|
||||
command: "NEW",
|
||||
},
|
||||
MenuItem {
|
||||
icon: "📂",
|
||||
label: "Open",
|
||||
desc: "Open an existing file",
|
||||
command: "OPEN",
|
||||
},
|
||||
MenuItem {
|
||||
icon: "💾",
|
||||
label: "Save",
|
||||
desc: "Save the current drawing",
|
||||
command: "SAVE",
|
||||
},
|
||||
MenuItem {
|
||||
icon: "📄",
|
||||
label: "Save As",
|
||||
desc: "Save as DWG or DXF",
|
||||
command: "SAVEAS",
|
||||
},
|
||||
MenuItem {
|
||||
icon: "🖨",
|
||||
label: "Print",
|
||||
desc: "Print or plot the drawing",
|
||||
command: "PRINT",
|
||||
},
|
||||
MenuItem {
|
||||
icon: "⚙",
|
||||
label: "Options",
|
||||
desc: "Open application settings",
|
||||
command: "OPTIONS",
|
||||
},
|
||||
MenuItem {
|
||||
icon: "❓",
|
||||
label: "Help",
|
||||
desc: "Open help documentation",
|
||||
command: "HELP",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Public state ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AppMenu {
|
||||
pub open: bool,
|
||||
pub search: String,
|
||||
/// Recently opened file paths (most recent first).
|
||||
pub recent: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AppMenu {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
search: String::new(),
|
||||
recent: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Call when a file is successfully loaded to push its path into recents.
|
||||
pub fn push_recent(&mut self, path: PathBuf) {
|
||||
self.recent.retain(|r| r != &path);
|
||||
self.recent.insert(0, path);
|
||||
self.recent.truncate(10);
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
self.open = !self.open;
|
||||
if self.open {
|
||||
self.search.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.open = false;
|
||||
self.search.clear();
|
||||
}
|
||||
|
||||
// ── View ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
if !self.open {
|
||||
return container(text("")).width(0).height(0).into();
|
||||
}
|
||||
|
||||
// Filter items by search query.
|
||||
let query = self.search.to_lowercase();
|
||||
let items: Vec<&MenuItem> = MENU_ITEMS
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
query.is_empty()
|
||||
|| m.label.to_lowercase().contains(&query)
|
||||
|| m.desc.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Search bar ────────────────────────────────────────────────────
|
||||
let search = container(
|
||||
text_input("Search commands...", &self.search)
|
||||
.on_input(Message::AppMenuSearch)
|
||||
.style(|_: &Theme, _| iced::widget::text_input::Style {
|
||||
background: Background::Color(SEARCH_BG),
|
||||
border: Border {
|
||||
color: ACCENT,
|
||||
width: 1.0,
|
||||
radius: 2.0.into(),
|
||||
},
|
||||
icon: Color::WHITE,
|
||||
placeholder: Color {
|
||||
r: 0.45,
|
||||
g: 0.45,
|
||||
b: 0.45,
|
||||
a: 1.0,
|
||||
},
|
||||
value: Color::WHITE,
|
||||
selection: Color {
|
||||
r: 0.20,
|
||||
g: 0.44,
|
||||
b: 0.72,
|
||||
a: 0.5,
|
||||
},
|
||||
})
|
||||
.size(12)
|
||||
.padding([6, 10]),
|
||||
)
|
||||
.padding([8, 10])
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(HEADER_BG)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// ── Left column — command list ─────────────────────────────────────
|
||||
let left: Element<'_, Message> = if items.is_empty() {
|
||||
container(text("No results").size(11).color(DIM_COLOR))
|
||||
.padding([12, 14])
|
||||
.into()
|
||||
} else {
|
||||
items
|
||||
.iter()
|
||||
.fold(column![].spacing(0), |col, item| {
|
||||
col.push(menu_item_btn(item))
|
||||
})
|
||||
.into()
|
||||
};
|
||||
|
||||
let left_col = container(scrollable(left))
|
||||
.width(220)
|
||||
.height(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(LEFT_BG)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// ── Right column — recent files ───────────────────────────────────
|
||||
let recent_header =
|
||||
container(text("Recent Documents").size(10).color(DIM_COLOR)).padding(iced::Padding {
|
||||
top: 10.0,
|
||||
right: 14.0,
|
||||
bottom: 6.0,
|
||||
left: 14.0,
|
||||
});
|
||||
|
||||
let recent_list = if self.recent.is_empty() {
|
||||
column![container(text("No recent files").size(11).color(DIM_COLOR)).padding([8, 14])]
|
||||
} else {
|
||||
self.recent.iter().fold(column![].spacing(0), |col, path| {
|
||||
col.push(recent_item_btn(path))
|
||||
})
|
||||
};
|
||||
|
||||
let right_col = container(column![recent_header, scrollable(recent_list)])
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(RIGHT_BG)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// ── Bottom bar — Exit button ───────────────────────────────────────
|
||||
let bottom = container(
|
||||
row![
|
||||
iced::widget::Space::new().width(Length::Fill),
|
||||
button(text("Exit H7CAD").size(11).color(Color::WHITE))
|
||||
.on_press(Message::Command("EXIT".into()))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color {
|
||||
r: 0.65,
|
||||
g: 0.12,
|
||||
b: 0.12,
|
||||
a: 1.0
|
||||
},
|
||||
button::Status::Pressed => Color {
|
||||
r: 0.45,
|
||||
g: 0.08,
|
||||
b: 0.08,
|
||||
a: 1.0
|
||||
},
|
||||
_ => EXIT_BTN,
|
||||
})),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 2.0.into()
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([5, 14]),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding([6, 10])
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(BOTTOM_BG)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// ── Outer panel ───────────────────────────────────────────────────
|
||||
let panel = container(column![
|
||||
search,
|
||||
container(row![left_col, right_col]).height(Length::Fill),
|
||||
bottom,
|
||||
])
|
||||
.width(520)
|
||||
.height(420)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(PANEL_BG)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Backdrop — clicking outside closes the menu.
|
||||
let backdrop = button(
|
||||
container(panel)
|
||||
// Offset so the panel sits just below the ribbon H7 button.
|
||||
.padding(iced::Padding {
|
||||
top: 56.0,
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.on_press(Message::CloseAppMenu)
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 0.55,
|
||||
})),
|
||||
text_color: Color::TRANSPARENT,
|
||||
border: Border::default(),
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding(0)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
|
||||
backdrop
|
||||
}
|
||||
}
|
||||
|
||||
// ── Item widgets ──────────────────────────────────────────────────────────
|
||||
|
||||
fn menu_item_btn<'a>(item: &'a MenuItem) -> Element<'a, Message> {
|
||||
button(
|
||||
row![
|
||||
container(text(item.icon).size(16).color(ICON_COLOR))
|
||||
.width(32)
|
||||
.padding([0, 4]),
|
||||
column![
|
||||
text(item.label).size(12).color(Color::WHITE),
|
||||
text(item.desc).size(10).color(DIM_COLOR),
|
||||
]
|
||||
.spacing(1),
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
.spacing(4),
|
||||
)
|
||||
.on_press(Message::CloseAppMenuAndRun(item.command.into()))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => ITEM_HOVER,
|
||||
button::Status::Pressed => ITEM_ACTIVE,
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn recent_item_btn(path: &PathBuf) -> Element<'_, Message> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
let type_label = match path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_uppercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("DWG") => "DWG Drawing",
|
||||
Some("DXF") => "DXF Drawing",
|
||||
_ => "CAD File",
|
||||
};
|
||||
let path_str = path.display().to_string();
|
||||
button(
|
||||
row![
|
||||
container(text("📄").size(13).color(ICON_COLOR)).width(24),
|
||||
column![
|
||||
text(name).size(11).color(Color::WHITE),
|
||||
text(type_label).size(9).color(DIM_COLOR),
|
||||
]
|
||||
.spacing(1),
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
.spacing(6),
|
||||
)
|
||||
.on_press(Message::Command(format!("OPEN_RECENT:{path_str}")))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => ITEM_HOVER,
|
||||
button::Status::Pressed => ITEM_ACTIVE,
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([6, 14])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Colors ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PANEL_BG: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.20,
|
||||
b: 0.20,
|
||||
a: 1.0,
|
||||
};
|
||||
const HEADER_BG: Color = Color {
|
||||
r: 0.17,
|
||||
g: 0.17,
|
||||
b: 0.17,
|
||||
a: 1.0,
|
||||
};
|
||||
const LEFT_BG: Color = Color {
|
||||
r: 0.22,
|
||||
g: 0.22,
|
||||
b: 0.22,
|
||||
a: 1.0,
|
||||
};
|
||||
const RIGHT_BG: Color = Color {
|
||||
r: 0.18,
|
||||
g: 0.18,
|
||||
b: 0.18,
|
||||
a: 1.0,
|
||||
};
|
||||
const BOTTOM_BG: Color = Color {
|
||||
r: 0.16,
|
||||
g: 0.16,
|
||||
b: 0.16,
|
||||
a: 1.0,
|
||||
};
|
||||
const SEARCH_BG: Color = Color {
|
||||
r: 0.14,
|
||||
g: 0.14,
|
||||
b: 0.14,
|
||||
a: 1.0,
|
||||
};
|
||||
const BORDER: Color = Color {
|
||||
r: 0.30,
|
||||
g: 0.30,
|
||||
b: 0.30,
|
||||
a: 1.0,
|
||||
};
|
||||
const ITEM_HOVER: Color = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.28,
|
||||
a: 1.0,
|
||||
};
|
||||
const ITEM_ACTIVE: Color = Color {
|
||||
r: 0.18,
|
||||
g: 0.42,
|
||||
b: 0.70,
|
||||
a: 1.0,
|
||||
};
|
||||
const DIM_COLOR: Color = Color {
|
||||
r: 0.50,
|
||||
g: 0.50,
|
||||
b: 0.50,
|
||||
a: 1.0,
|
||||
};
|
||||
const ICON_COLOR: Color = Color {
|
||||
r: 0.70,
|
||||
g: 0.80,
|
||||
b: 0.95,
|
||||
a: 1.0,
|
||||
};
|
||||
const ACCENT: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.55,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
const EXIT_BTN: Color = Color {
|
||||
r: 0.55,
|
||||
g: 0.10,
|
||||
b: 0.10,
|
||||
a: 1.0,
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
//! H7CAD-style command line — bottom panel with input and history
|
||||
|
||||
use crate::Message;
|
||||
use iced::widget::{column, container, row, text, text_input};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
pub const CMD_INPUT_ID: &str = "cmd_input";
|
||||
|
||||
fn cmd_input_id() -> iced::widget::Id {
|
||||
iced::widget::Id::new(CMD_INPUT_ID)
|
||||
}
|
||||
|
||||
const MAX_HISTORY: usize = 64;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommandLine {
|
||||
pub input: String,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
pub kind: EntryKind,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum EntryKind {
|
||||
Command,
|
||||
Output,
|
||||
Error,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl CommandLine {
|
||||
pub fn new() -> Self {
|
||||
let mut cl = Self::default();
|
||||
cl.push_info("H7CAD ready.");
|
||||
cl.push_info("Type a command or use the ribbon. Open OBJ: INSERT tab.");
|
||||
cl
|
||||
}
|
||||
pub fn submit(&mut self) -> Option<String> {
|
||||
let cmd = self.input.trim().to_uppercase();
|
||||
if cmd.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.push_command(&self.input.clone());
|
||||
self.input.clear();
|
||||
Some(cmd)
|
||||
}
|
||||
pub fn push_command(&mut self, cmd: &str) {
|
||||
self.push(EntryKind::Command, format!("Command: {cmd}"));
|
||||
}
|
||||
pub fn push_output(&mut self, msg: &str) {
|
||||
self.push(EntryKind::Output, msg.to_string());
|
||||
}
|
||||
pub fn push_error(&mut self, msg: &str) {
|
||||
self.push(EntryKind::Error, format!("*Invalid* {msg}"));
|
||||
}
|
||||
pub fn push_info(&mut self, msg: &str) {
|
||||
self.push(EntryKind::Info, msg.to_string());
|
||||
}
|
||||
fn push(&mut self, kind: EntryKind, text: String) {
|
||||
self.history.push(HistoryEntry { kind, text });
|
||||
if self.history.len() > MAX_HISTORY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let history_rows =
|
||||
self.history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.rev()
|
||||
.fold(column![].spacing(0), |col, entry| {
|
||||
let color = match entry.kind {
|
||||
EntryKind::Command => CMD_COLOR,
|
||||
EntryKind::Output => OUT_COLOR,
|
||||
EntryKind::Error => ERR_COLOR,
|
||||
EntryKind::Info => INFO_COLOR,
|
||||
};
|
||||
col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8]))
|
||||
});
|
||||
let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]);
|
||||
let input = text_input("", &self.input)
|
||||
.id(cmd_input_id())
|
||||
.on_input(Message::CommandInput)
|
||||
.on_submit(Message::CommandSubmit)
|
||||
.style(|_: &Theme, _| text_input::Style {
|
||||
background: Background::Color(INPUT_BG),
|
||||
border: Border {
|
||||
color: Color {
|
||||
r: 0.40,
|
||||
g: 0.60,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
icon: Color::WHITE,
|
||||
placeholder: Color {
|
||||
r: 0.4,
|
||||
g: 0.4,
|
||||
b: 0.4,
|
||||
a: 1.0,
|
||||
},
|
||||
value: Color::WHITE,
|
||||
selection: Color {
|
||||
r: 0.20,
|
||||
g: 0.44,
|
||||
b: 0.72,
|
||||
a: 0.5,
|
||||
},
|
||||
})
|
||||
.size(11)
|
||||
.padding([4, 6]);
|
||||
let input_row = row![prompt, input].align_y(iced::Center);
|
||||
container(column![
|
||||
container(history_rows)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(HISTORY_BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.padding([2, 0]),
|
||||
container(input_row)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(INPUT_ROW_BG)),
|
||||
border: Border {
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 0.0.into()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill),
|
||||
])
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(PANEL_BG)),
|
||||
border: Border {
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
const PANEL_BG: Color = Color {
|
||||
r: 0.15,
|
||||
g: 0.15,
|
||||
b: 0.15,
|
||||
a: 1.0,
|
||||
};
|
||||
const HISTORY_BG: Color = Color {
|
||||
r: 0.15,
|
||||
g: 0.15,
|
||||
b: 0.15,
|
||||
a: 1.0,
|
||||
};
|
||||
const INPUT_ROW_BG: Color = Color {
|
||||
r: 0.18,
|
||||
g: 0.18,
|
||||
b: 0.18,
|
||||
a: 1.0,
|
||||
};
|
||||
const INPUT_BG: Color = Color {
|
||||
r: 0.12,
|
||||
g: 0.12,
|
||||
b: 0.12,
|
||||
a: 1.0,
|
||||
};
|
||||
const BORDER_COLOR: Color = Color {
|
||||
r: 0.30,
|
||||
g: 0.30,
|
||||
b: 0.30,
|
||||
a: 1.0,
|
||||
};
|
||||
const PROMPT_COLOR: Color = Color {
|
||||
r: 0.55,
|
||||
g: 0.78,
|
||||
b: 0.55,
|
||||
a: 1.0,
|
||||
};
|
||||
const CMD_COLOR: Color = Color {
|
||||
r: 0.80,
|
||||
g: 0.80,
|
||||
b: 0.80,
|
||||
a: 1.0,
|
||||
};
|
||||
const OUT_COLOR: Color = Color {
|
||||
r: 0.65,
|
||||
g: 0.65,
|
||||
b: 0.65,
|
||||
a: 1.0,
|
||||
};
|
||||
const ERR_COLOR: Color = Color {
|
||||
r: 0.90,
|
||||
g: 0.35,
|
||||
b: 0.35,
|
||||
a: 1.0,
|
||||
};
|
||||
const INFO_COLOR: Color = Color {
|
||||
r: 0.50,
|
||||
g: 0.70,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
@@ -0,0 +1,607 @@
|
||||
//! Layer Manager — floating window.
|
||||
|
||||
use crate::ui::properties::{color_picker_dropdown, LinetypeItem, LwItem, lw_options};
|
||||
use crate::Message;
|
||||
use acadrust::tables::layer::Layer as DocLayer;
|
||||
use acadrust::tables::Table;
|
||||
use acadrust::types::aci_table::aci_to_rgb;
|
||||
use acadrust::types::{Color as AcadColor, LineWeight};
|
||||
use iced::widget::{
|
||||
button, column, combo_box, container, mouse_area, row, scrollable, text, text_input,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
|
||||
use iced::Padding;
|
||||
use crate::ui::ROW_H;
|
||||
|
||||
// ── Row-height-derived constants ─────────────────────────────────────────
|
||||
/// SVG icon size inside a layer-table cell.
|
||||
const ICON_SZ: f32 = ROW_H * 0.62; // ≈16 px at ROW_H=26
|
||||
/// Font size for cell text.
|
||||
const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26
|
||||
/// Vertical padding for combo_box / text_input so their total height = ROW_H.
|
||||
const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0;
|
||||
|
||||
// ── Layer data ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Layer {
|
||||
pub name: String,
|
||||
pub visible: bool,
|
||||
pub frozen: bool,
|
||||
pub locked: bool,
|
||||
pub color: Color,
|
||||
pub linetype: String,
|
||||
pub lineweight: LineWeight,
|
||||
pub transparency: i32,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
pub fn new(name: &str, color: Color) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
visible: true,
|
||||
frozen: false,
|
||||
locked: false,
|
||||
color,
|
||||
linetype: "Continuous".to_string(),
|
||||
lineweight: LineWeight::Default,
|
||||
transparency: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Panel state ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LayerPanel {
|
||||
pub layers: Vec<Layer>,
|
||||
#[allow(dead_code)]
|
||||
pub visible: bool,
|
||||
pub selected: Option<usize>,
|
||||
pub editing: Option<usize>,
|
||||
pub edit_buf: String,
|
||||
pub current_layer: String,
|
||||
pub linetype_items: Vec<LinetypeItem>,
|
||||
pub color_picker_row: Option<usize>,
|
||||
pub color_full_palette: bool,
|
||||
pub linetype_combo: combo_box::State<LinetypeItem>,
|
||||
pub lw_combo: combo_box::State<LwItem>,
|
||||
}
|
||||
|
||||
impl Default for LayerPanel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
visible: false,
|
||||
layers: vec![Layer::new("0", Color::WHITE)],
|
||||
selected: None,
|
||||
editing: None,
|
||||
edit_buf: String::new(),
|
||||
current_layer: "0".to_string(),
|
||||
linetype_items: vec![LinetypeItem { name: "Continuous".into(), art: String::new() }],
|
||||
color_picker_row: None,
|
||||
color_full_palette: false,
|
||||
linetype_combo: combo_box::State::new(vec![
|
||||
LinetypeItem { name: "Continuous".into(), art: String::new() }
|
||||
]),
|
||||
lw_combo: combo_box::State::new(lw_options()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerPanel {
|
||||
pub fn sync_from_doc(&mut self, doc_layers: &Table<DocLayer>) {
|
||||
self.layers = doc_layers
|
||||
.iter()
|
||||
.map(|l| Layer {
|
||||
name: l.name.clone(),
|
||||
visible: !l.flags.off,
|
||||
frozen: l.flags.frozen,
|
||||
locked: l.flags.locked,
|
||||
color: iced_color_from_acad(&l.color),
|
||||
linetype: if l.line_type.is_empty() {
|
||||
"Continuous".to_string()
|
||||
} else {
|
||||
l.line_type.clone()
|
||||
},
|
||||
lineweight: l.line_weight,
|
||||
transparency: 0,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub fn sync_linetypes(&mut self, items: Vec<LinetypeItem>) {
|
||||
self.linetype_combo = combo_box::State::new(items.clone());
|
||||
self.linetype_items = items;
|
||||
}
|
||||
|
||||
/// Render the layer panel as the full content of its own OS window.
|
||||
pub fn view_window(&self) -> Element<'_, Message> {
|
||||
self.view_content()
|
||||
}
|
||||
|
||||
fn view_content(&self) -> Element<'_, Message> {
|
||||
let has_sel = self.selected.is_some();
|
||||
let sel_is_zero = self
|
||||
.selected
|
||||
.map(|i| self.layers.get(i).map(|l| l.name == "0").unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────
|
||||
let toolbar = container(
|
||||
row![
|
||||
toolbar_btn("⊕ New", Message::LayerNew),
|
||||
toolbar_btn_cond("⊗ Delete", Message::LayerDelete, has_sel && !sel_is_zero),
|
||||
toolbar_btn_cond("✓ Set Current", Message::LayerSetCurrent, has_sel),
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(TOOLBAR_BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.padding([4, 8]);
|
||||
|
||||
// ── Column header ─────────────────────────────────────────────────
|
||||
let col_header = container(
|
||||
row![
|
||||
text("Status").size(10).color(DIM).width(50),
|
||||
text("Name").size(10).color(DIM).width(Length::Fixed(COL_NAME)),
|
||||
text("On").size(10).color(DIM).width(Length::Fixed(COL_ICON)),
|
||||
text("Freeze").size(10).color(DIM).width(Length::Fixed(COL_ICON)),
|
||||
text("Lock").size(10).color(DIM).width(Length::Fixed(COL_ICON)),
|
||||
text("Color").size(10).color(DIM).width(Length::Fixed(COL_COLOR)),
|
||||
text("Linetype").size(10).color(DIM).width(Length::Fixed(COL_LT)),
|
||||
text("Lineweight").size(10).color(DIM).width(Length::Fixed(COL_LW)),
|
||||
text("Transparency").size(10).color(DIM).width(Length::Fixed(COL_TRANS)),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(COL_HEADER_BG)),
|
||||
border: Border { color: BORDER_COLOR, width: 1.0, radius: 0.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding([4, 8])
|
||||
.width(Fill);
|
||||
|
||||
// ── Layer rows ────────────────────────────────────────────────────
|
||||
let mut rows_col = column![].spacing(0);
|
||||
for (i, layer) in self.layers.iter().enumerate() {
|
||||
let is_sel = self.selected == Some(i);
|
||||
let is_current = layer.name == self.current_layer;
|
||||
let is_editing = self.editing == Some(i);
|
||||
let color_open = self.color_picker_row == Some(i);
|
||||
|
||||
let (ltc, lwc) = if is_sel {
|
||||
(Some(&self.linetype_combo), Some(&self.lw_combo))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
rows_col = rows_col.push(layer_row(
|
||||
i, layer, is_sel, is_current, is_editing, &self.edit_buf, color_open, ltc, lwc,
|
||||
));
|
||||
|
||||
// Insert color picker dropdown below this row when open.
|
||||
if color_open {
|
||||
rows_col = rows_col.push(color_picker_palette(self.color_full_palette));
|
||||
}
|
||||
}
|
||||
|
||||
let table = scrollable(rows_col).height(Fill);
|
||||
|
||||
// ── Full-window frame ─────────────────────────────────────────────
|
||||
container(column![toolbar, col_header, table].spacing(0))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(PANEL_BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toolbar buttons ───────────────────────────────────────────────────────
|
||||
|
||||
fn toolbar_btn(label: &str, msg: Message) -> Element<'_, Message> {
|
||||
button(text(label).size(11).color(Color::WHITE))
|
||||
.on_press(msg)
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color { r: 0.32, g: 0.32, b: 0.32, a: 1.0 },
|
||||
button::Status::Pressed => Color { r: 0.25, g: 0.25, b: 0.25, a: 1.0 },
|
||||
_ => Color { r: 0.26, g: 0.26, b: 0.26, a: 1.0 },
|
||||
})),
|
||||
border: Border { radius: 3.0.into(), color: BORDER_COLOR, width: 1.0 },
|
||||
text_color: Color::WHITE,
|
||||
..Default::default()
|
||||
})
|
||||
.padding([4, 10])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn toolbar_btn_cond(label: &str, msg: Message, enabled: bool) -> Element<'_, Message> {
|
||||
let mut b = button(text(label).size(11).color(if enabled {
|
||||
Color::WHITE
|
||||
} else {
|
||||
Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0 }
|
||||
}))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color { r: 0.32, g: 0.32, b: 0.32, a: 1.0 },
|
||||
_ => Color { r: 0.26, g: 0.26, b: 0.26, a: 1.0 },
|
||||
})),
|
||||
border: Border { radius: 3.0.into(), color: BORDER_COLOR, width: 1.0 },
|
||||
text_color: Color::WHITE,
|
||||
..Default::default()
|
||||
})
|
||||
.padding([4, 10]);
|
||||
if enabled {
|
||||
b = b.on_press(msg);
|
||||
}
|
||||
b.into()
|
||||
}
|
||||
|
||||
// ── Layer row ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layer_row<'a>(
|
||||
index: usize,
|
||||
layer: &'a Layer,
|
||||
is_selected: bool,
|
||||
is_current: bool,
|
||||
is_editing: bool,
|
||||
edit_buf: &'a str,
|
||||
color_picker_open: bool,
|
||||
lt_combo: Option<&'a combo_box::State<LinetypeItem>>,
|
||||
lw_combo_state: Option<&'a combo_box::State<LwItem>>,
|
||||
) -> Element<'a, Message> {
|
||||
let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> {
|
||||
button(
|
||||
iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes))
|
||||
.width(ICON_SZ)
|
||||
.height(ICON_SZ),
|
||||
)
|
||||
.on_press(on_press)
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 },
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.height(Length::Fixed(ROW_H))
|
||||
.into()
|
||||
};
|
||||
|
||||
let vis_svg: &'static [u8] = if layer.visible {
|
||||
include_bytes!("../../assets/icons/layers/layon.svg")
|
||||
} else {
|
||||
include_bytes!("../../assets/icons/layers/layoff.svg")
|
||||
};
|
||||
let frz_svg: &'static [u8] = if layer.frozen {
|
||||
include_bytes!("../../assets/icons/layers/layfrz.svg")
|
||||
} else {
|
||||
include_bytes!("../../assets/icons/layers/laythw.svg")
|
||||
};
|
||||
let lck_svg: &'static [u8] = if layer.locked {
|
||||
include_bytes!("../../assets/icons/layers/laylck.svg")
|
||||
} else {
|
||||
include_bytes!("../../assets/icons/layers/layulk.svg")
|
||||
};
|
||||
|
||||
let status_dot: Element<'_, Message> = if is_current {
|
||||
text("✓").size(13).color(Color { r: 0.25, g: 0.85, b: 0.45, a: 1.0 }).into()
|
||||
} else {
|
||||
text("▣").size(11).color(Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }).into()
|
||||
};
|
||||
|
||||
// Name cell
|
||||
let name_cell: Element<'_, Message> = if is_editing {
|
||||
text_input("", edit_buf)
|
||||
.on_input(Message::LayerRenameEdit)
|
||||
.on_submit(Message::LayerRenameCommit)
|
||||
.size(FONT_SZ)
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.style(|_: &Theme, _| iced::widget::text_input::Style {
|
||||
background: iced::Background::Color(Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }),
|
||||
border: Border {
|
||||
radius: 2.0.into(),
|
||||
width: 1.0,
|
||||
color: Color { r: 0.45, g: 0.65, b: 0.90, a: 1.0 },
|
||||
},
|
||||
icon: Color::WHITE,
|
||||
placeholder: Color { r: 0.4, g: 0.4, b: 0.4, a: 1.0 },
|
||||
value: Color::WHITE,
|
||||
selection: Color { r: 0.25, g: 0.45, b: 0.75, a: 0.5 },
|
||||
})
|
||||
.width(Length::Fixed(COL_NAME))
|
||||
.into()
|
||||
} else {
|
||||
button(text(&layer.name).size(FONT_SZ).color(ROW_TEXT))
|
||||
.on_press(Message::LayerRenameStart(index))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 },
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.height(Length::Fixed(ROW_H))
|
||||
.width(Length::Fixed(COL_NAME))
|
||||
.into()
|
||||
};
|
||||
|
||||
// Color cell — looks like a combo_box input; click opens swatch dropdown below row.
|
||||
let aci = iced_to_aci(layer.color);
|
||||
let cur_color_name = color_label_aci(aci).to_string();
|
||||
let layer_color = layer.color;
|
||||
|
||||
let swatch = container(text(""))
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(layer_color)),
|
||||
border: Border {
|
||||
color: Color { r: 0.0, g: 0.0, b: 0.0, a: 0.5 },
|
||||
width: 1.0,
|
||||
radius: 1.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(12)
|
||||
.height(12);
|
||||
|
||||
let color_cell: Element<'_, Message> = button(
|
||||
row![
|
||||
swatch,
|
||||
text(cur_color_name).size(FONT_SZ).color(ROW_TEXT),
|
||||
iced::widget::Space::new().width(Fill),
|
||||
text(if color_picker_open { "▲" } else { "▼" })
|
||||
.size(FONT_SZ * 0.75)
|
||||
.color(DIM),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.on_press(Message::LayerColorPickerToggle(index))
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 },
|
||||
_ => Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 },
|
||||
})),
|
||||
border: Border {
|
||||
radius: 2.0.into(),
|
||||
width: 1.0,
|
||||
color: if color_picker_open {
|
||||
Color { r: 0.45, g: 0.65, b: 0.90, a: 1.0 }
|
||||
} else {
|
||||
BORDER_COLOR
|
||||
},
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.height(Length::Fixed(ROW_H))
|
||||
.width(Length::Fixed(COL_COLOR))
|
||||
.into();
|
||||
|
||||
// Linetype cell — uses LinetypeItem (with ASCII art) same as Properties panel
|
||||
let cur_lt_item = LinetypeItem {
|
||||
name: layer.linetype.clone(),
|
||||
art: String::new(), // art comes from combo state items; just match by name
|
||||
};
|
||||
let lt_cell: Element<'_, Message> = if let Some(state) = lt_combo {
|
||||
combo_box(
|
||||
state,
|
||||
"linetype",
|
||||
Some(&cur_lt_item),
|
||||
|item: LinetypeItem| Message::LayerLinetypeSet(item.name),
|
||||
)
|
||||
.size(FONT_SZ)
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.width(Length::Fixed(COL_LT))
|
||||
.input_style(combo_input_style)
|
||||
.into()
|
||||
} else {
|
||||
text(layer.linetype.as_str()).size(FONT_SZ).color(DIM).width(Length::Fixed(COL_LT)).into()
|
||||
};
|
||||
|
||||
// Lineweight cell
|
||||
let cur_lw_item = LwItem(layer.lineweight);
|
||||
let lw_cell: Element<'_, Message> = if let Some(state) = lw_combo_state {
|
||||
combo_box(state, "lineweight", Some(&cur_lw_item), |item: LwItem| {
|
||||
Message::LayerLineweightSet(item.0)
|
||||
})
|
||||
.size(FONT_SZ)
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.width(Length::Fixed(COL_LW))
|
||||
.input_style(combo_input_style)
|
||||
.into()
|
||||
} else {
|
||||
text(cur_lw_item.to_string()).size(FONT_SZ).color(DIM).width(Length::Fixed(COL_LW)).into()
|
||||
};
|
||||
|
||||
// Transparency cell
|
||||
let trans_str = layer.transparency.to_string();
|
||||
let trans_cell = text_input("0", &trans_str)
|
||||
.on_input(move |s| Message::LayerTransparencyEdit(index, s))
|
||||
.size(FONT_SZ)
|
||||
.padding(Padding { top: COMBO_PAD_V, bottom: COMBO_PAD_V, left: 4.0, right: 4.0 })
|
||||
.style(|_: &Theme, _| iced::widget::text_input::Style {
|
||||
background: iced::Background::Color(Color::TRANSPARENT),
|
||||
border: Border { radius: 2.0.into(), width: 1.0, color: BORDER_COLOR },
|
||||
icon: Color::WHITE,
|
||||
placeholder: DIM,
|
||||
value: ROW_TEXT,
|
||||
selection: Color { r: 0.25, g: 0.45, b: 0.75, a: 0.5 },
|
||||
})
|
||||
.width(Length::Fixed(COL_TRANS));
|
||||
|
||||
let bg = if is_selected { ROW_SEL } else if index % 2 == 0 { ROW_EVEN } else { ROW_ODD };
|
||||
|
||||
let row_content = row![
|
||||
container(status_dot).width(50).align_x(iced::Center).align_y(iced::Center),
|
||||
name_cell,
|
||||
container(svg_btn(vis_svg, Message::LayerToggleVisible(index)))
|
||||
.width(Length::Fixed(COL_ICON))
|
||||
.align_x(iced::Center),
|
||||
container(svg_btn(frz_svg, Message::LayerToggleFreeze(index)))
|
||||
.width(Length::Fixed(COL_ICON))
|
||||
.align_x(iced::Center),
|
||||
container(svg_btn(lck_svg, Message::LayerToggleLock(index)))
|
||||
.width(Length::Fixed(COL_ICON))
|
||||
.align_x(iced::Center),
|
||||
color_cell,
|
||||
lt_cell,
|
||||
lw_cell,
|
||||
trans_cell,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
|
||||
mouse_area(
|
||||
container(row_content)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
..Default::default()
|
||||
})
|
||||
.padding(Padding { top: 0.0, bottom: 0.0, left: 8.0, right: 8.0 })
|
||||
.height(Length::Fixed(ROW_H))
|
||||
.width(Fill),
|
||||
)
|
||||
.on_press(Message::LayerSelect(index))
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Combo style ────────────────────────────────────────────────────────────
|
||||
|
||||
fn combo_input_style(_theme: &Theme, _status: iced::widget::text_input::Status)
|
||||
-> iced::widget::text_input::Style
|
||||
{
|
||||
iced::widget::text_input::Style {
|
||||
background: iced::Background::Color(Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }),
|
||||
border: Border { radius: 2.0.into(), width: 1.0, color: BORDER_COLOR },
|
||||
icon: Color::WHITE,
|
||||
placeholder: DIM,
|
||||
value: Color::WHITE,
|
||||
selection: Color { r: 0.25, g: 0.45, b: 0.75, a: 0.5 },
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color picker palette (shown below row) ────────────────────────────────
|
||||
|
||||
fn color_picker_palette<'a>(full: bool) -> Element<'a, Message> {
|
||||
// Horizontal offset to align dropdown under the Color column.
|
||||
const LEFT_OFFSET: f32 = 20.0 + 4.0 + COL_NAME + 4.0 + COL_ICON + 4.0
|
||||
+ COL_ICON + 4.0 + COL_ICON + 4.0 + 8.0;
|
||||
|
||||
// Use the shared color_picker_dropdown from properties — no ByLayer/ByBlock for layers.
|
||||
let dropdown = color_picker_dropdown(
|
||||
full,
|
||||
Message::LayerColorMorePalette,
|
||||
None,
|
||||
None,
|
||||
|aci| Message::LayerColorSet(aci),
|
||||
);
|
||||
|
||||
row![
|
||||
iced::widget::Space::new().width(LEFT_OFFSET),
|
||||
dropdown,
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
// ── Display helpers ───────────────────────────────────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn aci_color_display(i: u8) -> (Color, &'static str) {
|
||||
let (r, g, b) = aci_to_rgb(i).unwrap_or((200, 200, 200));
|
||||
(Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0), "")
|
||||
}
|
||||
|
||||
fn iced_to_aci(c: Color) -> u8 {
|
||||
let r = (c.r * 255.0) as u8;
|
||||
let g = (c.g * 255.0) as u8;
|
||||
let b = (c.b * 255.0) as u8;
|
||||
for i in 1u8..=255 {
|
||||
if let Some((ar, ag, ab)) = aci_to_rgb(i) {
|
||||
if ar == r && ag == g && ab == b {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
|
||||
}
|
||||
|
||||
fn color_label_aci(i: u8) -> &'static str {
|
||||
match i {
|
||||
1 => "red",
|
||||
2 => "yellow",
|
||||
3 => "green",
|
||||
4 => "cyan",
|
||||
5 => "blue",
|
||||
6 => "magenta",
|
||||
7 => "white",
|
||||
8 => "dark gray",
|
||||
9 => "gray",
|
||||
_ => "white",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn color_name_to_aci(name: &str) -> u8 {
|
||||
match name {
|
||||
"red" => 1,
|
||||
"yellow" => 2,
|
||||
"green" => 3,
|
||||
"cyan" => 4,
|
||||
"blue" => 5,
|
||||
"magenta" => 6,
|
||||
"white" => 7,
|
||||
"dark gray" => 8,
|
||||
"gray" => 9,
|
||||
_ => 7,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn iced_color_from_acad(c: &AcadColor) -> Color {
|
||||
match c {
|
||||
AcadColor::Index(i) => {
|
||||
let (r, g, b) = aci_to_rgb(*i).unwrap_or((200, 200, 200));
|
||||
Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)
|
||||
}
|
||||
AcadColor::Rgb { r, g, b } => {
|
||||
Color::from_rgb(*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0)
|
||||
}
|
||||
_ => Color::WHITE,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Column widths ─────────────────────────────────────────────────────────
|
||||
|
||||
const COL_NAME: f32 = 130.0;
|
||||
const COL_ICON: f32 = 44.0;
|
||||
const COL_COLOR: f32 = 90.0;
|
||||
const COL_LT: f32 = 110.0;
|
||||
const COL_LW: f32 = 90.0;
|
||||
const COL_TRANS: f32 = 80.0;
|
||||
|
||||
// ── Colors ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PANEL_BG: Color = Color { r: 0.18, g: 0.18, b: 0.18, a: 1.0 };
|
||||
const TOOLBAR_BG: Color = Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 };
|
||||
const COL_HEADER_BG: Color = Color { r: 0.21, g: 0.21, b: 0.21, a: 1.0 };
|
||||
const ROW_EVEN: Color = Color { r: 0.18, g: 0.18, b: 0.18, a: 1.0 };
|
||||
const ROW_ODD: Color = Color { r: 0.21, g: 0.21, b: 0.21, a: 1.0 };
|
||||
const ROW_SEL: Color = Color { r: 0.18, g: 0.32, b: 0.52, a: 1.0 };
|
||||
const ROW_TEXT: Color = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 };
|
||||
const DIM: Color = Color { r: 0.50, g: 0.50, b: 0.50, a: 1.0 };
|
||||
const BORDER_COLOR: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 };
|
||||
#[allow(dead_code)]
|
||||
const ICON_COLOR: Color = Color { r: 0.80, g: 0.80, b: 0.80, a: 1.0 };
|
||||
@@ -0,0 +1,19 @@
|
||||
/// Single source of truth for UI row height (px).
|
||||
/// Change this to scale the ribbon, layer manager rows, and property panel rows uniformly.
|
||||
pub const ROW_H: f32 = 26.0;
|
||||
|
||||
pub mod app_menu;
|
||||
pub mod command_line;
|
||||
pub mod layers;
|
||||
pub mod overlay;
|
||||
pub mod properties;
|
||||
pub mod ribbon;
|
||||
pub mod snap_popup;
|
||||
pub mod statusbar;
|
||||
|
||||
pub use app_menu::AppMenu;
|
||||
pub use command_line::CommandLine;
|
||||
pub use layers::LayerPanel;
|
||||
pub use properties::PropertiesPanel;
|
||||
pub use ribbon::Ribbon;
|
||||
pub use statusbar::StatusBar;
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Viewport overlay widgets:
|
||||
//! • info_bar() — top-left bracket buttons showing view name / style
|
||||
//! • nav_toolbar() — vertical orbit/pan/zoom buttons on the right
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use iced::mouse;
|
||||
use iced::widget::{button, canvas, column, container, row, text};
|
||||
use iced::{Background, Border, Color, Element, Length, Point, Size, Theme};
|
||||
|
||||
use crate::scene::object::GripShape;
|
||||
use crate::scene::SelectionState;
|
||||
use crate::Message;
|
||||
|
||||
/// Half-size of the crosshair center square in screen pixels (square = SQ*2 × SQ*2).
|
||||
pub const CROSSHAIR_SQ: f32 = 7.5;
|
||||
/// Arm length of the crosshair from center — used as the snap aperture radius.
|
||||
pub const CROSSHAIR_ARM: f32 = 60.0;
|
||||
use crate::snap::SnapType;
|
||||
|
||||
// ── Grip marker data ──────────────────────────────────────────────────────
|
||||
|
||||
/// Describes one grip to be drawn in the viewport overlay.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GripMarker {
|
||||
/// Screen-space position (viewport-relative pixels).
|
||||
pub pos: Point,
|
||||
/// Explicit marker shape.
|
||||
pub shape: GripShape,
|
||||
/// True → grip is currently being dragged (drawn filled red).
|
||||
pub is_hot: bool,
|
||||
}
|
||||
|
||||
// ── Info bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn info_bar<'a>(view_name: &'a str, visual_style: &'a str) -> Element<'a, Message> {
|
||||
let bracket = |s: &'a str| -> Element<'a, Message> {
|
||||
container(text(s).size(10).color(Color {
|
||||
r: 0.78,
|
||||
g: 0.78,
|
||||
b: 0.78,
|
||||
a: 1.0,
|
||||
}))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
r: 0.10,
|
||||
g: 0.10,
|
||||
b: 0.10,
|
||||
a: 0.75,
|
||||
})),
|
||||
border: Border {
|
||||
color: Color {
|
||||
r: 0.35,
|
||||
g: 0.35,
|
||||
b: 0.35,
|
||||
a: 1.0,
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([2, 6])
|
||||
.into()
|
||||
};
|
||||
row![bracket("[-]"), bracket(view_name), bracket(visual_style)]
|
||||
.spacing(0)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Nav toolbar ───────────────────────────────────────────────────────────
|
||||
|
||||
pub fn nav_toolbar<'a>() -> Element<'a, Message> {
|
||||
let b = |icon: &'a str, cmd: &'a str| -> Element<'a, Message> {
|
||||
button(text(icon).size(14).color(Color::WHITE))
|
||||
.on_press(Message::Command(cmd.into()))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => Color {
|
||||
r: 0.32,
|
||||
g: 0.32,
|
||||
b: 0.32,
|
||||
a: 0.95,
|
||||
},
|
||||
button::Status::Pressed => Color {
|
||||
r: 0.18,
|
||||
g: 0.42,
|
||||
b: 0.70,
|
||||
a: 1.00,
|
||||
},
|
||||
_ => Color {
|
||||
r: 0.20,
|
||||
g: 0.20,
|
||||
b: 0.20,
|
||||
a: 0.85,
|
||||
},
|
||||
})),
|
||||
border: Border {
|
||||
color: Color {
|
||||
r: 0.30,
|
||||
g: 0.30,
|
||||
b: 0.30,
|
||||
a: 1.0,
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 2.0.into(),
|
||||
},
|
||||
text_color: Color::WHITE,
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([6, 8])
|
||||
.into()
|
||||
};
|
||||
container(
|
||||
column![
|
||||
b("⟳", "3DORBIT"),
|
||||
b("✥", "PAN"),
|
||||
b("⊕", "ZOOMIN"),
|
||||
b("⊖", "ZOOMOUT"),
|
||||
b("⊡", "ZOOMEXTENTS")
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Grid display params ───────────────────────────────────────────────────
|
||||
|
||||
/// Which world-space plane the grid is drawn on — switches with camera angle.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum GridPlane {
|
||||
/// Horizontal XY plane (Z = 0). Default top-down view (Z-up).
|
||||
Xy,
|
||||
/// Vertical XZ plane (Y = 0). Front/back view.
|
||||
Xz,
|
||||
/// Vertical YZ plane (X = 0). Side view.
|
||||
Yz,
|
||||
}
|
||||
|
||||
/// Passed to the canvas when the GRID display is active.
|
||||
#[derive(Clone)]
|
||||
pub struct GridParams {
|
||||
pub view_proj: Mat4,
|
||||
pub bounds: iced::Rectangle,
|
||||
pub plane: GridPlane,
|
||||
}
|
||||
|
||||
// ── Selection overlay ───────────────────────────────────────────────────
|
||||
|
||||
pub fn selection_overlay<'a>(
|
||||
selection: SelectionState,
|
||||
snap: Option<(Point, SnapType)>,
|
||||
grips: Vec<GripMarker>,
|
||||
grid: Option<GridParams>,
|
||||
) -> Element<'a, Message> {
|
||||
canvas(SelectionCanvas {
|
||||
selection,
|
||||
snap,
|
||||
grips,
|
||||
grid,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
struct SelectionCanvas {
|
||||
selection: SelectionState,
|
||||
snap: Option<(Point, SnapType)>,
|
||||
grips: Vec<GripMarker>,
|
||||
grid: Option<GridParams>,
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for SelectionCanvas {
|
||||
type State = ();
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_state: &(),
|
||||
bounds: iced::Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if cursor.is_over(bounds) {
|
||||
mouse::Interaction::Crosshair
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: iced::Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||
|
||||
// ── Grid display ──────────────────────────────────────────────────
|
||||
if let Some(ref g) = self.grid {
|
||||
draw_grid(&mut frame, g.view_proj, g.plane, g.bounds);
|
||||
}
|
||||
|
||||
if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) {
|
||||
let (fill, stroke) = if self.selection.box_crossing {
|
||||
(
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.72,
|
||||
b: 0.44,
|
||||
a: 0.12,
|
||||
},
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.72,
|
||||
b: 0.44,
|
||||
a: 0.9,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.44,
|
||||
b: 0.72,
|
||||
a: 0.12,
|
||||
},
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.44,
|
||||
b: 0.72,
|
||||
a: 0.9,
|
||||
},
|
||||
)
|
||||
};
|
||||
let x0 = a.x.min(b.x);
|
||||
let y0 = a.y.min(b.y);
|
||||
let w = (a.x - b.x).abs();
|
||||
let h = (a.y - b.y).abs();
|
||||
let rect = canvas::Path::rectangle(Point::new(x0, y0), Size::new(w, h));
|
||||
frame.fill(&rect, fill);
|
||||
frame.stroke(
|
||||
&rect,
|
||||
canvas::Stroke {
|
||||
width: 1.0,
|
||||
style: canvas::Style::Solid(stroke),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if self.selection.poly_active && self.selection.poly_points.len() > 1 {
|
||||
let (fill, stroke) = if self.selection.poly_crossing {
|
||||
(
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.72,
|
||||
b: 0.44,
|
||||
a: 0.12,
|
||||
},
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.72,
|
||||
b: 0.44,
|
||||
a: 0.9,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.44,
|
||||
b: 0.72,
|
||||
a: 0.12,
|
||||
},
|
||||
Color {
|
||||
r: 0.20,
|
||||
g: 0.44,
|
||||
b: 0.72,
|
||||
a: 0.9,
|
||||
},
|
||||
)
|
||||
};
|
||||
if let Some(cur) = self.selection.last_move_pos {
|
||||
let start = self.selection.poly_points[0];
|
||||
let fill_path = canvas::Path::new(|p| {
|
||||
p.move_to(start);
|
||||
for pt in &self.selection.poly_points[1..] {
|
||||
p.line_to(*pt);
|
||||
}
|
||||
p.line_to(cur);
|
||||
p.line_to(start);
|
||||
});
|
||||
frame.fill(&fill_path, fill);
|
||||
}
|
||||
let path = canvas::Path::new(|p| {
|
||||
p.move_to(self.selection.poly_points[0]);
|
||||
for pt in &self.selection.poly_points[1..] {
|
||||
p.line_to(*pt);
|
||||
}
|
||||
});
|
||||
frame.stroke(
|
||||
&path,
|
||||
canvas::Stroke {
|
||||
width: 1.0,
|
||||
style: canvas::Style::Solid(stroke),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if let Some(cur) = self.selection.last_move_pos {
|
||||
let start = self.selection.poly_points[0];
|
||||
let last = *self.selection.poly_points.last().unwrap();
|
||||
let preview = canvas::Path::new(|p| {
|
||||
p.move_to(last);
|
||||
p.line_to(cur);
|
||||
p.line_to(start);
|
||||
});
|
||||
frame.stroke(
|
||||
&preview,
|
||||
canvas::Stroke {
|
||||
width: 1.0,
|
||||
style: canvas::Style::Solid(stroke),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = self.selection.context_menu {
|
||||
let w = 140.0;
|
||||
let h = 72.0;
|
||||
let rect = canvas::Path::rectangle(Point::new(p.x, p.y), Size::new(w, h));
|
||||
frame.fill(
|
||||
&rect,
|
||||
Color {
|
||||
r: 0.12,
|
||||
g: 0.12,
|
||||
b: 0.12,
|
||||
a: 0.95,
|
||||
},
|
||||
);
|
||||
frame.stroke(
|
||||
&rect,
|
||||
canvas::Stroke {
|
||||
width: 1.0,
|
||||
style: canvas::Style::Solid(Color {
|
||||
r: 0.30,
|
||||
g: 0.30,
|
||||
b: 0.30,
|
||||
a: 1.0,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let items = ["Open", "Properties", "Hide"];
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
frame.fill_text(canvas::Text {
|
||||
content: item.to_string(),
|
||||
position: Point::new(p.x + 10.0, p.y + 10.0 + i as f32 * 20.0),
|
||||
color: Color::WHITE,
|
||||
size: iced::Pixels(11.0),
|
||||
font: iced::Font::DEFAULT,
|
||||
align_x: iced::alignment::Horizontal::Left.into(),
|
||||
align_y: iced::alignment::Vertical::Top.into(),
|
||||
max_width: f32::INFINITY,
|
||||
line_height: iced::widget::text::LineHeight::default(),
|
||||
shaping: iced::widget::text::Shaping::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grip markers ──────────────────────────────────────────────────
|
||||
for grip in &self.grips {
|
||||
let sp = grip.pos;
|
||||
let h = crate::scene::grip::GRIP_HALF_PX;
|
||||
let path = match grip.shape {
|
||||
GripShape::Square => canvas::Path::rectangle(
|
||||
Point::new(sp.x - h, sp.y - h),
|
||||
Size::new(h * 2.0, h * 2.0),
|
||||
),
|
||||
GripShape::Diamond => canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - h));
|
||||
b.line_to(Point::new(sp.x + h, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + h));
|
||||
b.line_to(Point::new(sp.x - h, sp.y));
|
||||
b.close();
|
||||
}),
|
||||
GripShape::Triangle => canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - h));
|
||||
b.line_to(Point::new(sp.x + h, sp.y + h));
|
||||
b.line_to(Point::new(sp.x - h, sp.y + h));
|
||||
b.close();
|
||||
}),
|
||||
};
|
||||
|
||||
if grip.is_hot {
|
||||
// Hot grip: filled red marker
|
||||
let color = Color {
|
||||
r: 1.0,
|
||||
g: 0.15,
|
||||
b: 0.10,
|
||||
a: 1.0,
|
||||
};
|
||||
frame.fill(&path, color);
|
||||
} else {
|
||||
// Normal grip: hollow blue marker
|
||||
let color = Color {
|
||||
r: 0.10,
|
||||
g: 0.45,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
let stroke = canvas::Stroke {
|
||||
width: 1.5,
|
||||
style: canvas::Style::Solid(color),
|
||||
..Default::default()
|
||||
};
|
||||
// Fill with semi-transparent background then stroke
|
||||
frame.fill(
|
||||
&path,
|
||||
Color {
|
||||
r: 0.10,
|
||||
g: 0.10,
|
||||
b: 0.20,
|
||||
a: 0.7,
|
||||
},
|
||||
);
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snap marker ───────────────────────────────────────────────────
|
||||
if let Some((sp, snap_type)) = self.snap {
|
||||
let yellow = Color {
|
||||
r: 1.0,
|
||||
g: 0.9,
|
||||
b: 0.1,
|
||||
a: 1.0,
|
||||
};
|
||||
let stroke = canvas::Stroke {
|
||||
width: 1.5,
|
||||
style: canvas::Style::Solid(yellow),
|
||||
..Default::default()
|
||||
};
|
||||
match snap_type {
|
||||
SnapType::Endpoint => {
|
||||
let half = 5.0_f32;
|
||||
let rect = canvas::Path::rectangle(
|
||||
Point::new(sp.x - half, sp.y - half),
|
||||
Size::new(half * 2.0, half * 2.0),
|
||||
);
|
||||
frame.stroke(&rect, stroke);
|
||||
}
|
||||
SnapType::Midpoint => {
|
||||
let r = 6.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5));
|
||||
b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
SnapType::Grid => {
|
||||
let arm = 4.0_f32;
|
||||
let h = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x - arm, sp.y));
|
||||
b.line_to(Point::new(sp.x + arm, sp.y));
|
||||
});
|
||||
let v = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - arm));
|
||||
b.line_to(Point::new(sp.x, sp.y + arm));
|
||||
});
|
||||
frame.stroke(&h, stroke.clone());
|
||||
frame.stroke(&v, stroke);
|
||||
}
|
||||
// Other snap types use the same diamond marker.
|
||||
_ => {
|
||||
let r = 5.0_f32;
|
||||
let path = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(sp.x, sp.y - r));
|
||||
b.line_to(Point::new(sp.x + r, sp.y));
|
||||
b.line_to(Point::new(sp.x, sp.y + r));
|
||||
b.line_to(Point::new(sp.x - r, sp.y));
|
||||
b.close();
|
||||
});
|
||||
frame.stroke(&path, stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CAD crosshair cursor ──────────────────────────────────────────────
|
||||
if let Some(cp) = self.selection.last_move_pos {
|
||||
let color = Color {
|
||||
r: 0.85,
|
||||
g: 0.85,
|
||||
b: 0.85,
|
||||
a: 0.90,
|
||||
};
|
||||
let stroke = canvas::Stroke {
|
||||
width: 1.0,
|
||||
style: canvas::Style::Solid(color),
|
||||
..Default::default()
|
||||
};
|
||||
let sq = CROSSHAIR_SQ; // square half-size → 15×15
|
||||
let arm = CROSSHAIR_ARM; // crosshair arm length from center
|
||||
|
||||
// Horizontal arms (start at square edge, end at arm length)
|
||||
let h_left = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(cp.x - sq, cp.y));
|
||||
b.line_to(Point::new(cp.x - arm, cp.y));
|
||||
});
|
||||
let h_right = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(cp.x + sq, cp.y));
|
||||
b.line_to(Point::new(cp.x + arm, cp.y));
|
||||
});
|
||||
// Vertical arms
|
||||
let v_top = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(cp.x, cp.y - sq));
|
||||
b.line_to(Point::new(cp.x, cp.y - arm));
|
||||
});
|
||||
let v_bot = canvas::Path::new(|b| {
|
||||
b.move_to(Point::new(cp.x, cp.y + sq));
|
||||
b.line_to(Point::new(cp.x, cp.y + arm));
|
||||
});
|
||||
// Center square
|
||||
let square = canvas::Path::rectangle(
|
||||
Point::new(cp.x - sq, cp.y - sq),
|
||||
Size::new(sq * 2.0, sq * 2.0),
|
||||
);
|
||||
|
||||
frame.stroke(&h_left, stroke.clone());
|
||||
frame.stroke(&h_right, stroke.clone());
|
||||
frame.stroke(&v_top, stroke.clone());
|
||||
frame.stroke(&v_bot, stroke.clone());
|
||||
frame.stroke(&square, stroke);
|
||||
}
|
||||
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid line drawing ─────────────────────────────────────────────────────
|
||||
|
||||
/// Minimum pixel gap between adjacent grid lines before stepping up to next spacing.
|
||||
const MIN_GRID_PX: f32 = 20.0;
|
||||
|
||||
fn draw_grid(frame: &mut canvas::Frame, vp: Mat4, plane: GridPlane, bounds: iced::Rectangle) {
|
||||
let w2s = |world: Vec3| -> Point {
|
||||
let ndc = vp.project_point3(world);
|
||||
Point::new(
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
)
|
||||
};
|
||||
|
||||
// Plane-tangent axes: axis1 and axis2 span the grid plane.
|
||||
let (axis1, axis2) = match plane {
|
||||
GridPlane::Xz => (Vec3::X, Vec3::Z),
|
||||
GridPlane::Xy => (Vec3::X, Vec3::Y),
|
||||
GridPlane::Yz => (Vec3::Y, Vec3::Z),
|
||||
};
|
||||
|
||||
// Adaptive spacing: measure pixels per 1-unit step along each axis,
|
||||
// then find the smallest power-of-5 multiple that gives ≥ MIN_GRID_PX.
|
||||
let o = w2s(Vec3::ZERO);
|
||||
let a1s = w2s(axis1);
|
||||
let a2s = w2s(axis2);
|
||||
let px1 = ((a1s.x - o.x).powi(2) + (a1s.y - o.y).powi(2)).sqrt();
|
||||
let px2 = ((a2s.x - o.x).powi(2) + (a2s.y - o.y).powi(2)).sqrt();
|
||||
let px_per_unit = px1.max(px2);
|
||||
if px_per_unit < 1e-6 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut s = 1.0_f32;
|
||||
while s * px_per_unit < MIN_GRID_PX {
|
||||
s *= 5.0;
|
||||
if s > 1e9 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Visible world extent: unproject screen corners (mid-depth approximation)
|
||||
// and project them onto the grid axes.
|
||||
let inv = vp.inverse();
|
||||
let unproject = |sx: f32, sy: f32| -> Vec3 {
|
||||
let ndc_x = (sx / bounds.width) * 2.0 - 1.0;
|
||||
let ndc_y = 1.0 - (sy / bounds.height) * 2.0;
|
||||
inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.5))
|
||||
};
|
||||
let corners = [
|
||||
unproject(0.0, 0.0),
|
||||
unproject(bounds.width, 0.0),
|
||||
unproject(0.0, bounds.height),
|
||||
unproject(bounds.width, bounds.height),
|
||||
];
|
||||
let range = |ax: Vec3| -> (f32, f32) {
|
||||
let vals: Vec<f32> = corners.iter().map(|p| p.dot(ax)).collect();
|
||||
(
|
||||
vals.iter().cloned().fold(f32::INFINITY, f32::min),
|
||||
vals.iter().cloned().fold(f32::NEG_INFINITY, f32::max),
|
||||
)
|
||||
};
|
||||
let (min1, max1) = range(axis1);
|
||||
let (min2, max2) = range(axis2);
|
||||
|
||||
let n1_s = (min1 / s).floor() as i32 - 1;
|
||||
let n1_e = (max1 / s).ceil() as i32 + 1;
|
||||
let n2_s = (min2 / s).floor() as i32 - 1;
|
||||
let n2_e = (max2 / s).ceil() as i32 + 1;
|
||||
if (n1_e - n1_s) > 500 || (n2_e - n2_s) > 500 {
|
||||
return;
|
||||
}
|
||||
|
||||
let gc = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.28,
|
||||
a: 0.7,
|
||||
};
|
||||
let st = canvas::Stroke {
|
||||
width: 0.5,
|
||||
style: canvas::Style::Solid(gc),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Lines parallel to axis2 (varying axis1 position)
|
||||
for i in n1_s..=n1_e {
|
||||
let v = i as f32 * s;
|
||||
let p0 = w2s(axis1 * v + axis2 * (min2 - s));
|
||||
let p1 = w2s(axis1 * v + axis2 * (max2 + s));
|
||||
frame.stroke(
|
||||
&canvas::Path::new(|b| {
|
||||
b.move_to(p0);
|
||||
b.line_to(p1);
|
||||
}),
|
||||
st.clone(),
|
||||
);
|
||||
}
|
||||
// Lines parallel to axis1 (varying axis2 position)
|
||||
for i in n2_s..=n2_e {
|
||||
let v = i as f32 * s;
|
||||
let p0 = w2s(axis2 * v + axis1 * (min1 - s));
|
||||
let p1 = w2s(axis2 * v + axis1 * (max1 + s));
|
||||
frame.stroke(
|
||||
&canvas::Path::new(|b| {
|
||||
b.move_to(p0);
|
||||
b.line_to(p1);
|
||||
}),
|
||||
st.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// World-space axes always drawn on top of the grid lines.
|
||||
let extent = (min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs()) + s) * 1.5;
|
||||
draw_axes(frame, vp, bounds, extent.max(10.0));
|
||||
}
|
||||
|
||||
// ── Coloured world-space axes ─────────────────────────────────────────────
|
||||
|
||||
fn draw_axes(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle, extent: f32) {
|
||||
let w2s = |world: Vec3| -> Point {
|
||||
let ndc = vp.project_point3(world);
|
||||
Point::new(
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
)
|
||||
};
|
||||
let e = extent;
|
||||
let axis_stroke = |r: f32, g: f32, b: f32| canvas::Stroke {
|
||||
width: 1.5,
|
||||
style: canvas::Style::Solid(Color { r, g, b, a: 0.85 }),
|
||||
..Default::default()
|
||||
};
|
||||
// X — red
|
||||
frame.stroke(
|
||||
&canvas::Path::new(|p| {
|
||||
p.move_to(w2s(Vec3::new(-e, 0.0, 0.0)));
|
||||
p.line_to(w2s(Vec3::new(e, 0.0, 0.0)));
|
||||
}),
|
||||
axis_stroke(0.90, 0.20, 0.20),
|
||||
);
|
||||
// Y — green
|
||||
frame.stroke(
|
||||
&canvas::Path::new(|p| {
|
||||
p.move_to(w2s(Vec3::new(0.0, -e, 0.0)));
|
||||
p.line_to(w2s(Vec3::new(0.0, e, 0.0)));
|
||||
}),
|
||||
axis_stroke(0.20, 0.85, 0.20),
|
||||
);
|
||||
// Z — blue
|
||||
frame.stroke(
|
||||
&canvas::Path::new(|p| {
|
||||
p.move_to(w2s(Vec3::new(0.0, 0.0, -e)));
|
||||
p.line_to(w2s(Vec3::new(0.0, 0.0, e)));
|
||||
}),
|
||||
axis_stroke(0.20, 0.40, 0.90),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1787
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,259 @@
|
||||
//! H7CAD-style OSNAP dropdown popup panel.
|
||||
//!
|
||||
//! Rendered as a floating overlay above the status bar. The popup is only
|
||||
//! inserted into the view stack when `snap_popup_open` is true.
|
||||
|
||||
use iced::widget::{button, column, container, mouse_area, row, text};
|
||||
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
|
||||
|
||||
use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES};
|
||||
use crate::Message;
|
||||
|
||||
/// Returns a full-screen overlay element:
|
||||
/// - a transparent click-catcher that closes the popup on outside click
|
||||
/// - the popup panel pinned to the bottom-right (above the status bar)
|
||||
pub fn snap_popup_overlay<'a>(snapper: &'a Snapper, right_offset: f32) -> Element<'a, Message> {
|
||||
// ── Panel content ─────────────────────────────────────────────────────
|
||||
let all_on = snapper.all_on();
|
||||
let none_on = snapper.none_on();
|
||||
|
||||
// "Select All / Clear All" header row
|
||||
let header = row![
|
||||
header_btn("Select All", Message::SnapSelectAll, !all_on),
|
||||
header_btn("Clear All", Message::SnapClearAll, !none_on),
|
||||
]
|
||||
.spacing(1)
|
||||
.padding([4u16, 8]);
|
||||
|
||||
// Divider
|
||||
let divider = container(iced::widget::Space::new().height(1))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(DIVIDER)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.padding([0, 4]);
|
||||
|
||||
// Snap mode rows
|
||||
let mut rows: Vec<Element<'_, Message>> = Vec::new();
|
||||
for &(snap_type, icon, label) in ALL_SNAP_MODES {
|
||||
rows.push(snap_row(snap_type, icon, label, snapper.is_on(snap_type)));
|
||||
}
|
||||
|
||||
// "Object Snap Settings…" footer
|
||||
let footer_divider = container(iced::widget::Space::new().height(1))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(DIVIDER)),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.padding([0, 4]);
|
||||
|
||||
let footer = button(text("Object Snap Settings…").size(11).color(Color {
|
||||
r: 0.75,
|
||||
g: 0.75,
|
||||
b: 0.75,
|
||||
a: 1.0,
|
||||
}))
|
||||
.on_press(Message::Command("DSETTINGS".into()))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => ROW_HOVER,
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.padding([5, 12]);
|
||||
|
||||
let panel_content = column![header, divider, column(rows), footer_divider, footer,];
|
||||
|
||||
let panel = container(panel_content)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(PANEL_BG)),
|
||||
border: Border {
|
||||
color: PANEL_BORDER,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fixed(210.0));
|
||||
|
||||
// ── Position: bottom-right, above the status bar (26 px) ─────────────
|
||||
let positioned = container(panel)
|
||||
.align_right(Fill)
|
||||
.align_bottom(Fill)
|
||||
.padding(Padding {
|
||||
bottom: 27.0,
|
||||
right: right_offset,
|
||||
top: 0.0,
|
||||
left: 0.0,
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
|
||||
// ── Click-catcher: closes popup on any outside click ──────────────────
|
||||
mouse_area(positioned)
|
||||
.on_press(Message::CloseSnapPopup)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Individual snap row ───────────────────────────────────────────────────
|
||||
|
||||
fn snap_row<'a>(
|
||||
snap_type: SnapType,
|
||||
icon: &'a str,
|
||||
label: &'a str,
|
||||
active: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let checkmark = text(if active { "✓" } else { " " })
|
||||
.size(11)
|
||||
.color(if active {
|
||||
CHECK_COLOR
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
})
|
||||
.width(Length::Fixed(14.0));
|
||||
|
||||
let icon_el = text(icon)
|
||||
.size(11)
|
||||
.color(ICON_COLOR)
|
||||
.width(Length::Fixed(16.0));
|
||||
|
||||
let label_el = text(label)
|
||||
.size(11)
|
||||
.color(if active { LABEL_ON } else { LABEL_OFF });
|
||||
|
||||
let content = row![checkmark, icon_el, label_el]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
|
||||
button(content)
|
||||
.on_press(Message::ToggleSnap(snap_type))
|
||||
.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => ROW_HOVER,
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.padding([3, 8])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn header_btn(label: &str, msg: Message, enabled: bool) -> Element<'_, Message> {
|
||||
let b = button(text(label).size(10).color(if enabled {
|
||||
Color {
|
||||
r: 0.70,
|
||||
g: 0.70,
|
||||
b: 0.70,
|
||||
a: 1.0,
|
||||
}
|
||||
} else {
|
||||
Color {
|
||||
r: 0.38,
|
||||
g: 0.38,
|
||||
b: 0.38,
|
||||
a: 1.0,
|
||||
}
|
||||
}));
|
||||
let b = if enabled { b.on_press(msg) } else { b };
|
||||
b.style(|_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => ROW_HOVER,
|
||||
_ => BTN_BG,
|
||||
})),
|
||||
border: Border {
|
||||
color: PANEL_BORDER,
|
||||
width: 1.0,
|
||||
radius: 2.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([3, 8])
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Colours ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PANEL_BG: Color = Color {
|
||||
r: 0.16,
|
||||
g: 0.16,
|
||||
b: 0.16,
|
||||
a: 0.98,
|
||||
};
|
||||
const PANEL_BORDER: Color = Color {
|
||||
r: 0.32,
|
||||
g: 0.32,
|
||||
b: 0.32,
|
||||
a: 1.0,
|
||||
};
|
||||
const DIVIDER: Color = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.28,
|
||||
a: 1.0,
|
||||
};
|
||||
const ROW_HOVER: Color = Color {
|
||||
r: 0.24,
|
||||
g: 0.24,
|
||||
b: 0.24,
|
||||
a: 1.0,
|
||||
};
|
||||
const BTN_BG: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.20,
|
||||
b: 0.20,
|
||||
a: 1.0,
|
||||
};
|
||||
const CHECK_COLOR: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.75,
|
||||
b: 0.35,
|
||||
a: 1.0,
|
||||
}; // green ✓
|
||||
const ICON_COLOR: Color = Color {
|
||||
r: 0.25,
|
||||
g: 0.75,
|
||||
b: 0.45,
|
||||
a: 1.0,
|
||||
}; // green icon
|
||||
const LABEL_ON: Color = Color {
|
||||
r: 0.92,
|
||||
g: 0.92,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
};
|
||||
const LABEL_OFF: Color = Color {
|
||||
r: 0.52,
|
||||
g: 0.52,
|
||||
b: 0.52,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
/// The `Point` parameter is unused today; kept for future tooltip positioning.
|
||||
#[allow(dead_code)]
|
||||
pub fn snap_marker_color(snap_type: SnapType) -> Color {
|
||||
match snap_type {
|
||||
SnapType::Endpoint | SnapType::Midpoint => Color {
|
||||
r: 1.0,
|
||||
g: 0.9,
|
||||
b: 0.1,
|
||||
a: 1.0,
|
||||
},
|
||||
SnapType::Grid => Color {
|
||||
r: 0.4,
|
||||
g: 0.9,
|
||||
b: 0.4,
|
||||
a: 1.0,
|
||||
},
|
||||
_ => Color {
|
||||
r: 1.0,
|
||||
g: 0.9,
|
||||
b: 0.1,
|
||||
a: 1.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
|
||||
|
||||
use iced::widget::tooltip::Position as TipPos;
|
||||
use iced::widget::{button, container, row, text, tooltip, Row};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use crate::snap::Snapper;
|
||||
use crate::Message;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StatusBar {
|
||||
#[allow(dead_code)]
|
||||
pub coord_display: String,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
coord_display: "MODEL".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
&'a self,
|
||||
snapper: &'a Snapper,
|
||||
popup_open: bool,
|
||||
ortho_mode: bool,
|
||||
polar_mode: bool,
|
||||
show_grid: bool,
|
||||
layouts: Vec<String>,
|
||||
current_layout: String,
|
||||
) -> Element<'a, Message> {
|
||||
let menu_btn = button(text("≡").size(14).color(ICON_COLOR))
|
||||
.on_press(Message::Command("MENU".into()))
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::TRANSPARENT)),
|
||||
..Default::default()
|
||||
})
|
||||
.padding([2, 8]);
|
||||
|
||||
let add_btn = button(text("+").size(12).color(ICON_COLOR))
|
||||
.on_press(Message::LayoutCreate)
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::TRANSPARENT)),
|
||||
..Default::default()
|
||||
})
|
||||
.padding([3, 7]);
|
||||
|
||||
// ── Right side ────────────────────────────────────────────────────
|
||||
let osnap_active = snapper.is_active();
|
||||
let snap_grid_on = snapper.is_on(crate::snap::SnapType::Grid) && snapper.snap_enabled;
|
||||
|
||||
let space_label = if current_layout == "Model" {
|
||||
"MODEL"
|
||||
} else {
|
||||
"LAYOUT"
|
||||
};
|
||||
let right_status = row![
|
||||
tip(
|
||||
toggle_pill("SNAP", snap_grid_on, Message::ToggleGridSnap),
|
||||
"Snap to Grid\nF9"
|
||||
),
|
||||
tip(
|
||||
toggle_pill("GRID", show_grid, Message::ToggleGrid),
|
||||
"Show Grid\nF7"
|
||||
),
|
||||
tip(
|
||||
toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho),
|
||||
"Orthogonal Mode\nF8"
|
||||
),
|
||||
tip(
|
||||
toggle_pill("POLAR", polar_mode, Message::TogglePolar),
|
||||
"Polar Tracking (45°)\nF10"
|
||||
),
|
||||
osnap_btn(osnap_active, snapper.snap_enabled, popup_open),
|
||||
status_pill(space_label),
|
||||
status_pill("1:1"),
|
||||
]
|
||||
.spacing(2);
|
||||
|
||||
let mut bar = Row::new().align_y(iced::Center).spacing(0);
|
||||
bar = bar.push(menu_btn);
|
||||
for name in layouts {
|
||||
let is_active = name == current_layout;
|
||||
bar = bar.push(space_tab(name, is_active));
|
||||
}
|
||||
bar = bar.push(add_btn);
|
||||
bar = bar.push(iced::widget::Space::new().width(Length::Fill));
|
||||
bar = bar.push(right_status);
|
||||
|
||||
container(bar)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(BAR_BG)),
|
||||
border: Border {
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(26)
|
||||
.padding([0, 4])
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tooltip helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn tip<'a>(content: Element<'a, Message>, label: &'static str) -> Element<'a, Message> {
|
||||
tooltip(
|
||||
content,
|
||||
container(text(label).size(11).color(Color::WHITE))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
r: 0.13,
|
||||
g: 0.13,
|
||||
b: 0.13,
|
||||
a: 0.97,
|
||||
})),
|
||||
border: Border {
|
||||
color: Color {
|
||||
r: 0.35,
|
||||
g: 0.35,
|
||||
b: 0.35,
|
||||
a: 1.0,
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
text_color: Some(Color::WHITE),
|
||||
..Default::default()
|
||||
})
|
||||
.padding([4, 8]),
|
||||
TipPos::Top,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Simple toggle pill ────────────────────────────────────────────────────
|
||||
|
||||
fn toggle_pill(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> {
|
||||
button(text(label).size(10).color(if active {
|
||||
OSNAP_ON_TEXT
|
||||
} else {
|
||||
OSNAP_OFF_TEXT
|
||||
}))
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match (active, status) {
|
||||
(true, button::Status::Hovered) => SNAP_ON_HOVER,
|
||||
(true, _) => SNAP_ON_BG,
|
||||
(false, button::Status::Hovered) => SNAP_OFF_HOVER,
|
||||
(false, _) => SNAP_OFF_BG,
|
||||
})),
|
||||
border: Border {
|
||||
color: if active { SNAP_BORDER_ON } else { BORDER_COLOR },
|
||||
width: 1.0,
|
||||
radius: 2.0.into(),
|
||||
},
|
||||
text_color: if active {
|
||||
OSNAP_ON_TEXT
|
||||
} else {
|
||||
OSNAP_OFF_TEXT
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([2, 6])
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── OSNAP split button ────────────────────────────────────────────────────
|
||||
//
|
||||
// Left part ("⚡ OSNAP"): toggles the global snap on/off.
|
||||
// Right part ("▾"): opens the snap-type dropdown.
|
||||
|
||||
fn osnap_btn(active: bool, snap_enabled: bool, open: bool) -> Element<'static, Message> {
|
||||
let bg = match (active || snap_enabled, open) {
|
||||
(true, true) => SNAP_ON_HOVER,
|
||||
(true, false) => SNAP_ON_BG,
|
||||
(false, _) => SNAP_OFF_BG,
|
||||
};
|
||||
let border_color = if open {
|
||||
ACCENT
|
||||
} else if active {
|
||||
SNAP_BORDER_ON
|
||||
} else {
|
||||
BORDER_COLOR
|
||||
};
|
||||
let text_color = if active {
|
||||
OSNAP_ON_TEXT
|
||||
} else {
|
||||
OSNAP_OFF_TEXT
|
||||
};
|
||||
|
||||
let left = button(text("⚡ OSNAP").size(10).color(text_color))
|
||||
.on_press(Message::ToggleSnapEnabled)
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => {
|
||||
if active || snap_enabled {
|
||||
SNAP_ON_HOVER
|
||||
} else {
|
||||
SNAP_OFF_HOVER
|
||||
}
|
||||
}
|
||||
_ => bg,
|
||||
})),
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width: 1.0,
|
||||
radius: iced::border::Radius {
|
||||
top_left: 2.0,
|
||||
top_right: 0.0,
|
||||
bottom_right: 0.0,
|
||||
bottom_left: 2.0,
|
||||
},
|
||||
},
|
||||
text_color,
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([2, 6]);
|
||||
|
||||
let right = button(text("▾").size(9).color(text_color))
|
||||
.on_press(Message::ToggleSnapPopup)
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => {
|
||||
if active || snap_enabled {
|
||||
SNAP_ON_HOVER
|
||||
} else {
|
||||
SNAP_OFF_HOVER
|
||||
}
|
||||
}
|
||||
_ => bg,
|
||||
})),
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width: 1.0,
|
||||
radius: iced::border::Radius {
|
||||
top_left: 0.0,
|
||||
top_right: 2.0,
|
||||
bottom_right: 2.0,
|
||||
bottom_left: 0.0,
|
||||
},
|
||||
},
|
||||
text_color,
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([2, 4]);
|
||||
|
||||
row![
|
||||
tip(left.into(), "Object Snap: toggle on/off\nF3"),
|
||||
tip(right.into(), "Object Snap settings\nClick ▾"),
|
||||
]
|
||||
.spacing(0)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn space_tab(label: String, is_active: bool) -> Element<'static, Message> {
|
||||
let msg = Message::LayoutSwitch(label.clone());
|
||||
button(text(label).size(11))
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, status| button::Style {
|
||||
background: Some(Background::Color(match (is_active, status) {
|
||||
(true, _) => TAB_ACTIVE,
|
||||
(false, button::Status::Hovered) => TAB_HOVER,
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
text_color: if is_active {
|
||||
Color::WHITE
|
||||
} else {
|
||||
Color {
|
||||
r: 0.65,
|
||||
g: 0.65,
|
||||
b: 0.65,
|
||||
a: 1.0,
|
||||
}
|
||||
},
|
||||
border: Border {
|
||||
color: if is_active {
|
||||
ACCENT
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
},
|
||||
width: if is_active { 1.0 } else { 0.0 },
|
||||
radius: 2.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
})
|
||||
.padding([3, 10])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn status_pill(label: &str) -> Element<'_, Message> {
|
||||
container(text(label).size(10).color(Color {
|
||||
r: 0.65,
|
||||
g: 0.65,
|
||||
b: 0.65,
|
||||
a: 1.0,
|
||||
}))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(PILL_BG)),
|
||||
border: Border {
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 2.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([2, 6])
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Colours ───────────────────────────────────────────────────────────────
|
||||
|
||||
const BAR_BG: Color = Color {
|
||||
r: 0.14,
|
||||
g: 0.14,
|
||||
b: 0.14,
|
||||
a: 1.0,
|
||||
};
|
||||
const TAB_ACTIVE: Color = Color {
|
||||
r: 0.25,
|
||||
g: 0.25,
|
||||
b: 0.25,
|
||||
a: 1.0,
|
||||
};
|
||||
const TAB_HOVER: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.20,
|
||||
b: 0.20,
|
||||
a: 1.0,
|
||||
};
|
||||
const PILL_BG: Color = Color {
|
||||
r: 0.19,
|
||||
g: 0.19,
|
||||
b: 0.19,
|
||||
a: 1.0,
|
||||
};
|
||||
const BORDER_COLOR: Color = Color {
|
||||
r: 0.28,
|
||||
g: 0.28,
|
||||
b: 0.28,
|
||||
a: 1.0,
|
||||
};
|
||||
const ICON_COLOR: Color = Color {
|
||||
r: 0.70,
|
||||
g: 0.70,
|
||||
b: 0.70,
|
||||
a: 1.0,
|
||||
};
|
||||
const ACCENT: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.55,
|
||||
b: 0.90,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
const OSNAP_ON_TEXT: Color = Color {
|
||||
r: 0.35,
|
||||
g: 0.75,
|
||||
b: 1.00,
|
||||
a: 1.0,
|
||||
};
|
||||
const OSNAP_OFF_TEXT: Color = Color {
|
||||
r: 0.42,
|
||||
g: 0.42,
|
||||
b: 0.42,
|
||||
a: 1.0,
|
||||
};
|
||||
const SNAP_ON_BG: Color = Color {
|
||||
r: 0.10,
|
||||
g: 0.20,
|
||||
b: 0.32,
|
||||
a: 1.0,
|
||||
};
|
||||
const SNAP_ON_HOVER: Color = Color {
|
||||
r: 0.14,
|
||||
g: 0.27,
|
||||
b: 0.42,
|
||||
a: 1.0,
|
||||
};
|
||||
const SNAP_BORDER_ON: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.50,
|
||||
b: 0.85,
|
||||
a: 1.0,
|
||||
};
|
||||
const SNAP_OFF_BG: Color = Color {
|
||||
r: 0.17,
|
||||
g: 0.17,
|
||||
b: 0.17,
|
||||
a: 1.0,
|
||||
};
|
||||
const SNAP_OFF_HOVER: Color = Color {
|
||||
r: 0.22,
|
||||
g: 0.22,
|
||||
b: 0.22,
|
||||
a: 1.0,
|
||||
};
|
||||
Reference in new issue
Block a user