git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

src/bin/cce-xembed-tray/x11.rs (30.5K)

  1 //! The X11 half: own the XEmbed system tray selection, adopt each docked
  2 //! icon into a hidden container, and report its pixels as they change.
  3 //!
  4 //! Everything here runs on one blocking thread that reads X events. The
  5 //! D-Bus side only ever *sends* requests (clicks, the release on exit)
  6 //! through an [`XHandle`]; `RustConnection` is thread-safe for that.
  7 
  8 use std::collections::HashMap;
  9 use std::sync::{Arc, Mutex};
 10 use std::time::{Duration, Instant};
 11 
 12 use tokio::sync::mpsc::UnboundedSender;
 13 use x11rb::connection::Connection;
 14 use x11rb::protocol::damage::{self, ConnectionExt as _};
 15 use x11rb::protocol::shape::{self, ConnectionExt as _};
 16 use x11rb::protocol::xfixes::ConnectionExt as _;
 17 use x11rb::protocol::xproto::*;
 18 use x11rb::protocol::Event;
 19 use x11rb::rust_connection::RustConnection;
 20 use x11rb::wrapper::ConnectionExt as _;
 21 use x11rb::{COPY_DEPTH_FROM_PARENT, COPY_FROM_PARENT, CURRENT_TIME, NONE};
 22 
 23 pub type Res<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
 24 
 25 /// One SNI `IconPixmap` entry: width, height, ARGB32 in network byte order
 26 /// with straight (not premultiplied) alpha.
 27 pub type SniImage = (i32, i32, Vec<u8>);
 28 
 29 x11rb::atom_manager! {
 30     pub Atoms: AtomsCookie {
 31         MANAGER,
 32         _NET_SYSTEM_TRAY_OPCODE,
 33         _NET_SYSTEM_TRAY_ORIENTATION,
 34         _NET_SYSTEM_TRAY_VISUAL,
 35         _XEMBED,
 36         _XEMBED_INFO,
 37         _NET_WM_NAME,
 38         _NET_WM_PID,
 39         _NET_WM_WINDOW_TYPE,
 40         _NET_WM_WINDOW_TYPE_TOOLTIP,
 41         UTF8_STRING,
 42         _CCE_XEMBED_TRAY_TIME,
 43     }
 44 }
 45 
 46 const SYSTEM_TRAY_REQUEST_DOCK: u32 = 0;
 47 const XEMBED_EMBEDDED_NOTIFY: u32 = 0;
 48 const XEMBED_MAPPED: u32 = 1;
 49 
 50 /// WM_CLASS (instance, class) of every container. The compositor matches
 51 /// the class and never shows these windows (`xwayland_override_redirect.rs`,
 52 /// `is_xembed_tray_container`) — keep the two in step.
 53 const CONTAINER_CLASS: &[u8] = b"cce-xembed-tray\0cce-xembed-tray\0";
 54 
 55 /// Icon windows are kept square, at their own size clamped to this range.
 56 const MIN_ICON: u16 = 16;
 57 const MAX_ICON: u16 = 64;
 58 
 59 pub enum TrayEvent {
 60     /// A new icon. `image` is `None` until it has drawn something;
 61     /// `title_settled` is false while the title is only a fallback
 62     /// (`title::resolve`), so the D-Bus side asks again later.
 63     Docked { icon: Window, title: String, title_settled: bool, image: Option<SniImage>, at: ClickPoint },
 64     Image { icon: Window, image: SniImage },
 65     Title { icon: Window, title: String },
 66     Undocked { icon: Window },
 67 }
 68 
 69 /// Where a forwarded click lands when the host gives no screen point: in
 70 /// root coordinates (what the app reads back as the cursor position, so
 71 /// where its menu opens) and in the icon's. Also the icon's container and
 72 /// size, which a click at a host-given point moves (`XHandle::click`).
 73 #[derive(Clone, Copy, Debug)]
 74 pub struct ClickPoint {
 75     pub root: (i16, i16),
 76     pub local: (i16, i16),
 77     pub container: Window,
 78     pub size: u16,
 79 }
 80 
 81 struct Icon {
 82     container: Window,
 83     damage: damage::Damage,
 84     slot: u16,
 85     size: u16,
 86     last: Option<Vec<u8>>,
 87 }
 88 
 89 /// How long after a forwarded click a newly mapped override-redirect
 90 /// window counts as the popup that click opened.
 91 const POPUP_ARM: Duration = Duration::from_millis(1500);
 92 
 93 /// The popup an app opened in answer to a forwarded click, shared between
 94 /// the X thread (which sees it map and go) and the D-Bus side (which arms
 95 /// the watch on a click and closes the popup on a click-away).
 96 #[derive(Default)]
 97 struct PopupWatch {
 98     /// When the last click was forwarded, and the root y (X pixels) the
 99     /// popup should not reach above: the bar's bottom edge, when the host
100     /// gave a screen point.
101     armed: Option<(Instant, Option<i32>)>,
102     popup: Option<Window>,
103 }
104 
105 /// The request-only view of the connection the D-Bus side holds.
106 pub struct XHandle {
107     conn: Arc<RustConnection>,
108     root: Window,
109     atoms: Atoms,
110     watch: Arc<Mutex<PopupWatch>>,
111 }
112 
113 impl XHandle {
114     /// The icon's name, as `Tray` resolves it at dock time. Blocking.
115     pub fn title(&self, icon: Window) -> crate::title::Title {
116         crate::title::resolve(&self.conn, self.root, &self.atoms, icon)
117     }
118 
119     /// Press and release `button` on `icon`. Sent with an empty event mask,
120     /// which delivers it to the client that created the window — the app
121     /// that docked the icon — whatever it selected.
122     ///
123     /// `screen` is the host's click point in screen (logical) coordinates,
124     /// as SNI's Activate/ContextMenu carry it; cce's bar sends the pointer's
125     /// x at the bar's bottom edge. An app opens its menu at the root
126     /// position the event reports, so that point — scaled into X's pixels —
127     /// is where the click is placed, and the container is moved under it
128     /// first so the icon's real position agrees for an app that asks X
129     /// instead. `None` (or the 0,0 a host with no idea sends) keeps the
130     /// container where it docked, at the top-right of the X screen.
131     pub fn click(&self, icon: Window, button: u8, at: ClickPoint, screen: Option<(i32, i32)>) -> Res<()> {
132         let placed = screen.filter(|&(x, y)| x > 0 || y > 0);
133         let at = match placed {
134             Some(point) => self.move_under(at, point, x11_scale(&self.conn, self.root)),
135             None => at,
136         };
137         // Whatever override-redirect window maps next is this click's
138         // popup: armed before the press goes out, so the map cannot win.
139         if let Ok(mut w) = self.watch.lock() {
140             w.armed = Some((Instant::now(), placed.map(|_| i32::from(at.root.1))));
141         }
142         let mask = 1u16 << (7 + button.min(5));
143         for (kind, state) in [(BUTTON_PRESS_EVENT, 0u16), (BUTTON_RELEASE_EVENT, mask)] {
144             let ev = ButtonPressEvent {
145                 response_type: kind,
146                 detail: button,
147                 sequence: 0,
148                 time: CURRENT_TIME,
149                 root: self.root,
150                 event: icon,
151                 child: NONE,
152                 root_x: at.root.0,
153                 root_y: at.root.1,
154                 event_x: at.local.0,
155                 event_y: at.local.1,
156                 state: KeyButMask::from(state),
157                 same_screen: true,
158             };
159             self.conn.send_event(false, icon, EventMask::NO_EVENT, ev)?;
160         }
161         self.conn.flush()?;
162         Ok(())
163     }
164 
165     /// Put `at`'s container so the icon's bottom-centre is the screen point
166     /// (in X pixels), and return the click point to match.
167     fn move_under(&self, at: ClickPoint, (sx, sy): (i32, i32), scale: f64) -> ClickPoint {
168         let (rx, ry) = to_x11_point(sx, sy, scale);
169         let size = i32::from(at.size);
170         let half = size / 2;
171         let cx = (rx - half).max(0);
172         let cy = (ry - (size - 1)).max(0);
173         let _ = self
174             .conn
175             .configure_window(at.container, &ConfigureWindowAux::new().x(cx).y(cy));
176         let local = ((rx - cx).clamp(0, size - 1), (ry - cy).clamp(0, size - 1));
177         ClickPoint {
178             root: (clamp_i16(cx + local.0), clamp_i16(cy + local.1)),
179             local: (clamp_i16(local.0), clamp_i16(local.1)),
180             ..at
181         }
182     }
183 
184     /// Close the popup a forwarded click opened, if it is still up: press
185     /// and release at a point just outside it, addressed to the popup. The
186     /// app holds the mouse capture while its menu is open, so it reads that
187     /// as a click outside the menu and closes it — what a click on any
188     /// other window would do on Windows, and what one on a Wayland window
189     /// cannot, since Xwayland never delivers it (see the compositor's
190     /// `clickaway` status topic, which is what calls this).
191     pub fn dismiss_popup(&self) {
192         let Some(popup) = self.watch.lock().ok().and_then(|w| w.popup) else {
193             return;
194         };
195         let Some(geom) = self.conn.get_geometry(popup).ok().and_then(|c| c.reply().ok()) else {
196             return;
197         };
198         let (ex, ey) = (-8i16, -8i16);
199         for (kind, state) in [(BUTTON_PRESS_EVENT, 0u16), (BUTTON_RELEASE_EVENT, 1u16 << 8)] {
200             let ev = ButtonPressEvent {
201                 response_type: kind,
202                 detail: 1,
203                 sequence: 0,
204                 time: CURRENT_TIME,
205                 root: self.root,
206                 event: popup,
207                 child: NONE,
208                 root_x: geom.x.saturating_add(ex),
209                 root_y: geom.y.saturating_add(ey),
210                 event_x: ex,
211                 event_y: ey,
212                 state: KeyButMask::from(state),
213                 same_screen: true,
214             };
215             let _ = self.conn.send_event(false, popup, EventMask::NO_EVENT, ev);
216         }
217         let _ = self.conn.flush();
218         log::info!("click-away: closing popup {popup:#x}");
219     }
220 
221     /// Hand every icon back on the way out: unmapped, on the root window,
222     /// which the XEmbed client reads as "the tray is gone" and so goes
223     /// looking for the next one (or its own fallback).
224     pub fn release(&self, icons: impl IntoIterator<Item = Window>) {
225         for icon in icons {
226             let _ = self.conn.unmap_window(icon);
227             let _ = self.conn.reparent_window(icon, self.root, 0, 0);
228         }
229         let _ = self.conn.flush();
230     }
231 }
232 
233 pub struct Tray {
234     conn: Arc<RustConnection>,
235     root: Window,
236     root_width: u16,
237     atoms: Atoms,
238     selection: Atom,
239     manager: Window,
240     depth: u8,
241     visual: Visualid,
242     colormap: Colormap,
243     icons: HashMap<Window, Icon>,
244     tx: UnboundedSender<TrayEvent>,
245     watch: Arc<Mutex<PopupWatch>>,
246 }
247 
248 impl Tray {
249     pub fn new(conn: Arc<RustConnection>, screen_num: usize, tx: UnboundedSender<TrayEvent>) -> Res<Self> {
250         let screen = conn.setup().roots[screen_num].clone();
251         let atoms = Atoms::new(&*conn)?.reply()?;
252         let selection = conn
253             .intern_atom(false, format!("_NET_SYSTEM_TRAY_S{screen_num}").as_bytes())?
254             .reply()?
255             .atom;
256         // Damage needs both versions negotiated before its first request.
257         conn.xfixes_query_version(5, 0)?.reply()?;
258         conn.damage_query_version(1, 1)?.reply()?;
259 
260         // An ARGB visual, advertised through _NET_SYSTEM_TRAY_VISUAL, lets
261         // an icon draw with real transparency instead of over a solid box.
262         let (depth, visual) = screen
263             .allowed_depths
264             .iter()
265             .filter(|d| d.depth == 32)
266             .flat_map(|d| {
267                 d.visuals
268                     .iter()
269                     .filter(|v| v.class == VisualClass::TRUE_COLOR)
270                     .map(move |v| (d.depth, v.visual_id))
271             })
272             .next()
273             .unwrap_or((screen.root_depth, screen.root_visual));
274         let colormap = conn.generate_id()?;
275         conn.create_colormap(ColormapAlloc::NONE, colormap, screen.root, visual)?;
276 
277         let manager = conn.generate_id()?;
278         conn.create_window(
279             COPY_DEPTH_FROM_PARENT,
280             manager,
281             screen.root,
282             -1,
283             -1,
284             1,
285             1,
286             0,
287             WindowClass::INPUT_ONLY,
288             COPY_FROM_PARENT,
289             &CreateWindowAux::new().override_redirect(1).event_mask(EventMask::PROPERTY_CHANGE),
290         )?;
291         conn.change_property32(
292             PropMode::REPLACE,
293             manager,
294             atoms._NET_SYSTEM_TRAY_ORIENTATION,
295             AtomEnum::CARDINAL,
296             &[0], // horizontal
297         )?;
298         conn.change_property32(
299             PropMode::REPLACE,
300             manager,
301             atoms._NET_SYSTEM_TRAY_VISUAL,
302             AtomEnum::VISUALID,
303             &[visual],
304         )?;
305         // Top-level map/unmap events, to catch the popup a forwarded click
306         // opens (`PopupWatch`). SubstructureNotify is shareable; only the
307         // redirect mask is the window manager's alone.
308         conn.change_window_attributes(
309             screen.root,
310             &ChangeWindowAttributesAux::new().event_mask(EventMask::SUBSTRUCTURE_NOTIFY),
311         )?;
312         conn.flush()?;
313         log::info!("tray visual {visual:#x} (depth {depth})");
314 
315         Ok(Tray {
316             conn,
317             root: screen.root,
318             root_width: screen.width_in_pixels,
319             atoms,
320             selection,
321             manager,
322             depth,
323             visual,
324             colormap,
325             icons: HashMap::new(),
326             tx,
327             watch: Arc::new(Mutex::new(PopupWatch::default())),
328         })
329     }
330 
331     pub fn handle(&self) -> XHandle {
332         XHandle { conn: self.conn.clone(), root: self.root, atoms: self.atoms, watch: self.watch.clone() }
333     }
334 
335     /// Take the tray selection and announce it. Another tray already
336     /// holding it is left alone: this waits for that owner to go away
337     /// rather than stealing it, so two trays never fight over the icons.
338     pub fn acquire(&mut self) -> Res<()> {
339         loop {
340             let owner = self.conn.get_selection_owner(self.selection)?.reply()?.owner;
341             if owner == NONE {
342                 break;
343             }
344             log::info!("tray selection held by window {owner:#x}; waiting for it to be released");
345             let watch = ChangeWindowAttributesAux::new().event_mask(EventMask::STRUCTURE_NOTIFY);
346             if self.conn.change_window_attributes(owner, &watch)?.check().is_err() {
347                 continue; // gone already
348             }
349             if self.conn.get_selection_owner(self.selection)?.reply()?.owner != owner {
350                 continue;
351             }
352             loop {
353                 if let Event::DestroyNotify(e) = self.conn.wait_for_event()? {
354                     if e.window == owner {
355                         break;
356                     }
357                 }
358             }
359         }
360         let time = self.server_time()?;
361         self.conn.set_selection_owner(self.manager, self.selection, time)?;
362         if self.conn.get_selection_owner(self.selection)?.reply()?.owner != self.manager {
363             return Err("lost the race for the tray selection".into());
364         }
365         let announce = ClientMessageEvent::new(
366             32,
367             self.root,
368             self.atoms.MANAGER,
369             [time, self.selection, self.manager, 0, 0],
370         );
371         self.conn.send_event(false, self.root, EventMask::STRUCTURE_NOTIFY, announce)?;
372         self.conn.flush()?;
373         log::info!("own the XEmbed system tray selection");
374         Ok(())
375     }
376 
377     /// A real server timestamp, which ICCCM asks selection owners to use:
378     /// touch a property on our own window and read the notify's time.
379     fn server_time(&self) -> Res<Timestamp> {
380         self.conn.change_property8(
381             PropMode::APPEND,
382             self.manager,
383             self.atoms._CCE_XEMBED_TRAY_TIME,
384             AtomEnum::STRING,
385             &[],
386         )?;
387         self.conn.flush()?;
388         loop {
389             if let Event::PropertyNotify(e) = self.conn.wait_for_event()? {
390                 if e.window == self.manager {
391                     return Ok(e.time);
392                 }
393             }
394         }
395     }
396 
397     /// Serve until the selection is taken by another tray (`Ok`) or the X
398     /// connection fails (`Err`).
399     pub fn run(&mut self) -> Res<()> {
400         loop {
401             let event = self.conn.wait_for_event()?;
402             match event {
403                 Event::ClientMessage(e)
404                     if e.window == self.manager && e.type_ == self.atoms._NET_SYSTEM_TRAY_OPCODE =>
405                 {
406                     let data = e.data.as_data32();
407                     // BEGIN_MESSAGE / CANCEL_MESSAGE (balloons) are ignored.
408                     if data[1] == SYSTEM_TRAY_REQUEST_DOCK {
409                         if let Err(err) = self.dock(data[2]) {
410                             log::warn!("dock of {:#x} failed: {err}", data[2]);
411                         }
412                     }
413                 }
414                 Event::SelectionClear(e) if e.selection == self.selection => {
415                     log::info!("another tray took the selection; handing over");
416                     return Ok(());
417                 }
418                 Event::DestroyNotify(e) => {
419                     self.popup_gone(e.window);
420                     self.undock(e.window, true)
421                 }
422                 Event::UnmapNotify(e) => self.popup_gone(e.window),
423                 Event::MapNotify(e) if e.override_redirect => self.popup_mapped(e.window)?,
424                 Event::ReparentNotify(e) => {
425                     if self.icons.get(&e.window).is_some_and(|i| i.container != e.parent) {
426                         self.undock(e.window, false);
427                     }
428                 }
429                 Event::ConfigureNotify(e) => {
430                     // The embedder decides an icon's size; an icon that
431                     // resizes itself is put back.
432                     if let Some(icon) = self.icons.get(&e.window) {
433                         if e.width != icon.size || e.height != icon.size || e.x != 0 || e.y != 0 {
434                             let size = u32::from(icon.size);
435                             let aux = ConfigureWindowAux::new().x(0).y(0).width(size).height(size);
436                             self.conn.configure_window(e.window, &aux)?;
437                         }
438                     }
439                 }
440                 Event::PropertyNotify(e) if self.icons.contains_key(&e.window) => {
441                     if e.atom == self.atoms._XEMBED_INFO {
442                         self.apply_xembed_info(e.window)?;
443                     } else if e.atom == self.atoms._NET_WM_NAME || e.atom == u32::from(AtomEnum::WM_NAME) {
444                         let title = crate::title::resolve(&self.conn, self.root, &self.atoms, e.window);
445                         let _ = self.tx.send(TrayEvent::Title { icon: e.window, title: title.text });
446                     }
447                 }
448                 Event::DamageNotify(e) => {
449                     self.conn.damage_subtract(e.damage, NONE, NONE)?;
450                     if let Some(image) = self.capture_changed(e.drawable) {
451                         let _ = self.tx.send(TrayEvent::Image { icon: e.drawable, image });
452                     }
453                 }
454                 // Requests on a window that vanished under us (an icon's
455                 // app exiting mid-dock) fail harmlessly.
456                 Event::Error(e) => log::debug!("X error: {e:?}"),
457                 _ => {}
458             }
459             self.conn.flush()?;
460         }
461     }
462 
463     /// An override-redirect window mapped: if a forwarded click is still
464     /// armed, it is that click's popup. Remember it for the click-away, and
465     /// if it covers the bar, move it down to the bar's bottom edge. Windows
466     /// tray apps open their menu UPWARD from the pointer, the taskbar being
467     /// at the bottom there; with the pointer on a top bar there is no room
468     /// above, and the app clamps the menu to the screen top — over the very
469     /// icon that opened it. The app keeps working in the moved window:
470     /// X reports pointer positions relative to the window, which is what
471     /// the app hit-tests with.
472     fn popup_mapped(&mut self, window: Window) -> Res<()> {
473         if self.icons.values().any(|i| i.container == window) {
474             return Ok(());
475         }
476         let armed = self.watch.lock().ok().and_then(|w| w.armed);
477         let anchor = match armed {
478             Some((at, anchor)) if at.elapsed() < POPUP_ARM => anchor,
479             _ => return Ok(()),
480         };
481         if self.is_tooltip(window) {
482             log::debug!("{window:#x} mapped after a click, but is a tooltip; not the popup");
483             return Ok(());
484         }
485         if let Ok(mut w) = self.watch.lock() {
486             w.popup = Some(window);
487         }
488         let geom = self.conn.get_geometry(window)?.reply()?;
489         log::info!("popup {window:#x} opened at {},{} {}x{}", geom.x, geom.y, geom.width, geom.height);
490         if let Some(bar_bottom) = anchor {
491             if i32::from(geom.y) < bar_bottom {
492                 self.conn.configure_window(window, &ConfigureWindowAux::new().y(bar_bottom))?;
493                 log::info!("popup {window:#x} moved below the bar to y={bar_bottom}");
494             }
495         }
496         Ok(())
497     }
498 
499     /// Whether a window that mapped while a click was armed is a tooltip,
500     /// not the click's popup. A forwarded click makes Wine's `explorer.exe`
501     /// — which hosts the prefix's tray icons — show the icon's tooltip, and
502     /// nothing tells it from the app's menu but whose it is: Wine gives the
503     /// two the same window type (DIALOG) and the same Win32 styles. Taken
504     /// for the popup, the tooltip stole the click-away (the menu stayed
505     /// open) and, mapping after the menu, replaced it in the watch. A
506     /// toolkit that declares `_NET_WM_WINDOW_TYPE_TOOLTIP` is taken at its
507     /// word.
508     fn is_tooltip(&self, window: Window) -> bool {
509         let typed = self
510             .conn
511             .get_property(false, window, self.atoms._NET_WM_WINDOW_TYPE, AtomEnum::ATOM, 0, 16)
512             .ok()
513             .and_then(|c| c.reply().ok())
514             .is_some_and(|r| r.value32().is_some_and(|mut v| v.any(|a| a == self.atoms._NET_WM_WINDOW_TYPE_TOOLTIP)));
515         if typed {
516             return true;
517         }
518         self.conn
519             .get_property(false, window, self.atoms._NET_WM_PID, AtomEnum::CARDINAL, 0, 1)
520             .ok()
521             .and_then(|c| c.reply().ok())
522             .and_then(|r| r.value32().and_then(|mut v| v.next()))
523             .is_some_and(crate::title::is_wine_plumbing)
524     }
525 
526     fn popup_gone(&self, window: Window) {
527         if let Ok(mut w) = self.watch.lock() {
528             if w.popup == Some(window) {
529                 w.popup = None;
530             }
531         }
532     }
533 
534     fn dock(&mut self, icon: Window) -> Res<()> {
535         if icon == NONE || self.icons.contains_key(&icon) {
536             return Ok(());
537         }
538         let geom = self.conn.get_geometry(icon)?.reply()?;
539         let size = geom.width.max(geom.height).clamp(MIN_ICON, MAX_ICON);
540         let slot = (0u16..).find(|s| !self.icons.values().any(|i| i.slot == *s)).unwrap_or(0);
541 
542         // Containers sit along the top-right of the X screen, one slot per
543         // icon. Nobody sees them — the compositor never draws them — but
544         // an app opens its menu at the click's root position, and the
545         // status bar's tray is up there.
546         let x = (i32::from(self.root_width) - i32::from(slot + 1) * i32::from(size)).max(0) as i16;
547         let container = self.conn.generate_id()?;
548         self.conn.create_window(
549             self.depth,
550             container,
551             self.root,
552             x,
553             0,
554             size,
555             size,
556             0,
557             WindowClass::INPUT_OUTPUT,
558             self.visual,
559             &CreateWindowAux::new()
560                 .background_pixel(0)
561                 .border_pixel(0)
562                 .colormap(self.colormap)
563                 .override_redirect(1),
564         )?;
565         self.conn.change_property8(
566             PropMode::REPLACE,
567             container,
568             AtomEnum::WM_CLASS,
569             AtomEnum::STRING,
570             CONTAINER_CLASS,
571         )?;
572         // An empty input region: X never routes the pointer into a
573         // container, so one can never swallow a click meant for a real X
574         // window it happens to sit above. Clicks reach icons only as the
575         // events `XHandle::click` sends.
576         self.conn.shape_rectangles(
577             shape::SO::SET,
578             shape::SK::INPUT,
579             ClipOrdering::UNSORTED,
580             container,
581             0,
582             0,
583             &[],
584         )?;
585 
586         self.conn.change_window_attributes(
587             icon,
588             &ChangeWindowAttributesAux::new()
589                 .event_mask(EventMask::STRUCTURE_NOTIFY | EventMask::PROPERTY_CHANGE),
590         )?;
591         // If this process dies, X hands the icon back to the root window
592         // instead of destroying it with the container.
593         self.conn.change_save_set(SetMode::INSERT, icon)?;
594         self.conn.reparent_window(icon, container, 0, 0)?;
595         let s = u32::from(size);
596         self.conn
597             .configure_window(icon, &ConfigureWindowAux::new().x(0).y(0).width(s).height(s))?;
598 
599         let damage = self.conn.generate_id()?;
600         self.conn.damage_create(damage, icon, damage::ReportLevel::NON_EMPTY)?;
601         self.icons.insert(icon, Icon { container, damage, slot, size, last: None });
602 
603         self.apply_xembed_info(icon)?;
604         self.conn
605             .configure_window(container, &ConfigureWindowAux::new().stack_mode(StackMode::BELOW))?;
606         self.conn.map_window(container)?;
607         let notify = ClientMessageEvent::new(
608             32,
609             icon,
610             self.atoms._XEMBED,
611             [CURRENT_TIME, XEMBED_EMBEDDED_NOTIFY, 0, container, 0],
612         );
613         self.conn.send_event(false, icon, EventMask::NO_EVENT, notify)?;
614         self.conn.flush()?;
615 
616         let title = crate::title::resolve(&self.conn, self.root, &self.atoms, icon);
617         let half = (size / 2) as i16;
618         let at = ClickPoint { root: (x + half, half), local: (half, half), container, size };
619         let image = self.capture_changed(icon);
620         log::info!("docked {icon:#x} ({:?}, {size}px, depth {}) in slot {slot}", title.text, geom.depth);
621         let _ = self.tx.send(TrayEvent::Docked {
622             icon,
623             title: title.text,
624             title_settled: title.settled,
625             image,
626             at,
627         });
628         Ok(())
629     }
630 
631     /// Map or unmap the icon as its `_XEMBED_INFO` flags say. An icon
632     /// without the property is mapped: that is what trays do in practice.
633     fn apply_xembed_info(&self, icon: Window) -> Res<()> {
634         let reply = self
635             .conn
636             .get_property(false, icon, self.atoms._XEMBED_INFO, AtomEnum::ANY, 0, 2)?
637             .reply()?;
638         let flags = reply.value32().and_then(|mut v| v.nth(1));
639         if flags.is_none_or(|f| f & XEMBED_MAPPED != 0) {
640             self.conn.map_window(icon)?;
641         } else {
642             self.conn.unmap_window(icon)?;
643         }
644         Ok(())
645     }
646 
647     fn undock(&mut self, icon: Window, destroyed: bool) {
648         let Some(entry) = self.icons.remove(&icon) else {
649             return;
650         };
651         // A destroyed drawable takes its damage object with it.
652         if !destroyed {
653             let _ = self.conn.damage_destroy(entry.damage);
654         }
655         let _ = self.conn.destroy_window(entry.container);
656         let _ = self.conn.flush();
657         log::info!("undocked {icon:#x}");
658         let _ = self.tx.send(TrayEvent::Undocked { icon });
659     }
660 
661     /// Read the icon's pixels; `Some` only when they differ from what was
662     /// last reported and are not entirely transparent (not drawn yet).
663     fn capture_changed(&mut self, icon: Window) -> Option<SniImage> {
664         let size = self.icons.get(&icon)?.size;
665         let reply = match self
666             .conn
667             .get_image(ImageFormat::Z_PIXMAP, icon, 0, 0, size, size, !0)
668             .ok()?
669             .reply()
670         {
671             Ok(r) => r,
672             Err(e) => {
673                 log::debug!("capture of {icon:#x} failed: {e}");
674                 return None;
675             }
676         };
677         let lsb = self.conn.setup().image_byte_order == ImageOrder::LSB_FIRST;
678         let pixels = to_sni_argb(&reply.data, reply.depth, lsb)?;
679         if pixels.as_chunks::<4>().0.iter().all(|p| p[0] == 0) {
680             return None;
681         }
682         let entry = self.icons.get_mut(&icon)?;
683         if entry.last.as_ref() == Some(&pixels) {
684             return None;
685         }
686         entry.last = Some(pixels.clone());
687         Some((i32::from(size), i32::from(size), pixels))
688     }
689 }
690 
691 /// How many X pixels one logical pixel is: cce writes `Xft.dpi` into the
692 /// root window's resources for its X11 scale (192 at scale 2), so it is
693 /// read back from there. 1 when absent.
694 fn x11_scale(conn: &RustConnection, root: Window) -> f64 {
695     let dpi = conn
696         .get_property(false, root, AtomEnum::RESOURCE_MANAGER, AtomEnum::STRING, 0, 1 << 16)
697         .ok()
698         .and_then(|c| c.reply().ok())
699         .and_then(|r| xft_dpi(&String::from_utf8_lossy(&r.value)));
700     dpi.map_or(1.0, |d| d / 96.0).max(1.0)
701 }
702 
703 /// `Xft.dpi` from an X resource database string.
704 pub fn xft_dpi(resources: &str) -> Option<f64> {
705     resources.lines().find_map(|l| {
706         let (k, v) = l.split_once(':')?;
707         (k.trim() == "Xft.dpi").then(|| v.trim().parse().ok()).flatten()
708     })
709 }
710 
711 /// A logical screen point in X root pixels.
712 pub fn to_x11_point(x: i32, y: i32, scale: f64) -> (i32, i32) {
713     ((f64::from(x) * scale).round() as i32, (f64::from(y) * scale).round() as i32)
714 }
715 
716 fn clamp_i16(v: i32) -> i16 {
717     v.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
718 }
719 
720 /// Convert a 32-bits-per-pixel ZPixmap to SNI's ARGB32: network byte
721 /// order, straight alpha. X's ARGB visuals are premultiplied; a depth-24
722 /// image has no alpha and is opaque. `None` for any other layout.
723 pub fn to_sni_argb(data: &[u8], depth: u8, lsb_first: bool) -> Option<Vec<u8>> {
724     if !data.len().is_multiple_of(4) || !(depth == 24 || depth == 32) {
725         return None;
726     }
727     let mut out = Vec::with_capacity(data.len());
728     for px in data.as_chunks::<4>().0 {
729         let bytes = [px[0], px[1], px[2], px[3]];
730         let v = if lsb_first { u32::from_le_bytes(bytes) } else { u32::from_be_bytes(bytes) };
731         let a = if depth == 32 { (v >> 24) as u8 } else { 255 };
732         let unpremultiply = |c: u32| -> u8 {
733             match a {
734                 0 => 0,
735                 255 => c as u8,
736                 a => ((c * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8,
737             }
738         };
739         out.extend_from_slice(&[
740             a,
741             unpremultiply((v >> 16) & 0xff),
742             unpremultiply((v >> 8) & 0xff),
743             unpremultiply(v & 0xff),
744         ]);
745     }
746     Some(out)
747 }
748 
749 #[cfg(test)]
750 mod tests {
751     use super::{to_sni_argb, to_x11_point, xft_dpi};
752 
753     #[test]
754     fn scale_comes_from_xft_dpi() {
755         assert_eq!(xft_dpi("Xcursor.size:\t48\nXft.dpi:\t192\n"), Some(192.0));
756         assert_eq!(xft_dpi("Xcursor.size: 48\n"), None);
757         assert_eq!(to_x11_point(1167, 27, 2.0), (2334, 54));
758         assert_eq!(to_x11_point(1167, 27, 1.0), (1167, 27));
759     }
760 
761     #[test]
762     fn premultiplied_argb_becomes_straight_network_order() {
763         // 50% alpha, premultiplied red 0x80: straight red is 0xff.
764         let px = 0x8080_0000u32.to_le_bytes();
765         assert_eq!(to_sni_argb(&px, 32, true).unwrap(), vec![0x80, 0xff, 0x00, 0x00]);
766         assert_eq!(to_sni_argb(&0x8080_0000u32.to_be_bytes(), 32, false).unwrap(), vec![0x80, 0xff, 0, 0]);
767     }
768 
769     #[test]
770     fn depth_24_is_opaque_and_transparent_stays_zero() {
771         let px = 0x0012_3456u32.to_le_bytes();
772         assert_eq!(to_sni_argb(&px, 24, true).unwrap(), vec![0xff, 0x12, 0x34, 0x56]);
773         assert_eq!(to_sni_argb(&[0, 0, 0, 0], 32, true).unwrap(), vec![0, 0, 0, 0]);
774     }
775 
776     #[test]
777     fn other_layouts_are_refused() {
778         assert!(to_sni_argb(&[0, 0, 0], 32, true).is_none());
779         assert!(to_sni_argb(&[0, 0, 0, 0], 16, true).is_none());
780     }
781 }