forked from 1b-agent/opencadstudio-upstream
Delete the byte-identical 0.68 muted_style copies across 13 window modules and import crate::ui::style::common::muted_style instead. print_all keeps its local name via muted_style as muted so call sites are untouched. Divergent alphas (0.60/0.62/0.65/0.72) and missing_fonts weak.text left alone to avoid visual changes. Net: +13 / -78, no visual change. Verified: cargo check --all-targets clean, cargo test --lib ui::style (3 passed).
1010 lines
36 KiB
Rust
1010 lines
36 KiB
Rust
//! Layer Manager — floating window.
|
|
|
|
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;
|
|
use acadrust::types::aci_table::aci_to_rgb;
|
|
use acadrust::types::{Color as AcadColor, LineWeight};
|
|
use acadrust::Handle;
|
|
use iced::widget::{
|
|
button, column, combo_box, container, mouse_area, row, scrollable, text, text_input, tooltip,
|
|
};
|
|
use iced::Padding;
|
|
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
|
|
use crate::t;
|
|
use std::borrow::Cow;
|
|
|
|
// ── Per-viewport column descriptor ───────────────────────────────────────────
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct VpCol {
|
|
pub handle: Handle,
|
|
pub label: String,
|
|
}
|
|
|
|
/// Sortable column in the Layer Manager table. Clicking a header sorts by
|
|
/// that column; clicking the active header again flips the direction (#133).
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum LayerSortCol {
|
|
Name,
|
|
On,
|
|
Freeze,
|
|
Lock,
|
|
Plot,
|
|
Color,
|
|
Linetype,
|
|
Lineweight,
|
|
Transparency,
|
|
}
|
|
|
|
// ── 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;
|
|
const LINETYPE_MENU_W: f32 = 220.0;
|
|
/// Widget id for the layer-table scrollable, so a freshly created layer can be
|
|
/// scrolled into view after it is added (#271).
|
|
pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll";
|
|
|
|
fn table_input_style(
|
|
theme: &Theme,
|
|
status: iced::widget::text_input::Status,
|
|
) -> iced::widget::text_input::Style {
|
|
let palette = theme.palette();
|
|
let border = match status {
|
|
iced::widget::text_input::Status::Focused { .. } => palette.primary.base.color,
|
|
_ => palette.background.neutral.color,
|
|
};
|
|
iced::widget::text_input::Style {
|
|
background: Background::Color(palette.background.base.color),
|
|
border: Border {
|
|
color: border,
|
|
width: 1.0,
|
|
radius: 2.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),
|
|
}
|
|
}
|
|
|
|
// ── Layer data ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Layer {
|
|
pub name: String,
|
|
pub visible: bool,
|
|
pub frozen: bool,
|
|
pub locked: bool,
|
|
pub plottable: bool,
|
|
pub color: AcadColor,
|
|
pub color_name: Option<String>,
|
|
pub book_name: Option<String>,
|
|
pub linetype: String,
|
|
pub lineweight: LineWeight,
|
|
pub transparency: i32,
|
|
/// Freeze state per-viewport, indexed parallel to LayerPanel::vp_cols.
|
|
pub vp_frozen: Vec<bool>,
|
|
}
|
|
|
|
impl Layer {
|
|
pub fn new(name: &str, color: AcadColor) -> Self {
|
|
Self {
|
|
name: name.to_string(),
|
|
visible: true,
|
|
frozen: false,
|
|
locked: false,
|
|
plottable: true,
|
|
color,
|
|
color_name: None,
|
|
book_name: None,
|
|
linetype: "Continuous".to_string(),
|
|
lineweight: LineWeight::Default,
|
|
transparency: 0,
|
|
vp_frozen: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Panel state ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone)]
|
|
pub struct LayerPanel {
|
|
pub layers: Vec<Layer>,
|
|
#[allow(dead_code)]
|
|
pub visible: bool,
|
|
/// Anchor row: the last row clicked. Drives the editable combos and the
|
|
/// Shift-range origin. Its layer is always part of `selected_multi`.
|
|
pub selected: Option<usize>,
|
|
/// All selected rows (Ctrl/Shift extend it). Bulk property changes and
|
|
/// deletion act on every row here. Empty ⇔ nothing selected.
|
|
pub selected_multi: Vec<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>,
|
|
/// Per-viewport columns (only populated when in a paper layout with viewports).
|
|
pub vp_cols: Vec<VpCol>,
|
|
/// Active sort column, or `None` for document order.
|
|
pub sort_col: Option<LayerSortCol>,
|
|
/// Sort direction; `true` = ascending.
|
|
pub sort_asc: bool,
|
|
/// Live name filter from the search box; empty shows every layer (#343).
|
|
pub filter: String,
|
|
}
|
|
|
|
impl Default for LayerPanel {
|
|
fn default() -> Self {
|
|
Self {
|
|
visible: false,
|
|
layers: vec![Layer::new("0", AcadColor::Index(7))],
|
|
selected: None,
|
|
selected_multi: Vec::new(),
|
|
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()),
|
|
vp_cols: vec![],
|
|
// Default to alphabetical (Name) order in both the Layer Manager
|
|
// table and the ribbon's quick layer dropdown (#270). A header
|
|
// click still re-sorts by any other column.
|
|
sort_col: Some(LayerSortCol::Name),
|
|
sort_asc: true,
|
|
filter: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LayerPanel {
|
|
/// Sync layers + update per-viewport freeze columns.
|
|
/// `vp_info`: list of (vp_handle, vp_label, frozen_layer_handles) from scene.
|
|
pub fn sync_with_viewports(
|
|
&mut self,
|
|
doc_layers: &Table<DocLayer>,
|
|
vp_info: Vec<(Handle, String, Vec<Handle>)>,
|
|
) {
|
|
// The rebuild below re-indexes rows, so capture the selection by name
|
|
// first and re-resolve it after (indices alone would go stale).
|
|
let anchor_name = self
|
|
.selected
|
|
.and_then(|i| self.layers.get(i))
|
|
.map(|l| l.name.clone());
|
|
let multi_names: Vec<String> = self
|
|
.selected_multi
|
|
.iter()
|
|
.filter_map(|&i| self.layers.get(i).map(|l| l.name.clone()))
|
|
.collect();
|
|
|
|
self.vp_cols = vp_info
|
|
.iter()
|
|
.map(|(h, label, _)| VpCol {
|
|
handle: *h,
|
|
label: label.clone(),
|
|
})
|
|
.collect();
|
|
|
|
self.layers = doc_layers
|
|
.iter()
|
|
.map(|l| {
|
|
let layer_handle = l.handle;
|
|
let vp_frozen = vp_info
|
|
.iter()
|
|
.map(|(_, _, frozen_handles)| frozen_handles.contains(&layer_handle))
|
|
.collect();
|
|
Layer {
|
|
name: l.name.clone(),
|
|
visible: !l.flags.off,
|
|
frozen: l.flags.frozen,
|
|
locked: l.flags.locked,
|
|
plottable: l.is_plottable,
|
|
color: l.color,
|
|
color_name: l.color_name.clone(),
|
|
book_name: l.book_name.clone(),
|
|
linetype: if l.line_type.is_empty() {
|
|
"Continuous".to_string()
|
|
} else {
|
|
l.line_type.clone()
|
|
},
|
|
lineweight: l.line_weight,
|
|
transparency: (l.transparency.as_percent() * 100.0).round() as i32,
|
|
vp_frozen,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Re-resolve the selection against the rebuilt rows by name.
|
|
self.selected = anchor_name
|
|
.and_then(|n| self.layers.iter().position(|l| l.name == n));
|
|
self.selected_multi = multi_names
|
|
.iter()
|
|
.filter_map(|n| self.layers.iter().position(|l| l.name == *n))
|
|
.collect();
|
|
|
|
self.apply_sort();
|
|
}
|
|
|
|
/// Set/flip the sort column from a header click, then re-sort.
|
|
pub fn sort_by(&mut self, col: LayerSortCol) {
|
|
if self.sort_col == Some(col) {
|
|
self.sort_asc = !self.sort_asc;
|
|
} else {
|
|
self.sort_col = Some(col);
|
|
self.sort_asc = true;
|
|
}
|
|
self.apply_sort();
|
|
}
|
|
|
|
pub fn refresh_sort(&mut self) {
|
|
self.apply_sort();
|
|
}
|
|
|
|
/// Reorder `self.layers` by the active sort column, preserving the current
|
|
/// selection by name. No-op in document order (`sort_col == None`).
|
|
fn apply_sort(&mut self) {
|
|
let Some(col) = self.sort_col else {
|
|
return;
|
|
};
|
|
let asc = self.sort_asc;
|
|
let sel_name = self
|
|
.selected
|
|
.and_then(|i| self.layers.get(i))
|
|
.map(|l| l.name.clone());
|
|
// Sort reorders rows, so the multi-selection (stored as indices) must
|
|
// be re-resolved by name afterward or it would point at the wrong rows.
|
|
let multi_names: Vec<String> = self
|
|
.selected_multi
|
|
.iter()
|
|
.filter_map(|&i| self.layers.get(i).map(|l| l.name.clone()))
|
|
.collect();
|
|
|
|
use std::cmp::Ordering;
|
|
self.layers.sort_by(|a, b| {
|
|
let ord = match col {
|
|
LayerSortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
LayerSortCol::On => a.visible.cmp(&b.visible),
|
|
LayerSortCol::Freeze => a.frozen.cmp(&b.frozen),
|
|
LayerSortCol::Lock => a.locked.cmp(&b.locked),
|
|
LayerSortCol::Plot => a.plottable.cmp(&b.plottable),
|
|
LayerSortCol::Color => color_sort_key(a.color).cmp(&color_sort_key(b.color)),
|
|
LayerSortCol::Linetype => {
|
|
a.linetype.to_lowercase().cmp(&b.linetype.to_lowercase())
|
|
}
|
|
LayerSortCol::Lineweight => a.lineweight.value().cmp(&b.lineweight.value()),
|
|
LayerSortCol::Transparency => a.transparency.cmp(&b.transparency),
|
|
};
|
|
// Stable tie-break by name so equal keys keep a predictable order.
|
|
let ord = ord.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
|
if asc {
|
|
ord
|
|
} else {
|
|
match ord {
|
|
Ordering::Less => Ordering::Greater,
|
|
Ordering::Greater => Ordering::Less,
|
|
Ordering::Equal => Ordering::Equal,
|
|
}
|
|
}
|
|
});
|
|
|
|
if let Some(n) = sel_name {
|
|
self.selected = self.layers.iter().position(|l| l.name == n);
|
|
}
|
|
self.selected_multi = multi_names
|
|
.iter()
|
|
.filter_map(|n| self.layers.iter().position(|l| l.name == *n))
|
|
.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,
|
|
name_col_w: f32,
|
|
sizing: crate::ui::modal::ModalSizing,
|
|
) -> Element<'_, Message> {
|
|
self.view_content(name_col_w, sizing)
|
|
}
|
|
|
|
fn view_content(
|
|
&self,
|
|
name_col_w: f32,
|
|
sizing: crate::ui::modal::ModalSizing,
|
|
) -> 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);
|
|
let can_set_current = self
|
|
.selected
|
|
.and_then(|i| self.layers.get(i))
|
|
.is_some_and(|layer| layer.name != self.current_layer);
|
|
|
|
// ── Toolbar ───────────────────────────────────────────────────────
|
|
let toolbar = container(
|
|
row![
|
|
toolbar_btn(crate::ui::icons::PLUS, t!("New"), Message::LayerNew),
|
|
toolbar_btn_cond(
|
|
crate::ui::icons::TRASH,
|
|
t!("Delete"),
|
|
Message::LayerDelete,
|
|
has_sel && !sel_is_zero,
|
|
),
|
|
toolbar_btn_cond(
|
|
crate::ui::icons::CHECK,
|
|
t!("Set Current"),
|
|
Message::LayerSetCurrent,
|
|
can_set_current,
|
|
),
|
|
iced::widget::Space::new().width(sizing.width),
|
|
// Search box: filters rows by name as the user types (#343).
|
|
text_input(t!("Search…").as_ref(), &self.filter)
|
|
.on_input(Message::LayerManagerFilterChanged)
|
|
.size(FONT_SZ)
|
|
.padding([3, 6])
|
|
.width(Length::Fixed(180.0))
|
|
.style(table_input_style),
|
|
]
|
|
.spacing(2)
|
|
.align_y(iced::Center),
|
|
)
|
|
.style(|theme: &Theme| container::Style {
|
|
background: Some(Background::Color(
|
|
theme.palette().background.weak.color
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.width(sizing.width)
|
|
.padding([4, 8]);
|
|
|
|
// ── Column header ─────────────────────────────────────────────────
|
|
let sc = self.sort_col;
|
|
let sa = self.sort_asc;
|
|
let mut header_row = row![
|
|
text(t!("Status")).size(10).style(muted_style).width(50),
|
|
sortable_header(t!("Name"), LayerSortCol::Name, Length::Fixed(name_col_w), sc, sa),
|
|
// Draggable divider: adjusts the Name column width (#359).
|
|
iced::widget::mouse_area(
|
|
container(iced::widget::Space::new().width(2).height(14)).style(
|
|
|theme: &Theme| container::Style {
|
|
background: Some(Background::Color(
|
|
theme.palette().background.neutral.color
|
|
)),
|
|
..Default::default()
|
|
},
|
|
),
|
|
)
|
|
.on_press(Message::LayerNameColGrab)
|
|
.interaction(iced::mouse::Interaction::ResizingHorizontally),
|
|
sortable_header(t!("On"), LayerSortCol::On, Length::Fixed(COL_ICON), sc, sa),
|
|
sortable_header(t!("Freeze"), LayerSortCol::Freeze, Length::Fixed(COL_ICON), sc, sa),
|
|
sortable_header(t!("Lock"), LayerSortCol::Lock, Length::Fixed(COL_ICON), sc, sa),
|
|
sortable_header(t!("Plot"), LayerSortCol::Plot, Length::Fixed(COL_ICON), sc, sa),
|
|
sortable_header(t!("Color"), LayerSortCol::Color, Length::Fixed(COL_COLOR), sc, sa),
|
|
sortable_header(t!("Linetype"), LayerSortCol::Linetype, Length::Fixed(COL_LT), sc, sa),
|
|
sortable_header(t!("Lineweight"), LayerSortCol::Lineweight, Length::Fixed(COL_LW), sc, sa),
|
|
sortable_header(
|
|
t!("Transparency"),
|
|
LayerSortCol::Transparency,
|
|
Length::Fixed(COL_TRANS),
|
|
sc,
|
|
sa
|
|
),
|
|
]
|
|
.spacing(4)
|
|
.width(sizing.width)
|
|
.align_y(iced::Center);
|
|
|
|
for vp in &self.vp_cols {
|
|
header_row = header_row.push(
|
|
text(vp.label.as_str())
|
|
.size(10)
|
|
.style(muted_style)
|
|
.width(Length::Fixed(COL_ICON)),
|
|
);
|
|
}
|
|
|
|
let col_header = container(header_row)
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(palette.background.weak.color)),
|
|
border: Border {
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding([4, 8])
|
|
.width(sizing.width);
|
|
|
|
// ── Layer rows ────────────────────────────────────────────────────
|
|
let mut rows_col = column![].spacing(0);
|
|
let filter = self.filter.to_lowercase();
|
|
for (i, layer) in self.layers.iter().enumerate() {
|
|
if !filter.is_empty() && !layer.name.to_lowercase().contains(&filter) {
|
|
continue;
|
|
}
|
|
// Highlight every selected row; show the editable combos only on the
|
|
// anchor (a single shared combo state can't drive several rows).
|
|
let is_anchor = self.selected == Some(i);
|
|
let is_sel = is_anchor || self.selected_multi.contains(&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_anchor {
|
|
(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,
|
|
&self.vp_cols,
|
|
name_col_w,
|
|
));
|
|
|
|
}
|
|
|
|
let table = scrollable(rows_col)
|
|
.id(iced::advanced::widget::Id::new(LAYER_TABLE_SCROLL_ID))
|
|
.height(sizing.height.min(240.0));
|
|
|
|
// ── Full-window frame ─────────────────────────────────────────────
|
|
container(column![toolbar, col_header, table].spacing(0))
|
|
.style(|theme: &Theme| container::Style {
|
|
background: Some(Background::Color(
|
|
theme.palette().background.base.color
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.width(sizing.width)
|
|
.height(sizing.height)
|
|
.into()
|
|
}
|
|
}
|
|
|
|
// ── Sorting helpers ─────────────────────────────────────────────────────────
|
|
|
|
fn layer_cell_button_style(
|
|
theme: &Theme,
|
|
status: button::Status,
|
|
is_selected: bool,
|
|
index: usize,
|
|
) -> button::Style {
|
|
let palette = theme.palette();
|
|
let highlighted = matches!(status, button::Status::Hovered);
|
|
let pair = if highlighted {
|
|
palette.background.strong
|
|
} else if is_selected {
|
|
palette.primary.weak
|
|
} else if index % 2 == 0 {
|
|
palette.background.base
|
|
} else {
|
|
palette.background.weak
|
|
};
|
|
button::Style {
|
|
background: highlighted.then_some(Background::Color(pair.color)),
|
|
text_color: pair.text,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn layer_header_button_style(theme: &Theme, status: button::Status) -> button::Style {
|
|
let palette = theme.palette();
|
|
let highlighted = matches!(
|
|
status,
|
|
button::Status::Hovered | button::Status::Pressed
|
|
);
|
|
let pair = if highlighted {
|
|
palette.background.strong
|
|
} else {
|
|
palette.background.weak
|
|
};
|
|
button::Style {
|
|
background: highlighted.then_some(Background::Color(pair.color)),
|
|
text_color: pair.text,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Packed RGB key for ordering colours deterministically by hue-ish bytes.
|
|
fn color_sort_key(c: AcadColor) -> u32 {
|
|
let c = iced_color_from_acad(&c);
|
|
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
|
|
(q(c.r) << 16) | (q(c.g) << 8) | q(c.b)
|
|
}
|
|
|
|
/// A clickable column header that sorts the table by `col`. Shows an up/down
|
|
/// SVG arrow when it is the active sort column (#133).
|
|
fn sortable_header<'a>(
|
|
label: Cow<'static, str>,
|
|
col: LayerSortCol,
|
|
width: Length,
|
|
active: Option<LayerSortCol>,
|
|
asc: bool,
|
|
) -> Element<'a, Message> {
|
|
let mut content = row![text(label).size(10).style(muted_style)]
|
|
.spacing(3)
|
|
.align_y(iced::Center);
|
|
if active == Some(col) {
|
|
content = content.push(if asc {
|
|
crate::ui::icons::themed_arrow_up(8.0)
|
|
} else {
|
|
crate::ui::icons::themed_arrow_down(8.0)
|
|
});
|
|
}
|
|
button(content)
|
|
.on_press(Message::LayerSort(col))
|
|
.style(layer_header_button_style)
|
|
.padding(Padding {
|
|
top: 0.0,
|
|
bottom: 0.0,
|
|
left: 2.0,
|
|
right: 2.0,
|
|
})
|
|
.width(width)
|
|
.into()
|
|
}
|
|
|
|
// ── Toolbar buttons ───────────────────────────────────────────────────────
|
|
|
|
fn toolbar_btn<'a>(icon: &'static [u8], label: Cow<'static, str>, msg: Message) -> Element<'a, Message> {
|
|
button(
|
|
row![
|
|
crate::ui::icons::themed(icon, 12.0),
|
|
text(label).size(11),
|
|
]
|
|
.spacing(5)
|
|
.align_y(iced::Center),
|
|
)
|
|
.on_press(msg)
|
|
.style(|theme: &Theme, status| {
|
|
let palette = theme.palette();
|
|
let pair = match status {
|
|
button::Status::Hovered | button::Status::Pressed => {
|
|
palette.background.strong
|
|
}
|
|
_ => palette.background.weak,
|
|
};
|
|
button::Style {
|
|
background: Some(Background::Color(pair.color)),
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
},
|
|
text_color: pair.text,
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding([4, 10])
|
|
.into()
|
|
}
|
|
|
|
fn toolbar_btn_cond<'a>(
|
|
icon: &'static [u8],
|
|
label: Cow<'static, str>,
|
|
msg: Message,
|
|
enabled: bool,
|
|
) -> Element<'a, Message> {
|
|
let mut b = button(
|
|
row![
|
|
if enabled {
|
|
crate::ui::icons::themed(icon, 12.0)
|
|
} else {
|
|
crate::ui::icons::themed_disabled(icon, 12.0)
|
|
},
|
|
if enabled {
|
|
text(label).size(11)
|
|
} else {
|
|
text(label).size(11).style(|theme: &Theme| iced::widget::text::Style {
|
|
color: Some(
|
|
theme.palette().background.base.text.scale_alpha(0.42)
|
|
),
|
|
})
|
|
},
|
|
]
|
|
.spacing(5)
|
|
.align_y(iced::Center),
|
|
)
|
|
.style(move |theme: &Theme, status| {
|
|
let palette = theme.palette();
|
|
let pair = match status {
|
|
button::Status::Hovered if enabled => palette.background.strong,
|
|
_ => palette.background.weak,
|
|
};
|
|
button::Style {
|
|
background: Some(Background::Color(pair.color)),
|
|
border: Border {
|
|
radius: 3.0.into(),
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
},
|
|
text_color: if enabled {
|
|
pair.text
|
|
} else {
|
|
pair.text.scale_alpha(0.42)
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.padding([4, 10]);
|
|
if enabled {
|
|
b = b.on_press(msg);
|
|
}
|
|
b.into()
|
|
}
|
|
|
|
// ── Layer row ─────────────────────────────────────────────────────────────
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
/// Hover popup showing a layer's full name when the cell truncates it.
|
|
fn name_tip<'a>(name: &'a str) -> Element<'a, Message> {
|
|
container(text(name).size(FONT_SZ))
|
|
.padding(Padding {
|
|
top: 3.0,
|
|
bottom: 3.0,
|
|
left: 7.0,
|
|
right: 7.0,
|
|
})
|
|
.style(|theme: &Theme| {
|
|
let palette = theme.palette();
|
|
container::Style {
|
|
background: Some(Background::Color(palette.background.strong.color)),
|
|
border: Border {
|
|
color: palette.background.neutral.color,
|
|
width: 1.0,
|
|
radius: 3.0.into(),
|
|
},
|
|
text_color: Some(palette.background.strong.text),
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
}
|
|
|
|
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>>,
|
|
vp_cols: &'a [VpCol],
|
|
name_col_w: f32,
|
|
) -> Element<'a, Message> {
|
|
let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> {
|
|
button(crate::ui::icons::semantic(bytes, ICON_SZ))
|
|
.on_press(on_press)
|
|
.style(move |theme: &Theme, status| {
|
|
layer_cell_button_style(theme, status, is_selected, index)
|
|
})
|
|
.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 = crate::ui::icons::layer_visible(layer.visible);
|
|
let frz_svg = crate::ui::icons::layer_freeze(layer.frozen);
|
|
let lck_svg = crate::ui::icons::layer_lock(layer.locked);
|
|
let plot_icon: Element<'_, Message> = if layer.plottable {
|
|
crate::ui::icons::themed(crate::ui::icons::PRINT, ICON_SZ)
|
|
} else {
|
|
row![
|
|
crate::ui::icons::themed(crate::ui::icons::PRINT, ICON_SZ),
|
|
crate::ui::icons::themed_danger(crate::ui::icons::CLOSE, ICON_SZ * 0.65),
|
|
]
|
|
.spacing(0)
|
|
.align_y(iced::Center)
|
|
.into()
|
|
};
|
|
|
|
let plot_btn: Element<'_, Message> = button(plot_icon)
|
|
.on_press(Message::LayerTogglePlot(index))
|
|
.style(move |theme: &Theme, status| {
|
|
layer_cell_button_style(theme, status, is_selected, index)
|
|
})
|
|
.padding(Padding {
|
|
top: COMBO_PAD_V,
|
|
bottom: COMBO_PAD_V,
|
|
left: 4.0,
|
|
right: 4.0,
|
|
})
|
|
.height(Length::Fixed(ROW_H))
|
|
.into();
|
|
|
|
let status_dot: Element<'_, Message> = if is_current {
|
|
crate::ui::icons::themed_success(crate::ui::icons::CHECK, 13.0)
|
|
} else {
|
|
crate::ui::icons::themed_secondary(crate::ui::icons::DOT, 9.0)
|
|
};
|
|
|
|
// 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(table_input_style)
|
|
.width(Length::Fixed(name_col_w))
|
|
.into()
|
|
} else {
|
|
// ~6 px per glyph at the 10 px row font — track the column width.
|
|
let name_budget = ((name_col_w / 6.0) as usize).max(8);
|
|
let name_btn = button(
|
|
text(crate::ui::text_util::elide(&layer.name, name_budget))
|
|
.size(FONT_SZ),
|
|
)
|
|
.on_press(Message::LayerRenameStart(index))
|
|
.style(move |theme: &Theme, status| {
|
|
layer_cell_button_style(theme, status, is_selected, index)
|
|
})
|
|
.padding(Padding {
|
|
top: COMBO_PAD_V,
|
|
bottom: COMBO_PAD_V,
|
|
left: 4.0,
|
|
right: 4.0,
|
|
})
|
|
.height(Length::Fixed(ROW_H))
|
|
.width(Length::Fixed(name_col_w));
|
|
// When the name is truncated, reveal the full text on hover so the
|
|
// user can still read it without widening the column.
|
|
if layer.name.chars().count() > name_budget {
|
|
tooltip(name_btn, name_tip(&layer.name), tooltip::Position::FollowCursor).into()
|
|
} else {
|
|
name_btn.into()
|
|
}
|
|
};
|
|
|
|
// Color cell — looks like a combo_box input; click opens swatch dropdown below row.
|
|
// Shared colour selector. Layers carry a concrete colour (no ByLayer /
|
|
// ByBlock); true colours stay RGB instead of being collapsed to ACI 7.
|
|
let color_cell: Element<'_, Message> = container(crate::ui::color_select::color_selector_with_name(
|
|
layer.color,
|
|
layer.color_name.as_deref(),
|
|
color_picker_open,
|
|
crate::ui::color_select::ColorExtras {
|
|
by_layer: false,
|
|
by_block: false,
|
|
..Default::default()
|
|
},
|
|
Message::LayerColorSet,
|
|
Message::LayerColorPickerToggle(index),
|
|
Message::OpenColorWindow(
|
|
crate::app::ColorPickTarget::Layer(index),
|
|
layer.color,
|
|
),
|
|
))
|
|
.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 {
|
|
crate::ui::wide_menu::wide_menu(
|
|
combo_box(
|
|
state,
|
|
t!("linetype").as_ref(),
|
|
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),
|
|
LINETYPE_MENU_W,
|
|
)
|
|
} else {
|
|
text(layer.linetype.as_str())
|
|
.size(FONT_SZ)
|
|
.style(muted_style)
|
|
.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, t!("lineweight").as_ref(), 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)
|
|
.style(muted_style)
|
|
.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: &Theme, status| {
|
|
let mut style = table_input_style(theme, status);
|
|
style.background = iced::Background::Color(Color::TRANSPARENT);
|
|
style
|
|
})
|
|
.width(Length::Fixed(COL_TRANS));
|
|
|
|
let mut row_content = row![
|
|
container(status_dot)
|
|
.width(50)
|
|
.align_x(iced::Center)
|
|
.align_y(iced::Center),
|
|
name_cell,
|
|
iced::widget::Space::new().width(2),
|
|
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),
|
|
container(plot_btn)
|
|
.width(Length::Fixed(COL_ICON))
|
|
.align_x(iced::Center),
|
|
color_cell,
|
|
lt_cell,
|
|
lw_cell,
|
|
trans_cell,
|
|
]
|
|
.spacing(4)
|
|
.width(Fill)
|
|
.align_y(iced::Center);
|
|
|
|
// Per-viewport freeze columns
|
|
for (vp_idx, _vp_col) in vp_cols.iter().enumerate() {
|
|
let is_vp_frozen = layer.vp_frozen.get(vp_idx).copied().unwrap_or(false);
|
|
let vp_frz_svg = crate::ui::icons::layer_freeze(is_vp_frozen);
|
|
row_content = row_content.push(
|
|
container(svg_btn(
|
|
vp_frz_svg,
|
|
Message::LayerToggleVpFreeze(index, vp_idx),
|
|
))
|
|
.width(Length::Fixed(COL_ICON))
|
|
.align_x(iced::Center),
|
|
);
|
|
}
|
|
|
|
mouse_area(
|
|
container(row_content)
|
|
.style(move |theme: &Theme| {
|
|
let palette = theme.palette();
|
|
let pair = if is_selected {
|
|
palette.primary.weak
|
|
} else if index % 2 == 0 {
|
|
palette.background.base
|
|
} else {
|
|
palette.background.weak
|
|
};
|
|
container::Style {
|
|
background: Some(Background::Color(pair.color)),
|
|
text_color: Some(pair.text),
|
|
..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 {
|
|
table_input_style(theme, status)
|
|
}
|
|
|
|
// ── Display helpers ───────────────────────────────────────────────────────
|
|
|
|
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 ─────────────────────────────────────────────────────────
|
|
|
|
// Name column width is user-adjustable via the header divider drag (#359);
|
|
// the app passes the current width into `view_window`.
|
|
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;
|