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 }