mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
feat(gui): menu-model context menus with keyboard and background menu
Context menus are PopoverMenus built from gio::Menu through a small ActionMenu helper, so they are keyboard navigable and read as menus. My Files follows the item order of UI_UX_PLAN.md §6, adds Open With…, Copy Link and Versions…, and gains a background menu on empty space and the Menu key / Shift+F10. The photo tile menu and the ⋮ row menus on Shared by Me and Computers use the same helper.
This commit is contained in:
1 parent
91a5f21340
commit
8d0a575a7c
8 files changed
+361
-273
No files matched your search
@@ -18,6 +18,7 @@ use pages::takeout::*;
|
|||||||
use pages::trash::*;
|
use pages::trash::*;
|
||||||
use pages::verify::*;
|
use pages::verify::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
|
use widgets::menu::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
use widgets::versions_dialog::*;
|
use widgets::versions_dialog::*;
|
||||||
@@ -1310,6 +1311,12 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
ui.browser.actions.activate_action("up", None)
|
ui.browser.actions.activate_action("up", None)
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
||||||
|
gtk4::gdk::Key::Menu if on_browser => popup_keyboard_context_menu(&ui),
|
||||||
|
gtk4::gdk::Key::F10
|
||||||
|
if on_browser && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) =>
|
||||||
|
{
|
||||||
|
popup_keyboard_context_menu(&ui)
|
||||||
|
}
|
||||||
gtk4::gdk::Key::F2 if on_browser => {
|
gtk4::gdk::Key::F2 if on_browser => {
|
||||||
// Renaming is one name at a time; saying so beats a key that
|
// Renaming is one name at a time; saying so beats a key that
|
||||||
// silently does nothing with several items highlighted.
|
// silently does nothing with several items highlighted.
|
||||||
|
|||||||
@@ -799,6 +799,8 @@ pub(crate) fn browser_views(ui: &Rc<Ui>) {
|
|||||||
/// button. Split out from [`build_browser_page`] because every renderer needs
|
/// button. Split out from [`build_browser_page`] because every renderer needs
|
||||||
/// the [`Ui`] handle to open entries and raise the context menu.
|
/// the [`Ui`] handle to open entries and raise the context menu.
|
||||||
pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k4::ColumnView) {
|
pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k4::ColumnView) {
|
||||||
|
attach_background_menu(ui, grid);
|
||||||
|
attach_background_menu(ui, column_view);
|
||||||
// Resize only realised grid cells. Rebuilding the whole model for every
|
// Resize only realised grid cells. Rebuilding the whole model for every
|
||||||
// slider step would repeatedly tear down selection state while the pointer
|
// slider step would repeatedly tear down selection state while the pointer
|
||||||
// is still moving.
|
// is still moving.
|
||||||
@@ -1023,251 +1025,243 @@ pub(crate) fn text_column(
|
|||||||
/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context
|
/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context
|
||||||
/// menu for whatever entry the owning `item` is currently bound to. Capturing the
|
/// menu for whatever entry the owning `item` is currently bound to. Capturing the
|
||||||
/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct
|
/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct
|
||||||
/// as the view recycles cells while scrolling.
|
/// as the view recycles cells while scrolling. The click is claimed, so the
|
||||||
|
/// view's own background menu does not open on top of it.
|
||||||
pub(crate) fn attach_context_menu(ui: &Rc<Ui>, item: >k4::ListItem, anchor: >k4::Box) {
|
pub(crate) fn attach_context_menu(ui: &Rc<Ui>, item: >k4::ListItem, anchor: >k4::Box) {
|
||||||
let gesture = gtk4::GestureClick::new();
|
let gesture = gtk4::GestureClick::new();
|
||||||
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
let item = item.clone();
|
let item = item.clone();
|
||||||
let target = anchor.clone();
|
let target = anchor.clone();
|
||||||
gesture.connect_pressed(move |_, _, x, y| {
|
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||||||
let entry = obj.borrow::<DirEntry>().clone();
|
let entry = obj.borrow::<DirEntry>().clone();
|
||||||
// Right-clicking a row that is part of a multi-selection acts on the
|
// Right-clicking a row that is part of a multi-selection acts on the
|
||||||
// batch; right-clicking outside one acts on the row, as before.
|
// batch; right-clicking outside one acts on the row, as before.
|
||||||
let selected = selected_entries(&ui);
|
let selected = selected_entries(&ui);
|
||||||
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
|
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
|
||||||
show_bulk_context_menu(&ui, selected, &target, x, y);
|
bulk_context_menu(&ui, selected).popup_at(&target, x, y);
|
||||||
} else {
|
} else {
|
||||||
show_context_menu(&ui, &entry, &target, x, y);
|
entry_context_menu(&ui, &entry).popup_at(&target, x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
anchor.add_controller(gesture);
|
anchor.add_controller(gesture);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop a context menu next to `anchor` at the click point, offering Open and a
|
/// Right-clicking the empty space of a view offers the folder's own actions.
|
||||||
/// Pin/Unpin toggle (files only). Built fresh per click because the items are
|
pub(crate) fn attach_background_menu(ui: &Rc<Ui>, view: &impl IsA<gtk4::Widget>) {
|
||||||
/// entry-specific; it unparents itself once dismissed.
|
let gesture = gtk4::GestureClick::new();
|
||||||
pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: >k4::Box, x: f64, y: f64) {
|
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||||
let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
let ui = ui.clone();
|
||||||
let popover = gtk4::Popover::builder()
|
let target = view.clone().upcast::<gtk4::Widget>();
|
||||||
.has_arrow(false)
|
gesture.connect_pressed(move |_, _, x, y| {
|
||||||
.position(gtk4::PositionType::Bottom)
|
background_context_menu(&ui).popup_at(&target, x, y);
|
||||||
.pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1))
|
});
|
||||||
.child(&menu)
|
view.add_controller(gesture);
|
||||||
.build();
|
}
|
||||||
popover.set_parent(anchor);
|
|
||||||
popover.connect_closed(|p| p.unparent());
|
|
||||||
|
|
||||||
// Media leads with Play (stream from the mount, no download); the plain Open
|
/// The Menu key or Shift+F10: the menu for the selection, or for the folder
|
||||||
// below still downloads a local copy for anyone who wants one.
|
/// when nothing is selected, opened at the focused item.
|
||||||
if is_streamable_media_entry(entry) {
|
pub(crate) fn popup_keyboard_context_menu(ui: &Rc<Ui>) {
|
||||||
let play = menu_item("Play (stream)", "media-playback-start-symbolic");
|
let view = ui
|
||||||
let ui_play = ui.clone();
|
.browser
|
||||||
let entry_play = entry.clone();
|
.views
|
||||||
let pop = popover.clone();
|
.visible_child()
|
||||||
play.connect_clicked(move |_| {
|
.unwrap_or_else(|| ui.browser.views.clone().upcast());
|
||||||
pop.popdown();
|
// The focused cell is where the eye is; without one, the middle of the view.
|
||||||
stream_entry(&ui_play, &entry_play);
|
let (x, y) = view
|
||||||
});
|
.root()
|
||||||
menu.append(&play);
|
.and_then(|root| root.focus())
|
||||||
}
|
.filter(|focus| focus.is_ancestor(&view))
|
||||||
|
.and_then(|focus| focus.compute_bounds(&view))
|
||||||
|
.map(|b| {
|
||||||
|
(
|
||||||
|
(b.x() + b.width() / 2.0) as f64,
|
||||||
|
(b.y() + b.height() / 2.0) as f64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0));
|
||||||
|
let selected = selected_entries(ui);
|
||||||
|
let menu = match selected.len() {
|
||||||
|
0 => background_context_menu(ui),
|
||||||
|
1 => entry_context_menu(ui, &selected[0]),
|
||||||
|
_ => bulk_context_menu(ui, selected),
|
||||||
|
};
|
||||||
|
menu.popup_at(&view, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
let open = menu_item("Open", "document-open-symbolic");
|
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open,
|
||||||
let ui_open = ui.clone();
|
/// offline, sharing, organising, and the destructive item last on its own.
|
||||||
let entry_open = entry.clone();
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let pop = popover.clone();
|
let mut menu = ActionMenu::new();
|
||||||
open.connect_clicked(move |_| {
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
pop.popdown();
|
menu.item("Open", move || {
|
||||||
// Open always means "download a local copy and hand off", even for a
|
// Open always means "download a local copy and hand off", even for a
|
||||||
// media file — `activate_entry` would otherwise stream it.
|
// media file — `activate_entry` would otherwise stream it.
|
||||||
if is_streamable_media_entry(&entry_open) {
|
if is_streamable_media_entry(&entry_c) {
|
||||||
download_and_open(&ui_open, &entry_open);
|
download_and_open(&ui_c, &entry_c);
|
||||||
} else {
|
} else {
|
||||||
activate_entry(&ui_open, &entry_open);
|
activate_entry(&ui_c, &entry_c);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
menu.append(&open);
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item("Open With…", move || open_entry_with(&ui_c, &entry_c));
|
||||||
|
// Play streams from the mount, no download; Open above still fetches a
|
||||||
|
// local copy for anyone who wants one.
|
||||||
|
if is_streamable_media_entry(entry) {
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item("Play", move || stream_entry(&ui_c, &entry_c));
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
|
||||||
if !entry.is_dir {
|
if !entry.is_dir {
|
||||||
let (label, icon) = if entry.pinned {
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
("Make online only", "pdfs-online-only-symbolic")
|
menu.toggle("Available offline", entry.pinned, move |_| {
|
||||||
} else {
|
toggle_pin(&ui_c, &entry_c)
|
||||||
("Make available offline", "pdfs-offline-symbolic")
|
|
||||||
};
|
|
||||||
let pin = menu_item(label, icon);
|
|
||||||
let ui_pin = ui.clone();
|
|
||||||
let entry_pin = entry.clone();
|
|
||||||
let pop = popover.clone();
|
|
||||||
pin.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
toggle_pin(&ui_pin, &entry_pin);
|
|
||||||
});
|
});
|
||||||
menu.append(&pin);
|
menu.section();
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item("Share…", move || open_share_dialog(&ui_c, &entry_c));
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item("Copy Link", move || copy_entry_link(&ui_c, &entry_c));
|
||||||
|
menu.section();
|
||||||
|
|
||||||
let rename = menu_item("Rename…", "document-edit-symbolic");
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
let ui_rn = ui.clone();
|
menu.item("Rename…", move || prompt_rename(&ui_c, &entry_c));
|
||||||
let entry_rn = entry.clone();
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
let pop = popover.clone();
|
menu.item("Move To…", move || {
|
||||||
rename.connect_clicked(move |_| {
|
prompt_move(&ui_c, vec![entry_c.clone()])
|
||||||
pop.popdown();
|
|
||||||
prompt_rename(&ui_rn, &entry_rn);
|
|
||||||
});
|
});
|
||||||
menu.append(&rename);
|
if !entry.is_dir {
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
menu.item("Versions…", move || open_versions_dialog(&ui_c, &entry_c));
|
||||||
|
}
|
||||||
|
menu.section();
|
||||||
|
|
||||||
let move_it = menu_item("Move…", "folder-move-symbolic");
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
let ui_mv = ui.clone();
|
menu.item("Move to Trash", move || trash_entry(&ui_c, &entry_c));
|
||||||
let entry_mv = entry.clone();
|
menu
|
||||||
let pop = popover.clone();
|
|
||||||
move_it.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
prompt_move(&ui_mv, vec![entry_mv.clone()]);
|
|
||||||
});
|
|
||||||
menu.append(&move_it);
|
|
||||||
|
|
||||||
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
|
||||||
|
|
||||||
let share = menu_item("Share…", "emblem-shared-symbolic");
|
|
||||||
let ui_sh = ui.clone();
|
|
||||||
let entry_sh = entry.clone();
|
|
||||||
let pop = popover.clone();
|
|
||||||
share.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
open_share_dialog(&ui_sh, &entry_sh);
|
|
||||||
});
|
|
||||||
menu.append(&share);
|
|
||||||
|
|
||||||
let trash = menu_item("Move to Trash", "user-trash-symbolic");
|
|
||||||
let ui_tr = ui.clone();
|
|
||||||
let entry_tr = entry.clone();
|
|
||||||
let pop = popover.clone();
|
|
||||||
trash.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
trash_entry(&ui_tr, &entry_tr);
|
|
||||||
});
|
|
||||||
menu.append(&trash);
|
|
||||||
|
|
||||||
popover.popup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The context menu for a multi-selection: the batch-capable actions only.
|
/// The menu for a multi-selection: the batch-capable actions only. Rename,
|
||||||
/// Rename, Move, Share and the revision history all need a single subject, so
|
/// Share and the revision history all need a single subject, so they are
|
||||||
/// they are simply absent here rather than offered and then refused.
|
/// simply absent here rather than offered and then refused.
|
||||||
pub(crate) fn show_bulk_context_menu(
|
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
ui: &Rc<Ui>,
|
let mut menu = ActionMenu::new();
|
||||||
entries: Vec<DirEntry>,
|
// Offline state is a file-only notion; the check shows whether the whole
|
||||||
anchor: >k4::Box,
|
// batch already is.
|
||||||
x: f64,
|
if entries.iter().all(|e| !e.is_dir) {
|
||||||
y: f64,
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
) {
|
let all_pinned = entries.iter().all(|e| e.pinned);
|
||||||
let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
menu.toggle("Available offline", all_pinned, move |pin| {
|
||||||
let popover = gtk4::Popover::builder()
|
run_bulk_pin(&ui_c, batch.clone(), pin)
|
||||||
.has_arrow(false)
|
|
||||||
.position(gtk4::PositionType::Bottom)
|
|
||||||
.pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1))
|
|
||||||
.child(&menu)
|
|
||||||
.build();
|
|
||||||
popover.set_parent(anchor);
|
|
||||||
popover.connect_closed(|p| p.unparent());
|
|
||||||
|
|
||||||
let header = gtk4::Label::builder()
|
|
||||||
.label(format!("{} selected", entries.len()))
|
|
||||||
.halign(gtk4::Align::Start)
|
|
||||||
.build();
|
|
||||||
header.add_css_class("dim-label");
|
|
||||||
header.add_css_class("caption");
|
|
||||||
header.set_margin_top(6);
|
|
||||||
header.set_margin_bottom(4);
|
|
||||||
header.set_margin_start(10);
|
|
||||||
menu.append(&header);
|
|
||||||
|
|
||||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
|
||||||
if files_only && entries.iter().any(|e| !e.pinned) {
|
|
||||||
let pin = menu_item("Make available offline", "pdfs-offline-symbolic");
|
|
||||||
let ui_pin = ui.clone();
|
|
||||||
let batch = entries.clone();
|
|
||||||
let pop = popover.clone();
|
|
||||||
pin.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
run_bulk_pin(&ui_pin, batch.clone(), true);
|
|
||||||
});
|
});
|
||||||
menu.append(&pin);
|
|
||||||
}
|
|
||||||
if files_only && entries.iter().any(|e| e.pinned) {
|
|
||||||
let unpin = menu_item("Make online only", "pdfs-online-only-symbolic");
|
|
||||||
let ui_unpin = ui.clone();
|
|
||||||
let batch = entries.clone();
|
|
||||||
let pop = popover.clone();
|
|
||||||
unpin.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
run_bulk_pin(&ui_unpin, batch.clone(), false);
|
|
||||||
});
|
|
||||||
menu.append(&unpin);
|
|
||||||
}
|
}
|
||||||
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
|
menu.item("Move To…", move || prompt_move(&ui_c, batch.clone()));
|
||||||
|
menu.labelled_section(&format!("{} selected", entries.len()));
|
||||||
|
|
||||||
// Offline items only exist for all-file selections; without them the
|
let ui_c = ui.clone();
|
||||||
// separator would sit directly under the header.
|
menu.item("Move to Trash", move || {
|
||||||
if files_only {
|
trash_entries(&ui_c, entries.clone())
|
||||||
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
|
||||||
}
|
|
||||||
|
|
||||||
let trash = menu_item("Move to Trash", "user-trash-symbolic");
|
|
||||||
let ui_tr = ui.clone();
|
|
||||||
let pop = popover.clone();
|
|
||||||
trash.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
trash_entries(&ui_tr, entries.clone());
|
|
||||||
});
|
});
|
||||||
menu.append(&trash);
|
menu
|
||||||
|
|
||||||
popover.popup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A left-aligned, flat icon+label button for the context menu.
|
/// The menu for the folder on screen, opened on empty space.
|
||||||
pub(crate) fn menu_item(label: &str, icon: &str) -> gtk4::Button {
|
pub(crate) fn background_context_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let mut menu = ActionMenu::new();
|
||||||
row.append(>k4::Image::from_icon_name(icon));
|
let run = |name: &'static str| {
|
||||||
row.append(
|
let ui = ui.clone();
|
||||||
>k4::Label::builder()
|
move || ui.browser.actions.activate_action(name, None)
|
||||||
.label(label)
|
};
|
||||||
.halign(gtk4::Align::Start)
|
let mounted = *ui.mounted.borrow();
|
||||||
.hexpand(true)
|
if mounted && ui.browser.search.text().is_empty() {
|
||||||
.build(),
|
menu.item("New Folder…", run("new-folder"));
|
||||||
|
menu.item("Upload Files…", run("upload"));
|
||||||
|
menu.item("Upload Folder…", run("upload-folder"));
|
||||||
|
menu.section();
|
||||||
|
}
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item("Select All", move || {
|
||||||
|
active_selection(&ui_c).select_all();
|
||||||
|
});
|
||||||
|
let ui_c = ui.clone();
|
||||||
|
menu.item("Refresh", move || reload_listing(&ui_c));
|
||||||
|
if mounted {
|
||||||
|
menu.item("Open in File Manager", run("open-folder"));
|
||||||
|
}
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Let the user pick the application, through the mount where the file keeps
|
||||||
|
/// its real name, so the chooser can tell what kind of file it is.
|
||||||
|
pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
|
let rel = entry_rel(ui, entry);
|
||||||
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
|
let Some(path) = mounted_target_rel(&mountpoint, &rel) else {
|
||||||
|
toast_error(
|
||||||
|
ui,
|
||||||
|
"Couldn't open file",
|
||||||
|
"Open With needs the Proton Drive folder to be mounted.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
|
||||||
|
launcher.set_always_ask(true);
|
||||||
|
let ui = ui.clone();
|
||||||
|
launcher.launch(
|
||||||
|
ui_window(&ui).as_ref(),
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
move |result| {
|
||||||
|
if let Err(e) = result
|
||||||
|
&& !e.matches(gtk4::DialogError::Dismissed)
|
||||||
|
{
|
||||||
|
toast_error(&ui, "Couldn't open file", &e.to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let button = gtk4::Button::builder().child(&row).build();
|
|
||||||
button.add_css_class("flat");
|
|
||||||
button
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An action run by a [`more_menu_button`] item.
|
/// Copy the entry's public link, when it has one this client can read the
|
||||||
pub(crate) type MenuAction = Box<dyn Fn()>;
|
/// password of. A listed link carries no URL, since the password fragment is
|
||||||
|
/// only known when the link is made, so otherwise open the Share dialog.
|
||||||
/// A flat ⋮ button whose popover lists `items` as (label, icon, action). The
|
pub(crate) fn copy_entry_link(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
/// popover closes before the action runs, so a dialog it opens gets the focus.
|
let req = if entry.path.is_empty() && !entry.uid.is_empty() {
|
||||||
pub(crate) fn more_menu_button(items: Vec<(&str, &str, MenuAction)>) -> gtk4::MenuButton {
|
Request::ListShareByUid {
|
||||||
let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
uid: entry.uid.clone(),
|
||||||
let popover = gtk4::Popover::builder().child(&menu).build();
|
}
|
||||||
for (label, icon, run) in items {
|
} else {
|
||||||
let button = menu_item(label, icon);
|
Request::ListShare {
|
||||||
let pop = popover.clone();
|
path: entry_rel(ui, entry),
|
||||||
button.connect_clicked(move |_| {
|
}
|
||||||
pop.popdown();
|
};
|
||||||
run();
|
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||||
});
|
let (ui, entry) = (ui.clone(), entry.clone());
|
||||||
menu.append(&button);
|
glib::spawn_future_local(async move {
|
||||||
}
|
match rx.recv().await {
|
||||||
let button = gtk4::MenuButton::builder()
|
Ok(Ok(Response::Share {
|
||||||
.icon_name("view-more-symbolic")
|
link: Some(PublicLinkInfo { url: Some(url), .. }),
|
||||||
.tooltip_text("More")
|
..
|
||||||
.valign(gtk4::Align::Center)
|
})) => {
|
||||||
.popover(&popover)
|
ui.stack.clipboard().set_text(&url);
|
||||||
.build();
|
toast(&ui, "Link copied");
|
||||||
button.add_css_class("flat");
|
}
|
||||||
button
|
Ok(Ok(Response::Share { .. })) => open_share_dialog(&ui, &entry),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't copy link", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't copy link",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the [`DirEntry`] backing the model item at `pos`, if any.
|
/// Fetch the [`DirEntry`] backing the model item at `pos`, if any.
|
||||||
|
|||||||
@@ -299,17 +299,14 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
|||||||
row.add_suffix(&more_menu_button(vec![
|
row.add_suffix(&more_menu_button(vec![
|
||||||
(
|
(
|
||||||
"Rename…",
|
"Rename…",
|
||||||
"document-edit-symbolic",
|
|
||||||
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Use This Computer's Identity…",
|
"Use This Computer's Identity…",
|
||||||
"insert-object-symbolic",
|
|
||||||
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Remove Computer…",
|
"Remove Computer…",
|
||||||
"user-trash-symbolic",
|
|
||||||
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
|
|||||||
@@ -2186,69 +2186,28 @@ pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
/// A tile's right-click menu.
|
/// A tile's right-click menu.
|
||||||
fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64, y: f64) {
|
fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64, y: f64) {
|
||||||
let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
let mut menu = ActionMenu::new();
|
||||||
let popover = gtk4::Popover::builder()
|
|
||||||
.has_arrow(false)
|
|
||||||
.position(gtk4::PositionType::Bottom)
|
|
||||||
.pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1))
|
|
||||||
.child(&menu)
|
|
||||||
.build();
|
|
||||||
popover.set_parent(anchor);
|
|
||||||
popover.connect_closed(|p| p.unparent());
|
|
||||||
let item = |label: &str, icon: &str, run: Box<dyn Fn()>| {
|
|
||||||
let button = menu_item(label, icon);
|
|
||||||
let pop = popover.clone();
|
|
||||||
button.connect_clicked(move |_| {
|
|
||||||
pop.popdown();
|
|
||||||
run();
|
|
||||||
});
|
|
||||||
menu.append(&button);
|
|
||||||
};
|
|
||||||
|
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
if photo.kind == PhotoKind::Video {
|
if photo.kind == PhotoKind::Video {
|
||||||
item(
|
menu.item("Play", move || play_video(&ui_c, uid.clone()));
|
||||||
"Play",
|
|
||||||
"media-playback-start-symbolic",
|
|
||||||
Box::new(move || play_video(&ui_c, uid.clone())),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
item(
|
menu.item("Open", move || open_photo_viewer(&ui_c, uid.clone()));
|
||||||
"Open",
|
|
||||||
"document-open-symbolic",
|
|
||||||
Box::new(move || open_photo_viewer(&ui_c, uid.clone())),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let (ui_c, uid, favorite) = (ui.clone(), photo.uid.clone(), photo.favorite);
|
|
||||||
item(
|
|
||||||
if favorite {
|
|
||||||
"Remove from Favorites"
|
|
||||||
} else {
|
|
||||||
"Add to Favorites"
|
|
||||||
},
|
|
||||||
if favorite {
|
|
||||||
"non-starred-symbolic"
|
|
||||||
} else {
|
|
||||||
"starred-symbolic"
|
|
||||||
},
|
|
||||||
Box::new(move || set_photo_favorite(&ui_c, uid.clone(), !favorite)),
|
|
||||||
);
|
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
item(
|
menu.toggle("Favorite", photo.favorite, move |favorite| {
|
||||||
"Select",
|
set_photo_favorite(&ui_c, uid.clone(), favorite)
|
||||||
"selection-mode-symbolic",
|
});
|
||||||
Box::new(move || {
|
|
||||||
set_selection_mode(&ui_c, true);
|
|
||||||
toggle_selected(&ui_c, &uid);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
item(
|
menu.item("Select", move || {
|
||||||
"Move to Trash…",
|
set_selection_mode(&ui_c, true);
|
||||||
"user-trash-symbolic",
|
toggle_selected(&ui_c, &uid);
|
||||||
Box::new(move || confirm_trash_photos(&ui_c, vec![uid.clone()])),
|
});
|
||||||
);
|
menu.section();
|
||||||
popover.popup();
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||||
|
menu.item("Move to Trash…", move || {
|
||||||
|
confirm_trash_photos(&ui_c, vec![uid.clone()])
|
||||||
|
});
|
||||||
|
menu.popup_at(anchor, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Star or unstar a photo from the grid, and show the new state.
|
/// Star or unstar a photo from the grid, and show the new state.
|
||||||
|
|||||||
@@ -236,32 +236,22 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
||||||
/// node by uid, so every shared item can be managed from here.
|
/// node by uid, so every shared item can be managed from here.
|
||||||
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
||||||
let mut items: Vec<(&str, &str, MenuAction)> = Vec::new();
|
let mut items: Vec<(&str, MenuAction)> = Vec::new();
|
||||||
if !entry.path.is_empty() {
|
if !entry.path.is_empty() {
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
items.push((
|
items.push(("Open", Box::new(move || open_shared_by_me(&ui_c, &entry_c))));
|
||||||
"Open",
|
|
||||||
"document-open-symbolic",
|
|
||||||
Box::new(move || open_shared_by_me(&ui_c, &entry_c)),
|
|
||||||
));
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
items.push((
|
items.push((
|
||||||
"Show in My Files",
|
"Show in My Files",
|
||||||
"folder-symbolic",
|
|
||||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(url) = url {
|
if let Some(url) = url {
|
||||||
items.push((
|
items.push(("Open Link", Box::new(move || open_uri(&url))));
|
||||||
"Open Link",
|
|
||||||
"external-link-symbolic",
|
|
||||||
Box::new(move || open_uri(&url)),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
items.push((
|
items.push((
|
||||||
"Manage Access…",
|
"Manage Access…",
|
||||||
"system-users-symbolic",
|
|
||||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
||||||
));
|
));
|
||||||
more_menu_button(items)
|
more_menu_button(items)
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// An action run by an [`ActionMenu`] item.
|
||||||
|
pub(crate) type MenuAction = Box<dyn Fn()>;
|
||||||
|
|
||||||
|
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
|
||||||
|
/// what they were opened for (a row, a selection, a photo). Each item gets its
|
||||||
|
/// own action in a private `menu` group, installed on whatever hosts the
|
||||||
|
/// popover, so the result is a real [`gtk4::PopoverMenu`]: arrow keys, Enter,
|
||||||
|
/// Escape and mnemonics work, and it reads as a menu to assistive technology.
|
||||||
|
pub(crate) struct ActionMenu {
|
||||||
|
menu: gio::Menu,
|
||||||
|
section: gio::Menu,
|
||||||
|
group: gio::SimpleActionGroup,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionMenu {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
menu: gio::Menu::new(),
|
||||||
|
section: gio::Menu::new(),
|
||||||
|
group: gio::SimpleActionGroup::new(),
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_action(&mut self) -> String {
|
||||||
|
self.count += 1;
|
||||||
|
format!("a{}", self.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain item.
|
||||||
|
pub(crate) fn item(&mut self, label: &str, run: impl Fn() + 'static) -> &mut Self {
|
||||||
|
let name = self.next_action();
|
||||||
|
let action = gio::SimpleAction::new(&name, None);
|
||||||
|
action.connect_activate(move |_, _| run());
|
||||||
|
self.group.add_action(&action);
|
||||||
|
self.section
|
||||||
|
.append(Some(label), Some(&format!("menu.{name}")));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A check item showing `active`; `run` gets the state the user asked for.
|
||||||
|
pub(crate) fn toggle(
|
||||||
|
&mut self,
|
||||||
|
label: &str,
|
||||||
|
active: bool,
|
||||||
|
run: impl Fn(bool) + 'static,
|
||||||
|
) -> &mut Self {
|
||||||
|
let name = self.next_action();
|
||||||
|
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||||
|
action.connect_activate(move |action, _| {
|
||||||
|
let next = !action
|
||||||
|
.state()
|
||||||
|
.and_then(|s| s.get::<bool>())
|
||||||
|
.unwrap_or(false);
|
||||||
|
action.set_state(&next.to_variant());
|
||||||
|
run(next);
|
||||||
|
});
|
||||||
|
self.group.add_action(&action);
|
||||||
|
self.section
|
||||||
|
.append(Some(label), Some(&format!("menu.{name}")));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the current section; the next item starts a new one below a
|
||||||
|
/// separator. An empty section is skipped, so callers can end a group of
|
||||||
|
/// conditional items unconditionally.
|
||||||
|
pub(crate) fn section(&mut self) -> &mut Self {
|
||||||
|
self.flush(None);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the current section under a heading.
|
||||||
|
pub(crate) fn labelled_section(&mut self, heading: &str) -> &mut Self {
|
||||||
|
self.flush(Some(heading));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self, heading: Option<&str>) {
|
||||||
|
if self.section.n_items() > 0 {
|
||||||
|
self.menu.append_section(heading, &self.section);
|
||||||
|
self.section = gio::Menu::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(mut self) -> (gio::Menu, gio::SimpleActionGroup) {
|
||||||
|
self.flush(None);
|
||||||
|
(self.menu, self.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||||
|
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||||
|
/// item that closed it still finds its action.
|
||||||
|
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
||||||
|
let (menu, group) = self.finish();
|
||||||
|
let popover = gtk4::PopoverMenu::from_model(Some(&menu));
|
||||||
|
popover.set_has_arrow(false);
|
||||||
|
popover.set_position(gtk4::PositionType::Bottom);
|
||||||
|
popover.set_halign(gtk4::Align::Start);
|
||||||
|
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
||||||
|
popover.insert_action_group("menu", Some(&group));
|
||||||
|
popover.set_parent(anchor);
|
||||||
|
popover.connect_closed(|p| {
|
||||||
|
let p = p.clone();
|
||||||
|
glib::idle_add_local_once(move || p.unparent());
|
||||||
|
});
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat ⋮ button that opens this menu.
|
||||||
|
pub(crate) fn button(self) -> gtk4::MenuButton {
|
||||||
|
let (menu, group) = self.finish();
|
||||||
|
let button = gtk4::MenuButton::builder()
|
||||||
|
.icon_name("view-more-symbolic")
|
||||||
|
.tooltip_text("More")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.menu_model(&menu)
|
||||||
|
.build();
|
||||||
|
button.insert_action_group("menu", Some(&group));
|
||||||
|
button.add_css_class("flat");
|
||||||
|
button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat ⋮ button whose menu lists `items` as (label, action).
|
||||||
|
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
|
||||||
|
let mut menu = ActionMenu::new();
|
||||||
|
for (label, run) in items {
|
||||||
|
menu.item(label, run);
|
||||||
|
}
|
||||||
|
menu.button()
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
|
pub(crate) mod menu;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
pub(crate) mod thumbnails;
|
||||||
pub(crate) mod versions_dialog;
|
pub(crate) mod versions_dialog;
|
||||||
@@ -66,6 +66,12 @@ Schema: **31** (unchanged).
|
|||||||
keyring, a skipped two-factor or verification step — instead of the raw error. The button spins
|
keyring, a skipped two-factor or verification step — instead of the raw error. The button spins
|
||||||
while signing in, and the page links to account creation and password reset. The two-factor
|
while signing in, and the page links to account creation and password reset. The two-factor
|
||||||
dialog mentions recovery codes.
|
dialog mentions recovery codes.
|
||||||
|
- **Real menus.** Right-click menus in My Files, Photos, Shared by Me and Computers are GTK menus:
|
||||||
|
arrow keys, Enter and Escape work, and the Menu key or Shift+F10 opens the one for the selection
|
||||||
|
in My Files. Items follow one order — Open, Open With…, Play · Available offline · Share…,
|
||||||
|
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.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user