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 *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 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 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 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 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 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 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 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 let pids = PidWindowMap::new(toplevels.iter());
224
225 let mut found = false;
228
229 loop {
230 if let Some(window) = pids.get(pid) {
231 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 pid = ppid;
252 } else {
253 break;
255 }
256 }
257 Err(e) => {
258 tracing::info!(pid, %e, "error walking up process tree");
262 break;
263 }
264 }
265 }
266
267 if found {
269 return;
270 }
271 }
272
273 tracing::trace!("no PID in notification, or no match found");
274
275 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) = ¬ification.notification().hints.desktop_entry else {
288 tracing::trace!("no desktop entry found in notification; nothing more to be done");
289 return;
290 };
291
292 let use_fuzzy = self.state.config().notifications_use_fuzzy_matching();
295 let mut fuzzy = Vec::new();
296
297 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 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 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 self.container.add(button.widget());
381 entry.insert(button)
382 }
383 };
384
385 button.set_focus(window.is_focused);
387 button.set_title(window.title.as_deref());
388
389 omitted.remove(&window.id);
391
392 self.container.reorder_child(button.widget(), -1);
396 }
397
398 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 self.container.show_all();
407
408 self.last_snapshot = Some(windows);
410 }
411}
412
413struct 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}