feat(ui): replace app-menu logo with a quick-access file toolbar

Remove the OCS-logo button and the dropdown menu it opened; the top strip
now shows direct New / Open / Save / Save As / Print buttons ahead of the
undo/redo controls. Drop the HELP and OPTIONS commands (HELP dumped text to
the command line, OPTIONS aliased the drafting-settings popup).

The app-menu overlay is gone, so its rendering code, MENU_ITEMS, four dead
messages, and the LOGO_RED / GEAR / HELP assets are removed. The recents
list it held is the only survivor — moved into OpenCADStudio.recent_files
via a new app/recent.rs, backing the Start page's Recent Documents panel.

Quick-access icons are redrawn in a cleaner style (file-plus, folder-open,
device-floppy, file-export, printer) and tinted light so they read on the
dark strip; FOLDER / DOC keep their old glyphs for the file browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-03 09:51:49 +03:00
1 parent 2bc5c2a686
commit 7bab7c7b8d
17 files changed
+154 -643

No files matched your search

-549
View File
@@ -1,549 +0,0 @@
//! 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<PathBuf>,
}
impl AppMenu {
pub fn new() -> Self {
Self {
open: false,
search: String::new(),
recent: vec![],
}
}
/// Call when a file is successfully loaded to push its path into recents.
pub fn push_recent(&mut self, path: PathBuf) {
self.recent.retain(|r| r != &path);
self.recent.insert(0, path);
self.recent.truncate(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<PathBuf> {
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::<Vec<_>>()
.join("\n");
std::fs::write(path, body)
}
fn load_recents() -> Vec<PathBuf> {
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()
}
+2 -2
View File
@@ -60,10 +60,10 @@ pub const UP: &[u8] = include_bytes!("../../assets/icons/ui/up.svg");
pub const DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
pub const DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg");
pub const FOLDER: &[u8] = include_bytes!("../../assets/icons/ui/folder.svg");
pub const FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg");
pub const SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg");
pub const FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg");
pub const PRINT: &[u8] = include_bytes!("../../assets/icons/ui/print.svg");
pub const GEAR: &[u8] = include_bytes!("../../assets/icons/ui/gear.svg");
pub const HELP: &[u8] = include_bytes!("../../assets/icons/ui/help.svg");
pub const HEART: &[u8] = include_bytes!("../../assets/icons/ui/heart.svg");
pub const DOT: &[u8] = include_bytes!("../../assets/icons/ui/dot.svg");
pub const TRI_LEFT_B: &[u8] = include_bytes!("../../assets/icons/ui/tri_left.svg");
-2
View File
@@ -2,7 +2,6 @@
/// Change this to scale the ribbon, layer manager rows, and property panel rows uniformly.
pub const ROW_H: f32 = 26.0;
pub mod app_menu;
pub mod color_select;
pub mod command_line;
pub mod icons;
@@ -17,7 +16,6 @@ pub mod style;
pub mod text_util;
pub mod window;
pub use app_menu::AppMenu;
pub use command_line::CommandLine;
pub use properties::PropertiesPanel;
pub use ribbon::Ribbon;
+11 -27
View File
@@ -217,32 +217,16 @@ impl Ribbon {
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]);
// ── Quick-access file commands ─────────────────────────────────────
let quick_access = row![
quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW"),
quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN"),
quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE"),
quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS"),
quick_access_btn(crate::ui::icons::PRINT, "Print", "PRINT"),
]
.spacing(TOP_HIST_GAP)
.align_y(iced::Center);
// ── Tab buttons ───────────────────────────────────────────────────
let history_controls = row![
@@ -253,7 +237,7 @@ impl Ribbon {
.align_y(iced::Center);
let tab_buttons = self.modules.iter().enumerate().fold(
row![logo, history_controls]
row![quick_access, history_controls]
.align_y(iced::Center)
.spacing(6),
|row_acc, (i, module)| {
+49 -4
View File
@@ -54,10 +54,11 @@ pub(super) const PROP_LW_ID: &str = "PROP_LW";
// ── Colours ────────────────────────────────────────────────────────────────
pub(super) const LOGO_RED: Color = Color {
r: 0.75,
g: 0.10,
b: 0.10,
/// Light chrome grey for the quick-access file-command icons on the top strip.
pub(super) const QA_ICON_COLOR: Color = Color {
r: 0.82,
g: 0.83,
b: 0.85,
a: 1.0,
};
pub(super) const TOPBAR_BG: Color = Color {
@@ -1082,6 +1083,50 @@ pub fn module_event_to_message(event: ModuleEvent) -> Message {
// ── History control ────────────────────────────────────────────────────────
/// Quick-access chrome button (New / Open / Save / Save As / Print) in the top
/// strip: an SVG icon that dispatches a command string, with a hover tooltip.
pub(super) fn quick_access_btn<'a>(
icon_bytes: &'static [u8],
label: &'static str,
cmd: &'static str,
) -> Element<'a, Message> {
// The bundled UI SVGs are black-stroked; tint them to a light chrome grey so
// they read on the dark top strip (raw black is invisible there).
let icon = icons::tinted(icon_bytes, 16.0, QA_ICON_COLOR);
let btn = button(
container(icon)
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
)
.on_press(Message::Command(cmd.to_string()))
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
},
_ => Color::TRANSPARENT,
})),
border: Border {
radius: 2.0.into(),
..Default::default()
},
..Default::default()
})
.width(Length::Fixed(TOP_HIST_W))
.height(24)
.padding([2, 0]);
tooltip(btn, make_tip(label.to_string()), TipPos::Bottom)
.gap(6.0)
.delay(Duration::from_millis(400))
.style(tip_style)
.into()
}
pub(super) fn render_history_control<'a>(
label: &'static str,
dropdown_id: &'static str,