forked from 1b-agent/opencadstudio-upstream
feat(app): add fixed Start tab with welcome page and recent documents
A pinned "Start" tab now lives at index 0. It cannot be closed, opens on launch, and replaces the model-space viewport with a welcome page — similar to Thunderbird's first-run screen. - Boot opens with only the Start tab; Drawing1 is no longer auto-spawned. - The Properties left panel becomes a Recent Documents rail on the Start tab. Recents persist to disk (%APPDATA%/H7CAD/recent.txt on Windows, $XDG_CONFIG_HOME/H7CAD/recent.txt on Linux, ~/Library/Application Support/H7CAD/recent.txt on macOS), up to 20 entries, saved on every open and rehydrated at boot. Each row opens or removes the file in place. - The Support ribbon group (Donate / Report / About / Changelog) is retired; the same actions surface on the welcome page instead. The Donate button is the primary call-to-action — solid #B03020 brand fill with a soft brand-tinted ambient glow halo behind the action row (Thunderbird-style coloured glow against the neutral page). - Drawing-only overlays (mouse-area, viewcube, navbar, dynamic input, selection markers) are suppressed on the Start tab so its clicks don't trigger CAD commands. OpenFile's "current_is_empty" check refuses to replace the Start tab, and TabClose ignores requests on it. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
3fb23ae31b
commit
35acdb2dc8
10 files changed
+515
-72
No files matched your search
@@ -44,6 +44,11 @@ pub(super) struct DocumentTab {
|
||||
pub(super) active_mleader_style: String,
|
||||
/// Last camera_generation value written back to the document.
|
||||
pub(super) last_synced_camera_gen: u64,
|
||||
/// Sentinel "Welcome / Start" tab. Always at index 0 when present.
|
||||
/// Cannot be closed; the viewport area renders a welcome page instead
|
||||
/// of the model-space shader. The scene is still constructed so the
|
||||
/// rest of the code can treat it as a normal tab when reading.
|
||||
pub(super) is_start: bool,
|
||||
}
|
||||
|
||||
impl DocumentTab {
|
||||
@@ -86,9 +91,20 @@ impl DocumentTab {
|
||||
refedit_session: None,
|
||||
active_mleader_style: "Standard".to_string(),
|
||||
last_synced_camera_gen: 0,
|
||||
is_start: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
|
||||
/// can read tab state uniformly; the viewport renderer detects
|
||||
/// `is_start` and shows a welcome page instead.
|
||||
pub(super) fn new_start() -> Self {
|
||||
let mut t = Self::new_drawing(0);
|
||||
t.tab_title = "Start".to_string();
|
||||
t.is_start = true;
|
||||
t
|
||||
}
|
||||
|
||||
pub(super) fn tab_display_name(&self) -> String {
|
||||
match &self.current_path {
|
||||
Some(p) => p
|
||||
|
||||
+14
-4
@@ -282,6 +282,11 @@ pub enum Message {
|
||||
/// File picker returned. `Some((path, size_in_bytes))` → start loading;
|
||||
/// `None` → user cancelled the dialog (no overlay shown).
|
||||
OpenPathPicked(Option<(PathBuf, u64)>),
|
||||
/// Open a path from the Start tab's recent-documents list (skips the
|
||||
/// file picker; the path is already known).
|
||||
OpenRecent(PathBuf),
|
||||
/// Drop a path from the recent-documents list.
|
||||
RecentRemove(PathBuf),
|
||||
/// User clicked Cancel on the loading overlay. The parser thread keeps
|
||||
/// running but its result is discarded.
|
||||
OpenCancel,
|
||||
@@ -688,14 +693,19 @@ pub enum Message {
|
||||
|
||||
impl H7CAD {
|
||||
fn new() -> Self {
|
||||
let first_tab = DocumentTab::new_drawing(1);
|
||||
// Boot with only the Welcome/Start tab. The user creates drawings
|
||||
// explicitly (File → New); we never auto-spawn Drawing1.
|
||||
let start_tab = DocumentTab::new_start();
|
||||
let mut app_menu = AppMenu::new();
|
||||
// Restore recents from disk so the Start page lists them across runs.
|
||||
app_menu.load_persistent_recents();
|
||||
let mut app = Self {
|
||||
start: Instant::now(),
|
||||
tabs: vec![first_tab],
|
||||
tabs: vec![start_tab],
|
||||
active_tab: 0,
|
||||
tab_counter: 1,
|
||||
tab_counter: 0,
|
||||
ribbon: Ribbon::new(),
|
||||
app_menu: AppMenu::new(),
|
||||
app_menu,
|
||||
command_line: CommandLine::new(),
|
||||
status_bar: StatusBar::new(),
|
||||
cursor_pos: Point::ORIGIN,
|
||||
|
||||
+32
-2
@@ -57,6 +57,27 @@ impl H7CAD {
|
||||
|
||||
Message::OpenPathPicked(None) => Task::none(),
|
||||
|
||||
Message::OpenRecent(path) => {
|
||||
// Recents are read from disk every save → the path may be
|
||||
// stale. Skip silently if the file no longer exists; the
|
||||
// entry stays in the list so the user can clean it up.
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
|
||||
Err(_) => {
|
||||
self.command_line.push_error(&format!(
|
||||
"Recent file no longer exists: {}",
|
||||
path.display()
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Message::RecentRemove(path) => {
|
||||
self.app_menu.remove_recent(&path);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::OpenPathPicked(Some((path, size_bytes))) => {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -107,7 +128,8 @@ impl H7CAD {
|
||||
|
||||
let current_is_empty = {
|
||||
let t = &self.tabs[self.active_tab];
|
||||
t.current_path.is_none()
|
||||
!t.is_start
|
||||
&& t.current_path.is_none()
|
||||
&& !t.dirty
|
||||
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
|
||||
};
|
||||
@@ -754,13 +776,21 @@ impl H7CAD {
|
||||
}
|
||||
|
||||
Message::TabClose(idx) => {
|
||||
// Start tab is fixed — close requests on it are no-ops.
|
||||
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
|
||||
self.pending_close = Some(super::PendingClose::Tab(idx));
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
// Only-tab case: when the lone non-start tab closes, fall
|
||||
// back to the Start tab if it exists; otherwise spawn a
|
||||
// fresh blank drawing (legacy behaviour).
|
||||
if self.tabs.len() == 1 {
|
||||
self.tab_counter += 1;
|
||||
self.tabs[0] = super::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
self.tabs[0] =
|
||||
super::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
self.active_tab = 0;
|
||||
} else {
|
||||
self.tabs.remove(idx);
|
||||
|
||||
+384
-52
@@ -226,7 +226,13 @@ impl H7CAD {
|
||||
let i = self.active_tab;
|
||||
let tab = &self.tabs[i];
|
||||
let is_paper = tab.scene.current_layout != "Model";
|
||||
let viewport_3d: Element<'_, Message> = if is_paper {
|
||||
// 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))
|
||||
@@ -414,22 +420,37 @@ impl H7CAD {
|
||||
None
|
||||
};
|
||||
|
||||
let mut viewport_stack = stack![
|
||||
container(viewport_3d)
|
||||
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),
|
||||
container(info).width(Fill).height(Fill),
|
||||
selection_overlay,
|
||||
viewport_mouse,
|
||||
]
|
||||
.width(Fill)
|
||||
.height(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 {
|
||||
if self.show_navbar && !tab.is_start {
|
||||
let nav = container(overlay::nav_toolbar())
|
||||
.align_right(Fill)
|
||||
.align_top(Fill)
|
||||
@@ -442,7 +463,7 @@ impl H7CAD {
|
||||
viewport_stack = viewport_stack.push(nav);
|
||||
}
|
||||
|
||||
if self.show_viewcube && !is_paper {
|
||||
if self.show_viewcube && !is_paper && !tab.is_start {
|
||||
let cube_click: Element<'_, Message> = container(
|
||||
mouse_area(container(
|
||||
iced::widget::Space::new()
|
||||
@@ -467,10 +488,16 @@ impl H7CAD {
|
||||
}
|
||||
|
||||
if let Some(dyn_ol) = dyn_input_overlay {
|
||||
viewport_stack = viewport_stack.push(dyn_ol);
|
||||
if !tab.is_start {
|
||||
viewport_stack = viewport_stack.push(dyn_ol);
|
||||
}
|
||||
}
|
||||
|
||||
let properties_el: Element<'_, Message> = if self.show_properties {
|
||||
// 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()
|
||||
@@ -877,47 +904,47 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
|
||||
snap: false,
|
||||
});
|
||||
|
||||
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()
|
||||
});
|
||||
|
||||
bar = bar.push(
|
||||
container(row![title_btn, close_btn].spacing(0).align_y(iced::Center)).style(
|
||||
move |_: &Theme| container::Style {
|
||||
border: Border {
|
||||
color: if is_active {
|
||||
BORDER_COLOR
|
||||
// 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 {
|
||||
Color::TRANSPARENT
|
||||
},
|
||||
width: if is_active { 1.0 } else { 0.0 },
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
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()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1557,3 +1584,308 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
|
||||
.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 H7CAD 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 };
|
||||
const BRAND_TINT_BG: Color = Color { r: 0.14, g: 0.085, b: 0.085, a: 1.0 }; // dark, faint red wash
|
||||
|
||||
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 "H7CAD" word takes the accent colour
|
||||
// (Thunderbird-style coloured headline split).
|
||||
let headline = row![
|
||||
text("Welcome to ").size(40).color(TEXT),
|
||||
text("H7CAD").size(40).color(BRAND),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
|
||||
let subtitle = text(
|
||||
"H7CAD 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",
|
||||
"H7CAD belongs to its users.",
|
||||
"Source is openly developed and distributed under a permissive licence. \
|
||||
You are free to use, study, modify, and share H7CAD.",
|
||||
),
|
||||
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()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "ABOUT",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "CHANGELOG",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
// Kept for the command-line route ("DONATE" cmd) and possible future ribbon
|
||||
// re-introduction; currently the Start tab invokes the same command directly.
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "DONATE",
|
||||
|
||||
+2
-13
@@ -31,10 +31,7 @@ impl CadModule for HomeModule {
|
||||
angular_dim, leader_cmd, linear_dim, mleader_cmd, mtext, radius_dim, text,
|
||||
};
|
||||
use crate::modules::insert::{create_block, insert_block};
|
||||
use about;
|
||||
use changelog;
|
||||
use clipboard::{copy_clip, cut, paste};
|
||||
use donate;
|
||||
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
|
||||
use groups::{group, ungroup};
|
||||
use layers::{
|
||||
@@ -45,7 +42,6 @@ impl CadModule for HomeModule {
|
||||
translate, trim,
|
||||
};
|
||||
use properties::match_prop;
|
||||
use report;
|
||||
|
||||
vec![
|
||||
RibbonGroup {
|
||||
@@ -223,15 +219,8 @@ impl CadModule for HomeModule {
|
||||
cut::tool().into(),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Support",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(donate::tool()),
|
||||
report::tool().into(),
|
||||
about::tool().into(),
|
||||
changelog::tool().into(),
|
||||
],
|
||||
},
|
||||
// Support group lives on the Start tab now (see view.rs:
|
||||
// start_page_view). Removed from the Home ribbon to declutter.
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "REPORT",
|
||||
|
||||
+61
-1
@@ -92,7 +92,20 @@ impl AppMenu {
|
||||
pub fn push_recent(&mut self, path: PathBuf) {
|
||||
self.recent.retain(|r| r != &path);
|
||||
self.recent.insert(0, path);
|
||||
self.recent.truncate(10);
|
||||
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) {
|
||||
@@ -484,3 +497,50 @@ const EXIT_BTN: Color = Color {
|
||||
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("H7CAD");
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user