Implement Driftwood AppImage manager - Phases 1 and 2
Phase 1 - Application scaffolding: - GTK4/libadwaita application window with AdwNavigationView - GSettings-backed window state persistence - GResource-compiled CSS and schema - Library view with grid/list toggle, search, sorting, filtering - Detail view with file info, desktop integration controls - Preferences window with scan directories, theme, behavior settings - CLI with list, scan, integrate, remove, clean, inspect commands - AppImage discovery, metadata extraction, desktop integration - Orphaned desktop entry detection and cleanup - AppImage packaging script Phase 2 - Intelligence layer: - Database schema v2 with migration for status tracking columns - FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher) - Wayland awareness engine (session type, toolkit detection, XWayland) - Update info parsing from AppImage ELF sections (.upd_info) - GitHub/GitLab Releases API integration for update checking - Update download with progress tracking and atomic apply - Launch wrapper with FUSE auto-detection and usage tracking - Duplicate and multi-version detection with recommendations - Dashboard with system health, library stats, disk usage - Update check dialog (single and batch) - Duplicate resolution dialog - Status badges on library cards and detail view - Extended CLI: status, check-updates, duplicates, launch commands 49 tests passing across all modules.
This commit is contained in:
480
src/ui/library_view.rs
Normal file
480
src/ui/library_view.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::AppImageRecord;
|
||||
use super::app_card;
|
||||
use super::widgets;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Grid,
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LibraryState {
|
||||
Loading,
|
||||
Empty,
|
||||
Populated,
|
||||
SearchEmpty,
|
||||
}
|
||||
|
||||
pub struct LibraryView {
|
||||
pub page: adw::NavigationPage,
|
||||
pub header_bar: adw::HeaderBar,
|
||||
stack: gtk::Stack,
|
||||
flow_box: gtk::FlowBox,
|
||||
list_box: gtk::ListBox,
|
||||
search_bar: gtk::SearchBar,
|
||||
search_entry: gtk::SearchEntry,
|
||||
subtitle_label: gtk::Label,
|
||||
view_mode: Rc<Cell<ViewMode>>,
|
||||
view_toggle: gtk::ToggleButton,
|
||||
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
||||
search_empty_page: adw::StatusPage,
|
||||
}
|
||||
|
||||
impl LibraryView {
|
||||
pub fn new(menu: >k::gio::Menu) -> Self {
|
||||
let records: Rc<RefCell<Vec<AppImageRecord>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let view_mode = Rc::new(Cell::new(ViewMode::Grid));
|
||||
|
||||
// --- Header bar ---
|
||||
let menu_button = gtk::MenuButton::builder()
|
||||
.icon_name("open-menu-symbolic")
|
||||
.menu_model(menu)
|
||||
.tooltip_text("Menu")
|
||||
.primary(true)
|
||||
.build();
|
||||
menu_button.add_css_class("flat");
|
||||
|
||||
let search_button = gtk::ToggleButton::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.tooltip_text("Search")
|
||||
.build();
|
||||
search_button.add_css_class("flat");
|
||||
|
||||
let view_toggle = gtk::ToggleButton::builder()
|
||||
.icon_name("view-list-symbolic")
|
||||
.tooltip_text("Toggle list view")
|
||||
.build();
|
||||
view_toggle.add_css_class("flat");
|
||||
|
||||
let subtitle_label = gtk::Label::builder()
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
|
||||
let title_widget = adw::WindowTitle::builder()
|
||||
.title("Driftwood")
|
||||
.build();
|
||||
|
||||
let header_bar = adw::HeaderBar::builder()
|
||||
.title_widget(&title_widget)
|
||||
.build();
|
||||
header_bar.pack_end(&menu_button);
|
||||
header_bar.pack_end(&search_button);
|
||||
header_bar.pack_end(&view_toggle);
|
||||
|
||||
// --- Search bar ---
|
||||
let search_entry = gtk::SearchEntry::builder()
|
||||
.placeholder_text("Search AppImages...")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let search_clamp = adw::Clamp::builder()
|
||||
.maximum_size(500)
|
||||
.child(&search_entry)
|
||||
.build();
|
||||
|
||||
let search_bar = gtk::SearchBar::builder()
|
||||
.child(&search_clamp)
|
||||
.search_mode_enabled(false)
|
||||
.build();
|
||||
search_bar.connect_entry(&search_entry);
|
||||
|
||||
// Bind search button to search bar
|
||||
search_button
|
||||
.bind_property("active", &search_bar, "search-mode-enabled")
|
||||
.bidirectional()
|
||||
.build();
|
||||
|
||||
// --- Content stack ---
|
||||
let stack = gtk::Stack::builder()
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Loading state
|
||||
let loading_page = adw::StatusPage::builder()
|
||||
.title("Scanning for AppImages...")
|
||||
.build();
|
||||
let spinner = gtk::Spinner::builder()
|
||||
.spinning(true)
|
||||
.width_request(32)
|
||||
.height_request(32)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
loading_page.set_child(Some(&spinner));
|
||||
stack.add_named(&loading_page, Some("loading"));
|
||||
|
||||
// Empty state
|
||||
let empty_button_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.halign(gtk::Align::Center)
|
||||
.spacing(12)
|
||||
.build();
|
||||
|
||||
let scan_now_btn = gtk::Button::builder()
|
||||
.label("Scan Now")
|
||||
.build();
|
||||
scan_now_btn.add_css_class("suggested-action");
|
||||
scan_now_btn.add_css_class("pill");
|
||||
|
||||
let prefs_btn = gtk::Button::builder()
|
||||
.label("Preferences")
|
||||
.build();
|
||||
prefs_btn.add_css_class("flat");
|
||||
prefs_btn.add_css_class("pill");
|
||||
|
||||
empty_button_box.append(&scan_now_btn);
|
||||
empty_button_box.append(&prefs_btn);
|
||||
|
||||
let empty_page = adw::StatusPage::builder()
|
||||
.icon_name("folder-saved-search-symbolic")
|
||||
.title("No AppImages Found")
|
||||
.description(
|
||||
"Driftwood looks for AppImages in ~/Applications and ~/Downloads.\n\
|
||||
Drop an AppImage file here, or add more scan locations in Preferences.",
|
||||
)
|
||||
.child(&empty_button_box)
|
||||
.build();
|
||||
stack.add_named(&empty_page, Some("empty"));
|
||||
|
||||
// Search empty state
|
||||
let search_empty_page = adw::StatusPage::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.title("No Results")
|
||||
.description("No AppImages match your search. Try a different search term.")
|
||||
.build();
|
||||
stack.add_named(&search_empty_page, Some("search-empty"));
|
||||
|
||||
// Grid view
|
||||
let flow_box = gtk::FlowBox::builder()
|
||||
.valign(gtk::Align::Start)
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.row_spacing(12)
|
||||
.column_spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let grid_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&flow_box)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&grid_scroll, Some("grid"));
|
||||
|
||||
// List view
|
||||
let list_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.build();
|
||||
list_box.add_css_class("boxed-list");
|
||||
|
||||
let list_clamp = adw::Clamp::builder()
|
||||
.maximum_size(900)
|
||||
.child(&list_box)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let list_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&list_clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&list_scroll, Some("list"));
|
||||
|
||||
// --- Assemble toolbar view ---
|
||||
let content_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
content_box.append(&search_bar);
|
||||
content_box.append(&stack);
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header_bar);
|
||||
toolbar_view.set_content(Some(&content_box));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Driftwood")
|
||||
.tag("library")
|
||||
.child(&toolbar_view)
|
||||
.build();
|
||||
|
||||
// --- Wire up view toggle ---
|
||||
{
|
||||
let stack_ref = stack.clone();
|
||||
let view_mode_ref = view_mode.clone();
|
||||
let toggle_ref = view_toggle.clone();
|
||||
view_toggle.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
view_mode_ref.set(ViewMode::List);
|
||||
toggle_ref.set_icon_name("view-grid-symbolic");
|
||||
toggle_ref.set_tooltip_text(Some("Toggle grid view"));
|
||||
stack_ref.set_visible_child_name("list");
|
||||
} else {
|
||||
view_mode_ref.set(ViewMode::Grid);
|
||||
toggle_ref.set_icon_name("view-list-symbolic");
|
||||
toggle_ref.set_tooltip_text(Some("Toggle list view"));
|
||||
stack_ref.set_visible_child_name("grid");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Wire up search filtering ---
|
||||
{
|
||||
let flow_box_ref = flow_box.clone();
|
||||
let list_box_ref = list_box.clone();
|
||||
let records_ref = records.clone();
|
||||
let stack_ref = stack.clone();
|
||||
let view_mode_ref = view_mode.clone();
|
||||
let search_empty_ref = search_empty_page.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
let query = entry.text().to_string().to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
flow_box_ref.set_filter_func(|_| true);
|
||||
let mut i = 0;
|
||||
while let Some(row) = list_box_ref.row_at_index(i) {
|
||||
row.set_visible(true);
|
||||
i += 1;
|
||||
}
|
||||
if !records_ref.borrow().is_empty() {
|
||||
let view_name = if view_mode_ref.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
stack_ref.set_visible_child_name(view_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a snapshot of match results for the filter closure
|
||||
let recs = records_ref.borrow();
|
||||
let match_flags: Vec<bool> = recs
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
let name = rec.app_name.as_deref().unwrap_or(&rec.filename).to_lowercase();
|
||||
let path = rec.path.to_lowercase();
|
||||
name.contains(&query) || path.contains(&query)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let flags_clone = match_flags.clone();
|
||||
flow_box_ref.set_filter_func(move |child| {
|
||||
let idx = child.index() as usize;
|
||||
flags_clone.get(idx).copied().unwrap_or(false)
|
||||
});
|
||||
|
||||
let mut visible_count = 0;
|
||||
for (i, matches) in match_flags.iter().enumerate() {
|
||||
if let Some(row) = list_box_ref.row_at_index(i as i32) {
|
||||
row.set_visible(*matches);
|
||||
}
|
||||
if *matches {
|
||||
visible_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if visible_count == 0 && !recs.is_empty() {
|
||||
search_empty_ref.set_description(Some(
|
||||
&format!("No AppImages match '{}'. Try a different search term.", query)
|
||||
));
|
||||
stack_ref.set_visible_child_name("search-empty");
|
||||
} else {
|
||||
let view_name = if view_mode_ref.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
stack_ref.set_visible_child_name(view_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Wire up empty state buttons ---
|
||||
// These will be connected to actions externally via the public methods
|
||||
scan_now_btn.set_action_name(Some("win.scan"));
|
||||
prefs_btn.set_action_name(Some("win.preferences"));
|
||||
|
||||
Self {
|
||||
page,
|
||||
header_bar,
|
||||
stack,
|
||||
flow_box,
|
||||
list_box,
|
||||
search_bar,
|
||||
search_entry,
|
||||
subtitle_label,
|
||||
view_mode,
|
||||
view_toggle,
|
||||
records,
|
||||
search_empty_page,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_state(&self, state: LibraryState) {
|
||||
match state {
|
||||
LibraryState::Loading => {
|
||||
self.stack.set_visible_child_name("loading");
|
||||
}
|
||||
LibraryState::Empty => {
|
||||
self.stack.set_visible_child_name("empty");
|
||||
}
|
||||
LibraryState::Populated => {
|
||||
let view_name = if self.view_mode.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
self.stack.set_visible_child_name(view_name);
|
||||
}
|
||||
LibraryState::SearchEmpty => {
|
||||
self.stack.set_visible_child_name("search-empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn populate(&self, new_records: Vec<AppImageRecord>) {
|
||||
// Clear existing
|
||||
while let Some(child) = self.flow_box.first_child() {
|
||||
self.flow_box.remove(&child);
|
||||
}
|
||||
while let Some(row) = self.list_box.row_at_index(0) {
|
||||
self.list_box.remove(&row);
|
||||
}
|
||||
|
||||
// Build cards and list rows
|
||||
for record in &new_records {
|
||||
// Grid card
|
||||
let card = app_card::build_app_card(record);
|
||||
self.flow_box.append(&card);
|
||||
|
||||
// List row
|
||||
let row = self.build_list_row(record);
|
||||
self.list_box.append(&row);
|
||||
}
|
||||
|
||||
*self.records.borrow_mut() = new_records;
|
||||
let count = self.records.borrow().len();
|
||||
|
||||
if count == 0 {
|
||||
self.set_state(LibraryState::Empty);
|
||||
} else {
|
||||
self.set_state(LibraryState::Populated);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let subtitle = if let Some(ref ver) = record.app_version {
|
||||
format!("{} - {}", ver, widgets::format_size(record.size_bytes))
|
||||
} else {
|
||||
widgets::format_size(record.size_bytes)
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
// Icon prefix
|
||||
if let Some(ref icon_path) = record.icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
image.set_paintable(Some(&texture));
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
} else {
|
||||
let image = gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
} else {
|
||||
let image = gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
|
||||
// Integration badge suffix
|
||||
let badge = widgets::integration_badge(record.integrated);
|
||||
row.add_suffix(&badge);
|
||||
|
||||
// Navigate arrow
|
||||
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
row.add_suffix(&arrow);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
/// Get the record ID at a given flow box index.
|
||||
pub fn record_at_grid_index(&self, index: usize) -> Option<i64> {
|
||||
self.records.borrow().get(index).map(|r| r.id)
|
||||
}
|
||||
|
||||
/// Get the record ID at a given list box index.
|
||||
pub fn record_at_list_index(&self, index: i32) -> Option<i64> {
|
||||
self.records.borrow().get(index as usize).map(|r| r.id)
|
||||
}
|
||||
|
||||
/// Connect a callback for when a grid card is activated.
|
||||
pub fn connect_grid_activated<F: Fn(i64) + 'static>(&self, f: F) {
|
||||
let records = self.records.clone();
|
||||
self.flow_box.connect_child_activated(move |_, child| {
|
||||
let idx = child.index() as usize;
|
||||
if let Some(record) = records.borrow().get(idx) {
|
||||
f(record.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect a callback for when a list row is activated.
|
||||
pub fn connect_list_activated<F: Fn(i64) + 'static>(&self, f: F) {
|
||||
let records = self.records.clone();
|
||||
self.list_box.connect_row_activated(move |_, row| {
|
||||
let idx = row.index() as usize;
|
||||
if let Some(record) = records.borrow().get(idx) {
|
||||
f(record.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn current_view_mode(&self) -> ViewMode {
|
||||
self.view_mode.get()
|
||||
}
|
||||
|
||||
pub fn toggle_search(&self) {
|
||||
let active = self.search_bar.is_search_mode();
|
||||
self.search_bar.set_search_mode(!active);
|
||||
if !active {
|
||||
self.search_entry.grab_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user