srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/udev/render.rs
blob: 65c21dd31c7b2b94beebfa73a1c9997f896aeca7 (plain) (blame)
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
use super::*;

impl CompState {
    /// Renders and (if there was damage) page-flips a new frame on every
    /// head that is ready for one. A head with a flip still in flight is
    /// skipped this pass and picked up when its page-flip event arrives, so
    /// monitors on different refresh rates each run at their own pace
    /// instead of the slowest one gating the rest.
    pub(crate) fn render_udev_frame(&mut self) {
        self.tick_animations();
        self.tick_dirty_broadcasts();
        let locked = self.lock.locked;
        let elapsed = self.start_time.elapsed();
        // Drained before the `&mut self.udev` borrow below, so screencopy can
        // be serviced with the renderer that borrow owns.
        let mut captures = std::mem::take(&mut self.screencopy_pending);
        // Same reason: the cursor needs the renderer that borrow owns.
        let cursor_status = self.cursor_status.clone();
        let cursor_buffers = self.cursor_buffers.clone();

        // Border geometry is in global space, independent of which head
        // renders it, so it's gathered once here rather than per head.
        // Buffers are pre-built for the same reason as `cursor_buffers`:
        // Rendered per window, front-to-back (topmost first), each window's
        // content immediately followed by its decoration and border --
        // fixes the same cross-window ordering bug documented in
        // `winit/render.rs`'s render loop: a background window's titlebar could
        // otherwise show through in front of the actually-focused window on
        // top of it, since decorations/borders used to be a single flat
        // layer drawn unconditionally above *every* window's content
        // regardless of real stacking order. `visible_windows_front_to_back`
        // is `WindowManager.order` reversed - not `visible_windows`, which
        // iterates the `windows` HashMap with no ordering guarantee - the
        // same "topmost first" convention `hit_test`/`window_at` use.
        // Fetched once here (`&mut self` fields, id_to_window/space lookups
        // happen per head below without needing `self` itself mutably) --
        // see the per-head loop for why decoration/border buffers still get
        // looked up fresh per head (head-local `origin` translation).
        let ids: Vec<srdwm_core::WindowId> = if locked { Vec::new() } else { self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect() };
        let focused = self.wm.borrow().focused_id();
        // Default `false` here, unlike winit's `unwrap_or(true)` - see
        // `rounded_corners_pixman`'s module doc comment for the CPU cost
        // that makes this backend opt-in rather than on by default.
        let rounded_corners_enabled = self.wm.borrow().rounded_corners_enabled.unwrap_or(false);
        let popup_targets = if locked { Vec::new() } else { crate::elements::popup_targets(self) };

        // Which heads are eligible, and what each needs, gathered before the
        // mutable borrow of `self.udev`. Both early-outs below give the
        // `captures` taken above nowhere to go this pass - put them back
        // rather than silently dropping a client's pending screenshot
        // because a VT switch happened to be in progress at that instant.
        let Some(udev) = self.udev.as_ref() else {
            self.screencopy_pending.extend(captures);
            return;
        };
        if !udev.active {
            self.screencopy_pending.extend(captures);
            return;
        }
        let ready: Vec<(usize, Output)> = udev
            .heads
            .iter()
            .enumerate()
            .filter(|(_, h)| !h.flip_pending)
            .map(|(i, h)| (i, h.output.clone()))
            .collect();
        // Kept separately from `presented` below: layer-shell surfaces
        // (bars, docks) get their frame callback every pass regardless of
        // `has_damage`, unlike toplevel windows - see the callback loop at
        // the end of this function for why the two can't share one gate.
        let ready_outputs: Vec<Output> = ready.iter().map(|(_, o)| o.clone()).collect();

        // Damage rects travel alongside each presented output so the
        // frame-callback loop below (after `udev` is no longer borrowed)
        // can notify only the windows that damage actually overlapped --
        // see `windows_touched_by_damage`'s doc comment in elements.rs.
        let mut presented: Vec<(Output, Vec<Rectangle<i32, Physical>>)> = Vec::new();
        for (index, output) in ready {
            let lock_surface = self.lock_surface_for(&output).cloned();

            // Content/decoration elements are built per head: both need the
            // renderer, and geometry is translated into head-local space.
            let origin = self.udev.as_ref().map(|u| u.heads[index].location).unwrap_or_default();

            let Some(udev) = self.udev.as_mut() else { return };
            let head = &mut udev.heads[index];
            let back = 1 - head.front;

            let mut custom_elements: Vec<crate::elements::OverlayElement<PixmanRenderer>> = Vec::new();
            if !locked {
                // Cursor first: `render_output` draws custom elements
                // front-to-back, so the earliest element is topmost. On a
                // bare TTY nothing else draws a pointer - see `cursor.rs`.
                let pointer_pos = udev.pointer_pos;
                let hsize = udev.heads[index].size;
                custom_elements.extend(crate::cursor::render_elements(
                    &cursor_status,
                    &cursor_buffers,
                    &mut udev.renderer,
                    pointer_pos,
                    origin,
                    hsize,
                ));
                // The right-click titlebar menu, if open - pushed right
                // after the cursor so it's still topmost over every window
                // but never hides the pointer itself (you need to see what
                // you're about to click).
                if let (Some(menu), Some(buffer)) = (self.context_menu.as_ref(), self.context_menu_buffer.as_ref()) {
                    let pos = ((menu.pos.0 - origin.x) as f64, (menu.pos.1 - origin.y) as f64);
                    match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) {
                        Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
                        Err(e) => log::warn!("udev: failed to import context menu buffer: {e}"),
                    }
                }
                // Popups next: always above every window's own content,
                // matching this codebase's long-standing behavior from
                // before content moved into this same `custom_elements`
                // list (see below) - pushing them here, ahead of every
                // window and every layer-shell surface, is what keeps that
                // true now that "above everything in `self.space`" is no
                // longer a free property of a separate tier.
                custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, &mut udev.renderer, (origin.x, origin.y)));

                // The bar/dock/launcher (`Layer::Top`/`Overlay`): rendered
                // ourselves via `output_layer_elements`, not through
                // `render_output`'s automatic inclusion of `self.space` +
                // `layer_map_for_output` - see this function's own call
                // site further down for why content had to stop flowing
                // through that convenience wrapper at all (per-window
                // opacity), which took layer-shell inclusion down with it as
                // a side effect. Skipped entirely - not just covered - for
                // a fullscreen window: `we should not see the bar at all`,
                // and unmapping it (`gtk_shell`) or covering it are two
                // different guarantees. `ids` is already front-to-back, so
                // checking every id for `fullscreen` here (rather than just
                // the frontmost) covers a fullscreen window stacked behind
                // an always-on-top one too.
                let hide_top_layers = ids.iter().any(|&id| self.wm.borrow().window(id).is_some_and(|w| w.fullscreen));
                if !hide_top_layers {
                    custom_elements.extend(crate::elements::output_layer_elements(
                        &mut udev.renderer,
                        &output,
                        (origin.x, origin.y),
                        |layer| matches!(layer, Layer::Top | Layer::Overlay),
                    ));
                }

                // Windows stacked in front of whichever one border/
                // decoration is being built right now - `ids` is already
                // front-to-back, so this only ever needs appending to, not
                // recomputing. A window's own *content*, pushed inside this
                // same loop below, needs no separate occlusion test: it
                // draws in the same front-to-back push order as everything
                // else here, so ordinary painter's-algorithm draw order
                // already occludes it correctly (this is exactly why content
                // used to occlude correctly via `self.space`'s own order,
                // before it had to move into this list for per-window
                // opacity to be possible at all). The border strips and
                // titlebar bitmap are different: outside `geometry`, drawn
                // via a bitmap that isn't itself window-shaped, so they
                // still need `occluders`' explicit clip against whichever
                // window is stacked in front.
                let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len());
                for &id in &ids {
                    let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
                    // `w.geometry` is the animation's *target*, not
                    // necessarily where the window is actually drawn this
                    // frame - during a maximize/fullscreen/open-slide tween,
                    // `sync_geometry` already renders the window's own
                    // content at `window_anims`' interpolated rect (see its
                    // doc comment), but this loop used to read `w.geometry`
                    // straight from the model regardless, so the border and
                    // titlebar sat at the final rect while the content they
                    // were supposed to outline slid past underneath them --
                    // reported live as the border "not flush" with the
                    // window during any animated transition. Every use of
                    // this window's geometry below (titlebar/border
                    // placement *and* the occlusion test against later
                    // windows) has to agree with what `sync_geometry` mapped
                    // the content to, or they drift apart again.
                    let geom = self.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry);
                    // Drawn first among this window's own decoration, and
                    // positioned from the same animated `geom` as everything
                    // else here - not `w.geometry` - for the identical
                    // reason: a shadow that stayed at the pre-tween rect
                    // while the window slid past it would look exactly as
                    // detached as the border did before that fix. Not
                    // fragment-clipped against `occluders` like the titlebar/
                    // border below: at `SHADOW_MAX_ALPHA`'s low opacity, a
                    // shadow bleeding slightly onto a window stacked in front
                    // of this one reads as a soft edge, not the hard-line
                    // bleed-through that made the titlebar/border need it.
                    if let Some(shadow) = self.shadow_buffers.get(&id) {
                        let rect = decoration::shadow_rect(geom);
                        let pos = ((rect.x - origin.x) as f64, (rect.y - origin.y) as f64);
                        match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, None, None, Kind::Unspecified) {
                            Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
                            Err(e) => log::warn!("udev: failed to import shadow buffer: {e}"),
                        }
                    }
                    if let Some(deco) = self.decorations.get(&id) {
                        // Fragment-clipped, same as the three solid border
                        // strips below - an *all-or-nothing* version of
                        // this (skip only once fully covered) was tried
                        // first and reported live as still showing "the
                        // behind window's bar": a titlebar only *partially*
                        // covered - the common case for cascaded/
                        // overlapping windows - drew in full regardless,
                        // bleeding through the covered part. `from_buffer`'s
                        // `src` parameter crops the bitmap itself, so each
                        // visible fragment can come from the matching
                        // sub-rect of the source image rather than the
                        // whole thing.
                        let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT);
                        for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) {
                            let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64);
                            let src = Rectangle::new(
                                Point::from(((fragment.x - titlebar_rect.x) as f64, (fragment.y - titlebar_rect.y) as f64)),
                                Size::from((fragment.width as f64, fragment.height as f64)),
                            );
                            match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, Some(src), None, Kind::Unspecified) {
                                Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
                                Err(e) => log::warn!("udev: failed to import titlebar buffer: {e}"),
                            }
                        }
                    }
                    // Border strips sit entirely outside this window's own
                    // `geometry` (see `decoration::border_strips`), so they
                    // never overlap its own decoration/content - draw
                    // order against those doesn't matter here, only against
                    // other windows', which iterating `ids` in stacking
                    // order already gets right *for windows also drawn via
                    // this same custom_elements loop* - but not against
                    // any window's own *content*, which is why `occluders`
                    // below is still needed even with that ordering.
                    if w.border_width > 0 {
                        let color = crate::state::effective_border_color(w.border_color, focused == Some(id));
                        let strips = decoration::border_strips(geom, w.border_width);
                        // Strip 0 (top) rounded to match the titlebar under
                        // it - see `render_border_top`'s doc comment - so
                        // it's a cached bitmap (rebuilt only in
                        // `redraw_decoration_buffer`, same as the titlebar
                        // itself), not rasterized fresh here every frame.
                        // Not fragment-clipped like the other three strips
                        // below - cropping a bitmap's source rect per
                        // fragment is real extra work for a strip that's
                        // only `border_width` pixels tall to begin with, so
                        // this only handles the all-or-nothing case: skip
                        // entirely once *fully* covered, accept a small
                        // residual bleed while only partially covered.
                        if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() {
                            if let Some(buffer) = self.border_top_decorations.get(&id) {
                                let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y) as f64);
                                match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) {
                                    Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
                                    Err(e) => log::warn!("udev: failed to import top border buffer: {e}"),
                                }
                            }
                        }
                        // The other three strips are persistent
                        // `SolidColorBuffer`s updated in place, not rebuilt
                        // with a fresh `Id` every frame - see
                        // `elements::border_side_render_element`'s doc
                        // comment for why that distinction is load-bearing
                        // for damage tracking, not cosmetic. Each strip is
                        // further split into whatever fragments remain
                        // visible after subtracting `occluders`, since a
                        // whole unclipped strip is exactly the bug fixed
                        // here.
                        let pool = self.border_side_buffers.entry(id).or_default();
                        let mut buf_index = 0;
                        for strip in &strips[1..] {
                            if strip.width == 0 || strip.height == 0 {
                                continue;
                            }
                            for fragment in crate::elements::visible_border_fragments(*strip, &occluders) {
                                let buf = crate::elements::border_fragment_buffer(pool, buf_index);
                                buf_index += 1;
                                custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (origin.x, origin.y))));
                            }
                        }
                    }
                    // The window's own content, at its own `opacity` --
                    // this, not decoration, is the entire reason content
                    // moved into this loop at all (see the doc comment on
                    // the popup push above). Positioned the same way
                    // `sync_geometry` maps it into `self.space` (band added
                    // for a decorated window's titlebar reservation), so
                    // switching rendering paths doesn't also shift content
                    // relative to where clicks still land (hit-testing is
                    // untouched, still `w.geometry`/`self.space`-based).
                    if let Some(dwindow) = self.id_to_window.get(&id) {
                        if let Some(surface) = crate::elements::window_wl_surface(dwindow) {
                            let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
                            let pos = (geom.x - origin.x, geom.y + band - origin.y);
                            let mut rounded_elem = None;
                            if rounded_corners_enabled {
                                let epoch = self.content_epoch.get(&id).copied().unwrap_or(0);
                                // Bottom-only for a decorated window, same
                                // reasoning as `winit/render.rs`'s identical split:
                                // the top two corners are already hidden
                                // under the titlebar band's own rounded
                                // bitmap.
                                let corners = if w.decorated { crate::rounded_corners::RoundedCorners::BOTTOM_ONLY } else { crate::rounded_corners::RoundedCorners::ALL };
                                if let Some(buffer) =
                                    crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, epoch, id, &surface, decoration::CORNER_RADIUS as f32, corners)
                                {
                                    match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, (pos.0 as f64, pos.1 as f64), buffer, Some(w.opacity), None, None, Kind::Unspecified)
                                    {
                                        Ok(elem) => rounded_elem = Some(elem),
                                        Err(e) => log::warn!("udev: failed to import rounded content buffer: {e}"),
                                    }
                                }
                            }
                            match rounded_elem {
                                Some(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
                                None => custom_elements.extend(crate::elements::surface_content_elements(&mut udev.renderer, &surface, pos, w.opacity)),
                            }
                        }
                    }
                    occluders.push(geom);
                }
                // Background/bottom layer-shell (wallpaper engines) last --
                // bottommost, matching smithay's own `space_render_elements`
                // ordering, which this whole custom loop now replaces.
                custom_elements.extend(crate::elements::output_layer_elements(
                    &mut udev.renderer,
                    &output,
                    (origin.x, origin.y),
                    |layer| matches!(layer, Layer::Background | Layer::Bottom),
                ));
            }
            let lock_elements = if locked {
                crate::lock::lock_render_elements(lock_surface.as_ref(), &mut udev.renderer)
            } else {
                Vec::new()
            };

            let head = &mut udev.heads[index];
            let mut framebuffer = match udev.renderer.bind(&mut head.buffers[back].image) {
                Ok(fb) => fb,
                Err(e) => {
                    log::error!("udev: pixman bind failed: {e}");
                    continue;
                }
            };

            // Locked heads draw the lock surface over opaque black and
            // nothing else; unlocked heads draw the normal scene.
            let result = if locked {
                head.damage_tracker
                    .render_output(&mut udev.renderer, &mut framebuffer, 0, &lock_elements, [0.0, 0.0, 0.0, 1.0])
                    .map(|r| (r.damage.is_some(), Vec::new()))
                    .map_err(|e| e.to_string())
            } else {
                // Not `smithay::desktop::space::render_output`: that
                // convenience wrapper draws `self.space`'s window content at
                // one `alpha` for the whole frame and pulls every
                // layer-shell surface in unconditionally, neither of which
                // leaves room for per-window opacity or hiding the bar/dock
                // during fullscreen. `custom_elements` above already carries
                // everything that wrapper would have built - window
                // content (`surface_content_elements`, one call per window,
                // each with its own `w.opacity`) and layer-shell surfaces
                // (`output_layer_elements`, split Top/Overlay above content
                // and Background/Bottom below it) - assembled by hand in
                // the correct front-to-back order instead. `self.space`
                // itself is untouched and still authoritative for
                // hit-testing/stacking bookkeeping (`sync_geometry`'s
                // `map_element` calls); only the *render* path stopped
                // reading from it.
                head.damage_tracker
                    .render_output(&mut udev.renderer, &mut framebuffer, head.ages[back], &custom_elements, [0.05, 0.05, 0.08, 1.0])
                    .map(|r| (r.damage.is_some(), r.damage.cloned().unwrap_or_default()))
                // Both arms reduce to "was there damage" plus the damage
                // rects themselves; the two error types differ, so they are
                // flattened to a message here.
                .map_err(|e| e.to_string())
            };
            if !locked {
                // Only this head's own captures: `captures` holds requests
                // for every output, and each must be read back from the
                // framebuffer it was actually requested against, not
                // whichever head happens to render first in this loop (a
                // multi-monitor capture would otherwise silently read the
                // wrong screen). Whatever doesn't match `output` stays in
                // `captures` for a later head this same pass.
                let (mine, rest): (Vec<_>, Vec<_>) = captures.into_iter().partition(|c| c.output == output);
                captures = rest;
                crate::screencopy::service_pending(mine, &mut udev.renderer, &framebuffer);
            }
            drop(framebuffer);

            let (has_damage, damage_rects) = match result {
                Ok(v) => v,
                Err(e) => {
                    log::error!("udev: render_output failed: {e}");
                    continue;
                }
            };
            if has_damage {
                let head = &mut udev.heads[index];
                if let Err(e) = head.copy_and_flip(&udev.card, back) {
                    log::error!("udev: page flip failed: {e}");
                    continue;
                }
                // This buffer is now fully up to date. It won't be rendered
                // into again until the *other* slot has also been presented
                // once (strict two-buffer alternation), so by then it will
                // be exactly 2 damage-producing renders stale - matching
                // `damage_tracker`'s own history, which only advances on
                // calls that actually found damage (see `ages`' doc
                // comment).
                head.ages[back] = 2;
                // Only a head that actually presented a new frame should
                // tell its windows they may render their next one - this
                // used to run unconditionally for every "ready" head (i.e.
                // every head not already mid-flip) on every single call to
                // this function, which is every ~16ms regardless of
                // activity. A client that renders on the standard
                // wait-for-frame-callback pattern (which is most of
                // them - confirmed live: wezterm-gui pinned at 140%+ CPU
                // sitting on a fully idle, unchanged terminal) had no
                // reason not to redraw at whatever rate this loop cycled,
                // forever, since it kept getting told a new frame was
                // wanted whether or not the screen had changed at all.
                presented.push((output, damage_rects));
            }
        }

        // Frame callbacks + lock confirmation, once the `udev` borrow is done.
        for (output, damage_rects) in presented {
            if locked {
                let surface = self.lock_surface_for(&output).cloned();
                crate::lock::send_lock_frame(surface.as_ref(), &output, elapsed);
                self.confirm_lock_if_presented(&output);
            } else {
                let out = output.clone();
                let scale = Scale::from(out.current_scale().fractional_scale());
                for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, scale) {
                    w.send_frame(&out, elapsed, None, |_, _| Some(out.clone()));
                }
            }
        }
        // Deliberately unconditional - not folded into the `presented`
        // loop above, and not gated on any head having had damage this
        // tick at all. The whole point of `always_notify` is covering the
        // case where the output has *no* damage whatsoever (a fully idle
        // desktop, cursor not moving) but the focused/hovered window still
        // has a pending callback it needs answered to unblock an input-
        // driven redraw - GTK's frame-clock model (Firefox's Wayland
        // vsync source included) paces every repaint through that
        // callback, even the first one after being idle, with no "just
        // commit immediately" fallback. Nesting this inside the `presented`
        // loop (the first version of this fix) meant it only ever ran on a
        // tick that already had damage from something else happening --
        // i.e. never in the exact scenario it exists for. Reported live as
        // clicks in Firefox still doing nothing at all, not just
        // intermittently, after the first version of this fix.
        if !locked {
            let pointer_pos = self.udev.as_ref().map(|u| u.pointer_pos).unwrap_or_default();
            let wm = self.wm.borrow();
            let always_notify = [wm.focused_id(), wm.window_at(pointer_pos.x as i32, pointer_pos.y as i32)];
            drop(wm);
            let outputs: Vec<Output> = self.outputs.iter().map(|e| e.output.clone()).collect();
            for w in always_notify.into_iter().flatten().filter_map(|id| self.id_to_window.get(&id)) {
                for out in &outputs {
                    w.send_frame(out, elapsed, None, |_, _| Some(out.clone()));
                }
            }
        }
        // Layer-shell surfaces (bars, docks, launchers) get their frame
        // callback on every pass, unconditionally - NOT folded into the
        // `presented`/`has_damage` gate above.
        //
        // That gate exists because most toplevel clients redraw on the
        // standard wait-for-callback loop regardless of whether their own
        // content changed (confirmed live: wezterm-gui pinned at 140%+ CPU
        // on a fully idle terminal when it got a callback every ~16ms
        // whether or not the screen had changed). Gating toplevel callbacks
        // on real output damage fixed that.
        //
        // Applying the same gate to layer surfaces creates a real deadlock
        // instead: many (GTK4/AGS among them) drive their *entire* repaint
        // loop off frame callbacks with no independent timer fallback --
        // paint once, request a callback, wait. If nothing ELSE on the
        // desktop ever produces damage again (a static terminal, no other
        // animation), that callback never arrives, so the surface can never
        // draw its next frame, which means it can never produce damage,
        // which means it never gets a callback - permanently frozen after
        // exactly one frame. Confirmed live: AGS and waybar both hung this
        // way, one frame in, with `wl_surface.frame` requests that were
        // never answered (see docs/PANEL_SUPPORT_TODO.md).
        //
        // Splitting the gate is safe rather than reintroducing the wezterm
        // bug: there are at most a handful of layer surfaces on a real
        // desktop (a bar, maybe a dock/launcher), their content is cheap to
        // redraw even when done needlessly, and periodic UI chrome (a
        // clock, a resource graph) is exactly the case frame callbacks
        // exist to pace - unlike a full toplevel window, whose redraw cost
        // is what made the unconditional case expensive in the first place.
        if !locked {
            for output in &ready_outputs {
                for layer in layer_map_for_output(output).layers() {
                    layer.send_frame(output, elapsed, None, |_, _| Some(output.clone()));
                }
            }
        }
        if locked {
            crate::screencopy::fail_pending(captures);
        } else if !captures.is_empty() {
            // Left over because their target head wasn't in `ready` this
            // pass (e.g. mid-page-flip). Put back rather than dropped: this
            // function runs again on the next poll tick (or the page-flip
            // completion that made the head ready), so the capture gets
            // another chance instead of silently vanishing - which is what
            // made `grim` hang waiting on a `ready`/`failed` that would
            // otherwise never come (see docs/PANEL_SUPPORT_TODO.md, P1).
            self.screencopy_pending.extend(captures);
        }
    }

    /// Sets a connector's DPMS mode via the generic KMS "DPMS" property --
    /// there is no dedicated legacy-API call for this in `drm-rs`, only the
    /// same `get_properties`/`set_property` pair every other connector
    /// property goes through, so the property has to be found by name each
    /// time rather than through some `Dpms` -specific method. `None` if the
    /// `wl_output` doesn't resolve to a live head, or the connector has no
    /// "DPMS" property at all (rare on real hardware, but virtual/headless
    /// outputs may not expose one) - either way maps to `zwlr_output_power_v1`'s
    /// `failed` event, matching what the protocol asks for when the mode
    /// can't be honoured.
    pub(crate) fn set_output_power(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, on: bool) -> Option<()> {
        // Raw KMS UAPI values for the "DPMS" connector property
        // (`DRM_MODE_DPMS_ON`/`_OFF` in `drm_sys`/the kernel's
        // `drm_mode.h`) - not worth a whole extra dependency on `drm-sys`
        // just for two constants that have been stable since DPMS was
        // added to the DRM UAPI.
        const DRM_MODE_DPMS_ON: u64 = 0;
        const DRM_MODE_DPMS_OFF: u64 = 3;

        let target = self.output_for_wl(wl_output)?.output.clone();
        let udev = self.udev.as_ref()?;
        let head = udev.heads.iter().find(|h| h.output == target)?;
        let props = udev.card.get_properties(head.connector).ok()?;
        let dpms_prop = props.as_props_and_values().0.iter().copied().find(|&handle| udev.card.get_property(handle).is_ok_and(|info| info.name().to_str() == Ok("DPMS")))?;
        let mode = if on { DRM_MODE_DPMS_ON } else { DRM_MODE_DPMS_OFF };
        udev.card.set_property(head.connector, dpms_prop, mode).ok()
    }

    /// The CRTC's gamma ramp length, in elements per channel - what
    /// `zwlr_gamma_control_v1.gamma_size` reports so a client knows how
    /// large a table `set_gamma` expects. `None` if the output doesn't
    /// resolve to a live head, or the CRTC reports a zero-length ramp
    /// (no gamma hardware, common on virtual/headless outputs).
    pub(crate) fn gamma_ramp_size(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput) -> Option<u32> {
        let target = self.output_for_wl(wl_output)?.output.clone();
        let udev = self.udev.as_ref()?;
        let head = udev.heads.iter().find(|h| h.output == target)?;
        let len = udev.card.get_crtc(head.crtc).ok()?.gamma_length();
        (len > 0).then_some(len)
    }

    /// Reads a client-supplied gamma table (`zwlr_gamma_control_v1.
    /// set_gamma`'s `fd`: a memory-mapped blob of `gamma_size` `u16`s per
    /// channel, red then green then blue, per the protocol) and applies it
    /// to the CRTC. `None` on any failure - output/head not found, the
    /// blob is the wrong size, or the DRM `set_gamma` call itself fails --
    /// which the caller maps to `zwlr_gamma_control_v1.failed`, exactly
    /// what the protocol specifies for "setting the gamma tables failed".
    pub(crate) fn set_gamma_ramp(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, fd: std::os::fd::OwnedFd) -> Option<()> {
        let target = self.output_for_wl(wl_output)?.output.clone();
        let udev = self.udev.as_ref()?;
        let head = udev.heads.iter().find(|h| h.output == target)?;
        let size = udev.card.get_crtc(head.crtc).ok()?.gamma_length() as usize;
        if size == 0 {
            return None;
        }
        // Three channels, two bytes (one native-endian u16) per element --
        // client and compositor are always the same machine, so there is
        // no cross-endianness concern to handle here, unlike an over-the-
        // wire protocol value.
        let expected_bytes = size * 3 * 2;
        // SAFETY: the fd is a client-supplied shared-memory blob, mapped
        // read-only for the duration of this call and never touched again
        // afterwards - the same trust boundary `wl_shm` buffers already
        // cross for every window's actual pixel content elsewhere in this
        // codebase.
        let map = unsafe { memmap2::MmapOptions::new().map(&fd) }.ok()?;
        if map.len() < expected_bytes {
            return None;
        }
        let read_channel = |offset: usize| -> Vec<u16> { map[offset..offset + size * 2].chunks_exact(2).map(|b| u16::from_ne_bytes([b[0], b[1]])).collect() };
        let red = read_channel(0);
        let green = read_channel(size * 2);
        let blue = read_channel(size * 4);
        udev.card.set_gamma(head.crtc, &red, &green, &blue).ok()
    }
}