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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#B4B6B9" stroke-width="1.8" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M6.5 16.5 L10.5 11.5 L13.5 14.5 L15.5 12.5 L18 16.5" stroke-width="1.4"/><circle cx="15.5" cy="8.5" r="1.3" stroke-width="1.4"/></svg>

After

Width:  |  Height:  |  Size: 331 B

+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = ستظهر الملفات التي تفتحها هنا.
.browser-storage = تخزين المتصفح
.keep-recent-files = الاحتفاظ بالملفات الأخيرة
.view-list = قائمة
.view-icons = أيقونات
.view-preview = معاينات كبيرة
donation =
.title = طلب تبرع
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Файловете, които отвориш, ще се появят тук.
.browser-storage = Хранилище на браузъра
.keep-recent-files = Пази скорошните файлове
.view-list = Списък
.view-icons = Икони
.view-preview = Големи прегледи
donation =
.title = Призив за дарение
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Otevřené soubory se zobrazí zde.
.browser-storage = Úložiště prohlížeče
.keep-recent-files = Uchovávat nedávné soubory
.view-list = Seznam
.view-icons = Ikony
.view-preview = Velké náhledy
donation =
.title = Žádost o dar
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Geöffnete Dateien werden hier angezeigt.
.browser-storage = Browserspeicher
.keep-recent-files = Zuletzt verwendete Dateien behalten
.view-list = Liste
.view-icons = Symbole
.view-preview = Große Vorschau
donation =
.title = Spendenaufruf
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Τα αρχεία που ανοίγετε θα εμφανίζονται εδώ.
.browser-storage = Αποθηκευτικός χώρος προγράμματος περιήγησης
.keep-recent-files = Διατήρηση πρόσφατων αρχείων
.view-list = Λίστα
.view-icons = Εικονίδια
.view-preview = Μεγάλες προεπισκοπήσεις
donation =
.title = Έκκληση για δωρεά
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Files you open will show up here.
.browser-storage = Browser storage
.keep-recent-files = Keep recent files
.view-list = List
.view-icons = Icons
.view-preview = Large previews
donation =
.title = Request for Donation
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Los archivos que abra aparecerán aquí.
.browser-storage = Almacenamiento del navegador
.keep-recent-files = Conservar archivos recientes
.view-list = Lista
.view-icons = Iconos
.view-preview = Vistas previas grandes
donation =
.title = Solicitud de donación
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Avaamasi tiedostot näkyvät tässä.
.browser-storage = Selaimen tallennustila
.keep-recent-files = Säilytä viimeisimmät tiedostot
.view-list = Luettelo
.view-icons = Kuvakkeet
.view-preview = Suuret esikatselut
donation =
.title = Lahjoituspyyntö
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Les fichiers que vous ouvrez apparaîtront ici.
.browser-storage = Stockage du navigateur
.keep-recent-files = Conserver les fichiers récents
.view-list = Liste
.view-icons = Icônes
.view-preview = Grands aperçus
donation =
.title = Appel aux dons
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = आपके द्वारा खोली गई फ़ाइलें यहाँ दिखाई देंगी।
.browser-storage = ब्राउज़र संग्रहण
.keep-recent-files = हाल की फ़ाइलें रखें
.view-list = सूची
.view-icons = आइकन
.view-preview = बड़े पूर्वावलोकन
donation =
.title = दान का अनुरोध
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = A megnyitott fájlok itt jelennek meg.
.browser-storage = Böngésző tárhelye
.keep-recent-files = Legutóbbi fájlok megőrzése
.view-list = Lista
.view-icons = Ikonok
.view-preview = Nagy előnézetek
donation =
.title = Adománykérés
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = I file aperti verranno visualizzati qui.
.browser-storage = Memoria del browser
.keep-recent-files = Conserva i file recenti
.view-list = Elenco
.view-icons = Icone
.view-preview = Anteprime grandi
donation =
.title = Richiesta di donazione
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = 開いたファイルがここに表示されます。
.browser-storage = ブラウザーストレージ
.keep-recent-files = 最近使ったファイルを保持
.view-list = 一覧
.view-icons = アイコン
.view-preview = 大きいプレビュー
donation =
.title = 寄付のお願い
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = 연 파일이 여기에 표시됩니다.
.browser-storage = 브라우저 저장소
.keep-recent-files = 최근 파일 유지
.view-list = 목록
.view-icons = 아이콘
.view-preview = 큰 미리 보기
donation =
.title = 기부 요청
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = ໄຟລ໌ທີ່ທ່ານເປີດຈະສະແດງຢູ່ນີ້.
.browser-storage = ບ່ອນເກັບຂໍ້ມູນບຣາວເຊີ
.keep-recent-files = ຮັກສາໄຟລ໌ຫຼ້າສຸດໄວ້
.view-list = ລາຍການ
.view-icons = ໄອຄອນ
.view-preview = ຕົວຢ່າງຂະໜາດໃຫຍ່
donation =
.title = ຄຳຮ້ອງຂໍການບໍລິຈາກ
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Bestanden die u opent, verschijnen hier.
.browser-storage = Browseropslag
.keep-recent-files = Recente bestanden bewaren
.view-list = Lijst
.view-icons = Pictogrammen
.view-preview = Grote voorbeelden
donation =
.title = Verzoek om een donatie
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Otwarte pliki pojawią się tutaj.
.browser-storage = Pamięć przeglądarki
.keep-recent-files = Zachowuj ostatnie pliki
.view-list = Lista
.view-icons = Ikony
.view-preview = Duże podglądy
donation =
.title = Prośba o darowiznę
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Os arquivos que você abrir aparecerão aqui.
.browser-storage = Armazenamento do navegador
.keep-recent-files = Manter arquivos recentes
.view-list = Lista
.view-icons = Ícones
.view-preview = Visualizações grandes
donation =
.title = Pedido de doação
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Открытые вами файлы появятся здесь.
.browser-storage = Хранилище браузера
.keep-recent-files = Хранить недавние файлы
.view-list = Список
.view-icons = Значки
.view-preview = Крупные эскизы
donation =
.title = Просьба о пожертвовании
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = Açtığınız dosyalar burada görünür.
.browser-storage = Tarayıcı depolama alanı
.keep-recent-files = Son dosyaları sakla
.view-list = Liste
.view-icons = Simgeler
.view-preview = Büyük önizlemeler
donation =
.title = Bağış Talebi
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = 您打开的文件将显示在此处。
.browser-storage = 浏览器存储
.keep-recent-files = 保留最近文件
.view-list = 列表
.view-icons = 图标
.view-preview = 大预览
donation =
.title = 捐款请求
+3
View File
@@ -99,6 +99,9 @@ start =
.no-recent-files = 您開啟的檔案將顯示在此處。
.browser-storage = 瀏覽器儲存空間
.keep-recent-files = 保留最近檔案
.view-list = 清單
.view-icons = 圖示
.view-preview = 大型預覽
donation =
.title = 捐款請求
+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");