//! Application menu — OpenCADStudio-style dropdown that opens when the OCS logo //! button is clicked. Overlays the entire viewport as a floating panel. //! //! Layout: //! ┌─────────────────────────────────────────────┐ //! │ [Search bar] │ //! ├──────────────────┬──────────────────────────┤ //! │ Command list │ Recent files │ //! │ (left column) │ (right column) │ //! └──────────────────┴──────────────────────────┘ use iced::widget::{button, column, container, row, scrollable, text, text_input}; use iced::{Background, Border, Color, Element, Length, Theme}; use std::path::PathBuf; use crate::app::Message; // ── Menu item definitions ───────────────────────────────────────────────── struct MenuItem { icon: &'static [u8], label: &'static str, desc: &'static str, command: &'static str, } const MENU_ITEMS: &[MenuItem] = &[ MenuItem { icon: crate::ui::icons::DOC_NEW, label: "New", desc: "Create a new drawing", command: "NEW", }, MenuItem { icon: crate::ui::icons::FOLDER, label: "Open", desc: "Open an existing file", command: "OPEN", }, MenuItem { icon: crate::ui::icons::SAVE, label: "Save", desc: "Save the current drawing", command: "SAVE", }, MenuItem { icon: crate::ui::icons::DOC, label: "Save As", desc: "Save as DWG or DXF", command: "SAVEAS", }, MenuItem { icon: crate::ui::icons::PRINT, label: "Print", desc: "Print or plot the drawing", command: "PRINT", }, MenuItem { icon: crate::ui::icons::GEAR, label: "Options", desc: "Open application settings", command: "OPTIONS", }, MenuItem { icon: crate::ui::icons::HELP, label: "Help", desc: "Open help documentation", command: "HELP", }, ]; // ── Public state ────────────────────────────────────────────────────────── #[derive(Clone, Default)] pub struct AppMenu { pub open: bool, pub search: String, /// Recently opened file paths (most recent first). pub recent: Vec, } impl AppMenu { pub fn new() -> Self { Self { open: false, search: String::new(), recent: vec![], } } /// Call when a file is successfully loaded to push its path into recents. pub fn push_recent(&mut self, path: PathBuf) { self.recent.retain(|r| r != &path); self.recent.insert(0, path); self.recent.truncate(20); // Best-effort persist; silent on failure (read-only home, full disk). let _ = save_recents(&self.recent); } /// Drop a path from the recents list (manual removal from Start page). pub fn remove_recent(&mut self, path: &std::path::Path) { self.recent.retain(|r| r.as_path() != path); let _ = save_recents(&self.recent); } /// Rehydrate the recents list from disk. Call once at app boot. pub fn load_persistent_recents(&mut self) { self.recent = load_recents(); } pub fn toggle(&mut self) { self.open = !self.open; if self.open { self.search.clear(); } } pub fn close(&mut self) { self.open = false; self.search.clear(); } // ── View ───────────────────────────────────────────────────────────── pub fn view(&self) -> Element<'_, Message> { if !self.open { return container(text("")).width(0).height(0).into(); } // Filter items by search query. let query = self.search.to_lowercase(); let items: Vec<&MenuItem> = MENU_ITEMS .iter() .filter(|m| { query.is_empty() || m.label.to_lowercase().contains(&query) || m.desc.to_lowercase().contains(&query) }) .collect(); // ── Search bar ──────────────────────────────────────────────────── let search = container( text_input("Search commands...", &self.search) .on_input(Message::AppMenuSearch) .style(|_: &Theme, _| iced::widget::text_input::Style { background: Background::Color(SEARCH_BG), border: Border { color: ACCENT, width: 1.0, radius: 2.0.into(), }, icon: Color::WHITE, placeholder: Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0, }, value: Color::WHITE, selection: Color { r: 0.20, g: 0.44, b: 0.72, a: 0.5, }, }) .size(12) .padding([6, 10]), ) .padding([8, 10]) .width(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(HEADER_BG)), border: Border { color: BORDER, width: 0.0, radius: 0.0.into(), }, ..Default::default() }); // ── Left column — command list ───────────────────────────────────── let left: Element<'_, Message> = if items.is_empty() { container(text("No results").size(11).color(DIM_COLOR)) .padding([12, 14]) .into() } else { items .iter() .fold(column![].spacing(0), |col, item| { col.push(menu_item_btn(item)) }) .into() }; let left_col = container(scrollable(left)) .width(220) .height(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(LEFT_BG)), ..Default::default() }); // ── Right column — recent files ─────────────────────────────────── let recent_header = container(text("Recent Documents").size(10).color(DIM_COLOR)).padding(iced::Padding { top: 10.0, right: 14.0, bottom: 6.0, left: 14.0, }); let recent_list = if self.recent.is_empty() { column![container(text("No recent files").size(11).color(DIM_COLOR)).padding([8, 14])] } else { self.recent.iter().fold(column![].spacing(0), |col, path| { col.push(recent_item_btn(path)) }) }; let right_col = container(column![recent_header, scrollable(recent_list)]) .width(Length::Fill) .height(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(RIGHT_BG)), border: Border { color: BORDER, width: 1.0, radius: 0.0.into(), }, ..Default::default() }); // ── Bottom bar — Exit button ─────────────────────────────────────── let bottom = container( row![ iced::widget::Space::new().width(Length::Fill), button(text("Exit Open CAD Studio").size(11).color(Color::WHITE)) .on_press(Message::Command("EXIT".into())) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => Color { r: 0.65, g: 0.12, b: 0.12, a: 1.0 }, button::Status::Pressed => Color { r: 0.45, g: 0.08, b: 0.08, a: 1.0 }, _ => EXIT_BTN, })), text_color: Color::WHITE, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 2.0.into() }, shadow: iced::Shadow::default(), snap: false, }) .padding([5, 14]), ] .align_y(iced::Center), ) .padding([6, 10]) .width(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(BOTTOM_BG)), border: Border { color: BORDER, width: 1.0, radius: 0.0.into(), }, ..Default::default() }); // ── Outer panel ─────────────────────────────────────────────────── let panel = container(column![ search, container(row![left_col, right_col]).height(Length::Fill), bottom, ]) .width(520) .height(420) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: BORDER, width: 1.0, radius: 0.0.into(), }, ..Default::default() }); // Backdrop — clicking outside closes the menu. let backdrop = button( container(panel) // Offset so the panel sits just below the ribbon OCS button. .padding(iced::Padding { top: 56.0, left: 0.0, right: 0.0, bottom: 0.0, }) .width(Length::Fill) .height(Length::Fill), ) .on_press(Message::CloseAppMenu) .style(|_: &Theme, _| button::Style { background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.55, })), text_color: Color::TRANSPARENT, border: Border::default(), shadow: iced::Shadow::default(), snap: false, }) .padding(0) .width(Length::Fill) .height(Length::Fill) .into(); backdrop } } // ── Item widgets ────────────────────────────────────────────────────────── fn menu_item_btn<'a>(item: &'a MenuItem) -> Element<'a, Message> { button( row![ container(crate::ui::icons::tinted(item.icon, 16.0, ICON_COLOR)) .width(32) .padding([0, 4]), column![ text(item.label).size(12).color(Color::WHITE), text(item.desc).size(10).color(DIM_COLOR), ] .spacing(1), ] .align_y(iced::Center) .spacing(4), ) .on_press(Message::CloseAppMenuAndRun(item.command.into())) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => ITEM_HOVER, button::Status::Pressed => ITEM_ACTIVE, _ => Color::TRANSPARENT, })), text_color: Color::WHITE, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, shadow: iced::Shadow::default(), snap: false, }) .padding([7, 10]) .width(Length::Fill) .into() } fn recent_item_btn(path: &PathBuf) -> Element<'_, Message> { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()); let type_label = match path .extension() .map(|e| e.to_string_lossy().to_uppercase()) .as_deref() { Some("DWG") => "DWG Drawing", Some("DXF") => "DXF Drawing", _ => "CAD File", }; let path_str = path.display().to_string(); button( row![ container(crate::ui::icons::tinted(crate::ui::icons::DOC, 13.0, ICON_COLOR)) .width(24), column![ text(name).size(11).color(Color::WHITE), text(type_label).size(9).color(DIM_COLOR), ] .spacing(1), ] .align_y(iced::Center) .spacing(6), ) .on_press(Message::CloseAppMenuAndRun(format!( "OPEN_RECENT:{path_str}" ))) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => ITEM_HOVER, button::Status::Pressed => ITEM_ACTIVE, _ => Color::TRANSPARENT, })), text_color: Color::WHITE, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, shadow: iced::Shadow::default(), snap: false, }) .padding([6, 14]) .width(Length::Fill) .into() } // ── Colors ──────────────────────────────────────────────────────────────── const PANEL_BG: Color = Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0, }; const HEADER_BG: Color = Color { r: 0.17, g: 0.17, b: 0.17, a: 1.0, }; const LEFT_BG: Color = Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0, }; const RIGHT_BG: Color = Color { r: 0.18, g: 0.18, b: 0.18, a: 1.0, }; const BOTTOM_BG: Color = Color { r: 0.16, g: 0.16, b: 0.16, a: 1.0, }; const SEARCH_BG: Color = Color { r: 0.14, g: 0.14, b: 0.14, a: 1.0, }; const BORDER: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0, }; const ITEM_HOVER: Color = Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0, }; const ITEM_ACTIVE: Color = Color { r: 0.18, g: 0.42, b: 0.70, a: 1.0, }; const DIM_COLOR: Color = Color { r: 0.50, g: 0.50, b: 0.50, a: 1.0, }; const ICON_COLOR: Color = Color { r: 0.70, g: 0.80, b: 0.95, a: 1.0, }; const ACCENT: Color = Color { r: 0.20, g: 0.55, b: 0.90, a: 1.0, }; const EXIT_BTN: Color = Color { r: 0.55, g: 0.10, b: 0.10, a: 1.0, }; // ── Recent-files persistence ───────────────────────────────────────────── // // Plain-text format, one path per line, newest first. Lives next to other // per-user config so we don't pull in a TOML/JSON crate just for this. fn recents_file_path() -> Option { let base: PathBuf = if cfg!(target_os = "windows") { std::env::var_os("APPDATA").map(PathBuf::from)? } else if cfg!(target_os = "macos") { let home = std::env::var_os("HOME")?; let mut p = PathBuf::from(home); p.push("Library"); p.push("Application Support"); p } else if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") { PathBuf::from(d) } else { let home = std::env::var_os("HOME")?; let mut p = PathBuf::from(home); p.push(".config"); p }; let mut p = base; p.push("OpenCADStudio"); Some(p.join("recent.txt")) } fn save_recents(list: &[PathBuf]) -> std::io::Result<()> { let Some(path) = recents_file_path() else { return Ok(()); }; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let body: String = list .iter() .map(|p| p.to_string_lossy().into_owned()) .collect::>() .join("\n"); std::fs::write(path, body) } fn load_recents() -> Vec { let Some(path) = recents_file_path() else { return vec![]; }; let Ok(body) = std::fs::read_to_string(path) else { return vec![]; }; body.lines() .filter(|l| !l.trim().is_empty()) .map(PathBuf::from) .collect() }