- Tag editor in detail view with add/remove pill chips - Tag filter chips in library view for filtering by tag - Shared backup module for app list export/import (JSON v2) - CLI export/import refactored to use shared module - GUI export/import via file picker dialogs in hamburger menu - GitHub release history enrichment for catalog apps - Changelog preview in updates view with expandable rows - DB migration v19 for catalog release_history column
1075 lines
39 KiB
Rust
1075 lines
39 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 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<Cell<ViewMode>>,
|
|
sort_mode: Rc<Cell<SortMode>>,
|
|
grid_button: gtk::ToggleButton,
|
|
list_button: gtk::ToggleButton,
|
|
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
|
search_empty_page: adw::StatusPage,
|
|
update_banner: adw::Banner,
|
|
// Tag filtering
|
|
tag_bar: gtk::Box,
|
|
tag_scroll: gtk::ScrolledWindow,
|
|
active_tag: Rc<RefCell<Option<String>>>,
|
|
// 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: >k::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));
|
|
|
|
// 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<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);
|
|
|
|
// --- 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<RefCell<Option<String>>> = 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<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"));
|
|
|
|
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<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);
|
|
}
|
|
|
|
// 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<AppImageRecord>) {
|
|
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<String>) {
|
|
// 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<gtk::ToggleButton> = 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<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);
|
|
}
|
|
|
|
/// 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<gtk::Widget>, 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::<gtk::Label>().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<bool> = 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);
|
|
}
|
|
}
|
|
}
|