Files
OneCAD-Draft/src/app/view.rs
T
Hakan SevenandClaude Opus 4.7 ef1f9a08d6 chore: rename H7CAD to Open CAD Studio
Joining the OpenAEC Foundation (open-aec.com). Crate/struct become
OpenCADStudio, user-facing strings use the spaced "Open CAD Studio"
form, repo URLs point to HakanSeven12/open-cad-studio. Logo (window
icon + assets/logo.svg) redrawn with OCS initials. Drop examples/
binaries — unused.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 17:16:15 +03:00

1893 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::document::DocumentTab;
use super::helpers::grid_plane_from_camera;
use super::history::history_dropdown_labels;
use super::{Message, OpenCADStudio};
use crate::scene::grip::{grips_to_screen, grips_to_screen_paper};
use crate::scene::paper_canvas::PaperCanvas;
use crate::scene::viewport_pane::{PaperViewportPane, ViewportPane};
use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD};
use crate::ui::overlay;
use iced::widget::{
button, canvas, column, container, mouse_area, pick_list, row, shader, stack, text, text_input,
Row, Space,
};
use iced::window;
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;
impl OpenCADStudio {
pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
// ── Floating panel windows ─────────────────────────────────────────
if Some(window_id) == self.layer_window {
let tab = &self.tabs[self.active_tab];
return tab.layers.view_window();
}
if Some(window_id) == self.page_setup_window {
return crate::ui::page_setup::view_window(
&self.page_setup_w,
&self.page_setup_h,
&self.page_setup_plot_area,
self.page_setup_center,
&self.page_setup_offset_x,
&self.page_setup_offset_y,
&self.page_setup_rotation,
&self.page_setup_scale,
);
}
if Some(window_id) == self.textstyle_window {
let tab = &self.tabs[self.active_tab];
let styles: Vec<String> = tab
.scene
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
return crate::ui::textstyle::view_window(
styles,
&self.textstyle_selected,
&self.textstyle_font,
&self.textstyle_width,
&self.textstyle_oblique,
);
}
if Some(window_id) == self.tablestyle_window {
use acadrust::objects::ObjectType;
let tab = &self.tabs[self.active_tab];
let styles: Vec<String> = tab
.scene
.document
.objects
.values()
.filter_map(|o| {
if let ObjectType::TableStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.collect();
let selected_style = tab.scene.document.objects.values().find_map(|o| {
if let ObjectType::TableStyle(s) = o {
if s.name == self.tablestyle_selected {
Some(s)
} else {
None
}
} else {
None
}
});
return crate::ui::tablestyle::view_window(
styles,
&self.tablestyle_selected,
selected_style,
);
}
if Some(window_id) == self.mlstyle_window {
use acadrust::objects::ObjectType;
let tab = &self.tabs[self.active_tab];
let styles: Vec<String> = tab
.scene
.document
.objects
.values()
.filter_map(|o| {
if let ObjectType::MLineStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.collect();
let selected_style = tab.scene.document.objects.values().find_map(|o| {
if let ObjectType::MLineStyle(s) = o {
if s.name == self.mlstyle_selected {
Some(s)
} else {
None
}
} else {
None
}
});
return crate::ui::mlstyle::view_window(
styles,
&self.mlstyle_selected,
selected_style,
tab.scene.document.header.multiline_style.clone(),
);
}
if Some(window_id) == self.layout_manager_window {
let i = self.active_tab;
let layouts = self.tabs[i].scene.layout_names();
let current = self.tabs[i].scene.current_layout.clone();
return crate::ui::layout_manager::view_window(
layouts,
&self.layout_manager_selected,
&self.layout_manager_rename_buf,
current,
);
}
if Some(window_id) == self.plotstyle_window {
return crate::ui::plotstyle::view_window(
self.active_plot_style.as_ref(),
self.plotstyle_panel_aci,
&self.ps_color_buf,
&self.ps_lineweight_buf,
&self.ps_screening_buf,
);
}
if Some(window_id) == self.dimstyle_window {
let tab = &self.tabs[self.active_tab];
let styles: Vec<String> = tab
.scene
.document
.dim_styles
.iter()
.map(|s| s.name.clone())
.collect();
return crate::ui::dimstyle::view_window(
styles,
&self.dimstyle_selected,
self.dimstyle_tab,
crate::ui::dimstyle::DimStyleValues {
dimdle: &self.ds_dimdle,
dimdli: &self.ds_dimdli,
dimgap: &self.ds_dimgap,
dimexe: &self.ds_dimexe,
dimexo: &self.ds_dimexo,
dimsd1: self.ds_dimsd1,
dimsd2: self.ds_dimsd2,
dimse1: self.ds_dimse1,
dimse2: self.ds_dimse2,
dimasz: &self.ds_dimasz,
dimcen: &self.ds_dimcen,
dimtsz: &self.ds_dimtsz,
dimtxt: &self.ds_dimtxt,
dimtxsty: &self.ds_dimtxsty,
dimtad: &self.ds_dimtad,
dimtih: self.ds_dimtih,
dimtoh: self.ds_dimtoh,
dimscale: &self.ds_dimscale,
dimlfac: &self.ds_dimlfac,
dimlunit: &self.ds_dimlunit,
dimdec: &self.ds_dimdec,
dimpost: &self.ds_dimpost,
dimtol: self.ds_dimtol,
dimlim: self.ds_dimlim,
dimtp: &self.ds_dimtp,
dimtm: &self.ds_dimtm,
dimtdec: &self.ds_dimtdec,
dimtfac: &self.ds_dimtfac,
},
);
}
if Some(window_id) == self.shortcuts_window {
return crate::ui::shortcuts::view_window(&self.shortcut_overrides);
}
if Some(window_id) == self.about_window {
return crate::ui::about::view_window();
}
if Some(window_id) == self.update_notice_window {
let latest = self
.update_notice_version
.as_deref()
.unwrap_or("?");
let body = self.update_notice_body.as_deref().unwrap_or("");
return crate::ui::update_notice::view_window(latest, body);
}
if Some(window_id) == self.unsaved_dialog_window {
let tab_name = match &self.pending_close {
Some(super::PendingClose::Tab(idx)) => self
.tabs
.get(*idx)
.map(|t| t.tab_display_name())
.unwrap_or_default(),
Some(super::PendingClose::Quit) => self
.tabs
.iter()
.find(|t| t.dirty)
.map(|t| t.tab_display_name())
.unwrap_or_default(),
None => String::new(),
};
return unsaved_changes_dialog_window(&tab_name);
}
if Some(window_id) == self.save_dialog_window {
return save_as_dialog_window(
&self.save_dialog_filename,
&self.save_dialog_folder,
&self.save_dialog_entries,
&self.save_dialog_format,
);
}
let i = self.active_tab;
let tab = &self.tabs[i];
let is_paper = tab.scene.current_layout != "Model";
// Start tab: render welcome page in place of the model/paper canvas.
// Surrounding chrome (tab bar, status bar) stays; the welcome widget
// returned here also flags the rest of `view` to skip drawing-only
// overlays via `tab.is_start`.
let viewport_3d: Element<'_, Message> = if tab.is_start {
start_page_view()
} else if is_paper {
paper_canvas_view(tab)
} else {
shader(ViewportPane::model(&tab.scene, self.show_viewcube))
.width(Fill)
.height(Fill)
.into()
};
let selection_overlay = {
let sel = tab.scene.selection.borrow().clone();
let snap_info = tab.snap_result.map(|s| (s.screen, s.snap_type));
let grips: Vec<overlay::GripMarker> =
if tab.active_cmd.is_none() && !tab.selected_grips.is_empty() {
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let sel_h = tab.selected_handle;
let screen_grips = if is_paper {
let cam = tab.scene.camera.borrow();
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x;
let ty = cam.target.y;
drop(cam);
grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds)
} else {
let vp_mat = tab.scene.camera.borrow().view_proj(bounds);
grips_to_screen(&tab.selected_grips, vp_mat, bounds)
};
screen_grips
.into_iter()
.filter(|(_, screen, _, _)| {
screen.x.is_finite()
&& screen.y.is_finite()
&& screen.x >= -bounds.width
&& screen.x <= bounds.width * 2.0
&& screen.y >= -bounds.height
&& screen.y <= bounds.height * 2.0
})
.map(|(grip_id, screen, _is_midpoint, shape)| {
let is_hot = tab
.active_grip
.as_ref()
.map_or(false, |g| Some(g.handle) == sel_h && g.grip_id == grip_id);
overlay::GripMarker {
pos: screen,
shape,
is_hot,
}
})
.collect()
} else {
vec![]
};
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let vp_bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let grid = if self.show_grid {
let cam = tab.scene.camera.borrow();
let plane = grid_plane_from_camera(cam.pitch, cam.yaw);
Some(overlay::GridParams {
view_proj: cam.view_proj(vp_bounds),
bounds: vp_bounds,
plane,
})
} else {
None
};
let ucs_icon = if self.show_ucs_icon && !is_paper {
let cam = tab.scene.camera.borrow();
Some(overlay::UcsIconParams {
view_proj: cam.view_proj(vp_bounds),
bounds: vp_bounds,
})
} else {
None
};
// OST tracking points → screen positions.
let ost_points: Vec<overlay::OstTrackPoint> = if self.snapper.otrack_enabled {
let vp_mat = tab.scene.camera.borrow().view_proj(vp_bounds);
self.snapper
.tracking_points
.iter()
.map(|&wp| {
let ndc = vp_mat.project_point3(wp);
overlay::OstTrackPoint {
screen: iced::Point::new(
(ndc.x + 1.0) * 0.5 * vp_bounds.width,
(1.0 - ndc.y) * 0.5 * vp_bounds.height,
),
}
})
.collect()
} else {
vec![]
};
overlay::selection_overlay(
sel,
snap_info,
grips,
grid,
ucs_icon,
ost_points,
tab.last_cursor_screen,
!is_paper && self.show_viewcube,
)
};
let info = container(overlay::info_bar(
if is_paper {
&tab.scene.current_layout
} else {
"Custom View"
},
&tab.visual_style,
))
.padding([4, 6]);
let viewport_mouse = mouse_area(container(
iced::widget::Space::new().width(Fill).height(Fill),
))
.on_move(Message::ViewportMove)
.on_press(Message::ViewportLeftPress)
.on_release(Message::ViewportLeftRelease)
.on_right_press(Message::ViewportRightPress)
.on_right_release(Message::ViewportRightRelease)
.on_middle_press(Message::ViewportMiddlePress)
.on_middle_release(Message::ViewportMiddleRelease)
.on_scroll(Message::ViewportScroll)
.on_exit(Message::ViewportExit);
let bg_color = if is_paper {
// Desk color — matches the DESK constant in paper_canvas.rs.
Color {
r: 0.22,
g: 0.24,
b: 0.28,
a: 1.0,
}
} else {
tab.bg_color
.map(|[r, g, b, a]| Color { r, g, b, a })
.unwrap_or(Color {
r: 0.11,
g: 0.11,
b: 0.11,
a: 1.0,
})
};
// Dynamic input overlay — shown when a command is active and DYN is on.
let dyn_input_overlay: Option<Element<'_, Message>> =
if self.dyn_input && tab.active_cmd.is_some() {
let w = tab.last_cursor_world;
let label = if let Some(base) = self.last_point {
// Show relative distance + angle when we have a base point.
let dx = (w.x - base.x) as f64;
let dy = (w.z - base.z) as f64;
let dist = (dx * dx + dy * dy).sqrt();
let ang = dy.atan2(dx).to_degrees();
format!("d={:.3} <{:.1}°", dist, ang)
} else {
format!("X:{:.3} Y:{:.3}", w.x, w.z)
};
Some(overlay::dynamic_input_overlay(
tab.last_cursor_screen,
label,
))
} else {
None
};
let mut viewport_stack = if tab.is_start {
// Start tab: only the welcome widget over a flat background.
// Skip every drawing-only overlay (selection markers, snap info,
// mouse-area capturing draw clicks, viewcube, nav toolbar, …).
stack![container(viewport_3d)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg_color)),
..Default::default()
})
.width(Fill)
.height(Fill)]
.width(Fill)
.height(Fill)
} else {
stack![
container(viewport_3d)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg_color)),
..Default::default()
})
.width(Fill)
.height(Fill),
container(info).width(Fill).height(Fill),
selection_overlay,
viewport_mouse,
]
.width(Fill)
.height(Fill)
};
if self.show_navbar && !tab.is_start {
let nav = container(overlay::nav_toolbar())
.align_right(Fill)
.align_top(Fill)
.padding(iced::Padding {
top: 148.0,
right: 8.0,
bottom: 0.0,
left: 0.0,
});
viewport_stack = viewport_stack.push(nav);
}
if self.show_viewcube && !is_paper && !tab.is_start {
let cube_click: Element<'_, Message> = container(
mouse_area(container(
iced::widget::Space::new()
.width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE))
.height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)),
))
.on_move(Message::CursorMoved)
.on_press(Message::ViewportClick),
)
.align_right(Fill)
.align_top(Fill)
.padding(iced::Padding {
top: VIEWCUBE_PAD,
right: VIEWCUBE_PAD,
bottom: 0.0,
left: 0.0,
})
.width(Fill)
.height(Fill)
.into();
viewport_stack = viewport_stack.push(cube_click);
}
if let Some(dyn_ol) = dyn_input_overlay {
if !tab.is_start {
viewport_stack = viewport_stack.push(dyn_ol);
}
}
// Properties / layers panels carry no useful state on the Start tab.
// Replace the properties panel with a Recent Documents list there.
let properties_el: Element<'_, Message> = if tab.is_start {
recent_files_panel(&self.app_menu.recent)
} else if self.show_properties {
tab.properties.view()
} else {
Space::new().into()
};
let center_stack =
iced::widget::stack![row![properties_el, viewport_stack].width(Fill).height(Fill),]
.width(Fill)
.height(Fill);
let main_ui = container({
let mut col = column![self.ribbon.view(
is_paper,
self.tabs[self.active_tab].history.undo_stack.len(),
self.tabs[self.active_tab].history.redo_stack.len(),
)];
if self.show_file_tabs {
col = col.push(doc_tab_bar(&self.tabs, self.active_tab));
}
col.push(center_stack)
.push(self.command_line.view())
.push({
let is_model = tab.scene.current_layout == "Model";
let scale_pill_enabled = is_model
|| tab.scene.active_viewport.is_some()
|| tab.scene.has_selected_viewport();
self.status_bar.view(
&self.snapper,
self.snap_popup_open,
self.ortho_mode,
self.polar_mode,
self.polar_increment_deg,
self.show_grid,
self.dyn_input,
self.snapper.otrack_enabled,
tab.scene.layout_names(),
tab.scene.current_layout.clone(),
self.layout_rename_state.as_ref(),
tab.scene.first_viewport_scale(),
tab.scene.viewport_count(),
tab.scene.active_viewport.is_some(),
self.show_layout_tabs,
tab.scene.annotation_scale,
self.scale_popup_open,
scale_pill_enabled,
tab.scene.document.header.lineweight_display,
)
})
.width(Fill)
.height(Fill)
})
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.11,
g: 0.11,
b: 0.11,
a: 1.0,
})),
..Default::default()
})
.width(Fill)
.height(Fill);
let snap_layer: Element<'_, Message> = if self.snap_popup_open {
crate::ui::snap_popup::snap_popup_overlay(&self.snapper, 4.0)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let scale_layer: Element<'_, Message> = if self.scale_popup_open {
let is_model = tab.scene.current_layout == "Model";
crate::ui::scale_popup::scale_popup_overlay(
is_model,
tab.scene.annotation_scale,
tab.scene.first_viewport_scale(),
)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let dropdown_layer: Element<'_, Message> = self
.ribbon
.dropdown_overlay(
&history_dropdown_labels(&self.tabs[self.active_tab].history.undo_stack),
&history_dropdown_labels(&self.tabs[self.active_tab].history.redo_stack),
)
.unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into());
let layout_ctx_layer: Element<'_, Message> = if let Some(name) = &self.layout_context_menu {
layout_context_menu_overlay(name)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
// ── Viewport right-click context menu ─────────────────────────────
let viewport_ctx_layer: Element<'_, Message> = {
let ctx_pos = tab.scene.selection.borrow().context_menu;
if let Some(p) = ctx_pos {
let has_cmd = tab.active_cmd.is_some();
let has_selection = !tab.scene.selected.is_empty();
let last_cmds: Vec<String> = self
.command_line
.cmd_recall
.iter()
.rev()
.take(3)
.cloned()
.collect();
viewport_context_menu_overlay(p, has_cmd, has_selection, last_cmds)
} else {
iced::widget::Space::new().width(0).height(0).into()
}
};
let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
crate::ui::open_progress::view(p, iced::time::Instant::now())
} else {
iced::widget::Space::new().width(0).height(0).into()
};
stack![
main_ui,
self.app_menu.view(),
snap_layer,
scale_layer,
dropdown_layer,
layout_ctx_layer,
viewport_ctx_layer,
open_progress_layer,
]
.into()
}
pub fn subscription(&self) -> Subscription<Message> {
use iced::event;
// Only request per-frame ticks while something on screen is animating
// (currently just the open-progress indicator). Without this gate the
// app burned 2-3% CPU continuously redrawing an unchanged view.
// See #18.
let needs_frames = self.opening.is_some();
let frames = if needs_frames {
window::frames().map(Message::Tick)
} else {
Subscription::none()
};
iced::Subscription::batch([
frames,
event::listen_with(|ev, status, win_id| {
use iced::event::Status;
match ev {
iced::Event::Window(window::Event::CloseRequested) => {
Some(Message::WindowCloseRequested(win_id))
}
iced::Event::Window(window::Event::Closed) => {
Some(Message::OsWindowClosed(win_id))
}
iced::Event::Window(window::Event::Resized(sz)) => {
Some(Message::WindowResized(sz.width as f32, sz.height as f32))
}
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key, modifiers, ..
}) => {
let ctrl = modifiers.control();
let shift = modifiers.shift();
match key {
keyboard::Key::Named(keyboard::key::Named::Enter)
| keyboard::Key::Named(keyboard::key::Named::Space)
if status == Status::Ignored =>
{
Some(Message::CommandFinalize)
}
keyboard::Key::Named(keyboard::key::Named::Escape) => {
Some(Message::CommandEscape)
}
keyboard::Key::Named(keyboard::key::Named::Delete)
if status == Status::Ignored =>
{
Some(Message::DeleteSelected)
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
Some(Message::CommandHistoryPrev)
}
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
Some(Message::CommandHistoryNext)
}
keyboard::Key::Named(keyboard::key::Named::F3) => {
Some(Message::ToggleSnapEnabled)
}
keyboard::Key::Named(keyboard::key::Named::F7) => {
Some(Message::ToggleGrid)
}
keyboard::Key::Named(keyboard::key::Named::F8) => {
Some(Message::ToggleOrtho)
}
keyboard::Key::Named(keyboard::key::Named::F9) => {
Some(Message::ToggleGridSnap)
}
keyboard::Key::Named(keyboard::key::Named::F10) => {
Some(Message::TogglePolar)
}
keyboard::Key::Named(keyboard::key::Named::F11) => {
Some(Message::ToggleOTrack)
}
keyboard::Key::Named(keyboard::key::Named::F12) => {
Some(Message::ToggleDynInput)
}
keyboard::Key::Character(c) if ctrl => match c.as_str() {
"n" => Some(Message::TabNew),
"o" => Some(Message::OpenFile),
"s" if !shift => Some(Message::SaveFile),
"s" if shift => Some(Message::SaveAs),
"z" if !shift => Some(Message::Undo),
"z" if shift => Some(Message::Redo),
"y" => Some(Message::Redo),
"c" => Some(Message::Command("COPYCLIP".to_string())),
"x" => Some(Message::Command("CUTCLIP".to_string())),
"v" => Some(Message::Command("PASTECLIP".to_string())),
_ => None,
},
_ => None,
}
}
_ => None,
}
}),
])
}
pub(super) fn focus_cmd_input(&self) -> Task<Message> {
iced::widget::operation::focus(iced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID))
}
pub(super) fn blur_cmd_input(&self) -> Task<Message> {
let op = iced::advanced::widget::operation::focusable::unfocus::<Message>();
iced::advanced::widget::operate(op)
}
}
// ── Paper canvas ──────────────────────────────────────────────────────────
//
// PSPACE: single full-canvas PaperSheet widget — renders paper entities plus
// model content of all viewports via CPU projection.
//
// MSPACE (active viewport): PaperSheet widget (excludes the active viewport
// from its CPU projection) + a PaperViewportPane widget overlaid at the
// active viewport's screen-space position. PaperViewportPane uses a
// distinct pipeline type (PaperViewportPipeline) so Iced's per-type storage
// keeps the two prepare() calls from overwriting each other.
fn paper_canvas_view<'a>(tab: &'a super::document::DocumentTab) -> Element<'a, Message> {
let scene = &tab.scene;
// 2-D canvas for the paper sheet — paper entities, viewport borders, and
// inactive viewport projections are rendered as vector paths. This lets
// users select/edit paper-space entities directly without entering MSPACE.
let paper_sheet = canvas(PaperCanvas::new(scene)).width(Fill).height(Fill);
if let Some(vp_handle) = scene.active_viewport {
let (canvas_w, canvas_h) = scene.selection.borrow().vp_size;
if let Some(rect) = scene.viewport_screen_rect(vp_handle, (canvas_w, canvas_h)) {
// Clamp to canvas bounds so Space widgets never get negative size.
let x = rect.x.max(0.0).min(canvas_w);
let y = rect.y.max(0.0).min(canvas_h);
let w = rect.width.clamp(1.0, canvas_w - x);
let h = rect.height.clamp(1.0, canvas_h - y);
let vp_widget = shader(PaperViewportPane::new(scene, vp_handle))
.width(iced::Length::Fixed(w))
.height(iced::Length::Fixed(h));
let positioned = column![
Space::new().height(iced::Length::Fixed(y)),
row![Space::new().width(iced::Length::Fixed(x)), vp_widget,],
]
.width(Fill)
.height(Fill);
// Blue border drawn on top of the 3-D overlay so the viewport
// boundary is always visible even when the shader fills the area.
const VP_BORDER: Color = Color {
r: 0.18,
g: 0.52,
b: 0.95,
a: 1.0,
};
let border_frame = container(
Space::new()
.width(iced::Length::Fixed(w))
.height(iced::Length::Fixed(h)),
)
.style(move |_: &Theme| container::Style {
border: iced::Border {
color: VP_BORDER,
width: 2.0,
radius: 0.0.into(),
},
..Default::default()
});
let border_layer = column![
Space::new().height(iced::Length::Fixed(y)),
row![Space::new().width(iced::Length::Fixed(x)), border_frame,],
]
.width(Fill)
.height(Fill);
return stack![paper_sheet, positioned, border_layer]
.width(Fill)
.height(Fill)
.into();
}
}
paper_sheet.into()
}
// ── Document tab bar ───────────────────────────────────────────────────────
pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> {
const BAR_BG: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const TAB_ACTIVE: Color = Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
};
const TAB_HOVER: Color = Color {
r: 0.18,
g: 0.18,
b: 0.18,
a: 1.0,
};
const TAB_INACTIVE: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const ACCENT: Color = Color {
r: 0.20,
g: 0.55,
b: 0.90,
a: 1.0,
};
const TEXT_ACTIVE: Color = Color::WHITE;
const TEXT_INACTIVE: Color = Color {
r: 0.60,
g: 0.60,
b: 0.60,
a: 1.0,
};
const CLOSE_HOVER: Color = Color {
r: 0.70,
g: 0.22,
b: 0.22,
a: 1.0,
};
const BORDER_COLOR: Color = Color {
r: 0.25,
g: 0.25,
b: 0.25,
a: 1.0,
};
let mut bar = Row::new().spacing(0).align_y(iced::Center);
for (idx, tab) in tabs.iter().enumerate() {
let is_active = idx == active_tab;
let name = tab.tab_display_name();
let label = if tab.dirty {
format!("● {}", name)
} else {
name
};
let title_btn = button(text(label).size(12))
.on_press(Message::TabSwitch(idx))
.padding([5, 12])
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match (is_active, status) {
(true, _) => TAB_ACTIVE,
(false, button::Status::Hovered) => TAB_HOVER,
_ => TAB_INACTIVE,
})),
text_color: if is_active {
TEXT_ACTIVE
} else {
TEXT_INACTIVE
},
border: Border {
color: if is_active {
ACCENT
} else {
Color::TRANSPARENT
},
width: if is_active { 1.0 } else { 0.0 },
radius: 0.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
});
// Start tab is fixed — no close button. Every other tab gets a close.
let row_inner: Row<'_, Message> = if tab.is_start {
row![title_btn].spacing(0).align_y(iced::Center)
} else {
let close_btn = button(text("×").size(11).color(Color {
r: 0.55,
g: 0.55,
b: 0.55,
a: 1.0,
}))
.on_press(Message::TabClose(idx))
.padding([3, 5])
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => CLOSE_HOVER,
_ => {
if is_active {
TAB_ACTIVE
} else {
TAB_INACTIVE
}
}
})),
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
});
row![title_btn, close_btn].spacing(0).align_y(iced::Center)
};
bar = bar.push(
container(row_inner).style(move |_: &Theme| container::Style {
border: Border {
color: if is_active { BORDER_COLOR } else { Color::TRANSPARENT },
width: if is_active { 1.0 } else { 0.0 },
radius: 0.0.into(),
},
..Default::default()
}),
);
}
let new_btn = button(text("+").size(14).color(Color {
r: 0.65,
g: 0.65,
b: 0.65,
a: 1.0,
}))
.on_press(Message::TabNew)
.padding([4, 10])
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => TAB_HOVER,
_ => Color::TRANSPARENT,
})),
border: Border {
radius: 0.0.into(),
..Default::default()
},
..Default::default()
});
bar = bar.push(new_btn);
bar = bar.push(iced::widget::Space::new().width(Fill));
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()
})
.height(30)
.width(Fill)
.padding([0, 2])
.into()
}
// ── Layout context-menu overlay ────────────────────────────────────────────
// ── Viewport right-click context menu ──────────────────────────────────────
fn viewport_context_menu_overlay(
pos: iced::Point,
has_cmd: bool,
has_selection: bool,
last_cmds: Vec<String>,
) -> Element<'static, Message> {
const MENU_BG: Color = Color {
r: 0.17,
g: 0.17,
b: 0.17,
a: 1.0,
};
const MENU_BORDER: Color = Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
};
const ITEM_HOVER: Color = Color {
r: 0.25,
g: 0.45,
b: 0.70,
a: 1.0,
};
const TEXT_COL: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
const SEP_COL: Color = Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
};
let item = |label: String, msg: Message| -> Element<'static, Message> {
button(text(label).size(12).color(TEXT_COL))
.on_press(msg)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
_ => Color::TRANSPARENT,
})),
text_color: TEXT_COL,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: false,
})
.padding([4, 12])
.width(Fill)
.into()
};
let sep = || -> Element<'static, Message> {
container(iced::widget::Space::new().width(Fill).height(1))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(SEP_COL)),
..Default::default()
})
.width(Fill)
.height(1)
.padding([0, 6])
.into()
};
let mut items: Vec<Element<'static, Message>> = Vec::new();
if has_cmd {
items.push(item("Cancel".to_string(), Message::CommandEscape));
items.push(item("Enter".to_string(), Message::CommandFinalize));
} else {
if !last_cmds.is_empty() {
let last = last_cmds[0].clone();
items.push(item(
format!("Repeat {last}"),
Message::Command(last.to_uppercase()),
));
if last_cmds.len() > 1 {
for cmd in last_cmds.iter().skip(1) {
let c = cmd.clone();
items.push(item(c.clone(), Message::Command(c.to_uppercase())));
}
}
items.push(sep());
}
if has_selection {
items.push(item("Delete".to_string(), Message::DeleteSelected));
items.push(item(
"Move".to_string(),
Message::Command("MOVE".to_string()),
));
items.push(item(
"Copy".to_string(),
Message::Command("COPY".to_string()),
));
items.push(sep());
}
items.push(item(
"Select All".to_string(),
Message::Command("SELECTALL".to_string()),
));
items.push(item(
"Zoom Extents".to_string(),
Message::Command("ZOOM".to_string()),
));
}
let menu_col = column(items).spacing(0).width(180);
let menu = container(menu_col)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(MENU_BG)),
border: Border {
color: MENU_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([4, 0]);
// Click-catcher to close the menu when clicking outside.
let catcher = mouse_area(container(Space::new()).width(Fill).height(Fill))
.on_press(Message::ViewportContextMenuClose)
.on_right_press(Message::ViewportContextMenuClose);
// Position using top/left spacing.
let positioned = column![
Space::new().height(pos.y),
row![Space::new().width(pos.x), menu],
]
.width(Fill)
.height(Fill);
stack![catcher, positioned].into()
}
/// A small right-click context menu rendered above the status bar.
/// The `name` is the layout tab that was right-clicked.
fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> {
const MENU_BG: Color = Color {
r: 0.17,
g: 0.17,
b: 0.17,
a: 1.0,
};
const MENU_BORDER: Color = Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
};
const ITEM_HOVER: Color = Color {
r: 0.25,
g: 0.45,
b: 0.70,
a: 1.0,
};
const TEXT_COLOR: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
let item = |label: &'static str, msg: Message| {
button(text(label).size(12).color(TEXT_COLOR))
.on_press(msg)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
_ => Color::TRANSPARENT,
})),
text_color: TEXT_COLOR,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: false,
})
.padding([4, 12])
.width(Fill)
};
let rename_name = name.to_string();
let delete_name = name.to_string();
let menu = container(
column![
item("Rename", Message::LayoutRenameStart(rename_name)),
item("Delete", Message::LayoutDelete(delete_name)),
]
.spacing(0)
.width(160),
)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(MENU_BG)),
border: Border {
color: MENU_BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([4, 0]);
// Click-catcher fills the whole screen to close the menu when clicking outside.
let catcher = mouse_area(
container(iced::widget::Space::new().width(Fill).height(Fill))
.width(Fill)
.height(Fill),
)
.on_press(Message::LayoutContextMenuClose)
.on_right_press(Message::LayoutContextMenuClose);
// Position the menu above the status bar at the left.
let positioned = container(menu)
.align_bottom(Fill)
.align_left(Fill)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 30.0,
left: 8.0,
});
stack![catcher, positioned].into()
}
/// Content for the floating "Unsaved Changes" OS window.
const SAVE_FORMAT_OPTIONS: &[&str] = &[
"DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018",
"DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14",
];
fn save_as_dialog_window<'a>(
filename: &'a str,
folder: &'a std::path::Path,
entries: &'a [(String, bool, std::path::PathBuf)],
format: &'a str,
) -> Element<'a, Message> {
const BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.17,
a: 1.0,
};
const LIST_BG: Color = Color {
r: 0.11,
g: 0.11,
b: 0.13,
a: 1.0,
};
const BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.36,
a: 1.0,
};
const TEXT: Color = Color {
r: 0.90,
g: 0.90,
b: 0.90,
a: 1.0,
};
const DIM: Color = Color {
r: 0.58,
g: 0.58,
b: 0.62,
a: 1.0,
};
const INPUT_BG: Color = Color {
r: 0.10,
g: 0.10,
b: 0.12,
a: 1.0,
};
const BTN_OK: Color = Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 1.0,
};
const BTN_HOV: Color = Color {
r: 0.26,
g: 0.55,
b: 0.92,
a: 1.0,
};
const BTN_GREY: Color = Color {
r: 0.26,
g: 0.26,
b: 0.29,
a: 1.0,
};
const BTN_GHOV: Color = Color {
r: 0.34,
g: 0.34,
b: 0.38,
a: 1.0,
};
const DIR_COL: Color = Color {
r: 0.75,
g: 0.85,
b: 1.00,
a: 1.0,
};
const FILE_COL: Color = TEXT;
const ROW_HOV: Color = Color {
r: 0.22,
g: 0.24,
b: 0.28,
a: 1.0,
};
let input_sty =
|_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
background: Background::Color(INPUT_BG),
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
icon: TEXT,
placeholder: DIM,
value: TEXT,
selection: Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 0.45,
},
};
let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
button(text(lbl).size(12).color(TEXT))
.on_press(msg)
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
hov
} else {
base
},
)),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([4, 12])
};
// ── Path bar ─────────────────────────────────────────────────────────
let path_str = folder.to_string_lossy().into_owned();
let up_path = folder.parent().map(|p| p.to_path_buf());
let path_bar = row![
{
let up_msg = up_path.map(Message::SaveDialogNavigate);
let b = button(text("↑").size(14).color(TEXT))
.style(|_: &Theme, st| button::Style {
background: Some(Background::Color(
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
BTN_GHOV
} else {
BTN_GREY
},
)),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([3, 10]);
if let Some(msg) = up_msg {
b.on_press(msg)
} else {
b
}
},
Space::new().width(8),
container(text(path_str.clone()).size(12).color(DIM))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(INPUT_BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into()
},
..Default::default()
})
.padding([4, 8])
.width(Fill),
]
.align_y(iced::Alignment::Center);
// ── File list ─────────────────────────────────────────────────────────
let file_list: Element<'_, Message> = {
let rows: Vec<Element<'_, Message>> = entries
.iter()
.map(|(name, is_dir, path)| {
let icon = if *is_dir { "📁" } else { "📄" };
let color = if *is_dir { DIR_COL } else { FILE_COL };
let p = path.clone();
let d = *is_dir;
mouse_area(
container(
row![
text(icon).size(13),
Space::new().width(6),
text(name.as_str()).size(13).color(color),
]
.align_y(iced::Alignment::Center),
)
.style(|_: &Theme| container::Style {
..Default::default()
})
.padding([3, 8])
.width(Fill),
)
.on_press(Message::SaveDialogEntryClicked(p, d))
.into()
})
.collect();
container(iced::widget::scrollable(
column(rows).spacing(1).width(Fill),
))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(LIST_BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
};
let _ = ROW_HOV; // used conceptually, suppress warning
let sel_fmt = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format);
let label = |s: &'static str| text(s).size(11).color(DIM);
// ── Bottom controls ───────────────────────────────────────────────────
let bottom = column![
row![
label("File name:").width(90),
text_input("drawing.dwg", filename)
.on_input(Message::SaveDialogFilenameChanged)
.style(input_sty)
.size(13)
.padding([5, 8])
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(6),
Space::new().height(6),
row![
label("Format:").width(90),
pick_list(SAVE_FORMAT_OPTIONS, sel_fmt, |s: &str| {
Message::SaveDialogFormatChanged(s.to_string())
})
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(6),
Space::new().height(12),
row![
Space::new().width(Fill),
btn("Save", Message::SaveDialogConfirm, BTN_OK, BTN_HOV),
Space::new().width(8),
btn("Cancel", Message::SaveDialogCancel, BTN_GREY, BTN_GHOV),
],
]
.spacing(0);
container(
column![
path_bar,
Space::new().height(8),
file_list,
Space::new().height(10),
bottom,
]
.spacing(0),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.padding([14, 16])
.width(Fill)
.height(Fill)
.into()
}
fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
const BG: Color = Color {
r: 0.18,
g: 0.18,
b: 0.20,
a: 1.0,
};
const BORDER_COL: Color = Color {
r: 0.38,
g: 0.38,
b: 0.42,
a: 1.0,
};
const TEXT_COL: Color = Color {
r: 0.90,
g: 0.90,
b: 0.90,
a: 1.0,
};
const BTN_SAVE: Color = Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 1.0,
};
const BTN_HOVER: Color = Color {
r: 0.26,
g: 0.55,
b: 0.92,
a: 1.0,
};
const BTN_DISC: Color = Color {
r: 0.28,
g: 0.28,
b: 0.30,
a: 1.0,
};
const BTN_DHOV: Color = Color {
r: 0.36,
g: 0.36,
b: 0.40,
a: 1.0,
};
let body_text = format!("Do you want to save changes to \"{}\"?", name);
let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
button(text(label).size(13).color(TEXT_COL))
.on_press(msg)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => hov,
_ => base,
})),
text_color: TEXT_COL,
border: Border {
color: BORDER_COL,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
})
.padding([6, 18])
};
container(
column![
text(body_text).size(13).color(TEXT_COL),
iced::widget::Space::new().height(20),
row![
btn("Save", Message::UnsavedDialogSave, BTN_SAVE, BTN_HOVER),
iced::widget::Space::new().width(8),
btn("Discard", Message::UnsavedDialogDiscard, BTN_DISC, BTN_DHOV),
iced::widget::Space::new().width(8),
btn("Cancel", Message::UnsavedDialogCancel, BTN_DISC, BTN_DHOV),
],
]
.spacing(0),
)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.center(Fill)
.padding([24, 28])
.into()
}
// ── Start / Welcome page ──────────────────────────────────────────────────
//
// Renders in place of the model-space viewport when the active tab is the
// fixed Start tab (`DocumentTab::is_start`). English-only by design — this
// is the public welcome screen and stays consistent across locales.
//
// The page picks up the application icon's red-brown (#B03020) as a tint so
// it visually belongs to OpenCADStudio without overpowering the dark workspace.
const BRAND: Color = Color { r: 0.690, g: 0.188, b: 0.125, a: 1.0 }; // #B03020
const BRAND_DARK: Color = Color { r: 0.45, g: 0.12, b: 0.08, a: 1.0 };
pub(super) fn start_page_view<'a>() -> Element<'a, Message> {
const TEXT: Color = Color { r: 0.94, g: 0.93, b: 0.92, a: 1.0 };
const MUTED: Color = Color { r: 0.62, g: 0.62, b: 0.62, a: 1.0 };
const CARD_BG: Color = Color { r: 0.12, g: 0.12, b: 0.13, a: 1.0 };
const CARD_BORDER: Color = Color { r: 0.20, g: 0.20, b: 0.22, a: 1.0 };
// Brand-tinted "Welcome to" — the "OpenCADStudio" word takes the accent colour
// (Thunderbird-style coloured headline split).
let headline = row![
text("Welcome to ").size(40).color(TEXT),
text("Open CAD Studio").size(40).color(BRAND),
]
.align_y(iced::Center);
let subtitle = text(
"Open CAD Studio is an open-source CAD viewer and editor — a gift from contributors like you. \
Open a DWG/DXF file, start a new drawing, or help shape what comes next.",
)
.size(13)
.color(MUTED);
// Plain outlined button (Open / New / Help / Contribute).
let outline_btn = |label: &'static str, msg: Message| {
button(text(label).size(14).color(TEXT))
.on_press(msg)
.padding([10, 22])
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 },
_ => Color { r: 0.13, g: 0.13, b: 0.15, a: 1.0 },
})),
text_color: TEXT,
border: Border {
color: Color { r: 0.30, g: 0.30, b: 0.33, a: 1.0 },
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
};
// Donate — the prominent call-to-action. Solid brand fill, white text.
let donate_btn = {
button(
row![
text("♥ ").size(15).color(Color::WHITE),
text("Donate").size(14).color(Color::WHITE),
]
.align_y(iced::Center),
)
.on_press(Message::RibbonToolClick {
tool_id: "DONATE".to_string(),
event: crate::modules::ModuleEvent::Command("DONATE".to_string()),
})
.padding([12, 28])
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => BRAND_DARK,
_ => BRAND,
})),
text_color: Color::WHITE,
border: Border {
color: BRAND_DARK,
width: 1.0,
radius: 6.0.into(),
},
shadow: iced::Shadow {
color: Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 },
offset: iced::Vector::new(0.0, 2.0),
blur_radius: 6.0,
},
..Default::default()
})
};
let primary_row = row![
outline_btn("New Drawing", Message::TabNew),
outline_btn("Open File…", Message::OpenFile),
donate_btn,
]
.spacing(12)
.align_y(iced::Center);
let secondary_row = row![
outline_btn(
"Contribute",
Message::RibbonToolClick {
tool_id: "REPORT".to_string(),
event: crate::modules::ModuleEvent::Command("REPORT".to_string()),
},
),
outline_btn(
"Release Notes",
Message::RibbonToolClick {
tool_id: "CHANGELOG".to_string(),
event: crate::modules::ModuleEvent::Command("CHANGELOG".to_string()),
},
),
outline_btn("About", Message::AboutOpen),
]
.spacing(12)
.align_y(iced::Center);
let card_section = |heading: &'static str, sub: &'static str, body: &'static str| {
container(
column![
text(heading).size(20).color(TEXT),
text(sub).size(13).color(MUTED),
Space::new().height(iced::Length::Fixed(10.0)),
text(body).size(12).color(MUTED),
]
.spacing(6),
)
.padding(20)
.width(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(CARD_BG)),
border: Border {
color: CARD_BORDER,
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
})
};
let cards = row![
card_section(
"Free and open source",
"Open CAD Studio belongs to its users.",
"Source is openly developed and distributed under a permissive licence. \
You are free to use, study, modify, and share Open CAD Studio.",
),
card_section(
"Community driven",
"Be part of the story.",
"Anyone can contribute — translations, bug reports, feature ideas, code, \
or simply spreading the word. Your involvement shapes the project.",
),
]
.spacing(16);
// Buttons sit on a transparent container with a large, brand-tinted
// ambient shadow (offset = 0, big blur) — produces a soft halo behind
// the action row, matching the Thunderbird coloured-glow look against
// the dark page.
let primary_glow = container(primary_row)
.padding(iced::Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 8.0 })
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
shadow: iced::Shadow {
color: Color { r: BRAND.r, g: BRAND.g, b: BRAND.b, a: 0.45 },
offset: iced::Vector::ZERO,
blur_radius: 80.0,
},
..Default::default()
});
let content = column![
Space::new().height(iced::Length::Fixed(28.0)),
container(headline).center_x(Fill),
container(subtitle).center_x(Fill).padding([10, 60]),
Space::new().height(iced::Length::Fixed(22.0)),
container(primary_glow).center_x(Fill),
Space::new().height(iced::Length::Fixed(10.0)),
container(secondary_row).center_x(Fill),
Space::new().height(iced::Length::Fixed(40.0)),
cards,
Space::new().height(Fill),
]
.spacing(0)
.width(Fill)
.height(Fill);
// Page background reverts to plain dark — the glow alone provides the
// brand colour cue, the rest of the page stays neutral so it reads as
// "workspace area" not "advertising banner".
const PAGE_BG: Color = Color { r: 0.08, g: 0.08, b: 0.085, a: 1.0 };
container(content)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PAGE_BG)),
..Default::default()
})
.padding(iced::Padding { top: 40.0, right: 60.0, bottom: 40.0, left: 60.0 })
.width(Fill)
.height(Fill)
.into()
}
// ── Recent Documents panel (Start tab left rail) ──────────────────────────
//
// Slots into the same `row![properties_el, viewport_stack]` position the
// Properties panel normally occupies, but only when the active tab is the
// Start tab. The list is restored from disk at boot and re-saved on every
// open — entries persist across sessions.
pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Element<'a, Message> {
const PANEL_BG: Color = Color { r: 0.10, g: 0.10, b: 0.11, a: 1.0 };
const PANEL_BORDER: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 };
const ITEM_HOVER: Color = Color { r: 0.16, g: 0.16, b: 0.18, a: 1.0 };
const TEXT: Color = Color { r: 0.92, g: 0.91, b: 0.90, a: 1.0 };
const MUTED: Color = Color { r: 0.60, g: 0.60, b: 0.62, a: 1.0 };
let header = container(text("Recent Documents").size(11).color(MUTED))
.padding(iced::Padding { top: 12.0, right: 14.0, bottom: 8.0, left: 14.0 });
let body: Element<'a, Message> = if recents.is_empty() {
container(
text("Files you open will show up here.")
.size(11)
.color(MUTED),
)
.padding([10, 14])
.into()
} else {
let mut col = column![].spacing(0);
for path in recents {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let dir = path
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let path_for_open = path.clone();
let open_btn = button(
column![
text(name).size(12).color(TEXT),
text(dir).size(10).color(MUTED),
]
.spacing(2),
)
.on_press(Message::OpenRecent(path_for_open))
.padding([6, 12])
.width(Fill)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ITEM_HOVER,
_ => Color::TRANSPARENT,
})),
text_color: TEXT,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
let path_for_remove = path.clone();
let remove_btn = button(text("×").size(12).color(MUTED))
.on_press(Message::RecentRemove(path_for_remove))
.padding([4, 8])
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => Color { r: 0.45, g: 0.15, b: 0.15, a: 1.0 },
_ => Color::TRANSPARENT,
})),
text_color: MUTED,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 3.0.into(),
},
..Default::default()
});
col = col.push(
row![open_btn, remove_btn]
.spacing(0)
.align_y(iced::Center),
);
}
iced::widget::scrollable(col).into()
};
container(column![header, body])
.width(iced::Length::Fixed(280.0))
.height(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: PANEL_BORDER,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
}