1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
//! GTK overlay helper for [`MenuSystem`] dropdowns.
//!
//! Encapsulates the `DrawingArea` boilerplate needed when a GTK app
//! renders the menu bar in a separate titlebar DA and the dropdown
//! popup in a full-window overlay DA. The helper handles the negative-y
//! coordinate transform (the bar sits above the overlay), DA property
//! setup (`set_focusable(false)`, `set_can_target` toggling), and
//! draw / click / motion wiring — eliminating the two bug classes that
//! arise from hand-rolling this (~120 lines of per-app code):
//!
//! 1. **Coordinate mismatch** — render applies the negative-y offset
//! but click/motion handlers forget it, so hit-testing is wrong.
//! 2. **Missing `set_focusable(false)`** — GTK steals Left/Right arrow
//! keys for focus navigation instead of routing them to MenuSystem.
//!
//! # Usage
//!
//! ```ignore
//! let overlay = MenuOverlay::new();
//! overlay.connect(menu_system, backend, bar_rect_cell, "Sans 11", |ev| {
//! match ev {
//! MenuEvent::Activated(id) => { /* dispatch action */ }
//! MenuEvent::Ignored => {}
//! _ => { /* trigger redraw */ }
//! }
//! });
//! window_overlay.add_overlay(overlay.drawing_area());
//!
//! // On MenuRedraw:
//! overlay.sync(menu_system.borrow().is_open());
//! ```
//! ## GTK-specific compositing glue
//!
//! `MenuOverlay` positions an already-painted `MenuSystem` popup
//! within GTK's native overlay-widget model (`gtk4::Overlay` +
//! a second `DrawingArea`). It implements no primitive's
//! `layout()` / paint contract, so no `draw_<name>` method or
//! `Surface` variant exists for it. See
//! `quadraui/docs/decisions/DECISIONS.md` D-007.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{pango, DrawingArea, EventControllerMotion, GestureClick};
use pangocairo::functions as pcfn;
use super::backend::GtkBackend;
use crate::backend::Backend;
use crate::compose::menu_system::{MenuEvent, MenuSystem};
use crate::event::{ButtonMask, MouseButton, Point, Rect, UiEvent, Viewport};
use crate::types::Modifiers;
/// Overlay `DrawingArea` wired to render and dispatch events for a
/// [`MenuSystem`] dropdown popup.
pub struct MenuOverlay {
da: DrawingArea,
}
impl Default for MenuOverlay {
fn default() -> Self {
Self::new()
}
}
impl MenuOverlay {
/// Create a correctly-configured overlay `DrawingArea`. The DA
/// starts with `can_target(false)` (events pass through to the
/// content below) and `focusable(false)` (GTK won't steal arrow
/// keys for focus navigation). Call [`Self::connect`] to wire
/// draw / click / motion handlers, then add the DA to your
/// `gtk4::Overlay` via [`Self::drawing_area`].
pub fn new() -> Self {
let da = DrawingArea::new();
da.set_hexpand(true);
da.set_vexpand(true);
da.set_can_target(false);
da.set_focusable(false);
Self { da }
}
/// The underlying `DrawingArea`. Pass to
/// `gtk4::Overlay::add_overlay`.
pub fn drawing_area(&self) -> &DrawingArea {
&self.da
}
/// Translate a bar_rect from the menu bar DA's local coordinates
/// into the overlay DA's coordinate space. The menu bar sits above
/// the overlay (it's a GTK titlebar), so the bar's y-origin becomes
/// negative in overlay space.
pub fn bar_rect_in_overlay(bar_rect: Rect) -> Rect {
Rect::new(
bar_rect.x,
-bar_rect.height,
bar_rect.width,
bar_rect.height,
)
}
/// Toggle event capture and queue a redraw. Call on every
/// `MenuEvent::StateChanged` / `MenuEvent::Consumed`.
///
/// When the menu is open, `can_target(true)` lets the overlay
/// intercept clicks (so they hit the dropdown, not the content
/// behind it). When closed, `can_target(false)` makes the overlay
/// transparent to events.
///
/// Optionally pass the menu bar's `DrawingArea` to queue its
/// redraw too (so the bar's highlight updates in sync).
pub fn sync(&self, is_open: bool, bar_da: Option<&DrawingArea>) {
self.da.set_can_target(is_open);
self.da.queue_draw();
if let Some(bar) = bar_da {
bar.queue_draw();
}
}
/// Wire draw, click, and motion handlers that delegate to
/// [`MenuSystem`]. Call once during setup.
///
/// - `menu_system` — shared `MenuSystem` state (the same instance
/// the menu bar DA's click handler and the keyboard handler use).
/// - `backend` — shared `GtkBackend`.
/// - `bar_rect` — the menu bar's rect in its own local coordinates,
/// updated each frame by the menu bar DA's draw function. The
/// helper applies the negative-y transform internally.
/// - `font` — Pango font description string (e.g. `"Sans 11"`).
/// Used to measure line_height and char_width for the overlay's
/// independent draw callback.
/// - `on_event` — callback invoked with every `MenuEvent` returned
/// by `MenuSystem::handle`. Typical dispatch: `Activated(id)` →
/// send to your action handler; `StateChanged` / `Consumed` →
/// call [`Self::sync`]; `Ignored` → no-op.
pub fn connect(
&self,
menu_system: Rc<RefCell<MenuSystem>>,
backend: Rc<RefCell<GtkBackend>>,
bar_rect: Rc<Cell<Rect>>,
font: &str,
on_event: impl Fn(MenuEvent) + 'static + Clone,
) {
self.wire_draw(menu_system.clone(), backend.clone(), bar_rect.clone(), font);
self.wire_click(
menu_system.clone(),
backend.clone(),
bar_rect.clone(),
on_event.clone(),
);
self.wire_motion(menu_system, backend, bar_rect, on_event);
}
fn wire_draw(
&self,
menu_system: Rc<RefCell<MenuSystem>>,
backend: Rc<RefCell<GtkBackend>>,
bar_rect: Rc<Cell<Rect>>,
font: &str,
) {
let font_str = font.to_string();
self.da.set_draw_func(move |_da, cr, w, h| {
if !menu_system.borrow().is_open() {
return;
}
let b_ref = backend.borrow();
let pango_layout = match b_ref.create_stable_pango_layout() {
Some(pl) => pl,
None => {
let ctx = pcfn::create_context(cr);
pango::Layout::new(&ctx)
}
};
pcfn::update_layout(cr, &pango_layout);
let font_desc = pango::FontDescription::from_string(&font_str);
pango_layout.set_font_description(Some(&font_desc));
let pango_ctx = pango_layout.context();
let metrics = pango_ctx.metrics(Some(&font_desc), None);
let lh = (metrics.ascent() + metrics.descent()) as f64 / pango::SCALE as f64;
// Delegates to `backend::measure_char_width_px`
// so this overlay's char width can't disagree with `gtk/run.rs`'s
// for the same font — `pixel_size()` rounds to whole pixels,
// which loses columns once divided into.
let cw = super::backend::measure_char_width_px(&pango_layout);
drop(b_ref);
let overlay_rect = Self::bar_rect_in_overlay(bar_rect.get());
let mut b = backend.borrow_mut();
b.begin_frame(Viewport::new(w as f32, h as f32, 1.0));
b.enter_frame_scope(cr, &pango_layout, |b| {
b.set_current_line_height(lh);
b.set_current_char_width(cw);
menu_system.borrow().render(b, overlay_rect);
});
});
}
fn wire_click(
&self,
menu_system: Rc<RefCell<MenuSystem>>,
backend: Rc<RefCell<GtkBackend>>,
bar_rect: Rc<Cell<Rect>>,
on_event: impl Fn(MenuEvent) + 'static,
) {
let gesture = GestureClick::new();
gesture.set_button(1);
gesture.connect_pressed(move |g, _, x, y| {
// `EventControllerExt::widget()` returns `Option<Widget>` as of
// gtk4 0.11 — `None` only when the controller has been
// detached, which can't happen while its own callback is
// firing, so a realized widget is guaranteed here.
let widget = g
.widget()
.expect("event controller has a widget while its callback runs");
let w = widget.width() as f32;
let h = widget.height() as f32;
let mut b = backend.borrow_mut();
b.begin_frame(Viewport::new(w, h, 1.0));
let overlay_rect = Self::bar_rect_in_overlay(bar_rect.get());
let ev = UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(x as f32, y as f32),
modifiers: Modifiers::default(),
};
let menu_event = menu_system.borrow_mut().handle(&ev, &mut *b, overlay_rect);
drop(b);
on_event(menu_event);
});
self.da.add_controller(gesture);
}
fn wire_motion(
&self,
menu_system: Rc<RefCell<MenuSystem>>,
backend: Rc<RefCell<GtkBackend>>,
bar_rect: Rc<Cell<Rect>>,
on_event: impl Fn(MenuEvent) + 'static,
) {
let motion = EventControllerMotion::new();
motion.connect_motion(move |m, x, y| {
if !menu_system.borrow().is_open() {
return;
}
// See the `connect_pressed` note above: `widget()` is
// `Option` since gtk4 0.11, but is always `Some` here.
let widget = m
.widget()
.expect("event controller has a widget while its callback runs");
let w = widget.width() as f32;
let h = widget.height() as f32;
let mut b = backend.borrow_mut();
b.begin_frame(Viewport::new(w, h, 1.0));
let overlay_rect = Self::bar_rect_in_overlay(bar_rect.get());
let ev = UiEvent::MouseMoved {
position: Point::new(x as f32, y as f32),
buttons: ButtonMask::default(),
};
let menu_event = menu_system.borrow_mut().handle(&ev, &mut *b, overlay_rect);
drop(b);
on_event(menu_event);
});
self.da.add_controller(motion);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bar_rect_in_overlay_applies_negative_y() {
let stored = Rect::new(0.0, 0.0, 800.0, 24.0);
let transformed = MenuOverlay::bar_rect_in_overlay(stored);
assert_eq!(transformed.x, 0.0);
assert_eq!(transformed.y, -24.0);
assert_eq!(transformed.width, 800.0);
assert_eq!(transformed.height, 24.0);
}
#[test]
fn bar_rect_in_overlay_preserves_x_offset() {
let stored = Rect::new(10.0, 0.0, 780.0, 20.0);
let transformed = MenuOverlay::bar_rect_in_overlay(stored);
assert_eq!(transformed.x, 10.0);
assert_eq!(transformed.y, -20.0);
assert_eq!(transformed.width, 780.0);
assert_eq!(transformed.height, 20.0);
}
}