Files
proton-drive-linux/crates/pdfs-gui/src/app/widgets/menu.rs
T
nvrl ad49e32de9 fix(gui): context menus size to fit instead of scrolling
A popover is sized and placed again when its parent's layout manager
allocates it. Context menus were parented to the outermost list view,
and the keyboard menu to the ScrolledWindow; neither has a layout
manager, so a menu kept the size it was first given and often opened
too short, with a scrollbar.

The host is now the first widget from the list view up that has a
layout manager, which still keeps menus out of list items and the theme
rules that style their insides.
2026-10-09 02:44:00 +02:00

162 lines
5.8 KiB
Rust

use crate::*;
/// 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 anchor = anchor.as_ref();
let host = popover_host(anchor);
let (x, y) = anchor
.compute_point(&host, &gtk4::graphene::Point::new(x as f32, y as f32))
.map(|p| (p.x() as f64, p.y() as f64))
.unwrap_or((x, y));
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(&gtk4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover.insert_action_group("menu", Some(&group));
popover.set_parent(&host);
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")
// Translators: tooltip of a button that opens a menu of further actions.
.tooltip_text(gettext("More"))
.valign(gtk4::Align::Center)
.menu_model(&menu)
.build();
button.insert_action_group("menu", Some(&group));
button.add_css_class("flat");
button
}
}
/// Where a menu opened over `anchor` should live in the widget tree.
///
/// Not inside a list item: CSS descendant selectors reach through a popover to
/// its parent, and themes style the insides of list items (`gridview > child
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
/// rules up and puts a wide margin around every section. So the search starts
/// at the outermost grid or list view around `anchor`.
///
/// Not on a widget without a layout manager either: a popover is sized and
/// placed again when its parent's layout manager allocates it. The list views
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
/// first given, often too small, and opened scrolling. The host is the first
/// widget from there up that has one.
fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
let mut start = anchor.clone();
let mut widget = anchor.parent();
while let Some(w) = widget {
widget = w.parent();
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
start = w;
}
}
let mut host = Some(start.clone());
while let Some(w) = host {
if w.layout_manager().is_some() {
return w;
}
host = w.parent();
}
start
}