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>, grid_button: gtk::ToggleButton, list_button: gtk::ToggleButton, records: Rc>>, search_empty_page: adw::StatusPage, // Batch selection selection_mode: Rc>, selected_ids: Rc>>, _action_bar: gtk::ActionBar, select_button: gtk::ToggleButton, selection_label: gtk::Label, } impl LibraryView { pub fn new(menu: >k::gio::Menu) -> Self { let records: Rc>> = 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>> = 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>> = 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 = 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) { // 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 { 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 { 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(&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(&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 { 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, §ion1); // 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, §ion2); // 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, §ion3); // 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, §ion4); menu } /// Attach a right-click context menu to a widget. fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::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(>k::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(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); popover_ref.popup(); }); widget.as_ref().add_controller(long_press); }