niri_taskbar/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap, btree_map::Entry},
3    sync::{Arc, LazyLock, Mutex},
4};
5
6use button::Button;
7use config::Config;
8use error::Error;
9use futures::StreamExt;
10use niri::{Snapshot, Window};
11use notify::EnrichedNotification;
12use output::Matcher;
13use process::Process;
14use state::{Event, State};
15use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan};
16use waybar_cffi::{
17    Module,
18    gtk::{
19        self, Orientation, gio,
20        glib::MainContext,
21        traits::{BoxExt, ContainerExt, StyleContextExt, WidgetExt},
22    },
23    waybar_module,
24};
25
26mod button;
27mod config;
28mod error;
29mod icon;
30mod niri;
31mod notify;
32mod output;
33mod process;
34mod state;
35
36static TRACING: LazyLock<()> = LazyLock::new(|| {
37    if let Err(e) = tracing_subscriber::fmt()
38        .with_env_filter(EnvFilter::from_default_env())
39        .with_span_events(FmtSpan::CLOSE)
40        .try_init()
41    {
42        eprintln!("cannot install global tracing subscriber: {e}");
43    }
44});
45
46struct TaskbarModule {}
47
48impl Module for TaskbarModule {
49    type Config = Config;
50
51    fn init(info: &waybar_cffi::InitInfo, config: Config) -> Self {
52        // Ensure tracing-subscriber is initialised.
53        *TRACING;
54
55        let module = Self {};
56        let state = State::new(config);
57
58        let context = MainContext::default();
59        if let Err(e) = context.block_on(init(info, state)) {
60            tracing::error!(%e, "Niri taskbar module init failed");
61        }
62
63        module
64    }
65}
66
67waybar_module!(TaskbarModule);
68
69#[tracing::instrument(level = "DEBUG", skip_all, err)]
70async fn init(info: &waybar_cffi::InitInfo, state: State) -> Result<(), Error> {
71    // Set up the box that we'll use to contain the actual window buttons.
72    let root = info.get_root_widget();
73    let container = gtk::Box::new(Orientation::Horizontal, 0);
74    container.style_context().add_class("niri-taskbar");
75    root.add(&container);
76
77    // We need to spawn a task to receive the window snapshots and update the container.
78    let context = MainContext::default();
79    context.spawn_local(async move { Instance::new(state, container).task().await });
80
81    Ok(())
82}
83
84struct Instance {
85    buttons: BTreeMap<u64, Button>,
86    container: gtk::Box,
87    last_snapshot: Option<Snapshot>,
88    state: State,
89}
90
91impl Instance {
92    pub fn new(state: State, container: gtk::Box) -> Self {
93        Self {
94            buttons: Default::default(),
95            container,
96            last_snapshot: None,
97            state,
98        }
99    }
100
101    pub async fn task(&mut self) {
102        // We have to build the output filter here, because until the Glib event loop has run the
103        // container hasn't been realised, which means we can't figure out which output we're on.
104        let output_filter = Arc::new(Mutex::new(self.build_output_filter().await));
105
106        let mut stream = match self.state.event_stream() {
107            Ok(stream) => Box::pin(stream),
108            Err(e) => {
109                tracing::error!(%e, "error starting event stream");
110                return;
111            }
112        };
113        while let Some(event) = stream.next().await {
114            match event {
115                Event::Notification(notification) => self.process_notification(notification).await,
116                Event::WindowSnapshot(windows) => {
117                    self.process_window_snapshot(windows, output_filter.clone())
118                        .await
119                }
120                Event::Workspaces(_) => {
121                    // We're just using this as a signal that the outputs may have changed.
122                    let new_filter = self.build_output_filter().await;
123                    *output_filter.lock().expect("output filter lock") = new_filter;
124                }
125            }
126        }
127    }
128
129    #[tracing::instrument(level = "DEBUG", skip(self))]
130    async fn build_output_filter(&self) -> output::Filter {
131        if self.state.config().show_all_outputs() {
132            return output::Filter::ShowAll;
133        }
134
135        // OK, so we need to figure out what output we're on. Easy, right?
136        //
137        // Not so fast!
138        //
139        // In-tree Waybar modules have access to a Wayland client called `Client`, which they can
140        // use to access the `wl_display` the bar is created against, and further access metadata
141        // from there. Unfortunately, none of that is exposed in CFFI, and, honestly, I'm not really
142        // sure how you would trivially wrap it in a C API.
143        //
144        // We have the Gtk 3 container, though, so that's something — we have to wait until the
145        // window has been realised, but that's happened by the time we're in the main loop
146        // callback. The problem is that we're also using Gdk 3, which doesn't expose the connection
147        // name of the monitor in use, which is the only thing we can match against the Niri output
148        // configuration.
149        //
150        // Now, this wouldn't be so bad on its own, because we _can_ get to the `wl_output` via
151        // `gdkwayland`, and version 4 of the core Wayland protocol includes the output name.
152        // Unfortunately, we have no way of accessing Gdk's Wayland connection, and Wayland
153        // identifiers aren't stable across connections, so we can't just connect to Wayland
154        // ourselves and enumerate the outputs. (Trust me, I tried.)
155        //
156        // So, until Waybar migrates to Gtk 4, that leaves us without a truly reliable solution.
157        //
158        // What we'll do instead is match up what we can. Niri can tell us everything we want to
159        // know about the output, and Gdk 3 does include things like the output geometry, make, and
160        // model. So we'll match on those and hope for the best.
161        let niri = *self.state.niri();
162        let outputs = match gio::spawn_blocking(move || niri.outputs()).await {
163            Ok(Ok(outputs)) => outputs,
164            Ok(Err(e)) => {
165                tracing::warn!(%e, "cannot get Niri outputs");
166                return output::Filter::ShowAll;
167            }
168            Err(_) => {
169                tracing::error!("error received from gio while waiting for task");
170                return output::Filter::ShowAll;
171            }
172        };
173
174        // If there's only one output, then none of this matching stuff matters anyway.
175        if outputs.len() == 1 {
176            return output::Filter::ShowAll;
177        }
178
179        let Some(window) = self.container.window() else {
180            tracing::warn!("cannot get Gdk window for container");
181            return output::Filter::ShowAll;
182        };
183
184        let display = window.display();
185        let Some(monitor) = display.monitor_at_window(&window) else {
186            tracing::warn!(display = ?window.display(), geometry = ?window.geometry(), "cannot get monitor for window");
187            return output::Filter::ShowAll;
188        };
189
190        for (name, output) in outputs.into_iter() {
191            let matches = output::Matcher::new(&monitor, &output);
192            if matches == Matcher::all() {
193                return output::Filter::Only(name);
194            }
195        }
196
197        tracing::warn!(?monitor, "no Niri output matched the Gdk monitor");
198        output::Filter::ShowAll
199    }
200
201    #[tracing::instrument(level = "TRACE", skip(self))]
202    async fn process_notification(&mut self, notification: Box<EnrichedNotification>) {
203        // We'll try to set the urgent class on the relevant window if we can
204        // figure out which toplevel is associated with the notification.
205        //
206        // Obviously, for that, we need toplevels.
207        let Some(toplevels) = &self.last_snapshot else {
208            return;
209        };
210
211        if let Some(mut pid) = notification.pid() {
212            tracing::trace!(
213                pid,
214                "got notification with PID; trying to match it to a toplevel"
215            );
216
217            // If we have the sender PID — either from the notification itself,
218            // or D-Bus — then the heuristic we'll use is to walk up from the
219            // sender PID and see if any of the parents are toplevels.
220            //
221            // The easiest way to do that is with a map, which we can build from
222            // the toplevels.
223            let pids = PidWindowMap::new(toplevels.iter());
224
225            // We'll track if we found anything, since we might fall back to
226            // some fuzzy matching.
227            let mut found = false;
228
229            loop {
230                if let Some(window) = pids.get(pid) {
231                    // If the window is already focused, there isn't really much
232                    // to do.
233                    if !window.is_focused {
234                        if let Some(button) = self.buttons.get(&window.id) {
235                            tracing::trace!(
236                                ?button,
237                                ?window,
238                                pid,
239                                "found matching window; setting urgent"
240                            );
241                            button.set_urgent();
242                            found = true;
243                        }
244                    }
245                }
246
247                match Process::new(pid).await {
248                    Ok(Process { ppid }) => {
249                        if let Some(ppid) = ppid {
250                            // Keep walking up.
251                            pid = ppid;
252                        } else {
253                            // There are no more parents.
254                            break;
255                        }
256                    }
257                    Err(e) => {
258                        // On error, we'll log but do nothing else: this
259                        // shouldn't be fatal for the bar, since it's possible
260                        // the process has simply already exited.
261                        tracing::info!(pid, %e, "error walking up process tree");
262                        break;
263                    }
264                }
265            }
266
267            // If we marked one or more toplevels as urgent, then we're done.
268            if found {
269                return;
270            }
271        }
272
273        tracing::trace!("no PID in notification, or no match found");
274
275        // Otherwise, we'll fall back to the desktop entry if we got one, and
276        // see what we can find.
277        //
278        // There are a bunch of things that can get in the way here.
279        // Applications don't necessarily know the application ID they're
280        // registered under on the system: Flatpaks, for instance, have no idea
281        // what the Flatpak actually called them when installed. So we'll do our
282        // best and make some educated guesses, but that's really what it is.
283        if !self.state.config().notifications_use_desktop_entry() {
284            tracing::trace!("use of desktop entries is disabled; no match found");
285            return;
286        }
287        let Some(desktop_entry) = &notification.notification().hints.desktop_entry else {
288            tracing::trace!("no desktop entry found in notification; nothing more to be done");
289            return;
290        };
291
292        // So we only have to walk the window list once, we'll keep track of the
293        // fuzzy matches we find, even if we don't use them.
294        let use_fuzzy = self.state.config().notifications_use_fuzzy_matching();
295        let mut fuzzy = Vec::new();
296
297        // XXX: do we still need this with fuzzy matching?
298        let mapped = self
299            .state
300            .config()
301            .notifications_app_map(desktop_entry)
302            .unwrap_or(desktop_entry);
303        let mapped_lower = mapped.to_lowercase();
304        let mapped_last_lower = mapped
305            .split('.')
306            .next_back()
307            .unwrap_or_default()
308            .to_lowercase();
309
310        let mut found = false;
311        for window in toplevels.iter() {
312            let Some(app_id) = window.app_id.as_deref() else {
313                continue;
314            };
315
316            if app_id == mapped {
317                if let Some(button) = self.buttons.get(&window.id) {
318                    tracing::trace!(app_id, ?button, ?window, "toplevel match found via app ID");
319                    button.set_urgent();
320                    found = true;
321                }
322            } else if use_fuzzy {
323                // See if we have a fuzzy match, which we'll basically specify
324                // as "does the app ID match case insensitively, or does the
325                // last component of the app ID match the last component of the
326                // desktop entry?".
327                if app_id.to_lowercase() == mapped_lower {
328                    tracing::trace!(
329                        app_id,
330                        ?window,
331                        "toplevel match found via case-transformed app ID"
332                    );
333                    fuzzy.push(window.id);
334                } else if app_id.contains('.') {
335                    tracing::trace!(
336                        app_id,
337                        ?window,
338                        "toplevel match found via last element of app ID"
339                    );
340                    if let Some(last) = app_id.split('.').next_back() {
341                        if last.to_lowercase() == mapped_last_lower {
342                            fuzzy.push(window.id);
343                        }
344                    }
345                }
346            }
347        }
348
349        if !found {
350            for id in fuzzy.into_iter() {
351                if let Some(button) = self.buttons.get(&id) {
352                    button.set_urgent();
353                }
354            }
355        }
356    }
357
358    #[tracing::instrument(level = "DEBUG", skip(self))]
359    async fn process_window_snapshot(
360        &mut self,
361        windows: Snapshot,
362        filter: Arc<Mutex<output::Filter>>,
363    ) {
364        // We need to track which, if any, windows are no longer present.
365        let mut omitted = self.buttons.keys().copied().collect::<BTreeSet<_>>();
366
367        for window in windows.iter().filter(|window| {
368            filter
369                .lock()
370                .expect("output filter lock")
371                .should_show(window.output().unwrap_or_default())
372        }) {
373            let button = match self.buttons.entry(window.id) {
374                Entry::Occupied(entry) => entry.into_mut(),
375                Entry::Vacant(entry) => {
376                    let button = Button::new(&self.state, window);
377
378                    // Implicitly adding the button widget to the box as we create it simplifies
379                    // reordering, since it means we can just do it as we go.
380                    self.container.add(button.widget());
381                    entry.insert(button)
382                }
383            };
384
385            // Update the window properties.
386            button.set_focus(window.is_focused);
387            button.set_title(window.title.as_deref());
388
389            // Ensure we don't remove this button from the container.
390            omitted.remove(&window.id);
391
392            // Since we get the windows in order in the snapshot, we can just
393            // push this to the back and then let other widgets push in front as
394            // we iterate.
395            self.container.reorder_child(button.widget(), -1);
396        }
397
398        // Remove any windows that no longer exist.
399        for id in omitted.into_iter() {
400            if let Some(button) = self.buttons.remove(&id) {
401                self.container.remove(button.widget());
402            }
403        }
404
405        // Ensure everything is rendered.
406        self.container.show_all();
407
408        // Update the last snapshot.
409        self.last_snapshot = Some(windows);
410    }
411}
412
413/// A basic map of PIDs to windows.
414///
415/// Windows that don't have a PID are ignored, since we can't match on them
416/// anyway. (Also, how does that happen?)
417struct PidWindowMap<'a>(HashMap<i64, &'a Window>);
418
419impl<'a> PidWindowMap<'a> {
420    fn new(iter: impl Iterator<Item = &'a Window>) -> Self {
421        Self(
422            iter.filter_map(|window| window.pid.map(|pid| (i64::from(pid), window)))
423                .collect(),
424        )
425    }
426
427    fn get(&self, pid: i64) -> Option<&'a Window> {
428        self.0.get(&pid).copied()
429    }
430}