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::()) .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, 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, >k4::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(>k4::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: >k4::Widget) -> gtk4::Widget { let mut start = anchor.clone(); let mut widget = anchor.parent(); while let Some(w) = widget { widget = w.parent(); if w.is::() || w.is::() || w.is::() { 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 }