Files
driftwood/src/ui/library_view.rs

759 lines
27 KiB
Rust

use adw::prelude::*;
use gtk::accessible::Property as AccessibleProperty;
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::rc::Rc;
use crate::core::database::AppImageRecord;
use crate::i18n::{i18n, i18n_f, ni18n_f};
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,
title_widget: adw::WindowTitle,
view_mode: Rc<Cell<ViewMode>>,
grid_button: gtk::ToggleButton,
list_button: gtk::ToggleButton,
records: Rc<RefCell<Vec<AppImageRecord>>>,
search_empty_page: adw::StatusPage,
// Batch selection
selection_mode: Rc<Cell<bool>>,
selected_ids: Rc<RefCell<HashSet<i64>>>,
_action_bar: gtk::ActionBar,
select_button: gtk::ToggleButton,
selection_label: gtk::Label,
}
impl LibraryView {
pub fn new(menu: &gtk::gio::Menu) -> Self {
let records: Rc<RefCell<Vec<AppImageRecord>>> = Rc::new(RefCell::new(Vec::new()));
// Read initial view mode from GSettings
let settings = gtk::gio::Settings::new(crate::config::APP_ID);
let saved_view = settings.string("view-mode");
let initial_mode = if saved_view.as_str() == "list" {
ViewMode::List
} else {
ViewMode::Grid
};
let view_mode = Rc::new(Cell::new(initial_mode));
// --- Header bar ---
let menu_button = gtk::MenuButton::builder()
.icon_name("open-menu-symbolic")
.menu_model(menu)
.tooltip_text(&i18n("Menu"))
.primary(true)
.build();
menu_button.add_css_class("flat");
menu_button.update_property(&[AccessibleProperty::Label("Main menu")]);
let search_button = gtk::ToggleButton::builder()
.icon_name("system-search-symbolic")
.tooltip_text(&i18n("Search"))
.build();
search_button.add_css_class("flat");
search_button.update_property(&[AccessibleProperty::Label("Toggle search")]);
// Linked view toggle (segmented control)
let grid_button = gtk::ToggleButton::builder()
.icon_name("view-grid-symbolic")
.tooltip_text(&i18n("Grid view"))
.active(initial_mode == ViewMode::Grid)
.build();
grid_button.update_property(&[AccessibleProperty::Label("Switch to grid view")]);
let list_button = gtk::ToggleButton::builder()
.icon_name("view-list-symbolic")
.tooltip_text(&i18n("List view"))
.active(initial_mode == ViewMode::List)
.group(&grid_button)
.build();
list_button.update_property(&[AccessibleProperty::Label("Switch to list view")]);
let view_toggle_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
view_toggle_box.add_css_class("linked");
view_toggle_box.append(&grid_button);
view_toggle_box.append(&list_button);
let title_widget = adw::WindowTitle::builder()
.title("Driftwood")
.build();
// Add button (shows drop overlay)
let add_button_icon = gtk::Image::from_icon_name("list-add-symbolic");
let add_button_label = gtk::Label::new(Some(&i18n("Add app")));
let add_button_content = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
add_button_content.append(&add_button_icon);
add_button_content.append(&add_button_label);
let add_button = gtk::Button::builder()
.child(&add_button_content)
.tooltip_text(&i18n("Add AppImage"))
.build();
add_button.add_css_class("flat");
add_button.set_action_name(Some("win.show-drop-hint"));
add_button.update_property(&[AccessibleProperty::Label("Add AppImage")]);
let select_button = gtk::ToggleButton::builder()
.icon_name("selection-mode-symbolic")
.tooltip_text(&i18n("Select multiple"))
.build();
select_button.add_css_class("flat");
select_button.update_property(&[AccessibleProperty::Label("Toggle selection mode")]);
let header_bar = adw::HeaderBar::builder()
.title_widget(&title_widget)
.build();
header_bar.pack_start(&add_button);
header_bar.pack_start(&select_button);
header_bar.pack_end(&menu_button);
header_bar.pack_end(&search_button);
header_bar.pack_end(&view_toggle_box);
// --- Search bar ---
let search_entry = gtk::SearchEntry::builder()
.placeholder_text(&i18n("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);
search_bar.set_accessible_role(gtk::AccessibleRole::Search);
// 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(&i18n("Scanning for AppImages..."))
.build();
let spinner = adw::Spinner::builder()
.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(&i18n("Scan Now"))
.build();
scan_now_btn.add_css_class("suggested-action");
scan_now_btn.add_css_class("pill");
scan_now_btn.update_property(&[AccessibleProperty::Label("Scan for AppImages")]);
let prefs_btn = gtk::Button::builder()
.label(&i18n("Preferences"))
.build();
prefs_btn.add_css_class("flat");
prefs_btn.add_css_class("pill");
prefs_btn.update_property(&[AccessibleProperty::Label("Open preferences")]);
empty_button_box.append(&scan_now_btn);
empty_button_box.append(&prefs_btn);
let empty_page = adw::StatusPage::builder()
.icon_name("application-x-executable-symbolic")
.title(&i18n("No AppImages Found"))
.description(&i18n(
"Driftwood manages your AppImage collection - scanning for apps, \
integrating them into your desktop, and keeping them up to date.\n\n\
Drag AppImage files here, or add them to ~/Applications or ~/Downloads, \
then use Scan Now to find them.",
))
.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(&i18n("No Results"))
.description(&i18n("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::None)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(5)
.row_spacing(12)
.column_spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.build();
flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]);
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::None)
.build();
list_box.add_css_class("boxed-list");
list_box.add_css_class("rich-list");
list_box.update_property(&[AccessibleProperty::Label("AppImage library 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"));
// --- Batch selection state ---
let selection_mode = Rc::new(Cell::new(false));
let selected_ids: Rc<RefCell<HashSet<i64>>> = Rc::new(RefCell::new(HashSet::new()));
// --- Bottom action bar (hidden until selection mode) ---
let action_bar = gtk::ActionBar::new();
action_bar.set_visible(false);
let selection_label = gtk::Label::builder()
.label("0 selected")
.build();
action_bar.set_center_widget(Some(&selection_label));
let integrate_btn = gtk::Button::builder()
.label(&i18n("Integrate"))
.tooltip_text(&i18n("Add selected to app menu"))
.build();
integrate_btn.add_css_class("suggested-action");
integrate_btn.set_action_name(Some("win.batch-integrate"));
action_bar.pack_start(&integrate_btn);
let delete_btn = gtk::Button::builder()
.label(&i18n("Delete"))
.tooltip_text(&i18n("Delete selected AppImages"))
.build();
delete_btn.add_css_class("destructive-action");
delete_btn.set_action_name(Some("win.batch-delete"));
action_bar.pack_end(&delete_btn);
// --- Assemble toolbar view ---
let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
content_box.append(&search_bar);
content_box.append(&stack);
content_box.append(&action_bar);
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 (linked buttons) ---
{
let stack_ref = stack.clone();
let view_mode_ref = view_mode.clone();
let settings_ref = settings.clone();
grid_button.connect_toggled(move |btn| {
if btn.is_active() {
view_mode_ref.set(ViewMode::Grid);
stack_ref.set_visible_child_name("grid");
settings_ref.set_string("view-mode", "grid").ok();
}
});
}
{
let stack_ref = stack.clone();
let view_mode_ref = view_mode.clone();
let settings_ref = settings;
list_button.connect_toggled(move |btn| {
if btn.is_active() {
view_mode_ref.set(ViewMode::List);
stack_ref.set_visible_child_name("list");
settings_ref.set_string("view-mode", "list").ok();
}
});
}
// --- Wire up search filtering (debounced at 150ms) ---
{
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();
let debounce_source: Rc<Cell<Option<glib::SourceId>>> = Rc::new(Cell::new(None));
search_entry.connect_search_changed(move |entry| {
// Cancel any pending debounce timer
if let Some(source_id) = debounce_source.take() {
source_id.remove();
}
let query = entry.text().to_string().to_lowercase();
// Immediate clear when search is empty (no debounce needed)
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;
}
// Debounce: schedule filter after 150ms of no typing
let flow_box_d = flow_box_ref.clone();
let list_box_d = list_box_ref.clone();
let records_d = records_ref.clone();
let stack_d = stack_ref.clone();
let view_mode_d = view_mode_ref.clone();
let search_empty_d = search_empty_ref.clone();
let debounce_clear = debounce_source.clone();
let source_id = glib::timeout_add_local_once(
std::time::Duration::from_millis(150),
move || {
// Clear the stored SourceId so nobody tries to remove a fired timer
debounce_clear.set(None);
let recs = records_d.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_d.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_d.row_at_index(i as i32) {
row.set_visible(*matches);
}
if *matches {
visible_count += 1;
}
}
if visible_count == 0 && !recs.is_empty() {
search_empty_d.set_description(Some(
&i18n_f("No AppImages match '{}'. Try a different search term.", &[("{}", &query)])
));
stack_d.set_visible_child_name("search-empty");
} else {
let view_name = if view_mode_d.get() == ViewMode::Grid {
"grid"
} else {
"list"
};
stack_d.set_visible_child_name(view_name);
}
},
);
debounce_source.set(Some(source_id));
});
}
// --- Wire up selection mode toggle ---
{
let selection_mode_ref = selection_mode.clone();
let selected_ids_ref = selected_ids.clone();
let action_bar_ref = action_bar.clone();
let selection_label_ref = selection_label.clone();
select_button.connect_toggled(move |btn| {
let active = btn.is_active();
selection_mode_ref.set(active);
action_bar_ref.set_visible(active);
if !active {
selected_ids_ref.borrow_mut().clear();
selection_label_ref.set_label("0 selected");
}
});
}
// --- Wire up empty state buttons ---
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,
title_widget,
view_mode,
grid_button,
list_button,
records,
search_empty_page,
selection_mode,
selected_ids,
_action_bar: action_bar,
select_button,
selection_label,
}
}
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");
self.title_widget.set_subtitle("");
}
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.search_empty_page.set_title(&i18n("No Results"));
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);
let card_menu = build_context_menu(record);
attach_context_menu(&card, &card_menu);
self.flow_box.append(&card);
// List row
let row = self.build_list_row(record);
let row_menu = build_context_menu(record);
attach_context_menu(&row, &row_menu);
self.list_box.append(&row);
}
*self.records.borrow_mut() = new_records;
let count = self.records.borrow().len();
// Update subtitle with count using i18n plurals
if count == 0 {
self.title_widget.set_subtitle("");
self.set_state(LibraryState::Empty);
} else {
let subtitle = ni18n_f(
"{} AppImage",
"{} AppImages",
count as u32,
&[("{}", &count.to_string())],
);
self.title_widget.set_subtitle(&subtitle);
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);
// Structured two-line subtitle:
// Line 1: Description snippet or file path (or "Analyzing..." if pending)
// Line 2: Version + size
let is_analyzing = record.app_name.is_none()
&& record.analysis_status.as_deref() != Some("complete");
let line1 = if is_analyzing {
i18n("Analyzing...")
} else if let Some(ref desc) = record.description {
if !desc.is_empty() {
let snippet: String = desc.chars().take(60).collect();
if snippet.len() < desc.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
}
} else {
record.path.clone()
}
} else {
record.path.clone()
};
let mut meta_parts = Vec::new();
if let Some(ref ver) = record.app_version {
meta_parts.push(ver.clone());
}
meta_parts.push(widgets::format_size(record.size_bytes));
let line2 = meta_parts.join(" - ");
let subtitle = format!("{}\n{}", line1, line2);
let row = adw::ActionRow::builder()
.title(name)
.subtitle(&subtitle)
.subtitle_lines(2)
.activatable(true)
.build();
// Icon prefix with rounded clipping and letter fallback
let icon = widgets::app_icon(
record.icon_path.as_deref(),
name,
48,
);
icon.add_css_class("icon-rounded");
row.add_prefix(&icon);
// Single most important badge as suffix (same priority as cards)
if let Some(badge) = app_card::build_priority_badge(record) {
badge.set_valign(gtk::Align::Center);
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();
}
}
/// Get the set of currently selected record IDs.
pub fn selected_ids(&self) -> HashSet<i64> {
self.selected_ids.borrow().clone()
}
/// Toggle selection of a record ID (used by card click in selection mode).
pub fn toggle_selection(&self, id: i64) {
let mut ids = self.selected_ids.borrow_mut();
if ids.contains(&id) {
ids.remove(&id);
} else {
ids.insert(id);
}
let count = ids.len();
self.selection_label.set_label(&format!("{} selected", count));
}
/// Whether the library is in selection mode.
pub fn in_selection_mode(&self) -> bool {
self.selection_mode.get()
}
/// Exit selection mode.
pub fn exit_selection_mode(&self) {
self.select_button.set_active(false);
}
/// Programmatically set the view mode by toggling the linked buttons.
pub fn set_view_mode(&self, mode: ViewMode) {
match mode {
ViewMode::Grid => self.grid_button.set_active(true),
ViewMode::List => self.list_button.set_active(true),
}
}
}
/// Build the right-click context menu model for an AppImage.
fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
let menu = gtk::gio::Menu::new();
// Section 1: Launch
let section1 = gtk::gio::Menu::new();
section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
menu.append_section(None, &section1);
// Section 2: Actions
let section2 = gtk::gio::Menu::new();
section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id)));
section2.append(Some("Security check"), Some(&format!("win.scan-security(int64 {})", record.id)));
menu.append_section(None, &section2);
// Section 3: Integration + folder
let section3 = gtk::gio::Menu::new();
let integrate_label = if record.integrated { "Remove from app menu" } else { "Add to app menu" };
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3);
// Section 4: Clipboard
let section4 = gtk::gio::Menu::new();
section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id)));
menu.append_section(None, &section4);
menu
}
/// Attach a right-click context menu to a widget.
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::gio::Menu) {
let popover = gtk::PopoverMenu::from_model(Some(menu_model));
popover.set_parent(widget.as_ref());
popover.set_has_arrow(false);
// Unparent the popover when the widget is destroyed to avoid GTK warnings
let popover_cleanup = popover.clone();
widget.as_ref().connect_destroy(move |_| {
popover_cleanup.unparent();
});
// Right-click
let click = gtk::GestureClick::new();
click.set_button(3);
let popover_ref = popover.clone();
click.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
popover_ref.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover_ref.popup();
});
widget.as_ref().add_controller(click);
// Long press for touch
let long_press = gtk::GestureLongPress::new();
let popover_ref = popover;
long_press.connect_pressed(move |gesture, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
popover_ref.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover_ref.popup();
});
widget.as_ref().add_controller(long_press);
}