Split large files: ribbon widgets and scene render/selection

- ribbon.rs → ribbon/mod.rs + ribbon/widgets.rs
  Extracted all rendering helpers, button styles, colour constants,
  layout constants and dropdown position calculators into widgets.rs.

- scene/mod.rs → scene/mod.rs + scene/selection.rs + scene/render.rs
  Moved SelectionState into selection.rs; moved CameraState, Primitive,
  shader::Program/Primitive impls, render_style and resolve_pattern
  into render.rs, reducing mod.rs from 1323 to ~1030 lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-03-22 23:07:43 +03:00
1 parent ed9cbc2092
commit bdde862597
6 files changed
+1916 -2053

No files matched your search

+818
View File
@@ -0,0 +1,818 @@
// Ribbon — tab bar + 3-row tool area.
//
// Button sizes:
// LargeTool / LargeDropdown — full ribbon height (3 rows), icon + label [+ ▾]
// Tool / Dropdown — 1-row height, icon only [+ ▾ on right]
//
// Dropdown items within a group are collected into columns of 3 rows.
use std::collections::HashMap;
use acadrust::types::{Color as AcadColor, LineWeight};
use iced::widget::{button, column, container, mouse_area, row, scrollable, svg, text};
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::modules::registry;
use crate::modules::{CadModule, IconKind, RibbonItem};
use crate::ui::properties::{LinetypeItem, color_picker_dropdown, lw_options};
use crate::app::Message;
mod widgets;
use widgets::*;
// ── Ribbon state ───────────────────────────────────────────────────────────
pub struct Ribbon {
modules: Vec<Box<dyn CadModule>>,
active: usize,
active_tool: Option<String>,
pub wireframe: bool,
pub ortho_mode: bool,
pub open_dropdown: Option<String>,
last_cmd: HashMap<&'static str, &'static str>,
pub layer_names: Vec<String>,
pub active_layer: String,
pub layer_infos: Vec<LayerInfo>,
/// Active object color — ACI / ByLayer / ByBlock.
pub active_color: AcadColor,
/// Active linetype override ("ByLayer", "Continuous", …).
pub active_linetype: String,
/// Active lineweight.
pub active_lineweight: LineWeight,
/// Linetypes loaded from the current document (with ASCII art).
pub available_linetypes: Vec<LinetypeItem>,
/// Whether the full ACI palette is expanded inside the color picker overlay.
pub prop_color_palette_open: bool,
}
/// Per-layer display data shown in the ribbon layer dropdown.
#[derive(Clone, Debug)]
pub struct LayerInfo {
pub name: String,
pub color: Color,
pub visible: bool,
pub frozen: bool,
pub locked: bool,
}
impl Ribbon {
pub fn new() -> Self {
Self {
modules: registry::all_modules(),
active: 0,
active_tool: None,
wireframe: false,
ortho_mode: true,
open_dropdown: None,
last_cmd: HashMap::new(),
layer_names: vec!["0".to_string()],
active_layer: "0".to_string(),
layer_infos: vec![LayerInfo {
name: "0".to_string(),
color: Color::WHITE,
visible: true,
frozen: false,
locked: false,
}],
active_color: AcadColor::ByLayer,
active_linetype: "ByLayer".to_string(),
active_lineweight: LineWeight::ByLayer,
available_linetypes: vec![LinetypeItem {
name: "Continuous".to_string(),
art: String::new(),
}],
prop_color_palette_open: false,
}
}
pub fn set_layers(&mut self, infos: Vec<LayerInfo>, active: &str) {
self.active_layer = active.to_string();
self.layer_names = infos.iter().map(|l| l.name.clone()).collect();
self.layer_infos = infos;
}
pub fn set_available_linetypes(&mut self, items: Vec<LinetypeItem>) {
self.available_linetypes = items;
}
pub fn select(&mut self, index: usize) {
if index < self.modules.len() {
self.active = index;
}
}
pub fn activate_tool(&mut self, id: &str) {
self.active_tool = Some(id.to_string());
}
pub fn set_wireframe(&mut self, w: bool) {
self.wireframe = w;
}
pub fn set_ortho(&mut self, ortho: bool) {
self.ortho_mode = ortho;
}
pub fn toggle_dropdown(&mut self, id: &str) {
if self.open_dropdown.as_deref() == Some(id) {
self.open_dropdown = None;
} else {
self.open_dropdown = Some(id.to_string());
}
}
pub fn close_dropdown(&mut self) {
self.open_dropdown = None;
}
/// Returns the index of the Layout module in the modules list.
pub fn layout_module_index(&self) -> Option<usize> {
self.modules.iter().position(|m| m.id() == "layout")
}
/// Returns true if the currently active tab is the Layout module.
pub fn active_is_layout(&self) -> bool {
self.modules
.get(self.active)
.map(|m| m.id() == "layout")
.unwrap_or(false)
}
pub fn select_dropdown_item(&mut self, dropdown_id: &'static str, cmd: &'static str) {
self.last_cmd.insert(dropdown_id, cmd);
self.open_dropdown = None;
}
#[allow(dead_code)]
pub fn last_dropdown_cmd(
&self,
dropdown_id: &'static str,
default: &'static str,
) -> &'static str {
self.last_cmd.get(dropdown_id).copied().unwrap_or(default)
}
// ── View ──────────────────────────────────────────────────────────────
pub fn view(&self, is_paper: bool, undo_count: usize, redo_count: usize) -> Element<'_, Message> {
// ── Logo ──────────────────────────────────────────────────────────
let logo_svg = {
let handle = svg::Handle::from_memory(include_bytes!("../../../assets/logo.svg"));
svg(handle).width(30).height(28)
};
let logo = button(logo_svg)
.on_press(Message::ToggleAppMenu)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.80,
g: 0.25,
b: 0.15,
a: 1.0,
},
_ => LOGO_RED,
})),
border: Border {
radius: 2.0.into(),
..Default::default()
},
shadow: iced::Shadow::default(),
snap: false,
..Default::default()
})
.padding([0, 4]);
// ── Tab buttons ───────────────────────────────────────────────────
let history_controls = row![
render_history_control("↶", "Undo", UNDO_HISTORY_ID, undo_count, &self.open_dropdown),
render_history_control("↷", "Redo", REDO_HISTORY_ID, redo_count, &self.open_dropdown),
]
.spacing(TOP_HIST_GAP)
.align_y(iced::Center);
let tab_buttons = self.modules.iter().enumerate().fold(
row![logo, history_controls].align_y(iced::Center).spacing(6),
|row_acc, (i, module)| {
if module.id() == "layout" && !is_paper {
return row_acc;
}
let is_active = i == self.active;
let is_contextual = module.id() == "layout";
let accent = if is_contextual { ACCENT_GOLD } else { ACCENT_BLUE };
let text_inactive = if is_contextual {
Color { r: 0.90, g: 0.72, b: 0.30, a: 1.0 }
} else {
Color { r: 0.75, g: 0.75, b: 0.75, a: 1.0 }
};
let hover_bg = if is_contextual {
Color { r: 0.28, g: 0.24, b: 0.12, a: 1.0 }
} else {
Color { r: 0.25, g: 0.25, b: 0.25, a: 1.0 }
};
let btn = container(
button(text(module.title()).size(12))
.on_press(Message::RibbonSelectTab(i))
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match (is_active, status) {
(true, _) => RIBBON_BG,
(false, button::Status::Hovered) => hover_bg,
_ => Color::TRANSPARENT,
})),
text_color: if is_active { Color::WHITE } else { text_inactive },
border: Border {
color: if is_active { accent } else { Color::TRANSPARENT },
width: if is_active { 2.0 } else { 0.0 },
radius: 0.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
})
.padding([5, 14]),
)
.style(move |_: &Theme| container::Style {
border: Border {
color: if is_active { accent } else { Color::TRANSPARENT },
width: if is_active { 2.0 } else { 0.0 },
radius: 0.0.into(),
},
..Default::default()
});
row_acc.push(btn)
},
);
let tab_bar = container(tab_buttons)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TOPBAR_BG)),
..Default::default()
})
.width(Length::Fill)
.height(28);
// ── Tool area ─────────────────────────────────────────────────────
let effective_active = if !is_paper
&& self
.modules
.get(self.active)
.map(|m| m.id() == "layout")
.unwrap_or(false)
{
0
} else {
self.active
};
let tool_area: Element<'_, Message> =
if let Some(module) = self.modules.get(effective_active) {
let groups = module.ribbon_groups();
let wireframe = self.wireframe;
let ortho_mode = self.ortho_mode;
let active_tool = self.active_tool.clone();
let open_dd = self.open_dropdown.clone();
let last_cmd = &self.last_cmd;
let layer_infos = &self.layer_infos;
let active_layer = &self.active_layer;
let active_color = self.active_color;
let active_linetype = &self.active_linetype;
let active_lineweight = self.active_lineweight;
let mut widgets: Vec<Element<Message>> = Vec::new();
let mut first_group = true;
for group in groups {
if !first_group {
widgets.push(
container(text(""))
.width(1)
.height(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER_DARK)),
..Default::default()
})
.into(),
);
}
first_group = false;
let mut items_row: Vec<Element<Message>> = Vec::new();
let mut small_buf: Vec<Element<Message>> = Vec::new();
for item in group.tools {
let is_large = matches!(
&item,
RibbonItem::LargeTool(_)
| RibbonItem::LargeDropdown { .. }
| RibbonItem::LayerComboGroup { .. }
| RibbonItem::PropertiesGroup { .. }
);
if is_large {
flush_small_col(&mut small_buf, &mut items_row);
items_row.push(render_large(
item,
&active_tool,
&open_dd,
last_cmd,
wireframe,
ortho_mode,
layer_infos,
active_layer,
active_color,
active_linetype,
active_lineweight,
));
} else {
small_buf.push(render_small(
item,
&active_tool,
&open_dd,
last_cmd,
wireframe,
ortho_mode,
));
if small_buf.len() == 3 {
flush_small_col(&mut small_buf, &mut items_row);
}
}
}
flush_small_col(&mut small_buf, &mut items_row);
let tools_el = items_row
.into_iter()
.fold(row![].spacing(2).height(Fill).align_y(iced::Top), |r, e| {
r.push(e)
});
widgets.push(
column![
tools_el,
container(text(group.title).size(9).color(GROUP_LABEL))
.padding([1, 4]),
]
.spacing(0)
.padding([3u16, 4])
.height(Length::Fill)
.into(),
);
}
widgets
.into_iter()
.fold(row![].spacing(0).height(Length::Fill), |r, w| r.push(w))
.into()
} else {
text("").into()
};
let tool_bar = container(tool_area)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(RIBBON_BG)),
border: Border {
color: BORDER_DARK,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.width(Length::Fill)
.height(TOOL_BAR_H);
column![tab_bar, tool_bar].into()
}
// ── Dropdown overlay ──────────────────────────────────────────────────
pub fn dropdown_overlay(
&self,
undo_labels: &[String],
redo_labels: &[String],
) -> Option<Element<'_, Message>> {
let open_id = self.open_dropdown.as_deref()?;
if open_id == UNDO_HISTORY_ID || open_id == REDO_HISTORY_ID {
let is_undo = open_id == UNDO_HISTORY_ID;
let labels = if is_undo { undo_labels } else { redo_labels };
if labels.is_empty() {
return None;
}
let rows: Vec<Element<Message>> = labels
.iter()
.enumerate()
.map(|(idx, label)| {
let step = idx + 1;
button(text(label.clone()).size(11).color(LABEL_ON))
.on_press(if is_undo {
Message::UndoMany(step)
} else {
Message::RedoMany(step)
})
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([5, 10])
.into()
})
.collect();
let panel = container(column(rows))
.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(170.0));
let positioned = container(panel)
.align_left(Fill)
.align_top(Fill)
.padding(Padding {
top: 28.0,
left: compute_history_dropdown_left(open_id),
..Default::default()
})
.width(Fill)
.height(Fill);
return Some(
mouse_area(positioned)
.on_press(Message::CloseRibbonDropdown)
.into(),
);
}
if open_id == LAYER_COMBO_ID {
return self.layer_combo_overlay();
}
if open_id == PROP_COLOR_ID {
return self.prop_color_overlay();
}
if open_id == PROP_LINETYPE_ID {
return self.prop_linetype_overlay();
}
if open_id == PROP_LW_ID {
return self.prop_lw_overlay();
}
let module = self.modules.get(self.active)?;
let groups = module.ribbon_groups();
let mut items_list: Option<Vec<(&'static str, &'static str, IconKind)>> = None;
let mut dd_default = "";
let mut dd_id: &'static str = "";
'outer: for group in &groups {
for item in &group.tools {
let (id, items, default) = match item {
RibbonItem::Dropdown { id, items, default, .. } => (id, items, default),
RibbonItem::LargeDropdown { id, items, default, .. } => (id, items, default),
_ => continue,
};
if *id == open_id {
items_list = Some(items.clone());
dd_default = default;
dd_id = id;
break 'outer;
}
}
}
let items = items_list?;
let last_cmd = self.last_cmd.get(dd_id).copied().unwrap_or(dd_default);
let rows: Vec<Element<Message>> = items
.iter()
.map(|(cmd, label, item_icon)| {
let is_current = *cmd == last_cmd;
let checkmark = text(if is_current { "✓" } else { " " })
.size(11)
.color(if is_current { CHECK_COLOR } else { Color::TRANSPARENT })
.width(Length::Fixed(14.0));
let icon_el: Element<Message> = match *item_icon {
IconKind::Glyph(s) => text(s)
.size(13)
.color(ICON_COLOR)
.width(Length::Fixed(20.0))
.into(),
IconKind::Svg(bytes) => {
let handle = svg::Handle::from_memory(bytes);
svg(handle).width(20).height(20).into()
}
};
let label_el = text(*label)
.size(11)
.color(if is_current { LABEL_ON } else { LABEL_OFF });
button(
row![checkmark, icon_el, label_el]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::DropdownSelectItem {
dropdown_id: dd_id,
cmd: *cmd,
})
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 10])
.into()
})
.collect();
let panel = container(column(rows))
.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(190.0));
let top_offset = 28.0 + TOOL_BAR_H;
let left_offset = compute_dropdown_left(&groups, open_id);
let positioned = container(panel)
.align_left(Fill)
.align_top(Fill)
.padding(Padding {
top: top_offset,
left: left_offset,
..Default::default()
})
.width(Fill)
.height(Fill);
Some(
mouse_area(positioned)
.on_press(Message::CloseRibbonDropdown)
.into(),
)
}
fn layer_combo_overlay(&self) -> Option<Element<'_, Message>> {
let rows: Vec<Element<Message>> = self
.layer_infos
.iter()
.map(|info| {
let is_active = info.name == self.active_layer;
let lc = info.color;
let lv = info.visible;
let lf = info.frozen;
let ll = info.locked;
let name = info.name.clone();
let swatch = container(text(""))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(lc)),
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 vis = text(if lv { "●" } else { "○" })
.size(10)
.color(if lv {
Color { r: 0.95, g: 0.85, b: 0.20, a: 1.0 }
} else {
Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0 }
});
let freeze = text("✱").size(10).color(if lf {
Color { r: 0.40, g: 0.80, b: 1.00, a: 1.0 }
} else {
Color { r: 0.95, g: 0.85, b: 0.20, a: 1.0 }
});
let lock = text(if ll { "🔒" } else { "🔓" }).size(10).color(if ll {
Color { r: 0.95, g: 0.70, b: 0.20, a: 1.0 }
} else {
Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }
});
let checkmark = text(if is_active { "✓" } else { " " })
.size(11)
.color(if is_active { CHECK_COLOR } else { Color::TRANSPARENT })
.width(Length::Fixed(14.0));
let label = text(&info.name)
.size(11)
.color(if is_active { LABEL_ON } else { LABEL_OFF });
button(
row![checkmark, vis, freeze, lock, swatch, label]
.spacing(5)
.align_y(iced::Center),
)
.on_press(Message::RibbonLayerChanged(name))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 8])
.into()
})
.collect();
let panel = container(column(rows))
.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(220.0));
let top_offset = 28.0 + TOOL_BAR_H;
let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_layer_combo_left(&groups);
let positioned = container(panel)
.align_left(Fill)
.align_top(Fill)
.padding(Padding { top: top_offset, left: left_offset, ..Default::default() })
.width(Fill)
.height(Fill);
Some(mouse_area(positioned).on_press(Message::CloseRibbonDropdown).into())
}
fn prop_color_overlay(&self) -> Option<Element<'_, Message>> {
let picker = color_picker_dropdown(
self.prop_color_palette_open,
Message::RibbonColorPaletteToggle,
Some(Message::RibbonColorChanged(AcadColor::ByLayer)),
Some(Message::RibbonColorChanged(AcadColor::ByBlock)),
|aci| Message::RibbonColorChanged(AcadColor::Index(aci)),
);
let panel = container(picker)
.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(200.0));
let top_offset = 28.0 + TOOL_BAR_H;
let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_prop_combo_left(&groups, PROP_COLOR_ID);
let positioned = container(panel)
.align_left(Fill)
.align_top(Fill)
.padding(Padding { top: top_offset, left: left_offset, ..Default::default() })
.width(Fill)
.height(Fill);
Some(mouse_area(positioned).on_press(Message::CloseRibbonDropdown).into())
}
fn prop_linetype_overlay(&self) -> Option<Element<'_, Message>> {
let active_lt = &self.active_linetype;
let mut items: Vec<LinetypeItem> = vec![
LinetypeItem { name: "ByLayer".to_string(), art: String::new() },
LinetypeItem { name: "ByBlock".to_string(), art: String::new() },
];
for lt in &self.available_linetypes {
if lt.name != "ByLayer" && lt.name != "ByBlock" {
items.push(lt.clone());
}
}
let rows: Vec<Element<Message>> = items
.into_iter()
.map(|lt| {
let is_cur = lt.name == *active_lt;
let check = text(if is_cur { "✓" } else { " " })
.size(11)
.color(if is_cur { CHECK_COLOR } else { Color::TRANSPARENT })
.width(Length::Fixed(14.0));
let name_col = text(lt.name.clone())
.size(11)
.color(if is_cur { LABEL_ON } else { LABEL_OFF })
.width(Length::Fixed(90.0));
let art_col =
text(lt.art.clone()).size(9).color(Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 });
let name = lt.name.clone();
button(row![check, name_col, art_col].spacing(4).align_y(iced::Center))
.on_press(Message::RibbonLinetypeChanged(name))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 6])
.into()
})
.collect();
let list = container(scrollable(column(rows)).height(Length::Fixed(200.0)))
.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(220.0));
let top_offset = 28.0 + TOOL_BAR_H;
let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_prop_combo_left(&groups, PROP_LINETYPE_ID);
let positioned = container(list)
.align_left(Fill)
.align_top(Fill)
.padding(Padding { top: top_offset, left: left_offset, ..Default::default() })
.width(Fill)
.height(Fill);
Some(mouse_area(positioned).on_press(Message::CloseRibbonDropdown).into())
}
fn prop_lw_overlay(&self) -> Option<Element<'_, Message>> {
let active_lw = self.active_lineweight;
let rows: Vec<Element<Message>> = lw_options()
.into_iter()
.map(|item| {
let is_cur = item.0 == active_lw;
let label = item.to_string();
let check = text(if is_cur { "✓" } else { " " })
.size(11)
.color(if is_cur { CHECK_COLOR } else { Color::TRANSPARENT })
.width(Length::Fixed(14.0));
button(
row![
check,
text(label)
.size(11)
.color(if is_cur { LABEL_ON } else { LABEL_OFF })
]
.spacing(5)
.align_y(iced::Center),
)
.on_press(Message::RibbonLineweightChanged(item.0))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ROW_HOVER,
_ => Color::TRANSPARENT,
})),
..Default::default()
})
.width(Fill)
.padding([4, 8])
.into()
})
.collect();
self.prop_overlay_positioned(rows, PROP_LW_ID, 140.0)
}
fn prop_overlay_positioned<'a>(
&'a self,
rows: Vec<Element<'a, Message>>,
dd_id: &str,
width: f32,
) -> Option<Element<'a, Message>> {
let panel = container(column(rows))
.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(width));
let top_offset = 28.0 + TOOL_BAR_H;
let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_prop_combo_left(&groups, dd_id);
let positioned = container(panel)
.align_left(Fill)
.align_top(Fill)
.padding(Padding { top: top_offset, left: left_offset, ..Default::default() })
.width(Fill)
.height(Fill);
Some(mouse_area(positioned).on_press(Message::CloseRibbonDropdown).into())
}
}
impl Default for Ribbon {
fn default() -> Self {
Self::new()
}
}