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 <noreply@anthropic.com>
This commit is contained in:
parent
3fb23ae31b
commit
35acdb2dc8
10 changed files with 513 additions and 70 deletions
|
|
@ -44,6 +44,11 @@ pub(super) struct DocumentTab {
|
||||||
pub(super) active_mleader_style: String,
|
pub(super) active_mleader_style: String,
|
||||||
/// Last camera_generation value written back to the document.
|
/// Last camera_generation value written back to the document.
|
||||||
pub(super) last_synced_camera_gen: u64,
|
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 {
|
impl DocumentTab {
|
||||||
|
|
@ -86,9 +91,20 @@ impl DocumentTab {
|
||||||
refedit_session: None,
|
refedit_session: None,
|
||||||
active_mleader_style: "Standard".to_string(),
|
active_mleader_style: "Standard".to_string(),
|
||||||
last_synced_camera_gen: 0,
|
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 {
|
pub(super) fn tab_display_name(&self) -> String {
|
||||||
match &self.current_path {
|
match &self.current_path {
|
||||||
Some(p) => p
|
Some(p) => p
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,11 @@ pub enum Message {
|
||||||
/// File picker returned. `Some((path, size_in_bytes))` → start loading;
|
/// File picker returned. `Some((path, size_in_bytes))` → start loading;
|
||||||
/// `None` → user cancelled the dialog (no overlay shown).
|
/// `None` → user cancelled the dialog (no overlay shown).
|
||||||
OpenPathPicked(Option<(PathBuf, u64)>),
|
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
|
/// User clicked Cancel on the loading overlay. The parser thread keeps
|
||||||
/// running but its result is discarded.
|
/// running but its result is discarded.
|
||||||
OpenCancel,
|
OpenCancel,
|
||||||
|
|
@ -688,14 +693,19 @@ pub enum Message {
|
||||||
|
|
||||||
impl H7CAD {
|
impl H7CAD {
|
||||||
fn new() -> Self {
|
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 {
|
let mut app = Self {
|
||||||
start: Instant::now(),
|
start: Instant::now(),
|
||||||
tabs: vec![first_tab],
|
tabs: vec![start_tab],
|
||||||
active_tab: 0,
|
active_tab: 0,
|
||||||
tab_counter: 1,
|
tab_counter: 0,
|
||||||
ribbon: Ribbon::new(),
|
ribbon: Ribbon::new(),
|
||||||
app_menu: AppMenu::new(),
|
app_menu,
|
||||||
command_line: CommandLine::new(),
|
command_line: CommandLine::new(),
|
||||||
status_bar: StatusBar::new(),
|
status_bar: StatusBar::new(),
|
||||||
cursor_pos: Point::ORIGIN,
|
cursor_pos: Point::ORIGIN,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,27 @@ impl H7CAD {
|
||||||
|
|
||||||
Message::OpenPathPicked(None) => Task::none(),
|
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))) => {
|
Message::OpenPathPicked(Some((path, size_bytes))) => {
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|
@ -107,7 +128,8 @@ impl H7CAD {
|
||||||
|
|
||||||
let current_is_empty = {
|
let current_is_empty = {
|
||||||
let t = &self.tabs[self.active_tab];
|
let t = &self.tabs[self.active_tab];
|
||||||
t.current_path.is_none()
|
!t.is_start
|
||||||
|
&& t.current_path.is_none()
|
||||||
&& !t.dirty
|
&& !t.dirty
|
||||||
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
|
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
|
||||||
};
|
};
|
||||||
|
|
@ -754,13 +776,21 @@ impl H7CAD {
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::TabClose(idx) => {
|
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) {
|
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
|
||||||
self.pending_close = Some(super::PendingClose::Tab(idx));
|
self.pending_close = Some(super::PendingClose::Tab(idx));
|
||||||
return self.open_unsaved_dialog_window();
|
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 {
|
if self.tabs.len() == 1 {
|
||||||
self.tab_counter += 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;
|
self.active_tab = 0;
|
||||||
} else {
|
} else {
|
||||||
self.tabs.remove(idx);
|
self.tabs.remove(idx);
|
||||||
|
|
|
||||||
362
src/app/view.rs
362
src/app/view.rs
|
|
@ -226,7 +226,13 @@ impl H7CAD {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let tab = &self.tabs[i];
|
let tab = &self.tabs[i];
|
||||||
let is_paper = tab.scene.current_layout != "Model";
|
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)
|
paper_canvas_view(tab)
|
||||||
} else {
|
} else {
|
||||||
shader(ViewportPane::model(&tab.scene, self.show_viewcube))
|
shader(ViewportPane::model(&tab.scene, self.show_viewcube))
|
||||||
|
|
@ -414,7 +420,21 @@ impl H7CAD {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut viewport_stack = stack![
|
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)
|
container(viewport_3d)
|
||||||
.style(move |_: &Theme| container::Style {
|
.style(move |_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(bg_color)),
|
background: Some(Background::Color(bg_color)),
|
||||||
|
|
@ -427,9 +447,10 @@ impl H7CAD {
|
||||||
viewport_mouse,
|
viewport_mouse,
|
||||||
]
|
]
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.height(Fill);
|
.height(Fill)
|
||||||
|
};
|
||||||
|
|
||||||
if self.show_navbar {
|
if self.show_navbar && !tab.is_start {
|
||||||
let nav = container(overlay::nav_toolbar())
|
let nav = container(overlay::nav_toolbar())
|
||||||
.align_right(Fill)
|
.align_right(Fill)
|
||||||
.align_top(Fill)
|
.align_top(Fill)
|
||||||
|
|
@ -442,7 +463,7 @@ impl H7CAD {
|
||||||
viewport_stack = viewport_stack.push(nav);
|
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(
|
let cube_click: Element<'_, Message> = container(
|
||||||
mouse_area(container(
|
mouse_area(container(
|
||||||
iced::widget::Space::new()
|
iced::widget::Space::new()
|
||||||
|
|
@ -467,10 +488,16 @@ impl H7CAD {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(dyn_ol) = dyn_input_overlay {
|
if let Some(dyn_ol) = dyn_input_overlay {
|
||||||
|
if !tab.is_start {
|
||||||
viewport_stack = viewport_stack.push(dyn_ol);
|
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()
|
tab.properties.view()
|
||||||
} else {
|
} else {
|
||||||
Space::new().into()
|
Space::new().into()
|
||||||
|
|
@ -877,6 +904,10 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
|
||||||
snap: false,
|
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 {
|
let close_btn = button(text("×").size(11).color(Color {
|
||||||
r: 0.55,
|
r: 0.55,
|
||||||
g: 0.55,
|
g: 0.55,
|
||||||
|
|
@ -902,22 +933,18 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
row![title_btn, close_btn].spacing(0).align_y(iced::Center)
|
||||||
|
};
|
||||||
|
|
||||||
bar = bar.push(
|
bar = bar.push(
|
||||||
container(row![title_btn, close_btn].spacing(0).align_y(iced::Center)).style(
|
container(row_inner).style(move |_: &Theme| container::Style {
|
||||||
move |_: &Theme| container::Style {
|
|
||||||
border: Border {
|
border: Border {
|
||||||
color: if is_active {
|
color: if is_active { BORDER_COLOR } else { Color::TRANSPARENT },
|
||||||
BORDER_COLOR
|
|
||||||
} else {
|
|
||||||
Color::TRANSPARENT
|
|
||||||
},
|
|
||||||
width: if is_active { 1.0 } else { 0.0 },
|
width: if is_active { 1.0 } else { 0.0 },
|
||||||
radius: 0.0.into(),
|
radius: 0.0.into(),
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1557,3 +1584,308 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
|
||||||
.padding([24, 28])
|
.padding([24, 28])
|
||||||
.into()
|
.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};
|
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn tool() -> ToolDef {
|
pub fn tool() -> ToolDef {
|
||||||
ToolDef {
|
ToolDef {
|
||||||
id: "ABOUT",
|
id: "ABOUT",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn tool() -> ToolDef {
|
pub fn tool() -> ToolDef {
|
||||||
ToolDef {
|
ToolDef {
|
||||||
id: "CHANGELOG",
|
id: "CHANGELOG",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
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 {
|
pub fn tool() -> ToolDef {
|
||||||
ToolDef {
|
ToolDef {
|
||||||
id: "DONATE",
|
id: "DONATE",
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,7 @@ impl CadModule for HomeModule {
|
||||||
angular_dim, leader_cmd, linear_dim, mleader_cmd, mtext, radius_dim, text,
|
angular_dim, leader_cmd, linear_dim, mleader_cmd, mtext, radius_dim, text,
|
||||||
};
|
};
|
||||||
use crate::modules::insert::{create_block, insert_block};
|
use crate::modules::insert::{create_block, insert_block};
|
||||||
use about;
|
|
||||||
use changelog;
|
|
||||||
use clipboard::{copy_clip, cut, paste};
|
use clipboard::{copy_clip, cut, paste};
|
||||||
use donate;
|
|
||||||
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
|
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
|
||||||
use groups::{group, ungroup};
|
use groups::{group, ungroup};
|
||||||
use layers::{
|
use layers::{
|
||||||
|
|
@ -45,7 +42,6 @@ impl CadModule for HomeModule {
|
||||||
translate, trim,
|
translate, trim,
|
||||||
};
|
};
|
||||||
use properties::match_prop;
|
use properties::match_prop;
|
||||||
use report;
|
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
RibbonGroup {
|
RibbonGroup {
|
||||||
|
|
@ -223,15 +219,8 @@ impl CadModule for HomeModule {
|
||||||
cut::tool().into(),
|
cut::tool().into(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
RibbonGroup {
|
// Support group lives on the Start tab now (see view.rs:
|
||||||
title: "Support",
|
// start_page_view). Removed from the Home ribbon to declutter.
|
||||||
tools: vec![
|
|
||||||
RibbonItem::LargeTool(donate::tool()),
|
|
||||||
report::tool().into(),
|
|
||||||
about::tool().into(),
|
|
||||||
changelog::tool().into(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn tool() -> ToolDef {
|
pub fn tool() -> ToolDef {
|
||||||
ToolDef {
|
ToolDef {
|
||||||
id: "REPORT",
|
id: "REPORT",
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,20 @@ impl AppMenu {
|
||||||
pub fn push_recent(&mut self, path: PathBuf) {
|
pub fn push_recent(&mut self, path: PathBuf) {
|
||||||
self.recent.retain(|r| r != &path);
|
self.recent.retain(|r| r != &path);
|
||||||
self.recent.insert(0, 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) {
|
pub fn toggle(&mut self) {
|
||||||
|
|
@ -484,3 +497,50 @@ const EXIT_BTN: Color = Color {
|
||||||
b: 0.10,
|
b: 0.10,
|
||||||
a: 1.0,
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue