1mod menu;
15mod watcher;
16mod widget;
17
18#[cfg(test)]
19mod tests;
20
21use std::cell::RefCell;
22use std::future::poll_fn;
23use std::path::{Path, PathBuf};
24use std::pin::Pin;
25use std::rc::Rc;
26use std::sync::Once;
27
28use crabshell_reactive::{ReadSignal, Signal};
29use crabshell_ui::{Cx, IconPixels, IconSource};
30use zbus::export::futures_core::Stream;
31use zbus::fdo::{RequestNameFlags, RequestNameReply};
32use zbus::message::Type as Kind;
33use zbus::names::BusName;
34use zbus::{Connection, DeserializeDict, DynamicType, Message, MessageStream, ObjectPath, Type};
35
36use crate::Shared;
37use menu::{DBUSMENU, OpenMenu};
38pub use menu::{MenuEntry, MenuToggle, TrayMenu};
39pub use widget::Tray;
40
41const WATCHER: &str = "org.kde.StatusNotifierWatcher";
42const WATCHER_PATH: &str = "/StatusNotifierWatcher";
43const ITEM: &str = "org.kde.StatusNotifierItem";
44const ITEM_PATH: &str = "/StatusNotifierItem";
45const BUS: &str = "org.freedesktop.DBus";
46const BUS_PATH: &str = "/org/freedesktop/DBus";
47const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
48
49const MAX_PIXMAP_SIDE: i32 = 512;
51
52const MAX_PIXMAP_BYTES: usize = 512 * 512 * 4;
55
56const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
59
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub enum TrayStatus {
63 Passive,
65 #[default]
67 Active,
68 NeedsAttention,
71}
72
73#[derive(Clone, Debug, Default, PartialEq)]
75pub struct TrayItem {
76 pub id: String,
80 pub app_id: String,
83 pub title: String,
85 pub status: TrayStatus,
87 pub icon_name: String,
89 pub icon_pixmaps: Vec<IconPixels>,
92 pub attention_icon_name: String,
94 pub icon_theme_path: String,
96 pub tooltip: String,
98 pub item_is_menu: bool,
101 pub menu: String,
104}
105
106impl TrayItem {
107 pub fn icon(&self, size: u32) -> Option<IconSource> {
115 let name = match self.status {
116 TrayStatus::NeedsAttention if !self.attention_icon_name.is_empty() => {
117 &self.attention_icon_name
118 }
119 _ => &self.icon_name,
120 };
121 if let Some(file) = app_icon_file(Path::new(&self.icon_theme_path), name) {
122 return Some(IconSource::File(file));
123 }
124 let closest = self
125 .icon_pixmaps
126 .iter()
127 .min_by_key(|pixels| pixels.width.max(pixels.height).abs_diff(size));
128 match closest {
129 Some(pixels) if name.is_empty() || !self.icon_theme_path.is_empty() => {
130 Some(IconSource::Pixels(pixels.clone()))
131 }
132 _ => (!name.is_empty()).then(|| IconSource::Name(name.clone())),
133 }
134 }
135}
136
137fn app_icon_file(dir: &Path, name: &str) -> Option<PathBuf> {
140 let safe = !matches!(name, "" | "." | "..") && !name.contains(['/', '\0']);
141 if !dir.is_absolute() || !safe {
142 return None;
143 }
144 ["png", "svg"]
145 .into_iter()
146 .map(|extension| dir.join(format!("{name}.{extension}")))
147 .find(|path| path.is_file())
148}
149
150#[derive(Clone)]
153pub struct TrayHost {
154 service: Option<Rc<Service>>,
156 items: ReadSignal<Vec<TrayItem>>,
157 menu: ReadSignal<Option<TrayMenu>>,
158}
159
160impl TrayHost {
161 pub fn items(&self) -> ReadSignal<Vec<TrayItem>> {
164 self.items
165 }
166
167 pub fn activate(&self, id: &str) {
171 self.call(id, ITEM, "Activate", (0, 0));
172 }
173
174 pub fn secondary_activate(&self, id: &str) {
176 self.call(id, ITEM, "SecondaryActivate", (0, 0));
177 }
178
179 pub fn scroll(&self, id: &str, steps: i32) {
182 self.call(id, ITEM, "Scroll", (steps, "vertical"));
183 }
184
185 pub fn menu(&self) -> ReadSignal<Option<TrayMenu>> {
190 self.menu
191 }
192
193 pub fn open_menu(&self, id: &str) {
197 if let Some(service) = &self.service {
198 service.open_menu(id);
199 }
200 }
201
202 pub fn open_submenu(&self, entry: i32) {
206 if let Some(service) = &self.service {
207 service.open_submenu(entry);
208 }
209 }
210
211 pub fn close_menu(&self) {
213 if let Some(service) = &self.service {
214 service.close_menu();
215 }
216 }
217
218 pub fn menu_event(&self, id: &str, entry: i32) {
221 let event = (entry, "clicked", zbus::Variant::from(0), 0u32);
222 self.call(id, DBUSMENU, "Event", event);
223 }
224
225 fn call<B>(&self, id: &str, interface: &'static str, method: &'static str, body: B)
228 where
229 B: serde::Serialize + DynamicType + 'static,
230 {
231 let target = self.service.as_ref().and_then(|service| {
232 let connection = service.state.borrow().as_ref()?.connection.clone()?;
233 let at = service.with_entry(id, |entry| {
234 let path = match interface {
235 ITEM => entry.path.clone(),
236 _ => entry.shown.as_ref()?.menu.clone(),
237 };
238 Some((entry.bus.clone(), path)).filter(|(_, path)| !path.is_empty())
239 });
240 let (bus, path) = at.flatten()?;
241 Some((service, connection, bus, path))
242 });
243 let Some((service, connection, bus, path)) = target else {
244 return tracing::warn!(id, method, "no such tray item");
245 };
246 let id = id.to_owned();
247 let shared = Rc::clone(&service.shared);
248 service.shared.spawn(async move {
249 let call = connection.call_method(
250 Some(bus.as_str()),
251 path.as_str(),
252 Some(interface),
253 method,
254 &body,
255 );
256 if let Err(error) = shared.timeout(CALL_TIMEOUT, call).await {
257 tracing::info!(id, method, %error, "the tray item refused");
258 }
259 });
260 }
261}
262
263pub(crate) fn tray(cx: &Cx) -> TrayHost {
265 let Some(service) = cx.service::<Service>() else {
266 static LOGGED: Once = Once::new();
267 LOGGED.call_once(|| {
268 tracing::warn!("cx.tray() needs the services the shell provides; the tray stays empty");
269 });
270 return TrayHost {
271 service: None,
272 items: cx.signal(Vec::new()).read_only(),
273 menu: cx.signal(None).read_only(),
274 };
275 };
276 let (items, menu) = service.start();
277 TrayHost {
278 service: Some(service),
279 items,
280 menu,
281 }
282}
283
284pub(crate) struct Service {
286 shared: Rc<Shared>,
287 state: RefCell<Option<State>>,
288}
289
290struct State {
291 items: Signal<Vec<TrayItem>>,
292 connection: Option<Connection>,
294 entries: Vec<Entry>,
296 watcher: bool,
298 menu: Signal<Option<TrayMenu>>,
300 open: Option<OpenMenu>,
302 menu_count: u64,
305}
306
307struct Entry {
308 id: String,
309 bus: String,
310 path: String,
311 owner: Option<String>,
314 reads: u64,
316 shown: Option<TrayItem>,
318}
319
320impl Service {
321 pub(crate) fn new(shared: Rc<Shared>) -> Service {
322 Service {
323 shared,
324 state: RefCell::new(None),
325 }
326 }
327
328 fn start(self: &Rc<Self>) -> (ReadSignal<Vec<TrayItem>>, ReadSignal<Option<TrayMenu>>) {
330 if let Some(state) = &*self.state.borrow() {
331 return (state.items.read_only(), state.menu.read_only());
332 }
333 let items = self.shared.root.signal(Vec::new());
334 let menu = self.shared.root.signal(None);
335 *self.state.borrow_mut() = Some(State {
336 items,
337 connection: None,
338 entries: Vec::new(),
339 watcher: false,
340 menu,
341 open: None,
342 menu_count: 0,
343 });
344 match self.shared.config.session_bus.clone() {
345 Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
346 None => tracing::info!("no session bus, so the tray is empty"),
347 }
348 (items.read_only(), menu.read_only())
349 }
350
351 fn alive(&self) -> bool {
353 let state = self.state.borrow();
354 let alive = state
355 .as_ref()
356 .and_then(|state| state.items.try_with(|_| ()));
357 alive.is_some()
358 }
359
360 fn is_watcher(&self) -> bool {
361 self
362 .state
363 .borrow()
364 .as_ref()
365 .is_some_and(|state| state.watcher)
366 }
367
368 fn ids(&self) -> Vec<String> {
370 let state = self.state.borrow();
371 let entries = state.as_ref().map(|state| state.entries.as_slice());
372 entries
373 .unwrap_or_default()
374 .iter()
375 .map(|entry| entry.id.clone())
376 .collect()
377 }
378
379 fn with_entry<R>(&self, id: &str, f: impl FnOnce(&mut Entry) -> R) -> Option<R> {
380 let mut state = self.state.borrow_mut();
381 let entry = state
382 .as_mut()?
383 .entries
384 .iter_mut()
385 .find(|entry| entry.id == id)?;
386 Some(f(entry))
387 }
388
389 async fn join(self: &Rc<Self>, connection: &Connection) -> zbus::Result<()> {
392 bus_call(
393 connection,
394 "AddMatch",
395 &format!("type='signal',interface='{ITEM}'"),
396 )
397 .await?;
398 let rule = format!("type='signal',sender='{WATCHER}',interface='{WATCHER}'");
400 bus_call(connection, "AddMatch", &rule).await?;
401 let host = format!("org.kde.StatusNotifierHost-{}", std::process::id());
402 connection.request_name(host.as_str()).await?;
403 let reply = connection
406 .request_name_with_flags(WATCHER, RequestNameFlags::empty())
407 .await?;
408 if reply == RequestNameReply::InQueue {
409 let (service, connection) = (Rc::clone(self), connection.clone());
411 self.shared.spawn(async move {
412 if let Err(error) = service.use_watcher(&connection, &host).await {
413 tracing::warn!(%error, "the running tray watcher didn't answer");
414 }
415 });
416 }
417 Ok(())
418 }
419
420 async fn use_watcher(self: &Rc<Self>, connection: &Connection, host: &str) -> zbus::Result<()> {
422 tracing::info!("another program runs the tray watcher; using it");
423 let register = "RegisterStatusNotifierHost";
424 let path = WATCHER_PATH;
425 let call = connection.call_method(Some(WATCHER), path, Some(WATCHER), register, &host);
426 self.shared.timeout(CALL_TIMEOUT, call).await?;
427 let get = (WATCHER, "RegisteredStatusNotifierItems");
428 let call = connection.call_method(Some(WATCHER), path, Some(PROPERTIES), "Get", &get);
429 let reply = self.shared.timeout(CALL_TIMEOUT, call).await?;
430 let list: Vec<String> = reply
431 .body()
432 .deserialize::<zbus::Variant<'_>>()?
433 .downcast()?;
434 for service in list {
435 self.add_registered(connection, &service);
436 }
437 Ok(())
438 }
439
440 async fn on_message(self: &Rc<Self>, connection: &Connection, message: &Message) {
442 match message.header().message_type() {
443 Kind::MethodCall => watcher::answer(self, connection, message).await,
444 Kind::Signal => self.on_signal(connection, message),
445 _ => {}
446 }
447 }
448
449 fn on_signal(self: &Rc<Self>, connection: &Connection, message: &Message) {
450 let header = message.header();
451 let (Some(interface), Some(member)) = (header.interface(), header.member()) else {
452 return;
453 };
454 let from_bus = header.sender().is_some_and(|sender| sender.as_str() == BUS);
455 let broadcast = header.destination().is_none();
458 let body = message.body();
459 match (interface.as_str(), member.as_str()) {
460 (BUS, "NameAcquired") if from_bus && body.deserialize::<&str>().ok() == Some(WATCHER) => {
461 self.become_watcher(connection);
462 }
463 (BUS, "NameOwnerChanged") if from_bus => {
464 if let Ok((name, _, owner)) = body.deserialize::<(&str, &str, &str)>() {
465 self.owner_changed(connection, name, owner);
466 }
467 }
468 (WATCHER, "StatusNotifierItemRegistered") if broadcast && !self.is_watcher() => {
470 if let Ok(service) = body.deserialize::<&str>() {
471 self.add_registered(connection, service);
472 }
473 }
474 (WATCHER, "StatusNotifierItemUnregistered") if broadcast && !self.is_watcher() => {
475 if let Ok(id) = body.deserialize::<&str>() {
476 self.remove(connection, id);
477 }
478 }
479 (DBUSMENU, "LayoutUpdated" | "ItemsPropertiesUpdated") => {
480 if let (Some(sender), Some(path)) = (header.sender(), header.path()) {
481 self.menu_changed(connection, sender.as_str(), path.as_str());
482 }
483 }
484 (ITEM, member) if member.starts_with("New") => {
486 let (sender, path) = (header.sender(), header.path());
487 let (Some(sender), Some(path)) = (sender, path) else {
488 return;
489 };
490 let state = self.state.borrow();
491 let entries = state.as_ref().map(|state| state.entries.as_slice());
492 let ids: Vec<String> = entries
493 .unwrap_or_default()
494 .iter()
495 .filter(|entry| entry.owner.as_deref() == Some(sender.as_str()))
496 .filter(|entry| entry.path == path.as_str())
497 .map(|entry| entry.id.clone())
498 .collect();
499 drop(state);
500 for id in ids {
501 self.read(connection, id);
502 }
503 }
504 _ => {}
505 }
506 }
507
508 fn become_watcher(&self, connection: &Connection) {
511 tracing::info!("running the tray watcher");
512 if let Some(state) = self.state.borrow_mut().as_mut() {
513 state.watcher = true;
514 }
515 self.emit(connection, "StatusNotifierHostRegistered", None);
516 }
517
518 fn add_registered(self: &Rc<Self>, connection: &Connection, service: &str) {
520 match parse_service(service, None) {
521 Some((bus, path)) => self.add(connection, bus, path),
522 None => tracing::warn!(service, "the tray watcher named a bad item; skipped"),
523 }
524 }
525
526 fn add(self: &Rc<Self>, connection: &Connection, bus: String, path: String) {
529 let id = format!("{bus}{path}");
530 let mut state = self.state.borrow_mut();
531 let Some(state) = state.as_mut() else {
532 return;
533 };
534 if state.entries.iter().any(|entry| entry.id == id) {
535 return;
536 }
537 state.entries.push(Entry {
538 id: id.clone(),
539 bus: bus.clone(),
540 path,
541 owner: None,
542 reads: 0,
543 shown: None,
544 });
545 if state.watcher {
546 self.emit(connection, "StatusNotifierItemRegistered", Some(id.clone()));
547 }
548 let service = Rc::clone(self);
549 let connection = connection.clone();
550 self.shared.spawn(async move {
551 if let Err(error) = bus_call(&connection, "AddMatch", &owner_rule(&bus)).await {
553 tracing::warn!(id, %error, "can't watch a tray item's name");
554 }
555 let ask = connection.call_method(Some(BUS), BUS_PATH, Some(BUS), "GetNameOwner", &bus);
556 let owner = ask
557 .await
558 .and_then(|reply| reply.body().deserialize::<String>());
559 match owner {
560 Ok(owner) => {
561 service.with_entry(&id, |entry| entry.owner = Some(owner));
562 service.read(&connection, id);
563 }
564 Err(error) => {
565 tracing::info!(id, %error, "a tray item left before it was read");
566 service.remove(&connection, &id);
567 }
568 }
569 });
570 }
571
572 fn read(self: &Rc<Self>, connection: &Connection, id: String) {
575 let read = self.with_entry(&id, |entry| {
576 entry.reads += 1;
577 (entry.bus.clone(), entry.path.clone(), entry.reads)
578 });
579 let Some((bus, path, read)) = read else {
580 return;
581 };
582 let service = Rc::clone(self);
583 let connection = connection.clone();
584 self.shared.spawn(async move {
585 let get_all = connection.call_method(
586 Some(bus.as_str()),
587 path.as_str(),
588 Some(PROPERTIES),
589 "GetAll",
590 &ITEM,
591 );
592 let properties = service
593 .shared
594 .timeout(CALL_TIMEOUT, get_all)
595 .await
596 .and_then(|reply| reply.body().deserialize::<Properties>());
597 let item = match properties {
598 Ok(properties) => properties.into_item(id.clone()),
599 Err(error) => return tracing::warn!(id, %error, "can't read a tray item"),
600 };
601 let newest = service.with_entry(&id, |entry| {
602 if entry.reads == read {
603 entry.shown = Some(item);
604 }
605 });
606 if newest.is_some() {
607 service.publish();
608 }
609 });
610 }
611
612 fn owner_changed(self: &Rc<Self>, connection: &Connection, name: &str, owner: &str) {
614 let ids: Vec<String> = {
615 let state = self.state.borrow();
616 let entries = state.as_ref().map(|state| state.entries.as_slice());
617 let at_name = entries
618 .unwrap_or_default()
619 .iter()
620 .filter(|entry| entry.bus == name);
621 at_name.map(|entry| entry.id.clone()).collect()
622 };
623 for id in ids {
624 if owner.is_empty() {
625 self.remove(connection, &id);
626 } else {
627 self.close_menu_of(&id);
629 self.with_entry(&id, |entry| entry.owner = Some(owner.to_owned()));
630 self.read(connection, id);
631 }
632 }
633 }
634
635 fn remove(&self, connection: &Connection, id: &str) {
637 let removed = {
638 let mut state = self.state.borrow_mut();
639 let Some(state) = state.as_mut() else {
640 return;
641 };
642 let at = state.entries.iter().position(|entry| entry.id == id);
643 at.map(|at| (state.entries.remove(at), state.watcher))
644 };
645 let Some((entry, watcher)) = removed else {
646 return;
647 };
648 self.close_menu_of(id);
649 let rule = owner_rule(&entry.bus);
650 let bus = connection.clone();
651 self.shared.spawn(async move {
652 if let Err(error) = bus_call(&bus, "RemoveMatch", &rule).await {
653 tracing::debug!(%error, "a tray item's name match was already gone");
654 }
655 });
656 if watcher {
657 self.emit(connection, "StatusNotifierItemUnregistered", Some(entry.id));
658 }
659 self.publish();
660 }
661
662 fn publish(&self) {
664 let state = self.state.borrow();
665 let Some(state) = state.as_ref() else {
666 return;
667 };
668 let list: Vec<TrayItem> = state
669 .entries
670 .iter()
671 .filter_map(|entry| entry.shown.clone())
672 .collect();
673 if state.items.try_with(|old| *old != list) == Some(true) {
674 state.items.set(list);
675 }
676 }
677
678 fn clear(&self) {
680 self.close_menu();
681 if let Some(state) = self.state.borrow_mut().as_mut() {
682 state.entries.clear();
683 state.connection = None;
684 }
685 self.publish();
686 }
687
688 fn emit(&self, connection: &Connection, signal: &'static str, id: Option<String>) {
690 let connection = connection.clone();
691 self.shared.spawn(async move {
692 let sent = match id {
693 Some(id) => {
694 let emit = connection.emit_signal(None::<&str>, WATCHER_PATH, WATCHER, signal, &id);
695 emit.await
696 }
697 None => {
698 let emit = connection.emit_signal(None::<&str>, WATCHER_PATH, WATCHER, signal, &());
699 emit.await
700 }
701 };
702 if let Err(error) = sent {
703 tracing::warn!(signal, %error, "the tray watcher couldn't send a signal");
704 }
705 });
706 }
707}
708
709async fn run(service: Rc<Service>, address: zbus::Address) {
712 let builder = zbus::connection::Builder::address(address);
715 let connection = match builder.build().await {
716 Ok(connection) => connection,
717 Err(error) => {
718 return tracing::info!(%error, "can't reach the session bus, so the tray is empty");
719 }
720 };
721 let mut messages = MessageStream::from(&connection);
723 if let Some(state) = service.state.borrow_mut().as_mut() {
724 state.connection = Some(connection.clone());
725 }
726 if let Err(error) = service.join(&connection).await {
727 return tracing::warn!(%error, "couldn't join the tray on the session bus");
728 }
729 loop {
730 let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
731 if !service.alive() {
732 return service.clear();
733 }
734 match next {
735 Some(Ok(message)) => service.on_message(&connection, &message).await,
736 Some(Err(error)) => tracing::warn!(%error, "a bad message on the session bus"),
737 None => break,
738 }
739 }
740 tracing::warn!("the session bus closed, so the tray is empty");
741 service.clear();
742}
743
744async fn bus_call(connection: &Connection, method: &str, rule: &str) -> zbus::Result<()> {
746 let call = connection.call_method(Some(BUS), BUS_PATH, Some(BUS), method, &rule);
747 call.await.map(drop)
748}
749
750fn owner_rule(bus: &str) -> String {
753 format!("type='signal',sender='{BUS}',interface='{BUS}',member='NameOwnerChanged',arg0='{bus}'")
754}
755
756fn parse_service(service: &str, sender: Option<&str>) -> Option<(String, String)> {
761 let (bus, path) = match service.find('/') {
762 Some(0) => (sender?, service),
763 Some(at) => service.split_at(at),
764 None => (service, ITEM_PATH),
765 };
766 BusName::try_from(bus).ok()?;
767 ObjectPath::try_from(path).ok()?;
768 Some((bus.to_owned(), path.to_owned()))
769}
770
771type Pixmaps = Vec<(i32, i32, Vec<u8>)>;
773
774#[derive(Default, DeserializeDict, Type)]
777#[cfg_attr(test, derive(Clone, zbus::SerializeDict))]
778#[zbus(signature = "a{sv}", rename_all = "PascalCase")]
779struct Properties {
780 id: Option<String>,
781 title: Option<String>,
782 status: Option<String>,
783 icon_name: Option<String>,
784 icon_pixmap: Option<Pixmaps>,
785 attention_icon_name: Option<String>,
786 icon_theme_path: Option<String>,
787 tool_tip: Option<(String, Pixmaps, String, String)>,
789 item_is_menu: Option<bool>,
790 menu: Option<zbus::OwnedObjectPath>,
791}
792
793impl Properties {
794 fn into_item(self, id: String) -> TrayItem {
795 let status = match self.status.as_deref() {
796 Some("Passive") => TrayStatus::Passive,
797 Some("NeedsAttention") => TrayStatus::NeedsAttention,
798 Some("Active") | None => TrayStatus::Active,
799 Some(other) => {
800 tracing::debug!(
801 id,
802 status = other,
803 "a tray item's status is unknown; taken as Active"
804 );
805 TrayStatus::Active
806 }
807 };
808 TrayItem {
809 icon_pixmaps: pixmaps(&id, self.icon_pixmap.unwrap_or_default()),
810 app_id: self.id.unwrap_or_default(),
811 title: self.title.unwrap_or_default(),
812 status,
813 icon_name: self.icon_name.unwrap_or_default(),
814 attention_icon_name: self.attention_icon_name.unwrap_or_default(),
815 icon_theme_path: self.icon_theme_path.unwrap_or_default(),
816 tooltip: self
817 .tool_tip
818 .map(|(_, _, title, _)| title)
819 .unwrap_or_default(),
820 item_is_menu: self.item_is_menu.unwrap_or_default(),
821 menu: self
823 .menu
824 .map(|path| path.to_string())
825 .filter(|path| path != "/NO_DBUSMENU")
826 .unwrap_or_default(),
827 id,
828 }
829 }
830}
831
832fn pixmaps(id: &str, raw: Pixmaps) -> Vec<IconPixels> {
836 let side = 1..=MAX_PIXMAP_SIDE;
837 let mut kept: Vec<IconPixels> = raw
838 .into_iter()
839 .filter_map(|(width, height, data)| {
840 let fits = side.contains(&width) && side.contains(&height);
841 if !fits || data.len() != width as usize * height as usize * 4 {
843 let bytes = data.len();
844 tracing::debug!(
845 id,
846 width,
847 height,
848 bytes,
849 "a tray icon pixmap of a bad size; skipped"
850 );
851 return None;
852 }
853 let (width, height) = (width as u32, height as u32);
854 Some(IconPixels {
855 width,
856 height,
857 data: data.into(),
858 })
859 })
860 .collect();
861 kept.sort_by_key(|pixels| pixels.data.len());
862 let mut total = 0;
863 kept.retain(|pixels| {
864 total += pixels.data.len();
865 total <= MAX_PIXMAP_BYTES
866 });
867 kept
868}