feat(gui): shared selection and rubberband in My Files

The grid and column view wrap one MultiSelection instead of one each, so
a selection survives switching layout, and both views enable rubberband
selection.
This commit is contained in:
nvrl committed 2026-09-23 03:05:07 +02:00
1 parent 8d0a575a7c
commit abf76cd5e8
4 files changed
+30 -37

No files matched your search

+1 -2
View File
@@ -435,8 +435,7 @@ fn build_window(app: &adw::Application) {
details: browser_widgets.details, details: browser_widgets.details,
details_entry: RefCell::new(None), details_entry: RefCell::new(None),
details_suppress: Cell::new(false), details_suppress: Cell::new(false),
grid_selection: browser_widgets.grid_selection.clone(), selection: browser_widgets.selection.clone(),
list_selection: browser_widgets.list_selection.clone(),
}, },
trash: TrashState { trash: TrashState {
model: trash_widgets.model.clone(), model: trash_widgets.model.clone(),
+20 -25
View File
@@ -53,8 +53,7 @@ pub(crate) struct BrowserState {
/// requested repeatedly, so comparing their text alone cannot reject an /// requested repeatedly, so comparing their text alone cannot reject an
/// older response that finishes after a manual refresh. /// older response that finishes after a manual refresh.
pub(crate) load_generation: Cell<u64>, pub(crate) load_generation: Cell<u64>,
/// The grid/list view stack, read to find out which of the two selections is /// The grid/list view stack, read to find out which view is on screen.
/// the one the user is actually working in.
pub(crate) views: gtk4::Stack, pub(crate) views: gtk4::Stack,
/// The bulk-action bar, revealed once more than one entry is selected. /// The bulk-action bar, revealed once more than one entry is selected.
pub(crate) bulk: gtk4::Revealer, pub(crate) bulk: gtk4::Revealer,
@@ -138,11 +137,10 @@ pub(crate) struct BrowserWidgets {
/// Wraps the views + the details pane; the pane slides in on selection. /// Wraps the views + the details pane; the pane slides in on selection.
pub(crate) split: adw::OverlaySplitView, pub(crate) split: adw::OverlaySplitView,
pub(crate) details: DetailsWidgets, pub(crate) details: DetailsWidgets,
/// The two selection models, so a selection change can drive the details pane /// The selection shared by both views, so a selection change can drive the
/// and so an action can re-read the entries the user has highlighted. /// details pane and an action can re-read the entries the user highlighted.
pub(crate) grid_selection: gtk4::MultiSelection, pub(crate) selection: gtk4::MultiSelection,
pub(crate) list_selection: gtk4::MultiSelection, /// The grid/list stack, so the page can tell which view is on screen.
/// The grid/list stack, so the page can tell which selection is live.
pub(crate) views: gtk4::Stack, pub(crate) views: gtk4::Stack,
pub(crate) bulk: gtk4::Revealer, pub(crate) bulk: gtk4::Revealer,
pub(crate) bulk_label: gtk4::Label, pub(crate) bulk_label: gtk4::Label,
@@ -310,11 +308,14 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
// Icon grid. Multi-select: acting on a batch is the common case for trashing // Icon grid. Multi-select: acting on a batch is the common case for trashing
// and for taking a folder's worth of files offline, and doing it one // and for taking a folder's worth of files offline, and doing it one
// confirmation dialog at a time is not a workflow. // confirmation dialog at a time is not a workflow.
let grid_selection = gtk4::MultiSelection::new(Some(model.clone())); // One selection for both views: switching layout keeps what is selected.
// Rubberband drags a selection box from empty space, as a file manager does.
let selection = gtk4::MultiSelection::new(Some(model.clone()));
let grid = gtk4::GridView::builder() let grid = gtk4::GridView::builder()
.model(&grid_selection) .model(&selection)
.min_columns(2) .min_columns(2)
.max_columns(10) .max_columns(10)
.enable_rubberband(true)
.build(); .build();
grid.add_css_class("file-grid"); grid.add_css_class("file-grid");
let grid_scroll = gtk4::ScrolledWindow::builder() let grid_scroll = gtk4::ScrolledWindow::builder()
@@ -322,9 +323,11 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.child(&grid) .child(&grid)
.build(); .build();
// Column list, with its own selection over the same model. // Column list, over the same selection.
let list_selection = gtk4::MultiSelection::new(Some(model.clone())); let column_view = gtk4::ColumnView::builder()
let column_view = gtk4::ColumnView::builder().model(&list_selection).build(); .model(&selection)
.enable_rubberband(true)
.build();
column_view.add_css_class("data-table"); column_view.add_css_class("data-table");
let column_scroll = gtk4::ScrolledWindow::builder() let column_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true) .vexpand(true)
@@ -519,8 +522,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
refresh, refresh,
split, split,
details, details,
grid_selection, selection,
list_selection,
views: view_stack, views: view_stack,
bulk, bulk,
bulk_label, bulk_label,
@@ -550,15 +552,9 @@ pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
hide_details(ui); hide_details(ui);
} }
/// The selection model of whichever view is on screen. The grid and the list /// The selection model shared by the grid and the list.
/// each own one over the same model, so "what is selected" depends on which the
/// user is looking at.
pub(crate) fn active_selection(ui: &Rc<Ui>) -> gtk4::MultiSelection { pub(crate) fn active_selection(ui: &Rc<Ui>) -> gtk4::MultiSelection {
if ui.browser.views.visible_child_name().as_deref() == Some("list") { ui.details.selection.clone()
ui.details.list_selection.clone()
} else {
ui.details.grid_selection.clone()
}
} }
/// Every entry highlighted in the view on screen, in model order. /// Every entry highlighted in the view on screen, in model order.
@@ -575,10 +571,9 @@ pub(crate) fn selected_entries(ui: &Rc<Ui>) -> Vec<DirEntry> {
.collect() .collect()
} }
/// Drop the selection in both views, which also retracts the bulk bar. /// Drop the selection, which also retracts the bulk bar.
pub(crate) fn clear_selection(ui: &Rc<Ui>) { pub(crate) fn clear_selection(ui: &Rc<Ui>) {
ui.details.grid_selection.unselect_all(); ui.details.selection.unselect_all();
ui.details.list_selection.unselect_all();
} }
/// Reflect the current selection in the bulk bar: how many are selected, and /// Reflect the current selection in the bulk bar: how many are selected, and
+7 -10
View File
@@ -9,10 +9,9 @@ pub(crate) struct DetailsState {
/// Set while the details pane is being populated, so setting the offline /// Set while the details pane is being populated, so setting the offline
/// switch programmatically doesn't fire a pin/unpin round-trip. /// switch programmatically doesn't fire a pin/unpin round-trip.
pub(crate) details_suppress: Cell<bool>, pub(crate) details_suppress: Cell<bool>,
/// The grid's and list's selection models. Both wrap the one browser model, so /// The one selection model behind both the grid and the list, so switching
/// a selection in either drives the details pane. /// views keeps what is selected; it drives the details pane.
pub(crate) grid_selection: gtk4::MultiSelection, pub(crate) selection: gtk4::MultiSelection,
pub(crate) list_selection: gtk4::MultiSelection,
} }
/// The widgets in the browser's details pane that a selection repaints. /// The widgets in the browser's details pane that a selection repaints.
@@ -152,13 +151,11 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
/// Connect the details pane: mirror the grid's and list's selection into it, and /// Connect the details pane: mirror the grid's and list's selection into it, and
/// wire its buttons back onto the entry it's showing. /// wire its buttons back onto the entry it's showing.
pub(crate) fn wire_details(ui: &Rc<Ui>) { pub(crate) fn wire_details(ui: &Rc<Ui>) {
// Both views share one model but have their own selection, so watch both. {
for selection in [
ui.details.grid_selection.clone(),
ui.details.list_selection.clone(),
] {
let ui_sel = ui.clone(); let ui_sel = ui.clone();
selection.connect_selection_changed(move |_, _, _| { ui.details
.selection
.connect_selection_changed(move |_, _, _| {
// The details pane describes *one* entry. A batch is the bulk bar's // The details pane describes *one* entry. A batch is the bulk bar's
// business, so the pane steps aside rather than picking a member of // business, so the pane steps aside rather than picking a member of
// the selection to speak for the rest. // the selection to speak for the rest.
+2
View File
@@ -72,6 +72,8 @@ Schema: **31** (unchanged).
Copy Link · Rename…, Move To…, Versions… · Move to Trash. Right-clicking empty space offers New Copy Link · Rename…, Move To…, Versions… · Move to Trash. Right-clicking empty space offers New
Folder, the uploads, Select All, Refresh and Open in File Manager. Open With… asks which Folder, the uploads, Select All, Refresh and Open in File Manager. Open With… asks which
application to use. application to use.
- **One selection in My Files.** The grid and the list share their selection, so switching views
keeps what is selected, and dragging across empty space draws a selection box in both.
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command. `pdfs favorite` command.