Files
waydock/src/menu.rs
claude 2d361306b2
All checks were successful
check / check (push) Successful in 1m42s
Fixa krasch: RefCell-dubbellåning när popover-stacken tömdes
popdown() emittar closed synkront, och closed-hanteraren lånar också
popover-stacken — register_popover höll kvar sitt borrow_mut under
drain-loopen → 'RefCell already borrowed'-panik vid högerklick när en
tidigare popover låg kvar i stacken. Stacken töms nu till en lokal
Vec innan popdown körs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:16:48 +02:00

645 lines
22 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Kontextmenyer: höger­klick på app-ikon (konfigurerbar via
// config.context_menu), grupp-popup med fönsterlistan, samt panelmenyn
// på tom yta (Inställningar…, Ladda om, Avsluta).
use std::rc::Rc;
use gtk4 as gtk;
use gtk4::gdk;
use gtk4::gio;
use gtk4::glib;
use gtk4::prelude::*;
use log::warn;
use crate::config::{self, GroupPopup, ListHover, MenuItem};
use crate::dock::{Ctx, Item};
use crate::icons;
use crate::settings;
use crate::toplevel::ToplevelInfo;
use crate::wayfire_ipc;
/// Handle till en öppen fönsterlista: gruppikonen skroll-stegar via denna.
pub struct GroupList {
pub key: String,
pub connector: String,
pub step: Rc<dyn Fn(f64)>,
}
/// Målet för den senast öppnade ikon-menyn (actions läser härifrån).
pub struct MenuTarget {
pub key: String,
pub windows: Vec<ToplevelInfo>,
pub pinned: bool,
pub connector: String,
}
fn with_service(ctx: &Ctx, f: impl Fn(&crate::toplevel::ToplevelService, &ToplevelInfo)) {
let target = ctx.menu_target.borrow();
let service = ctx.service.borrow();
if let (Some(t), Some(s)) = (target.as_ref(), service.as_ref()) {
for w in &t.windows {
f(s, w);
}
}
}
/// Registrera action-gruppen "dock" på ett dockfönster.
pub fn setup_dock_actions(ctx: &Rc<Ctx>, win: &gtk::ApplicationWindow) {
let group = gio::SimpleActionGroup::new();
let minimize = gio::SimpleAction::new("minimize", None);
{
let ctx = ctx.clone();
minimize.connect_activate(move |_, _| {
let all_min = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| t.windows.iter().all(|w| w.minimized))
.unwrap_or(false);
with_service(&ctx, |s, w| s.set_minimized(w, !all_min));
});
}
group.add_action(&minimize);
let maximize = gio::SimpleAction::new("maximize", None);
{
let ctx = ctx.clone();
maximize.connect_activate(move |_, _| {
let all_max = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| t.windows.iter().all(|w| w.maximized))
.unwrap_or(false);
with_service(&ctx, |s, w| s.set_maximized(w, !all_max));
});
}
group.add_action(&maximize);
let close = gio::SimpleAction::new("close", None);
{
let ctx = ctx.clone();
close.connect_activate(move |_, _| with_service(&ctx, |s, w| s.close(w)));
}
group.add_action(&close);
let pin = gio::SimpleAction::new("pin", None);
{
let ctx = ctx.clone();
pin.connect_activate(move |_, _| {
let connector = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| t.connector.clone())
.unwrap_or_default();
toggle_pin(&ctx, &connector);
});
}
group.add_action(&pin);
let move_to = gio::SimpleAction::new("move-to-output", Some(glib::VariantTy::STRING));
{
let ctx = ctx.clone();
move_to.connect_activate(move |_, param| {
let Some(output) = param.and_then(|v| v.get::<String>()) else { return };
let target = ctx.menu_target.borrow();
let Some(t) = target.as_ref() else { return };
for w in &t.windows {
match wayfire_ipc::find_view_id(&w.app_id, &w.title) {
Some(id) => wayfire_ipc::move_view_to_output(id, &output),
None => warn!("hittade ingen wayfire-vy för {}", w.app_id),
}
}
});
}
group.add_action(&move_to);
win.insert_action_group("dock", Some(&group));
}
fn toggle_pin(ctx: &Rc<Ctx>, connector: &str) {
let Some((key, pinned)) = ctx
.menu_target
.borrow()
.as_ref()
.map(|t| (t.key.clone(), t.pinned))
else {
return;
};
{
let mut cfg = ctx.cfg.borrow_mut();
// skriv i skärmens pin-override om den har en, annars i de globala
let has_own = cfg
.outputs
.get(connector)
.map(|o| o["pins"].is_array())
.unwrap_or(false);
let mutate = |pins: &mut Vec<String>| {
if pinned {
pins.retain(|p| p.to_lowercase() != key);
} else if !pins.iter().any(|p| p.to_lowercase() == key) {
pins.push(key.clone());
}
};
if has_own {
let over = cfg.outputs.get_mut(connector).expect("has_own kollades nyss");
let mut pins: Vec<String> =
serde_json::from_value(over["pins"].clone()).unwrap_or_default();
mutate(&mut pins);
over["pins"] = serde_json::to_value(pins).expect("strängar serialiserbara");
} else {
mutate(&mut cfg.pins);
}
if let Err(e) = config::save(&ctx.cfg_path, &cfg) {
warn!("kunde inte spara config: {e}");
}
}
crate::dock::refresh_items(ctx);
}
/// Registrera en popover i stacken. Alla våra popovers är GRAB-FRIA
/// (autohide av) — grabbande popups låser GTK:s pekar-grab på Wayland
/// och krockar med tooltips. En popover vars ankare sitter i en redan
/// öppen popover nästlas (t.ex. radmeny i fönsterlistan); annars stängs
/// allt gammalt först. Uppskjuten ikon-refresh körs när allt stängts.
fn register_popover(ctx: &Rc<Ctx>, popover: &impl IsA<gtk::Popover>) {
let p = popover.as_ref().clone();
p.set_autohide(false);
// nästlad om ankaret sitter inne i någon redan öppen popover
let nested = p
.parent()
.map(|anchor| {
ctx.popover_stack
.borrow()
.iter()
.any(|open| anchor.is_ancestor(open))
})
.unwrap_or(false);
if !nested {
// töm stacken FÖRE popdown — closed-hanterarna lånar också
// stacken (popdown emittar closed synkront → dubbellåning annars)
let old: Vec<gtk::Popover> = ctx.popover_stack.borrow_mut().drain(..).collect();
for p in old {
p.popdown();
}
}
ctx.popover_stack.borrow_mut().push(p.clone());
ctx.open_popovers.set(ctx.open_popovers.get() + 1);
let ctx = ctx.clone();
p.connect_closed(move |p| {
ctx.open_popovers
.set(ctx.open_popovers.get().saturating_sub(1));
ctx.popover_stack.borrow_mut().retain(|x| x != p);
let p = p.clone();
let ctx2 = ctx.clone();
glib::idle_add_local_once(move || {
p.unparent();
if ctx2.open_popovers.get() == 0 && ctx2.refresh_pending.get() {
crate::dock::refresh_items(&ctx2);
}
});
});
}
/// Är popovern överst i stacken? (bara då får den auto-stängas — annars
/// stänger förälderns leave-timer den medan en nästlad meny är öppen)
fn is_topmost(ctx: &Ctx, p: &gtk::Popover) -> bool {
ctx.popover_stack.borrow().last() == Some(p)
}
/// Gemensam auto-stängning: pekaren lämnar → stäng efter delay;
/// watchdog om pekaren aldrig når popovern.
fn setup_autoclose(
ctx: &Rc<Ctx>,
popover: &impl IsA<gtk::Popover>,
leave_delay_ms: u64,
keep_open: Option<Rc<dyn Fn() -> bool>>,
) {
let p = popover.as_ref().clone();
let mc = gtk::EventControllerMotion::new();
{
let p2 = p.clone();
let ctx2 = ctx.clone();
mc.connect_leave(move |ctl| {
let p3 = p2.clone();
let ctl2 = ctl.clone();
let ctx3 = ctx2.clone();
glib::timeout_add_local_once(
std::time::Duration::from_millis(leave_delay_ms),
move || {
if !ctl2.contains_pointer() && is_topmost(&ctx3, &p3) {
p3.popdown();
}
},
);
});
}
p.add_controller(mc.clone());
// watchdog: aldrig besökt + överst + inget händer → stäng
{
let p2 = p.clone();
let ctx2 = ctx.clone();
let visited = std::cell::Cell::new(false);
glib::timeout_add_local(std::time::Duration::from_millis(1800), move || {
if mc.contains_pointer() {
visited.set(true);
}
if visited.get() || !is_topmost(&ctx2, &p2) {
return glib::ControlFlow::Break; // besökt/inte överst: leave-logiken tar över
}
// aktivitet utan pekare (t.ex. skroll-stegning från ikonen)
if keep_open.as_ref().map(|f| f()).unwrap_or(false) {
return glib::ControlFlow::Continue;
}
p2.popdown();
glib::ControlFlow::Break
});
}
}
/// Popover-riktning bort från dockans kant (så listan inte öppnas rakt
/// under muspekaren och råkar hovra första raden direkt).
fn popover_position(ctx: &Rc<Ctx>, anchor: &gtk::Button) -> gtk::PositionType {
let connector = connector_of(ctx, anchor);
match ctx.cfg.borrow().for_output(&connector).position {
crate::config::Position::Bottom => gtk::PositionType::Top,
crate::config::Position::Top => gtk::PositionType::Bottom,
crate::config::Position::Left => gtk::PositionType::Right,
crate::config::Position::Right => gtk::PositionType::Left,
}
}
fn connector_of(ctx: &Rc<Ctx>, widget: &gtk::Button) -> String {
ctx.docks
.borrow()
.iter()
.find(|d| {
widget
.ancestor(gtk::ApplicationWindow::static_type())
.map(|w| w == *d.window.upcast_ref::<gtk::Widget>())
.unwrap_or(false)
})
.map(|d| d.connector.clone())
.unwrap_or_default()
}
/// Högerklicksmenyn på en app-ikon.
pub fn popup_item_menu(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) {
let cfg = ctx.cfg.borrow();
let has_windows = !item.windows.is_empty();
let all_min = has_windows && item.windows.iter().all(|w| w.minimized);
let all_max = has_windows && item.windows.iter().all(|w| w.maximized);
let menu = gio::Menu::new();
for entry in &cfg.context_menu {
match entry {
MenuItem::Minimize if has_windows => {
menu.append(
Some(if all_min { "Återställ" } else { "Minimera" }),
Some("dock.minimize"),
);
}
MenuItem::Maximize if has_windows => {
menu.append(
Some(if all_max { "Avmaximera" } else { "Maximera" }),
Some("dock.maximize"),
);
}
MenuItem::MoveToOutput if has_windows && wayfire_ipc::available() => {
let sub = gio::Menu::new();
let docks = ctx.docks.borrow();
for dock in docks.iter() {
let mi = gio::MenuItem::new(Some(&dock.connector), None);
mi.set_action_and_target_value(
Some("dock.move-to-output"),
Some(&dock.connector.to_variant()),
);
sub.append_item(&mi);
}
menu.append_submenu(Some("Flytta till skärm"), &sub);
}
MenuItem::Pin => {
menu.append(
Some(if item.pinned { "Släpp pin" } else { "Pinna" }),
Some("dock.pin"),
);
}
MenuItem::Close if has_windows => {
menu.append(Some("Stäng"), Some("dock.close"));
}
_ => {}
}
}
drop(cfg);
// hitta skärmens connector via dockan som äger knappen
let connector = connector_of(ctx, anchor);
*ctx.menu_target.borrow_mut() = Some(MenuTarget {
key: item.meta.key.clone(),
windows: item.windows.clone(),
pinned: item.pinned,
connector,
});
let popover = gtk::PopoverMenu::from_model(Some(&menu));
popover.add_css_class("waydock-menu");
popover.set_position(popover_position(ctx, anchor));
popover.set_parent(anchor);
register_popover(ctx, &popover);
setup_autoclose(ctx, &popover, 700, None);
popover.popup();
}
/// Tona ner alla andra fönster än det hovrade (peek). Returnerar id:n
/// som dimmats så de kan återställas.
fn peek_apply(hovered: &crate::toplevel::ToplevelInfo) -> Vec<i64> {
let Some(views) = wayfire_ipc::list_views() else {
return Vec::new();
};
let hovered_id = views
.iter()
.find(|v| {
v["app-id"].as_str() == Some(hovered.app_id.as_str())
&& v["title"].as_str() == Some(hovered.title.as_str())
})
.and_then(|v| v["id"].as_i64());
let mut dimmed = Vec::new();
for v in &views {
if v["role"].as_str() != Some("toplevel")
|| !v["mapped"].as_bool().unwrap_or(false)
|| v["minimized"].as_bool().unwrap_or(false)
{
continue;
}
let Some(id) = v["id"].as_i64() else { continue };
if Some(id) != hovered_id {
wayfire_ipc::set_view_alpha(id, 0.15);
dimmed.push(id);
}
}
dimmed
}
fn peek_restore(dimmed: &[i64]) {
for id in dimmed {
wayfire_ipc::set_view_alpha(*id, 1.0);
}
}
/// Vänsterklick (eller hover) på grupp med flera fönster: lista fönstren.
pub fn popup_group_list(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) {
let popover = gtk::Popover::new();
popover.add_css_class("waydock-menu");
popover.set_position(popover_position(ctx, anchor));
let list = gtk::Box::new(gtk::Orientation::Vertical, 2);
let connector = connector_of(ctx, anchor);
let (list_hover, hover_mode) = {
let b = ctx.cfg.borrow().for_output(&connector).behavior;
(b.list_hover.clone(), b.group_popup == GroupPopup::Hover)
};
// fönsterlistan grabbar ALDRIG: grabben gör att första klicket
// utanför bara stänger listan (man kan inte öppna nästa grupp
// direkt) och grabbing-popups i följd låser GTK på Wayland.
// Stängs i stället på pointer-leave + watchdog.
popover.set_autohide(false);
// hover-effekterna armeras först efter en stund så att raden som
// råkar hamna under pekaren vid öppning inte triggar direkt
let armed = Rc::new(std::cell::Cell::new(false));
{
let armed = armed.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(250), move || {
armed.set(true);
});
}
let peek_possible = list_hover == ListHover::Peek && wayfire_ipc::available();
// fönster som tonats ner av peek — återställs vid radbyte/stängning
let dimmed: Rc<std::cell::RefCell<Vec<i64>>> = Rc::new(std::cell::RefCell::new(Vec::new()));
// bygg raderna först, koppla handlers efteråt (select behöver alla)
let windows: Rc<Vec<crate::toplevel::ToplevelInfo>> = Rc::new(item.windows.clone());
let mut rows_vec = Vec::new();
for w in windows.iter() {
let row = gtk::Button::new();
row.add_css_class("flat");
let title = if w.title.is_empty() { item.meta.display_name.clone() } else { w.title.clone() };
let label = gtk::Label::new(Some(&title));
label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
label.set_max_width_chars(40);
label.set_halign(gtk::Align::Start);
row.set_child(Some(&label));
list.append(&row);
rows_vec.push(row);
}
let rows: Rc<Vec<gtk::Button>> = Rc::new(rows_vec);
// markera rad idx: visuell markering + förhandsvisning (peek/fokus)
let selected = Rc::new(std::cell::Cell::new(None::<usize>));
let select: Rc<dyn Fn(usize)> = Rc::new({
let rows = rows.clone();
let windows = windows.clone();
let dimmed = dimmed.clone();
let selected = selected.clone();
let list_hover = list_hover.clone();
let ctx2 = ctx.clone();
move |idx: usize| {
for (i, r) in rows.iter().enumerate() {
if i == idx {
r.add_css_class("selected");
} else {
r.remove_css_class("selected");
}
}
selected.set(Some(idx));
let w = &windows[idx];
match list_hover {
ListHover::Peek if peek_possible => {
peek_restore(&dimmed.borrow());
*dimmed.borrow_mut() = peek_apply(w);
}
ListHover::Focus => {
if let Some(s) = ctx2.service.borrow().as_ref() {
s.activate(w);
}
}
_ => {}
}
}
});
// skroll-stegning (från gruppikonen eller över listan)
let step: Rc<dyn Fn(f64)> = Rc::new({
let select = select.clone();
let selected = selected.clone();
let n = windows.len();
move |dy: f64| {
if n == 0 {
return;
}
let next = match selected.get() {
None => {
if dy > 0.0 { 0 } else { n - 1 }
}
Some(i) => {
if dy > 0.0 { (i + 1) % n } else { (i + n - 1) % n }
}
};
select(next);
}
});
// per rad: klick aktiverar; hover markerar (efter armering)
for (i, (row, w)) in rows.iter().zip(windows.iter()).enumerate() {
let ctx2 = ctx.clone();
let w2 = w.clone();
let pop2 = popover.clone();
let dimmed2 = dimmed.clone();
row.connect_clicked(move |_| {
peek_restore(&dimmed2.borrow());
dimmed2.borrow_mut().clear();
if let Some(s) = ctx2.service.borrow().as_ref() {
s.activate(&w2);
}
pop2.popdown();
});
if list_hover != ListHover::None {
let mc = gtk::EventControllerMotion::new();
let select2 = select.clone();
let armed2 = armed.clone();
mc.connect_enter(move |_, _, _| {
if armed2.get() {
select2(i);
}
});
row.add_controller(mc);
}
// högerklick på raden: den rutans egen meny (nästlad popover)
{
let rc = gtk::GestureClick::new();
rc.set_button(3);
let ctx3 = ctx.clone();
let w3 = w.clone();
let meta3 = item.meta.clone();
let pinned3 = item.pinned;
let row3 = row.clone();
rc.connect_released(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
let single = Rc::new(Item {
meta: meta3.clone(),
windows: vec![w3.clone()],
pinned: pinned3,
});
popup_item_menu(&ctx3, &single, &row3);
});
row.add_controller(rc);
}
}
// skroll över själva listan stegar också
{
let sc = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL);
let step2 = step.clone();
sc.connect_scroll(move |_, _, dy| {
step2(dy);
glib::Propagation::Stop
});
popover.add_controller(sc);
}
// registrera handlet så gruppikonens skroll når stegningen
*ctx.group_list.borrow_mut() = Some(GroupList {
key: item.meta.key.clone(),
connector: connector.clone(),
step: step.clone(),
});
{
let ctx2 = ctx.clone();
let key = item.meta.key.clone();
popover.connect_closed(move |_| {
let mut gl = ctx2.group_list.borrow_mut();
if gl.as_ref().map(|h| h.key == key).unwrap_or(false) {
*gl = None;
}
});
}
popover.set_child(Some(&list));
popover.set_parent(anchor);
// säkerhetsnät: återställ peek när popovern stängs
{
let dimmed5 = dimmed.clone();
popover.connect_closed(move |_| {
peek_restore(&dimmed5.borrow());
dimmed5.borrow_mut().clear();
});
}
register_popover(ctx, &popover);
// skroll-stegning från ikonen räknas som aktivitet — markerad rad
// håller listan öppen även om pekaren aldrig går in i den
let keep = {
let selected = selected.clone();
Rc::new(move || selected.get().is_some()) as Rc<dyn Fn() -> bool>
};
setup_autoclose(ctx, &popover, if hover_mode { 400 } else { 700 }, Some(keep));
popover.popup();
}
/// Högerklick på tom panelyta.
pub fn popup_panel_menu(ctx: &Rc<Ctx>, panel: &gtk::Box, x: f64, y: f64) {
let menu = gio::Menu::new();
menu.append(Some("Inställningar…"), Some("panel.settings"));
menu.append(Some("Ladda om config"), Some("panel.reload"));
menu.append(Some("Avsluta waydock"), Some("panel.quit"));
let group = gio::SimpleActionGroup::new();
let open_settings = gio::SimpleAction::new("settings", None);
{
let ctx = ctx.clone();
open_settings.connect_activate(move |_, _| settings::open(&ctx));
}
group.add_action(&open_settings);
let reload = gio::SimpleAction::new("reload", None);
{
let ctx = ctx.clone();
reload.connect_activate(move |_, _| {
let fresh = config::load_or_create(&ctx.cfg_path);
*ctx.cfg.borrow_mut() = fresh;
if let Some(cb) = ctx.on_config_changed.borrow().as_ref() {
cb();
}
});
}
group.add_action(&reload);
let quit = gio::SimpleAction::new("quit", None);
{
let ctx = ctx.clone();
quit.connect_activate(move |_, _| ctx.app.quit());
}
group.add_action(&quit);
panel.insert_action_group("panel", Some(&group));
let popover = gtk::PopoverMenu::from_model(Some(&menu));
popover.add_css_class("waydock-menu");
popover.set_parent(panel);
popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
register_popover(ctx, &popover);
setup_autoclose(ctx, &popover, 700, None);
popover.popup();
}
/// Starta en app från menyn (används inte ännu — behålls för settings-GUI:t).
#[allow(dead_code)]
pub fn launch_item(item: &Item) {
icons::launch(&item.meta);
}