feat(start): recent files as a list, icons or large previews

Three toggles beside the Recent Documents title switch the panel between
the existing list, a grid of thumbnail tiles and one large preview per
file. The choice is kept in the recent section of the app config.

Refs #1494

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 21:46:40 +03:00
1 parent 58b2f22c09
commit adbcac8a02
29 files changed
+244 -2

No files matched your search

+3
View File
@@ -518,6 +518,8 @@ pub struct RecentConfig {
pub files: Vec<String>,
/// How many recent files to keep.
pub limit: usize,
/// List, icons or large previews.
pub view: super::RecentView,
}
impl Default for RecentConfig {
@@ -525,6 +527,7 @@ impl Default for RecentConfig {
Self {
files: Vec::new(),
limit: super::recent::RECENT_DEFAULT,
view: super::RecentView::default(),
}
}
}
+12
View File
@@ -433,6 +433,15 @@ pub enum StartSection {
Supporters,
}
/// How the Start page lists recent files.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum RecentView {
#[default]
List,
Icons,
Preview,
}
/// What the Space / Enter keys currently mean at the command line. One
/// decision point (`OpenCADStudio::text_entry_mode`) for every keyboard
/// route that used to re-derive the answer from editor state.
@@ -472,6 +481,7 @@ pub(super) struct OpenCADStudio {
/// Live text of the recent-limit input box (may differ from `recent_limit`
/// mid-edit; applied on Enter). Kept in sync when the +/- buttons change it.
recent_limit_input: String,
recent_view: RecentView,
command_line: CommandLine,
/// Recent Patreon supporters shown on the Start page (name, USD cents),
/// fetched once at boot, highest payment first.
@@ -2449,6 +2459,7 @@ pub enum Message {
RecentRemove(PathBuf),
/// Set how many recent documents to keep (persisted).
SetRecentLimit(usize),
SetRecentView(RecentView),
/// Live edit of the recent-limit input box (not applied until Enter).
RecentLimitInput(String),
/// User clicked Cancel on the loading overlay. The parser thread keeps
@@ -4488,6 +4499,7 @@ impl OpenCADStudio {
recent_thumbs: std::collections::HashMap::new(),
recent_limit: recent::RECENT_DEFAULT,
recent_limit_input: recent::RECENT_DEFAULT.to_string(),
recent_view: RecentView::default(),
command_line: CommandLine::new(),
patrons: Vec::new(),
videos: Vec::new(),
+2
View File
@@ -1326,6 +1326,7 @@ impl OpenCADStudio {
.map(|p| p.to_string_lossy().into_owned())
.collect(),
limit: self.recent_limit,
view: self.recent_view,
},
start: crate::app::config::StartConfig {
section: self.start_section,
@@ -1397,6 +1398,7 @@ impl OpenCADStudio {
);
self.recent_files.truncate(self.recent_limit);
self.recent_limit_input = self.recent_limit.to_string();
self.recent_view = cfg.recent.view;
// Thumbnails are decoded by a background task queued at boot
// (`refresh_recent_thumbs`) — never here on the boot path.
self.start_section = cfg.start.section;
+6
View File
@@ -1153,6 +1153,12 @@ impl OpenCADStudio {
Task::none()
}
Message::SetRecentView(view) => {
self.recent_view = view;
self.save_config();
Task::none()
}
Message::RecentLimitInput(s) => {
// Keep only digits while typing; applied on Enter (SetRecentLimit).
self.recent_limit_input = s.chars().filter(|c| c.is_ascii_digit()).collect();
+153 -2
View File
@@ -339,6 +339,7 @@ impl OpenCADStudio {
&self.recent_thumbs,
self.recent_limit,
&self.recent_limit_input,
self.recent_view,
self.start_action_w.clone(),
self.start_section,
)
@@ -3098,6 +3099,7 @@ pub(super) fn start_page_view<'a>(
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
recent_limit: usize,
recent_limit_input: &'a str,
recent_view: super::RecentView,
action_width_out: std::sync::Arc<std::sync::atomic::AtomicU32>,
active: super::StartSection,
) -> Element<'a, Message> {
@@ -3113,6 +3115,7 @@ pub(super) fn start_page_view<'a>(
thumbs,
recent_limit,
recent_limit_input,
recent_view,
size.width,
action_width_out.clone(),
active,
@@ -3132,6 +3135,7 @@ fn start_page_content<'a>(
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
recent_limit: usize,
recent_limit_input: &'a str,
recent_view: super::RecentView,
avail_w: f32,
action_width_out: std::sync::Arc<std::sync::atomic::AtomicU32>,
active: super::StartSection,
@@ -3355,6 +3359,7 @@ fn start_page_content<'a>(
thumbs,
recent_limit,
recent_limit_input,
recent_view,
match start_layout {
StartLayout::AllPanels
| StartLayout::WithoutVideos
@@ -3792,16 +3797,149 @@ fn start_page_content<'a>(
// 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.
/// Recent files as tiles: small thumbnails wrapping across the panel, or one
/// large preview per row. A tile opens its file; the list view keeps the
/// remove buttons.
fn recent_tiles<'a>(
recents: &'a [std::path::PathBuf],
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
large: bool,
) -> iced::widget::Column<'a, Message> {
let (thumb_w, thumb_h, name_chars) = if large {
(Fill, iced::Length::Fixed(180.0), 40)
} else {
(iced::Length::Fixed(72.0), iced::Length::Fixed(54.0), 11)
};
let tile = |path: &'a std::path::PathBuf| -> Element<'a, Message> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let picture: Element<'a, Message> = match thumbs.get(path).and_then(|o| o.as_ref()) {
Some(h) => iced::widget::image(h.clone())
.content_fit(iced::ContentFit::Contain)
.width(Fill)
.height(Fill)
.into(),
None => crate::ui::icons::themed_secondary(crate::ui::icons::DOC, 28.0),
};
let picture = container(picture)
.width(thumb_w)
.height(thumb_h)
.center_x(thumb_w)
.center_y(thumb_h)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
let mut label = column![text(crate::ui::text_util::elide(&name, name_chars)).size(12)];
if large {
let dir = path
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
label = label.push(
text(crate::ui::text_util::elide(&dir, 52))
.size(10)
.style(start_muted_style),
);
}
button(column![picture, label.spacing(2)].spacing(4))
.on_press(Message::OpenRecent(path.clone()))
.padding(6)
.width(if large { Fill } else { iced::Length::Shrink })
.style(|theme: &Theme, status| {
let palette = theme.palette();
button::Style {
background: matches!(status, button::Status::Hovered)
.then_some(Background::Color(palette.background.strong.color)),
text_color: palette.background.base.text,
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
})
.into()
};
if large {
column(recents.iter().map(tile)).spacing(6)
} else {
column![iced::widget::Row::with_children(recents.iter().map(tile))
.spacing(4)
.wrap()]
.width(Fill)
}
}
pub(super) fn recent_files_panel<'a>(
recents: &'a [std::path::PathBuf],
thumbs: &'a std::collections::HashMap<std::path::PathBuf, Option<iced::widget::image::Handle>>,
limit: usize,
limit_input: &'a str,
view: super::RecentView,
width: iced::Length,
) -> Element<'a, Message> {
use super::RecentView;
// Title mirrors the Supporters rail: size 15 in the bright text colour,
// followed by a 12px gap before the content.
let title = text(crate::tr!("start", "recent-documents")).size(15);
// followed by a 12px gap before the content. The view-mode toggles sit
// on the title's right.
let mode_button = |icon: &'static [u8], mode: RecentView, tip: String| {
let active = view == mode;
iced::widget::tooltip(
button(crate::ui::icons::themed(icon, 13.0))
.on_press(Message::SetRecentView(mode))
.padding([3, 5])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let background = if active {
Some(palette.primary.weak.color)
} else {
matches!(status, button::Status::Hovered)
.then_some(palette.background.strong.color)
};
button::Style {
background: background.map(Background::Color),
text_color: palette.background.base.text,
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
}
}),
container(text(tip).size(11)).padding([2, 6]).style(container::rounded_box),
iced::widget::tooltip::Position::Bottom,
)
};
let title = row![
text(crate::tr!("start", "recent-documents"))
.size(15)
.width(Fill),
mode_button(
crate::ui::icons::MENU,
RecentView::List,
crate::tr!("start", "view-list"),
),
mode_button(
crate::ui::icons::GRID,
RecentView::Icons,
crate::tr!("start", "view-icons"),
),
mode_button(
crate::ui::icons::PREVIEW,
RecentView::Preview,
crate::tr!("start", "view-preview"),
),
]
.spacing(2)
.align_y(iced::Center);
let body: Element<'a, Message> = if recents.is_empty() {
container(
@@ -3811,6 +3949,18 @@ pub(super) fn recent_files_panel<'a>(
)
.height(Fill)
.into()
} else if view != RecentView::List {
iced::widget::scrollable(
recent_tiles(recents, thumbs, view == RecentView::Preview).padding(
iced::Padding {
right: 12.0,
..iced::Padding::ZERO
},
),
)
.width(Fill)
.height(Fill)
.into()
} else {
// Right padding reserves a gutter for the scrollbar so it doesn't sit on
// top of the row's ✕ remove button.
@@ -4020,6 +4170,7 @@ mod tests {
&std::collections::HashMap::new(),
50,
"18446744073709551615",
super::super::RecentView::default(),
800.0,
std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
super::super::StartSection::default(),
+1
View File
@@ -80,6 +80,7 @@ pub static PIN_ACTIVE: &[u8] = include_bytes!("../../assets/icons/ui/pin_active.
pub static SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");
pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg");
pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");
pub static PREVIEW: &[u8] = include_bytes!("../../assets/icons/ui/preview.svg");
pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg");
pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg");