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
+40 -47

No files matched your search

+1 -2
View File
@@ -435,8 +435,7 @@ fn build_window(app: &adw::Application) {
details: browser_widgets.details,
details_entry: RefCell::new(None),
details_suppress: Cell::new(false),
grid_selection: browser_widgets.grid_selection.clone(),
list_selection: browser_widgets.list_selection.clone(),
selection: browser_widgets.selection.clone(),
},
trash: TrashState {
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
/// older response that finishes after a manual refresh.
pub(crate) load_generation: Cell<u64>,
/// The grid/list view stack, read to find out which of the two selections is
/// the one the user is actually working in.
/// The grid/list view stack, read to find out which view is on screen.
pub(crate) views: gtk4::Stack,
/// The bulk-action bar, revealed once more than one entry is selected.
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.
pub(crate) split: adw::OverlaySplitView,
pub(crate) details: DetailsWidgets,
/// The two selection models, so a selection change can drive the details pane
/// and so an action can re-read the entries the user has highlighted.
pub(crate) grid_selection: gtk4::MultiSelection,
pub(crate) list_selection: gtk4::MultiSelection,
/// The grid/list stack, so the page can tell which selection is live.
/// The selection shared by both views, so a selection change can drive the
/// details pane and an action can re-read the entries the user highlighted.
pub(crate) selection: gtk4::MultiSelection,
/// The grid/list stack, so the page can tell which view is on screen.
pub(crate) views: gtk4::Stack,
pub(crate) bulk: gtk4::Revealer,
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
// and for taking a folder's worth of files offline, and doing it one
// 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()
.model(&grid_selection)
.model(&selection)
.min_columns(2)
.max_columns(10)
.enable_rubberband(true)
.build();
grid.add_css_class("file-grid");
let grid_scroll = gtk4::ScrolledWindow::builder()
@@ -322,9 +323,11 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.child(&grid)
.build();
// Column list, with its own selection over the same model.
let list_selection = gtk4::MultiSelection::new(Some(model.clone()));
let column_view = gtk4::ColumnView::builder().model(&list_selection).build();
// Column list, over the same selection.
let column_view = gtk4::ColumnView::builder()
.model(&selection)
.enable_rubberband(true)
.build();
column_view.add_css_class("data-table");
let column_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
@@ -519,8 +522,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
refresh,
split,
details,
grid_selection,
list_selection,
selection,
views: view_stack,
bulk,
bulk_label,
@@ -550,15 +552,9 @@ pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
hide_details(ui);
}
/// The selection model of whichever view is on screen. The grid and the list
/// each own one over the same model, so "what is selected" depends on which the
/// user is looking at.
/// The selection model shared by the grid and the list.
pub(crate) fn active_selection(ui: &Rc<Ui>) -> gtk4::MultiSelection {
if ui.browser.views.visible_child_name().as_deref() == Some("list") {
ui.details.list_selection.clone()
} else {
ui.details.grid_selection.clone()
}
ui.details.selection.clone()
}
/// 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()
}
/// 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>) {
ui.details.grid_selection.unselect_all();
ui.details.list_selection.unselect_all();
ui.details.selection.unselect_all();
}
/// Reflect the current selection in the bulk bar: how many are selected, and
+17 -20
View File
@@ -9,10 +9,9 @@ pub(crate) struct DetailsState {
/// Set while the details pane is being populated, so setting the offline
/// switch programmatically doesn't fire a pin/unpin round-trip.
pub(crate) details_suppress: Cell<bool>,
/// The grid's and list's selection models. Both wrap the one browser model, so
/// a selection in either drives the details pane.
pub(crate) grid_selection: gtk4::MultiSelection,
pub(crate) list_selection: gtk4::MultiSelection,
/// The one selection model behind both the grid and the list, so switching
/// views keeps what is selected; it drives the details pane.
pub(crate) selection: gtk4::MultiSelection,
}
/// The widgets in the browser's details pane that a selection repaints.
@@ -152,23 +151,21 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
/// 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.
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();
selection.connect_selection_changed(move |_, _, _| {
// 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
// the selection to speak for the rest.
let entries = selected_entries(&ui_sel);
match entries.as_slice() {
[entry] => show_details(&ui_sel, entry),
_ => hide_details(&ui_sel),
}
sync_bulk_bar(&ui_sel);
});
ui.details
.selection
.connect_selection_changed(move |_, _, _| {
// 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
// the selection to speak for the rest.
let entries = selected_entries(&ui_sel);
match entries.as_slice() {
[entry] => show_details(&ui_sel, entry),
_ => hide_details(&ui_sel),
}
sync_bulk_bar(&ui_sel);
});
}
let ui_close = ui.clone();
+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
Folder, the uploads, Select All, Refresh and Open in File Manager. Open With… asks which
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
`pdfs favorite` command.