Skip to main content

wlr_capture/
mirror.rs

1//! xdg-toplevel windowing host for the live mirror.
2//!
3//! A normal floating window (so the compositor handles stacking; pair with sway
4//! rules `floating enable, sticky enable` for always-on-top across workspaces).
5//! Rendering uses the shared [`wlr_capture::render::Gpu`]. Capture frames arrive
6//! over a calloop channel, so we only repaint when there is new content or an
7//! interaction — no free-running render loop for an always-on tile.
8//!
9//! Interactions (all hit-tested here, in logical coordinates, so egui stays a
10//! pure painter): drag the body to move (`xdg_toplevel.move`), the bottom-right
11//! grip to resize (`xdg_toplevel.resize`), the toolbar buttons to collapse to an
12//! icon badge or close, and `Esc` to close. The tile keeps the source aspect
13//! ratio; collapsing shrinks it, and any new frame while collapsed restores it.
14
15use crate::render::{DmabufImporter, Gpu};
16use crate::stream;
17use crate::theme::Theme;
18use crate::tr;
19use crate::wl;
20use smithay_client_toolkit::{
21    compositor::{CompositorHandler, CompositorState},
22    delegate_compositor, delegate_keyboard, delegate_output, delegate_pointer, delegate_registry,
23    delegate_seat, delegate_xdg_shell, delegate_xdg_window,
24    output::{OutputHandler, OutputState},
25    reexports::calloop::EventLoop,
26    reexports::calloop::channel::{Channel, Event as ChannelEvent, channel},
27    reexports::calloop_wayland_source::WaylandSource,
28    reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge,
29    registry::{ProvidesRegistryState, RegistryState},
30    registry_handlers,
31    seat::{
32        Capability, SeatHandler, SeatState,
33        keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers},
34        pointer::{PointerEvent, PointerEventKind, PointerHandler},
35    },
36    shell::{
37        WaylandSurface,
38        xdg::{
39            XdgShell,
40            window::{Window, WindowConfigure, WindowDecorations, WindowHandler},
41        },
42    },
43};
44use std::time::{Duration, Instant};
45use wayland_client::{
46    Connection, QueueHandle,
47    globals::registry_queue_init,
48    protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
49};
50
51/// Texture cache key for the single mirrored source.
52const KEY: &str = "pip";
53/// Default tile width (logical px) before the source aspect ratio is known.
54const DEFAULT_W: u32 = 480;
55/// Side of the collapsed icon badge (logical px).
56const BADGE: u32 = 132;
57/// Smallest tile width the user can resize to.
58const MIN_W: u32 = 120;
59/// Repaint frames to keep the "just restored" accent border visible.
60const ACCENT_FRAMES: u32 = 60;
61/// How long to show the "window closed" notice before exiting.
62const GONE_LINGER: Duration = Duration::from_millis(1400);
63/// Frame budget per capture round (~30 fps ceiling; capture is damage-driven).
64const ROUND: Duration = Duration::from_millis(33);
65/// How long to wait for the target window to appear before giving up.
66const APPEAR_GRACE: Duration = Duration::from_secs(5);
67
68/// Frames + textures for the mirrored source; a pure painter (the host owns input).
69struct Content {
70    /// shm texture (CPU upload path).
71    tex: Option<egui::TextureHandle>,
72    /// dma-buf texture (GPU zero-copy path): egui id + source pixel size.
73    native: Option<(egui::TextureId, egui::Vec2)>,
74    icon: Option<egui::TextureHandle>,
75    /// Frames awaiting upload/import at the next render.
76    pending: Vec<PipMsg>,
77    gone: bool,
78    label: String,
79    theme: Theme,
80    /// Region mode: the normalized sub-rectangle of the captured frame to show
81    /// (the magnified region). `None` shows the whole frame (toplevel mirror).
82    crop_uv: Option<egui::Rect>,
83}
84
85impl Content {
86    /// Drain pending frames into textures (needs the egui ctx for shm uploads and
87    /// the host importer for dma-buf). Called inside the render closure.
88    fn pump(&mut self, ctx: &egui::Context, importer: &mut dyn DmabufImporter) {
89        for msg in self.pending.drain(..) {
90            match msg {
91                PipMsg::Shm { w, h, rgba } if w > 0 && h > 0 => {
92                    let img = egui::ColorImage::from_rgba_unmultiplied([w, h], &rgba);
93                    match self.tex.as_mut() {
94                        Some(t) => t.set(img, egui::TextureOptions::LINEAR),
95                        None => {
96                            self.tex =
97                                Some(ctx.load_texture(KEY, img, egui::TextureOptions::LINEAR))
98                        }
99                    }
100                    self.native = None; // shm now authoritative
101                }
102                PipMsg::Dmabuf { frame } => {
103                    if let Some(t) = importer.import(KEY, frame) {
104                        self.native = Some(t);
105                    }
106                }
107                PipMsg::Shm { .. } => {}
108                PipMsg::Gone => self.gone = true,
109            }
110        }
111    }
112
113    /// The drawable texture: dma-buf if present, else shm.
114    fn tex(&self) -> Option<(egui::TextureId, egui::Vec2)> {
115        if let Some(t) = self.native {
116            return Some(t);
117        }
118        self.tex.as_ref().map(|t| (t.id(), t.size_vec2()))
119    }
120
121    /// Paint one frame into the surface-sized area.
122    #[allow(clippy::too_many_arguments)]
123    fn draw(
124        &self,
125        ui: &mut egui::Ui,
126        size: (f32, f32),
127        collapsed: bool,
128        hovered: bool,
129        accent: bool,
130        frozen: bool,
131        opacity: f32,
132    ) {
133        let (w, h) = size;
134        let full = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h));
135        let t = &self.theme;
136        // Image/icon tint carries the window opacity (the GL backdrop carries it too).
137        let tint = egui::Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0) as u8);
138        egui::CentralPanel::default()
139            .frame(egui::Frame::NONE)
140            .show_inside(ui, |ui| {
141                let p = ui.painter();
142                // The captured image, contained (letterboxed) in the tile. In region
143                // mode only `crop_uv` of the frame is shown, so the visible source
144                // size is the texture scaled by the crop's normalized extent.
145                if let Some((id, ts)) = self.tex() {
146                    let uv = self.crop_uv.unwrap_or(egui::Rect::from_min_max(
147                        egui::pos2(0.0, 0.0),
148                        egui::pos2(1.0, 1.0),
149                    ));
150                    let src = egui::vec2(ts.x * uv.width(), ts.y * uv.height());
151                    let scale = (full.width() / src.x).min(full.height() / src.y);
152                    let draw = egui::Rect::from_center_size(full.center(), src * scale);
153                    p.image(id, draw, uv, tint);
154                } else if let Some(icon) = &self.icon {
155                    let isz = icon.size_vec2();
156                    let scale = (full.width() / isz.x).min(full.height() / isz.y).min(1.0);
157                    let draw = egui::Rect::from_center_size(full.center(), isz * scale);
158                    p.image(
159                        icon.id(),
160                        draw,
161                        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
162                        tint,
163                    );
164                } else {
165                    p.text(
166                        full.center(),
167                        egui::Align2::CENTER_CENTER,
168                        tr!("loading"),
169                        egui::FontId::proportional(16.0),
170                        t.text_dim,
171                    );
172                }
173
174                if self.gone {
175                    p.rect_filled(full, 0.0, egui::Color32::from_black_alpha(180));
176                    p.text(
177                        full.center(),
178                        egui::Align2::CENTER_CENTER,
179                        tr!("pip-gone"),
180                        egui::FontId::proportional(16.0),
181                        t.text,
182                    );
183                    return;
184                }
185
186                // "Just restored" / live accent border.
187                if accent {
188                    p.rect_stroke(
189                        full,
190                        0.0,
191                        egui::Stroke::new(2.0, t.window_accent),
192                        egui::StrokeKind::Inside,
193                    );
194                }
195
196                // Freeze indicator: a pause glyph in the top-left corner.
197                if frozen {
198                    let bar = egui::Stroke::new(3.0, egui::Color32::from_white_alpha(220));
199                    let (x, y) = (8.0, 8.0);
200                    p.line_segment([egui::pos2(x, y), egui::pos2(x, y + 12.0)], bar);
201                    p.line_segment([egui::pos2(x + 6.0, y), egui::pos2(x + 6.0, y + 12.0)], bar);
202                }
203
204                if collapsed {
205                    return; // badge: just the contained image/icon
206                }
207
208                if hovered {
209                    let (close, collapse) = toolbar_rects(w);
210                    let strip = egui::Rect::from_min_max(
211                        egui::pos2(0.0, 0.0),
212                        egui::pos2(w, close.bottom() + 6.0),
213                    );
214                    p.rect_filled(strip, 0.0, egui::Color32::from_black_alpha(150));
215                    // Title at the left.
216                    let mut job = egui::text::LayoutJob::simple_singleline(
217                        self.label.clone(),
218                        egui::FontId::proportional(12.0),
219                        egui::Color32::WHITE,
220                    );
221                    job.wrap = egui::text::TextWrapping::truncate_at_width(
222                        (collapse.left() - 12.0).max(0.0),
223                    );
224                    let galley = ui.painter().layout_job(job);
225                    p.galley(
226                        egui::pos2(8.0, strip.center().y - galley.size().y / 2.0),
227                        galley,
228                        egui::Color32::WHITE,
229                    );
230                    // Collapse glyph (a downward chevron) and close glyph (an X).
231                    draw_collapse(p, collapse, egui::Color32::WHITE);
232                    draw_close(p, close, egui::Color32::WHITE);
233                    // Resize grip in the bottom-right corner.
234                    draw_grip(p, grip_rect(w, h), egui::Color32::from_white_alpha(160));
235                }
236            });
237    }
238}
239
240/// Smallest tile size honouring the source aspect (width is the floor at `MIN_W`).
241/// Falls back to 16:9 before the aspect is known (toplevel mirror's first frame).
242fn min_size_for(aspect: Option<f32>) -> (u32, u32) {
243    match aspect {
244        Some(a) if a > 0.0 => (MIN_W, ((MIN_W as f32 / a).round() as u32).max(1)),
245        _ => (MIN_W, (MIN_W * 9 / 16).max(1)),
246    }
247}
248
249/// Close / collapse button rects (top-right), in logical coordinates.
250fn toolbar_rects(w: f32) -> (egui::Rect, egui::Rect) {
251    let s = 22.0;
252    let pad = 6.0;
253    let close = egui::Rect::from_min_size(egui::pos2(w - pad - s, pad), egui::vec2(s, s));
254    let collapse =
255        egui::Rect::from_min_size(egui::pos2(w - pad - 2.0 * s - 6.0, pad), egui::vec2(s, s));
256    (close, collapse)
257}
258
259/// Resize-grip rect (bottom-right), in logical coordinates.
260fn grip_rect(w: f32, h: f32) -> egui::Rect {
261    let s = 18.0;
262    egui::Rect::from_min_size(egui::pos2(w - s, h - s), egui::vec2(s, s))
263}
264
265fn draw_close(p: &egui::Painter, r: egui::Rect, c: egui::Color32) {
266    let r = r.shrink(5.0);
267    let s = egui::Stroke::new(2.0, c);
268    p.line_segment([r.left_top(), r.right_bottom()], s);
269    p.line_segment([r.right_top(), r.left_bottom()], s);
270}
271
272fn draw_collapse(p: &egui::Painter, r: egui::Rect, c: egui::Color32) {
273    let r = r.shrink(5.0);
274    let s = egui::Stroke::new(2.0, c);
275    // A downward chevron ⌄.
276    p.line_segment([r.left_top(), egui::pos2(r.center().x, r.bottom())], s);
277    p.line_segment([egui::pos2(r.center().x, r.bottom()), r.right_top()], s);
278}
279
280fn draw_grip(p: &egui::Painter, r: egui::Rect, c: egui::Color32) {
281    let s = egui::Stroke::new(1.5, c);
282    for f in [0.35_f32, 0.7] {
283        p.line_segment(
284            [
285                egui::pos2(r.right() - r.width() * f, r.bottom()),
286                egui::pos2(r.right(), r.bottom() - r.height() * f),
287            ],
288            s,
289        );
290    }
291}
292
293struct State {
294    registry_state: RegistryState,
295    seat_state: SeatState,
296    output_state: OutputState,
297
298    window: Window,
299    seat: Option<wl_seat::WlSeat>,
300    keyboard: Option<wl_keyboard::WlKeyboard>,
301    pointer: Option<wl_pointer::WlPointer>,
302
303    egui_ctx: egui::Context,
304    gpu: Option<Gpu>,
305    content: Content,
306
307    // logical size we render at, integer scale, and the source aspect (w/h).
308    width: u32,
309    height: u32,
310    scale: u32,
311    aspect: Option<f32>,
312    /// Region mode: the aspect is pinned to the region, so frame sizes (the whole
313    /// output) must not retune it.
314    fixed_aspect: bool,
315    /// Remembered expanded size, restored when un-collapsing.
316    expanded: (u32, u32),
317    collapsed: bool,
318
319    hovered: bool,
320    pointer_pos: egui::Pos2,
321    accent: u32,
322    gone_since: Option<Instant>,
323
324    /// Window opacity (1.0 = opaque); adjusted with the wheel or `+`/`-`.
325    opacity: f32,
326    /// Freeze the live feed on the last frame (Space toggles).
327    frozen: bool,
328
329    start: Instant,
330    closing: bool,
331    configured: bool,
332    /// Args to re-exec ourselves with on re-pick (`r`); empty disables it.
333    relaunch: Vec<String>,
334}
335
336/// What the mirror window streams.
337pub enum Source {
338    /// Mirror the toplevel with this `ext-foreign-toplevel` identifier.
339    Toplevel(String),
340    /// Mirror (and magnify) a fixed logical region. We capture the covering output
341    /// live and show only the region's sub-rectangle of it; mono-output for now.
342    Region {
343        /// Name of the output to capture (the one covering the region's top-left).
344        output: String,
345        /// Normalized sub-rectangle `(min_x, min_y, max_x, max_y)` of the region
346        /// within that output's frame (scale-independent, so it survives any
347        /// physical resolution the frames arrive at).
348        crop: [f32; 4],
349        /// Logical region width; fixes the window aspect ratio.
350        region_w: u32,
351        /// Logical region height; fixes the window aspect ratio.
352        region_h: u32,
353        /// Magnification factor (initial window size = region × zoom).
354        zoom: f32,
355    },
356    /// Mirror (and magnify) a sub-rectangle of a *window*. Captures the toplevel
357    /// (so it follows the window across moves/workspaces) and shows only `crop` of it.
358    ToplevelRegion {
359        /// `ext-foreign-toplevel` identifier of the window to capture.
360        id: String,
361        /// Normalized sub-rectangle of the region within the window's content.
362        crop: [f32; 4],
363        /// Logical region width; fixes the window aspect ratio.
364        region_w: u32,
365        /// Logical region height; fixes the window aspect ratio.
366        region_h: u32,
367        /// Magnification factor (initial window size = region × zoom).
368        zoom: f32,
369    },
370}
371
372/// Window chrome + behaviour for [`run`].
373pub struct Config {
374    /// Wayland `app_id` (for compositor window rules).
375    pub app_id: String,
376    /// Title shown in the hover toolbar.
377    pub label: String,
378    /// App icon for the collapsed badge, as `(w, h, rgba)`.
379    pub icon: Option<(u32, u32, Vec<u8>)>,
380    /// Args to re-exec the current binary with when the user presses `r` (re-pick).
381    /// Empty disables re-pick.
382    pub relaunch: Vec<String>,
383}
384
385/// Run the mirror until the source closes or the user quits.
386pub fn run(source: Source, config: Config) -> anyhow::Result<()> {
387    let conn = Connection::connect_to_env()?;
388    run_on(&conn, source, config)
389}
390
391/// [`run`] on a caller-provided connection, so a process that first ran another GPU/EGL
392/// overlay (e.g. the region selector) reuses the same `wl_display` and `EGLDisplay`
393/// instead of opening a second one — a second EGL connection in one process can alias a
394/// freed display and fail (`eglCreateWindowSurface: BadAlloc`).
395pub fn run_on(conn: &Connection, source: Source, config: Config) -> anyhow::Result<()> {
396    let Config {
397        app_id,
398        label,
399        icon,
400        relaunch,
401    } = config;
402    let (globals, event_queue) = registry_queue_init::<State>(conn)?;
403    let qh = event_queue.handle();
404    let mut event_loop: EventLoop<State> = EventLoop::try_new()?;
405    let lh = event_loop.handle();
406    WaylandSource::new(conn.clone(), event_queue)
407        .insert(lh.clone())
408        .map_err(|e| anyhow::anyhow!("calloop wayland source: {e}"))?;
409
410    let compositor =
411        CompositorState::bind(&globals, &qh).map_err(|e| anyhow::anyhow!("wl_compositor: {e}"))?;
412    let xdg_shell =
413        XdgShell::bind(&globals, &qh).map_err(|e| anyhow::anyhow!("xdg-shell missing: {e}"))?;
414
415    // Region mode fixes the window aspect to the region and starts at region × zoom,
416    // showing only the region's sub-rectangle of the captured output. Toplevel mode
417    // learns its aspect from the first frame and starts at a default 16:9 tile.
418    let (init_w, init_h, fixed_aspect, aspect0, crop_uv) = match &source {
419        Source::Toplevel(_) => (DEFAULT_W, DEFAULT_W * 9 / 16, false, None, None),
420        Source::Region {
421            crop,
422            region_w,
423            region_h,
424            zoom,
425            ..
426        }
427        | Source::ToplevelRegion {
428            crop,
429            region_w,
430            region_h,
431            zoom,
432            ..
433        } => {
434            let w = ((*region_w as f32 * zoom).round() as u32).max(MIN_W);
435            let h = ((*region_h as f32 * zoom).round() as u32).max(1);
436            let aspect = *region_w as f32 / (*region_h).max(1) as f32;
437            let uv = egui::Rect::from_min_max(
438                egui::pos2(crop[0], crop[1]),
439                egui::pos2(crop[2], crop[3]),
440            );
441            (w, h, true, Some(aspect), Some(uv))
442        }
443    };
444    let min0 = min_size_for(aspect0);
445
446    let surface = compositor.create_surface(&qh);
447    let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);
448    window.set_app_id(&app_id);
449    window.set_title(label.clone());
450    window.set_min_size(Some(min0));
451    window.commit();
452
453    // Capture thread streams frames over a calloop channel; we repaint on each.
454    // A region mirrors its covering output (the host crops to the region's sub-rect).
455    let stream_source = match &source {
456        Source::Toplevel(id) | Source::ToplevelRegion { id, .. } => {
457            stream::Source::Toplevel(id.clone())
458        }
459        Source::Region { output, .. } => stream::Source::Output(output.clone()),
460    };
461    let (tx, ch): (_, Channel<PipMsg>) = channel();
462    std::thread::spawn(move || capture_thread(stream_source, move |m| tx.send(m).is_ok()));
463    lh.insert_source(ch, |event, _, state: &mut State| {
464        if let ChannelEvent::Msg(m) = event {
465            state.on_msg(m);
466        }
467    })
468    .map_err(|e| anyhow::anyhow!("calloop channel source: {e}"))?;
469
470    let theme = Theme::load();
471    let egui_ctx = egui::Context::default();
472    theme.apply(&egui_ctx);
473    let icon_tex = icon.map(|(w, h, rgba)| {
474        let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba);
475        egui_ctx.load_texture("pip-icon", img, egui::TextureOptions::LINEAR)
476    });
477
478    let mut state = State {
479        registry_state: RegistryState::new(&globals),
480        seat_state: SeatState::new(&globals, &qh),
481        output_state: OutputState::new(&globals, &qh),
482        window,
483        seat: None,
484        keyboard: None,
485        pointer: None,
486        egui_ctx,
487        gpu: None,
488        content: Content {
489            tex: None,
490            native: None,
491            icon: icon_tex,
492            pending: Vec::new(),
493            gone: false,
494            label,
495            theme,
496            crop_uv,
497        },
498        width: init_w,
499        height: init_h,
500        scale: 1,
501        aspect: aspect0,
502        fixed_aspect,
503        expanded: (init_w, init_h),
504        collapsed: false,
505        hovered: false,
506        pointer_pos: egui::Pos2::ZERO,
507        accent: 0,
508        gone_since: None,
509        opacity: 1.0,
510        frozen: false,
511        start: Instant::now(),
512        closing: false,
513        configured: false,
514        relaunch,
515    };
516
517    while !state.closing {
518        event_loop.dispatch(Duration::from_millis(400), &mut state)?;
519        if let Some(t) = state.gone_since
520            && t.elapsed() >= GONE_LINGER
521        {
522            break;
523        }
524    }
525    Ok(())
526}
527
528impl State {
529    /// A capture message arrived (host context): note source size / demise, queue
530    /// the frame, restore from the badge on activity, and repaint.
531    fn on_msg(&mut self, m: PipMsg) {
532        match &m {
533            PipMsg::Gone => {
534                self.gone_since.get_or_insert(Instant::now());
535                self.content.pending.push(m);
536                self.redraw();
537                return;
538            }
539            PipMsg::Shm { w, h, .. } => self.on_source_size(*w as u32, *h as u32),
540            PipMsg::Dmabuf { frame } => self.on_source_size(frame.width, frame.height),
541        }
542        // Frozen: keep showing the last frame, drop incoming ones (the dropped
543        // dma-buf fd closes here). Source-size tracking above still runs so a later
544        // unfreeze keeps the right aspect.
545        if self.frozen {
546            return;
547        }
548        // Activity while collapsed pops the tile back open ("notify me on change").
549        if self.collapsed {
550            self.set_collapsed(false);
551            self.accent = ACCENT_FRAMES;
552        }
553        self.content.pending.push(m);
554        self.redraw();
555    }
556
557    /// Learn (or update) the source aspect ratio and, when expanded, keep the
558    /// tile's height matching it.
559    fn on_source_size(&mut self, sw: u32, sh: u32) {
560        // Region mode pins the aspect to the region; the frame is the whole output,
561        // so its size must not retune the tile.
562        if self.fixed_aspect || sw == 0 || sh == 0 {
563            return;
564        }
565        let a = sw as f32 / sh as f32;
566        let first = self.aspect.is_none();
567        self.aspect = Some(a);
568        if !self.collapsed && (first || (self.height as f32 - self.width as f32 / a).abs() > 1.0) {
569            let h = ((self.width as f32 / a).round() as u32).max(1);
570            self.apply_size(self.width, h);
571            self.expanded = (self.width, h);
572        }
573    }
574
575    /// Snap a width/height to the source aspect ratio (width is authoritative).
576    fn snap(&self, w: u32, h: u32) -> (u32, u32) {
577        match self.aspect {
578            Some(a) if a > 0.0 => (w.max(MIN_W), ((w as f32 / a).round() as u32).max(1)),
579            _ => (w.max(MIN_W), h.max(1)),
580        }
581    }
582
583    /// Set the render size and resize the EGL window to match (the floating
584    /// compositor follows our buffer size).
585    fn apply_size(&mut self, w: u32, h: u32) {
586        self.width = w;
587        self.height = h;
588        if let Some(gpu) = &self.gpu {
589            gpu.resize((w * self.scale) as i32, (h * self.scale) as i32);
590        }
591    }
592
593    fn set_collapsed(&mut self, collapsed: bool) {
594        if collapsed == self.collapsed {
595            return;
596        }
597        self.collapsed = collapsed;
598        if collapsed {
599            self.expanded = (self.width, self.height);
600            self.window.set_min_size(Some((BADGE, BADGE)));
601            self.window.set_max_size(Some((BADGE, BADGE)));
602            self.apply_size(BADGE, BADGE);
603        } else {
604            self.window.set_min_size(Some(min_size_for(self.aspect)));
605            self.window.set_max_size(None);
606            let (w, h) = self.expanded;
607            self.apply_size(w, h);
608        }
609        self.window.commit();
610        self.redraw();
611    }
612
613    fn ensure_gpu(&mut self, conn: &Connection) {
614        if self.gpu.is_some() || self.width == 0 {
615            return;
616        }
617        let (pw, ph) = (
618            (self.width * self.scale) as i32,
619            (self.height * self.scale) as i32,
620        );
621        self.gpu = Some(Gpu::new(conn, self.window.wl_surface(), pw, ph));
622    }
623
624    /// Render one frame (no frame-callback chain: driven by capture/interaction).
625    fn redraw(&mut self) {
626        if !self.configured {
627            return;
628        }
629        let (pw, ph) = (self.width * self.scale, self.height * self.scale);
630        let raw_input = egui::RawInput {
631            screen_rect: Some(egui::Rect::from_min_size(
632                egui::Pos2::ZERO,
633                egui::vec2(self.width as f32, self.height as f32),
634            )),
635            time: Some(self.start.elapsed().as_secs_f64()),
636            focused: true,
637            ..Default::default()
638        };
639        let opacity = self.opacity;
640        let backdrop = {
641            let c = self.content.theme.thumb.to_normalized_gamma_f32();
642            [c[0], c[1], c[2], opacity]
643        };
644        let size = (self.width as f32, self.height as f32);
645        let (collapsed, hovered, accent) = (self.collapsed, self.hovered, self.accent > 0);
646        let frozen = self.frozen;
647        let content = &mut self.content;
648        let Some(gpu) = self.gpu.as_mut() else {
649            return;
650        };
651        gpu.render(
652            &self.egui_ctx,
653            raw_input,
654            self.scale as f32,
655            (pw, ph),
656            backdrop,
657            |ui, imp| {
658                let ctx = ui.ctx().clone();
659                content.pump(&ctx, imp);
660                content.draw(ui, size, collapsed, hovered, accent, frozen, opacity);
661            },
662        );
663        if self.accent > 0 {
664            self.accent -= 1;
665        }
666    }
667}
668
669impl CompositorHandler for State {
670    fn scale_factor_changed(
671        &mut self,
672        _: &Connection,
673        _: &QueueHandle<Self>,
674        _: &wl_surface::WlSurface,
675        new_factor: i32,
676    ) {
677        self.scale = new_factor.max(1) as u32;
678        self.window.wl_surface().set_buffer_scale(new_factor.max(1));
679        if let Some(gpu) = &self.gpu {
680            gpu.resize(
681                (self.width * self.scale) as i32,
682                (self.height * self.scale) as i32,
683            );
684        }
685        self.redraw();
686    }
687
688    fn transform_changed(
689        &mut self,
690        _: &Connection,
691        _: &QueueHandle<Self>,
692        _: &wl_surface::WlSurface,
693        _: wayland_client::protocol::wl_output::Transform,
694    ) {
695    }
696
697    fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
698        // Driven by capture frames instead; nothing to do.
699    }
700
701    fn surface_enter(
702        &mut self,
703        _: &Connection,
704        _: &QueueHandle<Self>,
705        _: &wl_surface::WlSurface,
706        _: &wl_output::WlOutput,
707    ) {
708    }
709    fn surface_leave(
710        &mut self,
711        _: &Connection,
712        _: &QueueHandle<Self>,
713        _: &wl_surface::WlSurface,
714        _: &wl_output::WlOutput,
715    ) {
716    }
717}
718
719impl WindowHandler for State {
720    fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {
721        self.closing = true;
722    }
723
724    fn configure(
725        &mut self,
726        conn: &Connection,
727        _: &QueueHandle<Self>,
728        _: &Window,
729        configure: WindowConfigure,
730        _: u32,
731    ) {
732        if let (Some(w), Some(h)) = configure.new_size {
733            let (w, h) = if self.collapsed {
734                (BADGE, BADGE)
735            } else {
736                self.snap(w.get(), h.get())
737            };
738            self.apply_size(w, h);
739            if !self.collapsed {
740                self.expanded = (w, h);
741            }
742        }
743        self.ensure_gpu(conn);
744        self.configured = true;
745        self.redraw();
746    }
747}
748
749impl SeatHandler for State {
750    fn seat_state(&mut self) -> &mut SeatState {
751        &mut self.seat_state
752    }
753    fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
754    fn new_capability(
755        &mut self,
756        _: &Connection,
757        qh: &QueueHandle<Self>,
758        seat: wl_seat::WlSeat,
759        cap: Capability,
760    ) {
761        if cap == Capability::Keyboard && self.keyboard.is_none() {
762            self.keyboard = self.seat_state.get_keyboard(qh, &seat, None).ok();
763        }
764        if cap == Capability::Pointer && self.pointer.is_none() {
765            self.pointer = self.seat_state.get_pointer(qh, &seat).ok();
766        }
767        self.seat = Some(seat);
768    }
769    fn remove_capability(
770        &mut self,
771        _: &Connection,
772        _: &QueueHandle<Self>,
773        _: wl_seat::WlSeat,
774        _: Capability,
775    ) {
776    }
777    fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
778}
779
780impl KeyboardHandler for State {
781    fn enter(
782        &mut self,
783        _: &Connection,
784        _: &QueueHandle<Self>,
785        _: &wl_keyboard::WlKeyboard,
786        _: &wl_surface::WlSurface,
787        _: u32,
788        _: &[u32],
789        _: &[Keysym],
790    ) {
791    }
792    fn leave(
793        &mut self,
794        _: &Connection,
795        _: &QueueHandle<Self>,
796        _: &wl_keyboard::WlKeyboard,
797        _: &wl_surface::WlSurface,
798        _: u32,
799    ) {
800    }
801    fn press_key(
802        &mut self,
803        _: &Connection,
804        _: &QueueHandle<Self>,
805        _: &wl_keyboard::WlKeyboard,
806        _: u32,
807        event: KeyEvent,
808    ) {
809        self.on_key(event.keysym);
810    }
811    fn release_key(
812        &mut self,
813        _: &Connection,
814        _: &QueueHandle<Self>,
815        _: &wl_keyboard::WlKeyboard,
816        _: u32,
817        _: KeyEvent,
818    ) {
819    }
820    fn repeat_key(
821        &mut self,
822        _: &Connection,
823        _: &QueueHandle<Self>,
824        _: &wl_keyboard::WlKeyboard,
825        _: u32,
826        _: KeyEvent,
827    ) {
828    }
829    fn update_modifiers(
830        &mut self,
831        _: &Connection,
832        _: &QueueHandle<Self>,
833        _: &wl_keyboard::WlKeyboard,
834        _: u32,
835        _: Modifiers,
836        _: RawModifiers,
837        _: u32,
838    ) {
839    }
840}
841
842impl PointerHandler for State {
843    fn pointer_frame(
844        &mut self,
845        _: &Connection,
846        _: &QueueHandle<Self>,
847        _: &wl_pointer::WlPointer,
848        events: &[PointerEvent],
849    ) {
850        let Some(seat) = self.seat.clone() else {
851            return;
852        };
853        for e in events {
854            let pos = egui::pos2(e.position.0 as f32, e.position.1 as f32);
855            match e.kind {
856                PointerEventKind::Enter { .. } => {
857                    self.pointer_pos = pos;
858                    self.hovered = true;
859                    self.redraw();
860                }
861                PointerEventKind::Motion { .. } => {
862                    self.pointer_pos = pos;
863                }
864                PointerEventKind::Leave { .. } => {
865                    self.hovered = false;
866                    self.redraw();
867                }
868                // 0x110 == BTN_LEFT.
869                PointerEventKind::Press {
870                    button: 0x110,
871                    serial,
872                    ..
873                } => {
874                    self.pointer_pos = pos;
875                    self.on_press(&seat, serial);
876                }
877                // Wheel adjusts opacity (up = more opaque).
878                PointerEventKind::Axis { vertical, .. } if vertical.absolute != 0.0 => {
879                    let step = if vertical.absolute < 0.0 { 0.1 } else { -0.1 };
880                    self.set_opacity(self.opacity + step);
881                }
882                _ => {}
883            }
884        }
885    }
886}
887
888impl State {
889    /// Left-button press: hit-test the toolbar / grip / body and act.
890    fn on_press(&mut self, seat: &wl_seat::WlSeat, serial: u32) {
891        if self.collapsed {
892            self.set_collapsed(false);
893            return;
894        }
895        let (w, h) = (self.width as f32, self.height as f32);
896        let (close, collapse) = toolbar_rects(w);
897        let p = self.pointer_pos;
898        if self.hovered && close.contains(p) {
899            self.closing = true;
900        } else if self.hovered && collapse.contains(p) {
901            self.set_collapsed(true);
902        } else if self.hovered && grip_rect(w, h).contains(p) {
903            self.window
904                .xdg_toplevel()
905                .resize(seat, serial, ResizeEdge::BottomRight);
906        } else {
907            self.window.xdg_toplevel()._move(seat, serial);
908        }
909    }
910
911    /// Keyboard shortcuts (the window must hold keyboard focus — click it first).
912    fn on_key(&mut self, key: Keysym) {
913        match key {
914            Keysym::Escape | Keysym::q => self.closing = true,
915            Keysym::space => {
916                self.frozen = !self.frozen;
917                self.redraw();
918            }
919            Keysym::c => self.set_collapsed(!self.collapsed),
920            // Opacity: `+`/`=` more opaque, `-` more transparent.
921            Keysym::plus | Keysym::equal | Keysym::KP_Add => self.set_opacity(self.opacity + 0.1),
922            Keysym::minus | Keysym::KP_Subtract => self.set_opacity(self.opacity - 0.1),
923            Keysym::r => self.repick(),
924            _ => {}
925        }
926    }
927
928    fn set_opacity(&mut self, o: f32) {
929        let o = o.clamp(0.2, 1.0);
930        if (o - self.opacity).abs() > f32::EPSILON {
931            self.opacity = o;
932            self.redraw();
933        }
934    }
935
936    /// Re-pick the mirrored source: re-exec ourselves (which runs the chooser) and
937    /// close this tile. Simpler and more robust than swapping the capture thread in
938    /// place, and the compositor controls position anyway. No-op if disabled.
939    fn repick(&mut self) {
940        if self.relaunch.is_empty() {
941            return;
942        }
943        if let Ok(exe) = std::env::current_exe() {
944            let _ = std::process::Command::new(exe).args(&self.relaunch).spawn();
945        }
946        self.closing = true;
947    }
948}
949
950impl OutputHandler for State {
951    fn output_state(&mut self) -> &mut OutputState {
952        &mut self.output_state
953    }
954    fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
955    fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
956    fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
957}
958
959impl ProvidesRegistryState for State {
960    fn registry(&mut self) -> &mut RegistryState {
961        &mut self.registry_state
962    }
963    registry_handlers![OutputState, SeatState];
964}
965
966delegate_compositor!(State);
967delegate_output!(State);
968delegate_seat!(State);
969delegate_keyboard!(State);
970delegate_pointer!(State);
971delegate_xdg_shell!(State);
972delegate_xdg_window!(State);
973delegate_registry!(State);
974/// A captured frame (or the source's demise) for the single mirrored window.
975pub enum PipMsg {
976    /// CPU shm frame at full resolution (RGBA8).
977    Shm {
978        /// Width in pixels.
979        w: usize,
980        /// Height in pixels.
981        h: usize,
982        /// Tightly-packed RGBA8 pixels, row-major.
983        rgba: Vec<u8>,
984    },
985    /// GPU dma-buf frame to import zero-copy as a GL texture (host-side).
986    Dmabuf {
987        /// The dma-buf descriptor to import.
988        frame: wl::DmabufFrame,
989    },
990    /// The source window is gone (closed, or never appeared): the mirror ends.
991    Gone,
992}
993
994/// Capture thread body: open its own Wayland connection and stream `source` via the
995/// shared [`stream::Stream`] driver, forwarding each frame (or the source's demise)
996/// as a [`PipMsg`] until the source closes or the host drops the channel.
997///
998/// `sink` consumes each message and returns `false` once the receiver is gone (so
999/// the thread can stop). It is generic so the host (calloop channel) and the
1000/// headless bench (std mpsc) can both drive it.
1001pub fn capture_thread(source: stream::Source, mut sink: impl FnMut(PipMsg) -> bool) {
1002    let mut client = match wl::Client::connect() {
1003        Ok(c) => c,
1004        Err(e) => {
1005            eprintln!("wlr-capture: mirror: {e:#}");
1006            sink(PipMsg::Gone);
1007            return;
1008        }
1009    };
1010
1011    let mut s = stream::Stream::new(source, APPEAR_GRACE);
1012    loop {
1013        let step = s.step(&mut client, ROUND);
1014        // Single source, so every delivered frame is ours.
1015        for frame in step.frames {
1016            let msg = match frame {
1017                wl::Frame::Shm(img) => PipMsg::Shm {
1018                    w: img.width as usize,
1019                    h: img.height as usize,
1020                    rgba: img.rgba,
1021                },
1022                wl::Frame::Dmabuf(frame) => PipMsg::Dmabuf { frame },
1023            };
1024            if !sink(msg) {
1025                return; // host gone
1026            }
1027        }
1028        if step.end.is_some() {
1029            sink(PipMsg::Gone);
1030            return;
1031        }
1032    }
1033}