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 SortMode { NameAsc, RecentlyAdded, Size, } #[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>, sort_mode: Rc>, grid_button: gtk::ToggleButton, list_button: gtk::ToggleButton, records: Rc>>, search_empty_page: adw::StatusPage, update_banner: adw::Banner, // Tag filtering tag_bar: gtk::Box, tag_scroll: gtk::ScrolledWindow, active_tag: Rc>>, // 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)); // Sort mode from settings let saved_sort = settings.string("sort-mode"); let initial_sort = match saved_sort.as_str() { "recently-added" => SortMode::RecentlyAdded, "size" => SortMode::Size, _ => SortMode::NameAsc, }; let sort_mode = Rc::new(Cell::new(initial_sort)); // --- 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 (Ctrl+F)")) .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(); // Scan button let scan_button = gtk::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text(&i18n("Scan for AppImages (Ctrl+R)")) .build(); scan_button.add_css_class("flat"); scan_button.set_action_name(Some("win.scan")); scan_button.update_property(&[AccessibleProperty::Label("Scan for AppImages")]); // Sort dropdown let sort_menu = gtk::gio::Menu::new(); sort_menu.append(Some(&i18n("Name A-Z")), Some("win.sort-library::name")); sort_menu.append(Some(&i18n("Recently Added")), Some("win.sort-library::recent")); sort_menu.append(Some(&i18n("Size")), Some("win.sort-library::size")); let sort_button = gtk::MenuButton::builder() .icon_name("view-sort-descending-symbolic") .menu_model(&sort_menu) .tooltip_text(&i18n("Sort")) .build(); sort_button.add_css_class("flat"); sort_button.update_property(&[AccessibleProperty::Label("Sort library")]); // 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(&scan_button); 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(&sort_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 for AppImages")) .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 browse_catalog_btn = gtk::Button::builder() .label(&i18n("Browse Catalog")) .build(); browse_catalog_btn.add_css_class("flat"); browse_catalog_btn.add_css_class("pill"); browse_catalog_btn.set_action_name(Some("win.catalog")); browse_catalog_btn.update_property(&[AccessibleProperty::Label("Browse app catalog")]); let learn_btn = gtk::Button::builder() .label(&i18n("What is an AppImage?")) .build(); learn_btn.add_css_class("flat"); learn_btn.add_css_class("pill"); learn_btn.connect_clicked(|btn| { let dialog = adw::AlertDialog::builder() .heading(&i18n("What is an AppImage?")) .body(&i18n( "AppImages are self-contained app files for Linux, similar to .exe files on Windows or .dmg files on Mac.\n\n\ Key differences from traditional Linux packages:\n\ - No installation needed - just download and run\n\ - One file per app - easy to back up and share\n\ - Works on most Linux distributions\n\ - Does not require admin/root access\n\n\ Driftwood helps you discover, organize, and keep your AppImages up to date." )) .build(); dialog.add_response("learn-more", &i18n("Learn More Online")); dialog.add_response("ok", &i18n("Got It")); dialog.set_default_response(Some("ok")); dialog.set_close_response("ok"); dialog.connect_response(Some("learn-more"), |_, _| { gtk::UriLauncher::new("https://appimage.org") .launch(gtk::Window::NONE, gtk::gio::Cancellable::NONE, |_| {}); }); dialog.present(Some(btn)); }); empty_button_box.append(&scan_now_btn); empty_button_box.append(&browse_catalog_btn); empty_button_box.append(&learn_btn); let empty_page = adw::StatusPage::builder() .icon_name("application-x-executable-symbolic") .title(&i18n("No AppImages Yet")) .description(&i18n( "AppImages are portable apps for Linux - like .exe files, but they run without installation. Drag one here, scan your system, or browse the catalog to get started.", )) .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); // --- Updates-available banner --- let update_banner = adw::Banner::builder() .title(&i18n("Updates available")) .button_label(&i18n("View Updates")) .revealed(false) .build(); update_banner.set_action_name(Some("win.show-updates")); // --- Tag filter bar --- let tag_bar = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(6) .margin_start(12) .margin_end(12) .margin_top(6) .margin_bottom(2) .visible(false) .build(); let active_tag: Rc>> = Rc::new(RefCell::new(None)); let tag_scroll = gtk::ScrolledWindow::builder() .child(&tag_bar) .hscrollbar_policy(gtk::PolicyType::Automatic) .vscrollbar_policy(gtk::PolicyType::Never) .visible(false) .build(); // --- Assemble toolbar view --- let content_box = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); content_box.append(&update_banner); content_box.append(&search_bar); content_box.append(&tag_scroll); 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)); widgets::apply_pointer_cursors(&toolbar_view); // Enable type-to-search: any keypress in the view opens the search bar search_bar.set_key_capture_widget(Some(&toolbar_view)); 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")); Self { page, header_bar, stack, flow_box, list_box, search_bar, search_entry, title_widget, view_mode, sort_mode, grid_button, list_button, records, search_empty_page, update_banner, tag_bar, tag_scroll, active_tag, 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); } // Reset active tag filter *self.active_tag.borrow_mut() = None; // Sort records based on current sort mode let mut new_records = new_records; self.sort_records(&mut new_records); // Collect all unique tags for the tag filter bar let mut all_tags = std::collections::BTreeSet::new(); for record in &new_records { if let Some(ref tags) = record.tags { for tag in tags.split(',') { let trimmed = tag.trim(); if !trimmed.is_empty() { all_tags.insert(trimmed.to_string()); } } } } // Build tag filter chip bar self.build_tag_chips(&all_tags); // 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, record.id); 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, record.id); 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 sort_records(&self, records: &mut Vec) { match self.sort_mode.get() { SortMode::NameAsc => { records.sort_by(|a, b| { let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase(); let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase(); name_a.cmp(&name_b) }); } SortMode::RecentlyAdded => { records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen)); } SortMode::Size => { records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)); } } } fn build_tag_chips(&self, all_tags: &std::collections::BTreeSet) { // Clear existing chips while let Some(child) = self.tag_bar.first_child() { self.tag_bar.remove(&child); } if all_tags.is_empty() { self.tag_scroll.set_visible(false); self.tag_bar.set_visible(false); return; } self.tag_scroll.set_visible(true); self.tag_bar.set_visible(true); // "All" chip let all_chip = gtk::ToggleButton::builder() .label(&i18n("All")) .active(true) .css_classes(["pill"]) .build(); widgets::set_pointer_cursor(&all_chip); self.tag_bar.append(&all_chip); // Tag chips let mut chips: Vec = vec![all_chip.clone()]; for tag in all_tags { let chip = gtk::ToggleButton::builder() .label(tag) .css_classes(["pill"]) .group(&all_chip) .build(); widgets::set_pointer_cursor(&chip); self.tag_bar.append(&chip); chips.push(chip); } // Connect "All" chip { let active_tag = self.active_tag.clone(); let flow_box = self.flow_box.clone(); let list_box = self.list_box.clone(); let records = self.records.clone(); all_chip.connect_toggled(move |btn| { if btn.is_active() { *active_tag.borrow_mut() = None; apply_tag_filter(&flow_box, &list_box, &records.borrow(), None); } }); } // Connect each tag chip for chip in &chips[1..] { let tag_name = chip.label().map(|l| l.to_string()).unwrap_or_default(); let active_tag = self.active_tag.clone(); let flow_box = self.flow_box.clone(); let list_box = self.list_box.clone(); let records = self.records.clone(); chip.connect_toggled(move |btn| { if btn.is_active() { *active_tag.borrow_mut() = Some(tag_name.clone()); apply_tag_filter(&flow_box, &list_box, &records.borrow(), Some(&tag_name)); } }); } } 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); // Quick launch button let launch_btn = gtk::Button::builder() .icon_name("media-playback-start-symbolic") .tooltip_text(&i18n("Launch")) .css_classes(["flat", "circular"]) .valign(gtk::Align::Center) .build(); launch_btn.set_action_name(Some("win.launch-appimage")); launch_btn.set_action_target_value(Some(&record.id.to_variant())); widgets::set_pointer_cursor(&launch_btn); row.add_suffix(&launch_btn); // 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); } /// Set the sort mode and re-populate with current records. pub fn set_sort_mode(&self, mode: SortMode) { self.sort_mode.set(mode); let records = self.records.borrow().clone(); if !records.is_empty() { self.populate(records); } } /// Update the "updates available" banner. pub fn set_update_count(&self, count: i64) { self.update_banner.set_revealed(count > 0); if count > 0 { let text = ni18n_f( "{} update available", "{} updates available", count as u32, &[("{}", &count.to_string())], ); self.update_banner.set_title(&text); } } /// 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("Open"), 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 launcher" } else { "Add to launcher" }; 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); // Section 5: Destructive actions let section5 = gtk::gio::Menu::new(); let uninstall_item = gtk::gio::MenuItem::new(None, Some(&format!("win.uninstall-appimage(int64 {})", record.id))); uninstall_item.set_attribute_value("custom", Some(&"uninstall".to_variant())); section5.append_item(&uninstall_item); menu.append_section(None, §ion5); menu } /// Attach a right-click context menu to a widget. fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::gio::Menu, record_id: i64) { let popover = gtk::PopoverMenu::from_model_full(menu_model, gtk::PopoverMenuFlags::NESTED); popover.set_parent(widget.as_ref()); popover.set_has_arrow(false); // Add custom destructive-styled uninstall button let uninstall_btn = gtk::Button::builder() .label("Uninstall") .build(); uninstall_btn.add_css_class("destructive-context-item"); // Left-align the label to match other menu items if let Some(label) = uninstall_btn.child().and_then(|c| c.downcast::().ok()) { label.set_halign(gtk::Align::Start); } uninstall_btn.set_action_name(Some("win.uninstall-appimage")); uninstall_btn.set_action_target_value(Some(&record_id.to_variant())); let popover_ref = popover.clone(); uninstall_btn.connect_clicked(move |_| { popover_ref.popdown(); }); popover.add_child(&uninstall_btn, "uninstall"); // 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); } /// Apply tag filtering to both flow_box (grid) and list_box (list). fn apply_tag_filter( flow_box: >k::FlowBox, list_box: >k::ListBox, records: &[AppImageRecord], tag: Option<&str>, ) { let match_flags: Vec = records .iter() .map(|rec| { match tag { None => true, // "All" - show everything Some(filter_tag) => { rec.tags.as_ref().map_or(false, |tags| { tags.split(',') .any(|t| t.trim().eq_ignore_ascii_case(filter_tag)) }) } } }) .collect(); // Filter grid view let flags_grid = match_flags.clone(); flow_box.set_filter_func(move |child| { let idx = child.index() as usize; flags_grid.get(idx).copied().unwrap_or(false) }); // Filter list view for (i, visible) in match_flags.iter().enumerate() { if let Some(row) = list_box.row_at_index(i as i32) { row.set_visible(*visible); } } }