[GH-ISSUE #1196] Duplicate Bar Appears After Disconnecting Secondary Monitor #3179

Closed
opened 2026-05-22 23:54:41 +01:00 by JakeStanger · 8 comments
Owner

Originally created by @benoitlx on GitHub (Oct 15, 2025).
Original GitHub issue: https://github.com/JakeStanger/ironbar/issues/1196

Describe the bug

A duplicate bar appears on my screen after I disconnect my secondary monitor.

To reproduce
Steps to reproduce the behavior:

  1. Connect a secondary monitor.
  2. Launch a graphical session that spawns Ironbar.
  3. Disconnect the secondary monitor.
  4. Observe a second bar appearing beneath the first one on the primary screen.

Expected behavior

Disconnecting a monitor should remove the associated bar.

System information:

  • Distro: Fedora Silverblue
  • Compositor: Niri
  • Ironbar version: 0.18.0-pre (I believe the bug was introduced by the GTK4 merge, but I can pinpoint the exact commit if needed.)

Configuration

Config
let {
  $tray = {
    type = "tray"
  }
  $clock = {
    type = "clock"
    format = "%B %d %H:%M:%S"
    format_popup = ""
  }
  $battery = {
    type = "battery"
    format = "{percentage}%"
    icon_size = 5
  }
  $workspaces = {
    type = "workspaces"
    name_map.1 = "main"
  }
  $notifications = {
    type = "notifications"
    show_count = true
  }

  $sys_info = {
    type = "sys_info"
    
    interval.memory = 30
    interval.cpu = 1
    interval.disks = 300
    interval.temps = 3

    format = [
      "{cpu_percent}%"
      "{memory_percent}%"
      "{temp_c:CPUTIN}°C"
      "{disk_used@/var}GB"
    ]
  }

  $music = {
    type = "music"
    player_type = "mpd"
  }

  $volume = {
    type = "volume"
    format = "{icon} {percentage}%"
    max_volume = 100
  }

  $cava = {
    transition_type = "slide_end"
    transition_duration = 750
    name = "cava"
    type = "script"
    mode = "watch"
    cmd = "~/.config/ironbar/scripts/cava"
  }

  $network = {
    type = "network_manager"
  }
  $ble = {
    type = "bluetooth"
    icon_size = 32
    format.not_found = ""
    format.disabled = " Off"
    format.enabled = " On"
    format.connected = " {device_alias}"
    format.connected_battery = " {device_alias} • {device_battery_percent}%"
    popup.scrollable = true
    popup.header = " Enable Bluetooth"
    popup.disabled = "{adapter_status}"
    popup.device.header = "{device_alias}"
    popup.device.header_battery = "{device_alias}"
    popup.device.footer = "{device_status}"
    popup.device.footer_battery = "{device_status} • Battery {device_battery_percent}%"
    adapter_status.not_found = "No Bluetooth adapters found"
    adapter_status.enabled = "Bluetooth enabled"
    adapter_status.enabling = "Enabling Bluetooth..."
    adapter_status.disabled = "Bluetooth disabled"
    adapter_status.disabling = "Disabling Bluetooth..."
    device_status.connected = "Connected"
    device_status.connecting = "Connecting..."
    device_status.disconnected = "Disconnect"
    device_status.disconnecting = "Disconnecting..."
  }

  $left = [$volume]
  $center = [$clock]
  $right = [$tray $sys_info $battery $network $ble $notifications]
}
in {
  position = "top"

  start = $left
  center = $center
  end = $right

  height = 20
  margin.top = 10
}
Styles
@import "colors.css";

/* General */

* {
  border-radius: 10px;
  transition: all 0.3s ease;
}

.background {
  background-color: rgba(0, 0, 0, 0);
}

.widget {
  font-family: Maple Mono;
  font-style: italic;
  font-size: 15px;
  background: @primary_container;
  box-shadow: none;
  border: none;
  opacity: 0.8;
  margin-left: 5px;
  margin-right: 5px;
  color: @on_primary_container;
}

*:hover {
  opacity: 0.99;
}

Additional context

Tested on a laptop with two different secondary screens.

Screenshots

Image
Originally created by @benoitlx on GitHub (Oct 15, 2025). Original GitHub issue: https://github.com/JakeStanger/ironbar/issues/1196 **Describe the bug** <!-- A clear and concise description of what the bug is. --> A duplicate bar appears on my screen after I disconnect my secondary monitor. **To reproduce** Steps to reproduce the behavior: 1. Connect a secondary monitor. 2. Launch a graphical session that spawns Ironbar. 3. Disconnect the secondary monitor. 4. Observe a second bar appearing beneath the first one on the primary screen. **Expected behavior** <!-- A clear and concise description of what you expected to happen. --> Disconnecting a monitor should remove the associated bar. **System information:** - Distro: Fedora Silverblue - Compositor: Niri - Ironbar version: 0.18.0-pre (I believe the bug was introduced by the GTK4 merge, but I can pinpoint the exact commit if needed.) **Configuration** <!-- Share your bar configuration and stylesheet as applicable: --> <details><summary>Config</summary> ``` let { $tray = { type = "tray" } $clock = { type = "clock" format = "%B %d %H:%M:%S" format_popup = "" } $battery = { type = "battery" format = "{percentage}%" icon_size = 5 } $workspaces = { type = "workspaces" name_map.1 = "main" } $notifications = { type = "notifications" show_count = true } $sys_info = { type = "sys_info" interval.memory = 30 interval.cpu = 1 interval.disks = 300 interval.temps = 3 format = [ "{cpu_percent}%" "{memory_percent}%" "{temp_c:CPUTIN}°C" "{disk_used@/var}GB" ] } $music = { type = "music" player_type = "mpd" } $volume = { type = "volume" format = "{icon} {percentage}%" max_volume = 100 } $cava = { transition_type = "slide_end" transition_duration = 750 name = "cava" type = "script" mode = "watch" cmd = "~/.config/ironbar/scripts/cava" } $network = { type = "network_manager" } $ble = { type = "bluetooth" icon_size = 32 format.not_found = "" format.disabled = " Off" format.enabled = " On" format.connected = " {device_alias}" format.connected_battery = " {device_alias} • {device_battery_percent}%" popup.scrollable = true popup.header = " Enable Bluetooth" popup.disabled = "{adapter_status}" popup.device.header = "{device_alias}" popup.device.header_battery = "{device_alias}" popup.device.footer = "{device_status}" popup.device.footer_battery = "{device_status} • Battery {device_battery_percent}%" adapter_status.not_found = "No Bluetooth adapters found" adapter_status.enabled = "Bluetooth enabled" adapter_status.enabling = "Enabling Bluetooth..." adapter_status.disabled = "Bluetooth disabled" adapter_status.disabling = "Disabling Bluetooth..." device_status.connected = "Connected" device_status.connecting = "Connecting..." device_status.disconnected = "Disconnect" device_status.disconnecting = "Disconnecting..." } $left = [$volume] $center = [$clock] $right = [$tray $sys_info $battery $network $ble $notifications] } in { position = "top" start = $left center = $center end = $right height = 20 margin.top = 10 } ``` </details> <details><summary>Styles</summary> ```css @import "colors.css"; /* General */ * { border-radius: 10px; transition: all 0.3s ease; } .background { background-color: rgba(0, 0, 0, 0); } .widget { font-family: Maple Mono; font-style: italic; font-size: 15px; background: @primary_container; box-shadow: none; border: none; opacity: 0.8; margin-left: 5px; margin-right: 5px; color: @on_primary_container; } *:hover { opacity: 0.99; } ``` </details> **Additional context** <!-- Add any other context about the problem here. --> Tested on a laptop with two different secondary screens. **Screenshots** <!-- If applicable, add screenshots to help explain your problem. --> <img width="2560" height="113" alt="Image" src="https://github.com/user-attachments/assets/473caf90-fe01-4998-abdc-bce91d2ed298" />
JakeStanger 2026-05-22 23:54:41 +01:00
Author
Owner

@benoitlx commented on GitHub (Oct 15, 2025):

Running ironbar reload removes the duplicated bar afterward.

<!-- gh-comment-id:3404745434 --> @benoitlx commented on GitHub (Oct 15, 2025): Running `ironbar reload` removes the duplicated bar afterward.
Author
Owner

@azertyfun commented on GitHub (Oct 15, 2025):

Hah, funny, I was currently debugging this.

The problem is twofold for me (on Hyprland):

  1. Your issue: Bars are not destroyed when removed, so you end up with a duplicate bar. That is easily solved:
impl Drop for Bar {
    fn drop(&mut self) {
        self.window.close();
        self.window.destroy();
    }
}
  1. With the above, no more duplicate bars. Great. But the bar doesn't come back when the monitor is re-connected. That is because there is a race condition between rx_outputs.recv() and GDK. At the time the event is fired, get_display().monitors() does not yet contain the monitor.
    This is quite tricky to fix. I have a working patch by doing away with rx_outputs entirely and instead doing get_display().monitors().connect_items_changed(...). But then on top of that GDK sends the event before the Monitor is initialized (connector is None), so I had to listen for the Notify event so I can have a connector. The end result is quite messy (attached below).
    @JakeStanger To fully fix race conditions I think a proper solution needs to be designed which properly synchronizes the compositor events with GDK before we create the bar. I already spent quite a bit of time on this ugly approach, but does a particular architecture come to mind looking at the problem?
Ugly Patch

diff --git a/src/bar.rs b/src/bar.rs
index cb2742e..ced1712 100644
--- a/src/bar.rs
+++ b/src/bar.rs
@@ -40,6 +40,13 @@ pub struct Bar {
     inner: Inner,
 }
 
+impl Drop for Bar {
+    fn drop(&mut self) {
+        self.window.close();
+        self.window.destroy();
+    }
+}
+
 impl Bar {
     pub fn new(
         app: &Application,
diff --git a/src/ipc/server/mod.rs b/src/ipc/server/mod.rs
index c8f9ea1..2c57049 100644
--- a/src/ipc/server/mod.rs
+++ b/src/ipc/server/mod.rs
@@ -7,6 +7,7 @@ use std::path::Path;
 use std::rc::Rc;
 
 use color_eyre::{Report, Result};
+use gtk::gdk::Monitor;
 use gtk::Application;
 use gtk::prelude::*;
 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -142,13 +143,13 @@ impl Ipc {
                     window.close();
                 }
 
-                let wl = ironbar.clients.borrow_mut().wayland();
-                let outputs = wl.output_info_all();
-
                 ironbar.reload_config();
 
-                for output in outputs {
-                    match crate::load_output_bars(ironbar, application, &output) {
+                let display = crate::get_display();
+                let monitors = display.monitors();
+                for i in 0..monitors.n_items() {
+                    let Some(monitor) = monitors.item(i).and_downcast::<Monitor>() else { continue; };
+                    match crate::load_output_bars(ironbar, application, &monitor) {
                         Ok(mut bars) => ironbar.bars.borrow_mut().append(&mut bars),
                         Err(err) => error!("{err:?}"),
                     }
diff --git a/src/main.rs b/src/main.rs
index 3e14de3..7cda552 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,14 @@
 #![doc = include_str!("../README.md")]
 
 use std::cell::RefCell;
+use std::collections::{HashMap};
 use std::env;
 use std::future::Future;
 use std::path::PathBuf;
 use std::process::exit;
 use std::rc::Rc;
 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
-use std::sync::{Arc, OnceLock, mpsc};
+use std::sync::{mpsc, Arc, Mutex, OnceLock};
 
 use cfg_if::cfg_if;
 #[cfg(feature = "cli")]
@@ -15,10 +16,10 @@ use clap::Parser;
 use color_eyre::Report;
 use color_eyre::eyre::Result;
 use dirs::config_dir;
+use gtk::gio::ListModel;
 use gtk::Application;
 use gtk::gdk::{Display, Monitor};
 use gtk::prelude::*;
-use smithay_client_toolkit::output::OutputInfo;
 use tokio::runtime::Runtime;
 use tokio::task::{JoinHandle, block_in_place};
 use tracing::{debug, error, info, warn};
@@ -27,7 +28,6 @@ use universal_config::ConfigLoader;
 use crate::bar::{Bar, create_bar};
 use crate::channels::SyncSenderExt;
 use crate::clients::Clients;
-use crate::clients::wayland::OutputEventType;
 use crate::config::{Config, MonitorConfig};
 use crate::desktop_file::DesktopFiles;
 use crate::error::ExitCode;
@@ -159,7 +159,6 @@ impl Ironbar {
 
         // force start wayland client ahead of ui
         let wl = instance.clients.borrow_mut().wayland();
-        let mut rx_outputs = wl.subscribe_outputs();
         wl.roundtrip();
 
         app.connect_activate(move |app| {
@@ -229,26 +228,97 @@ impl Ironbar {
                     .image_provider
                     .set_icon_theme(instance.config.borrow().icon_theme.as_deref());
 
-                while let Ok(event) = rx_outputs.recv().await {
-                    match event.event_type {
-                        OutputEventType::New => {
-                            match load_output_bars(&instance, &app, &event.output) {
-                                Ok(mut new_bars) => {
-                                    instance.bars.borrow_mut().append(&mut new_bars);
-                                }
-                                Err(err) => error!("{err:?}"),
+                #[derive(Debug)]
+                enum MonitorEvent {
+                    Created(Monitor, tokio::sync::mpsc::Receiver<String>),
+                    Destroyed(String),
+                }
+                let (tx, mut rx) = tokio::sync::mpsc::channel(8);
+
+                let get_monitor_map = |monitors: &ListModel| {
+                    let mut names = HashMap::new();
+                    for i in 0..monitors.n_items() {
+                        let Some(monitor) = monitors.item(i).and_downcast::<Monitor>() else {
+                            warn!("Unexpected object in monitor list: {:?}", monitors.item(i));
+                            continue;
+                        };
+
+                        let Some(name) = monitor.connector() else {
+                            warn!("Unknown connector for monitor: {:?}", monitor);
+                            continue;
+                        };
+
+                        names.insert(name.to_string(), monitor);
+                    }
+                    names
+                };
+
+                let display = get_display();
+                let monitors = display.monitors();
+                let known_monitors = Arc::new(Mutex::new(get_monitor_map(&monitors)));
+
+                for monitor in known_monitors.lock().unwrap().values() {
+                    match crate::load_output_bars(&instance, &app, &monitor) {
+                        Ok(mut bars) => instance.bars.borrow_mut().append(&mut bars),
+                        Err(err) => error!("{err:?}"),
+                    }
+                }
+
+                let known_monitors_2 = known_monitors.clone();
+                monitors.connect_items_changed(
+                    move |list, position, _removed, added| {
+                        for i in position..position+added {
+                            if let Some(monitor) = list.item(i).and_downcast::<Monitor>() {
+                                info!("Monitor added");
+                                let (connector_tx, connector_rx) = tokio::sync::mpsc::channel(1);
+                                monitor.connect_notify(Some("connector"), move |m, _| {
+                                    info!("Monitor connector notified: {:?}", m.connector());
+                                    if let Some(c) = m.connector() {
+                                        connector_tx.blocking_send(c.to_string()).unwrap_or(());
+                                    }
+                                });
+                                tx.blocking_send(MonitorEvent::Created(monitor, connector_rx)).inspect_err(|e| error!("Failed to send monitor creation event: {e:?}")).unwrap_or(());
                             }
                         }
-                        OutputEventType::Destroyed => {
-                            let Some(name) = event.output.name else {
-                                continue;
-                            };
+
+                        let new_known_monitors = get_monitor_map(&display.monitors());
+                        for (name, _) in known_monitors_2.lock().as_mut().unwrap().extract_if(|name, _| !new_known_monitors.contains_key(name)) {
+                            info!("Monitor {name} destroyed");
+                            tx.blocking_send(MonitorEvent::Destroyed(name)).inspect_err(|e| error!("Failed to send monitor destruction event: {e:?}")).unwrap_or(());
+                        }
+                    }
+                );
+
+                while let Some(event) = rx.recv().await {
+                    info!("Monitor event: {event:?}");
+                    match event {
+                        MonitorEvent::Created(monitor, mut rx) => {
+                            let instance = instance.clone();
+                            let app = app.clone();
+                            let known_monitors = known_monitors.clone();
+                            glib::spawn_future_local(async move {
+                                if let Some(connector) = rx.recv().await {
+                                    known_monitors.lock().unwrap().insert(connector, monitor.clone());
+                                    match load_output_bars(&instance, &app, &monitor) {
+                                        Ok(mut new_bars) => {
+                                            instance.bars.borrow_mut().append(&mut new_bars);
+                                        }
+                                        Err(err) => error!("{err:?}")
+                                    };
+                                }
+                            });
+                        },
+                        MonitorEvent::Destroyed(name) => {
                             instance
                                 .bars
                                 .borrow_mut()
-                                .retain(|bar| bar.monitor_name() != name);
-                        }
-                        OutputEventType::Update => {}
+                                .retain(|bar| {
+                                    if bar.monitor_name() == name {
+                                        info!("Destroying {} -> bar {}", name, bar.name());
+                                    }
+                                    bar.monitor_name() != name
+                                });
+                        },
                     }
                 }
             });
@@ -386,44 +456,23 @@ pub fn get_display() -> Display {
 fn load_output_bars(
     ironbar: &Rc<Ironbar>,
     app: &Application,
-    output: &OutputInfo,
+    monitor: &Monitor,
 ) -> Result<Vec<Bar>> {
-    let Some(monitor_name) = &output.name else {
+    let Some(monitor_name) = monitor.connector() else {
         return Err(Report::msg("Output missing monitor name"));
     };
 
-    let monitor_desc = &output.description.clone().unwrap_or_default();
-
-    let config = ironbar.config.borrow();
-
-    let display = get_display();
-
-    let monitors = display.monitors();
-    let find_monitor = || {
-        for i in 0..monitors.n_items() {
-            let Some(monitor) = monitors.item(i).and_downcast::<Monitor>() else {
-                continue;
-            };
-
-            if monitor.description().unwrap_or_default().as_str() == monitor_desc
-                || monitor.connector().unwrap_or_default().as_str() == monitor_name
-            {
-                return Some(monitor);
-            }
-        }
-
-        None
+    let Some(monitor_desc) = monitor.description() else {
+        return Err(Report::msg("Output missing monitor name"));
     };
 
-    let Some(monitor) = find_monitor() else {
-        return Err(Report::msg("failed to find matching monitor"));
-    };
+    let config = ironbar.config.borrow();
 
     let show_default_bar =
         config.bar.start.is_some() || config.bar.center.is_some() || config.bar.end.is_some();
 
     let bars = match config.monitors.as_ref().and_then(|config| {
-        config.get(monitor_name).or_else(|| {
+        config.get(monitor_name.as_str()).or_else(|| {
             config
                 .keys()
                 .find(|&k| monitor_desc.to_lowercase().starts_with(&k.to_lowercase()))

<!-- gh-comment-id:3405952395 --> @azertyfun commented on GitHub (Oct 15, 2025): Hah, funny, I was currently debugging this. The problem is twofold for me (on Hyprland): 1. Your issue: Bars are not destroyed when removed, so you end up with a duplicate bar. That is easily solved: ```rust impl Drop for Bar { fn drop(&mut self) { self.window.close(); self.window.destroy(); } } ``` 2. With the above, no more duplicate bars. Great. But the bar doesn't come back when the monitor is re-connected. That is because there is a race condition between `rx_outputs.recv()` and GDK. At the time the event is fired, `get_display().monitors()` does not yet contain the monitor. This is quite tricky to fix. I have a working patch by doing away with `rx_outputs` entirely and instead doing `get_display().monitors().connect_items_changed(...)`. But then on top of that GDK sends the event before the `Monitor` is initialized (`connector` is `None`), so I had to listen for the Notify event so I can have a `connector`. The end result is quite messy (attached below). @JakeStanger To fully fix race conditions I think a proper solution needs to be designed which properly synchronizes the compositor events with GDK before we create the bar. I already spent quite a bit of time on this ugly approach, but does a particular architecture come to mind looking at the problem? <details><summary>Ugly Patch</summary> <p> ```diff diff --git a/src/bar.rs b/src/bar.rs index cb2742e..ced1712 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -40,6 +40,13 @@ pub struct Bar { inner: Inner, } +impl Drop for Bar { + fn drop(&mut self) { + self.window.close(); + self.window.destroy(); + } +} + impl Bar { pub fn new( app: &Application, diff --git a/src/ipc/server/mod.rs b/src/ipc/server/mod.rs index c8f9ea1..2c57049 100644 --- a/src/ipc/server/mod.rs +++ b/src/ipc/server/mod.rs @@ -7,6 +7,7 @@ use std::path::Path; use std::rc::Rc; use color_eyre::{Report, Result}; +use gtk::gdk::Monitor; use gtk::Application; use gtk::prelude::*; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -142,13 +143,13 @@ impl Ipc { window.close(); } - let wl = ironbar.clients.borrow_mut().wayland(); - let outputs = wl.output_info_all(); - ironbar.reload_config(); - for output in outputs { - match crate::load_output_bars(ironbar, application, &output) { + let display = crate::get_display(); + let monitors = display.monitors(); + for i in 0..monitors.n_items() { + let Some(monitor) = monitors.item(i).and_downcast::<Monitor>() else { continue; }; + match crate::load_output_bars(ironbar, application, &monitor) { Ok(mut bars) => ironbar.bars.borrow_mut().append(&mut bars), Err(err) => error!("{err:?}"), } diff --git a/src/main.rs b/src/main.rs index 3e14de3..7cda552 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,14 @@ #![doc = include_str!("../README.md")] use std::cell::RefCell; +use std::collections::{HashMap}; use std::env; use std::future::Future; use std::path::PathBuf; use std::process::exit; use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock, mpsc}; +use std::sync::{mpsc, Arc, Mutex, OnceLock}; use cfg_if::cfg_if; #[cfg(feature = "cli")] @@ -15,10 +16,10 @@ use clap::Parser; use color_eyre::Report; use color_eyre::eyre::Result; use dirs::config_dir; +use gtk::gio::ListModel; use gtk::Application; use gtk::gdk::{Display, Monitor}; use gtk::prelude::*; -use smithay_client_toolkit::output::OutputInfo; use tokio::runtime::Runtime; use tokio::task::{JoinHandle, block_in_place}; use tracing::{debug, error, info, warn}; @@ -27,7 +28,6 @@ use universal_config::ConfigLoader; use crate::bar::{Bar, create_bar}; use crate::channels::SyncSenderExt; use crate::clients::Clients; -use crate::clients::wayland::OutputEventType; use crate::config::{Config, MonitorConfig}; use crate::desktop_file::DesktopFiles; use crate::error::ExitCode; @@ -159,7 +159,6 @@ impl Ironbar { // force start wayland client ahead of ui let wl = instance.clients.borrow_mut().wayland(); - let mut rx_outputs = wl.subscribe_outputs(); wl.roundtrip(); app.connect_activate(move |app| { @@ -229,26 +228,97 @@ impl Ironbar { .image_provider .set_icon_theme(instance.config.borrow().icon_theme.as_deref()); - while let Ok(event) = rx_outputs.recv().await { - match event.event_type { - OutputEventType::New => { - match load_output_bars(&instance, &app, &event.output) { - Ok(mut new_bars) => { - instance.bars.borrow_mut().append(&mut new_bars); - } - Err(err) => error!("{err:?}"), + #[derive(Debug)] + enum MonitorEvent { + Created(Monitor, tokio::sync::mpsc::Receiver<String>), + Destroyed(String), + } + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + + let get_monitor_map = |monitors: &ListModel| { + let mut names = HashMap::new(); + for i in 0..monitors.n_items() { + let Some(monitor) = monitors.item(i).and_downcast::<Monitor>() else { + warn!("Unexpected object in monitor list: {:?}", monitors.item(i)); + continue; + }; + + let Some(name) = monitor.connector() else { + warn!("Unknown connector for monitor: {:?}", monitor); + continue; + }; + + names.insert(name.to_string(), monitor); + } + names + }; + + let display = get_display(); + let monitors = display.monitors(); + let known_monitors = Arc::new(Mutex::new(get_monitor_map(&monitors))); + + for monitor in known_monitors.lock().unwrap().values() { + match crate::load_output_bars(&instance, &app, &monitor) { + Ok(mut bars) => instance.bars.borrow_mut().append(&mut bars), + Err(err) => error!("{err:?}"), + } + } + + let known_monitors_2 = known_monitors.clone(); + monitors.connect_items_changed( + move |list, position, _removed, added| { + for i in position..position+added { + if let Some(monitor) = list.item(i).and_downcast::<Monitor>() { + info!("Monitor added"); + let (connector_tx, connector_rx) = tokio::sync::mpsc::channel(1); + monitor.connect_notify(Some("connector"), move |m, _| { + info!("Monitor connector notified: {:?}", m.connector()); + if let Some(c) = m.connector() { + connector_tx.blocking_send(c.to_string()).unwrap_or(()); + } + }); + tx.blocking_send(MonitorEvent::Created(monitor, connector_rx)).inspect_err(|e| error!("Failed to send monitor creation event: {e:?}")).unwrap_or(()); } } - OutputEventType::Destroyed => { - let Some(name) = event.output.name else { - continue; - }; + + let new_known_monitors = get_monitor_map(&display.monitors()); + for (name, _) in known_monitors_2.lock().as_mut().unwrap().extract_if(|name, _| !new_known_monitors.contains_key(name)) { + info!("Monitor {name} destroyed"); + tx.blocking_send(MonitorEvent::Destroyed(name)).inspect_err(|e| error!("Failed to send monitor destruction event: {e:?}")).unwrap_or(()); + } + } + ); + + while let Some(event) = rx.recv().await { + info!("Monitor event: {event:?}"); + match event { + MonitorEvent::Created(monitor, mut rx) => { + let instance = instance.clone(); + let app = app.clone(); + let known_monitors = known_monitors.clone(); + glib::spawn_future_local(async move { + if let Some(connector) = rx.recv().await { + known_monitors.lock().unwrap().insert(connector, monitor.clone()); + match load_output_bars(&instance, &app, &monitor) { + Ok(mut new_bars) => { + instance.bars.borrow_mut().append(&mut new_bars); + } + Err(err) => error!("{err:?}") + }; + } + }); + }, + MonitorEvent::Destroyed(name) => { instance .bars .borrow_mut() - .retain(|bar| bar.monitor_name() != name); - } - OutputEventType::Update => {} + .retain(|bar| { + if bar.monitor_name() == name { + info!("Destroying {} -> bar {}", name, bar.name()); + } + bar.monitor_name() != name + }); + }, } } }); @@ -386,44 +456,23 @@ pub fn get_display() -> Display { fn load_output_bars( ironbar: &Rc<Ironbar>, app: &Application, - output: &OutputInfo, + monitor: &Monitor, ) -> Result<Vec<Bar>> { - let Some(monitor_name) = &output.name else { + let Some(monitor_name) = monitor.connector() else { return Err(Report::msg("Output missing monitor name")); }; - let monitor_desc = &output.description.clone().unwrap_or_default(); - - let config = ironbar.config.borrow(); - - let display = get_display(); - - let monitors = display.monitors(); - let find_monitor = || { - for i in 0..monitors.n_items() { - let Some(monitor) = monitors.item(i).and_downcast::<Monitor>() else { - continue; - }; - - if monitor.description().unwrap_or_default().as_str() == monitor_desc - || monitor.connector().unwrap_or_default().as_str() == monitor_name - { - return Some(monitor); - } - } - - None + let Some(monitor_desc) = monitor.description() else { + return Err(Report::msg("Output missing monitor name")); }; - let Some(monitor) = find_monitor() else { - return Err(Report::msg("failed to find matching monitor")); - }; + let config = ironbar.config.borrow(); let show_default_bar = config.bar.start.is_some() || config.bar.center.is_some() || config.bar.end.is_some(); let bars = match config.monitors.as_ref().and_then(|config| { - config.get(monitor_name).or_else(|| { + config.get(monitor_name.as_str()).or_else(|| { config .keys() .find(|&k| monitor_desc.to_lowercase().starts_with(&k.to_lowercase())) ``` </p> </details>
Author
Owner

@jficz commented on GitHub (Oct 15, 2025):

Same on Hyprland 0.51.1 if that helps.

<!-- gh-comment-id:3406182381 --> @jficz commented on GitHub (Oct 15, 2025): Same on Hyprland 0.51.1 if that helps.
Author
Owner

@JakeStanger commented on GitHub (Oct 15, 2025):

If that's the case, I think you're right. I'd probably approach it with some sort of monitor handling client/service. That would listen to both GDK and Wayland for (dis)connect events in whatever order they come, and only send a connected event when both are present. A disconnected event should be able to rely on either - Wayland would be preference, as that should come in first.

enum MonitorState {
  WaylandConnected(OutputInfo),
  GdkConnected(Monitor),
  BothConnected(OutputInfo, Monitor)
}
stateDiagram
  direction TB

  [*] --> WaylandConnected
  [*] --> GdkConnected

  WaylandConnected --> BothConnected
  GdkConnected --> BothConnected

  BothConnected --> [*]
<!-- gh-comment-id:3406603995 --> @JakeStanger commented on GitHub (Oct 15, 2025): If that's the case, I think you're right. I'd probably approach it with some sort of monitor handling client/service. That would listen to both GDK and Wayland for (dis)connect events in whatever order they come, and only send a connected event when both are present. A disconnected event should be able to rely on either - Wayland would be preference, as that *should* come in first. ```rs enum MonitorState { WaylandConnected(OutputInfo), GdkConnected(Monitor), BothConnected(OutputInfo, Monitor) } ``` ```mermaid stateDiagram direction TB [*] --> WaylandConnected [*] --> GdkConnected WaylandConnected --> BothConnected GdkConnected --> BothConnected BothConnected --> [*] ```
Author
Owner

@azertyfun commented on GitHub (Oct 15, 2025):

Sound good.

A couple other things I noticed while experimenting and forgot to mention:

  • When there are zero outputs available and we destroy all bars, ironbar exits. I assume that's just the event loop closing since there's nothing to manage, and it's fine when running with Restart=Always, but probably not intended behavior.
  • I have fixed workspace moves on Hyprland breaking across monitors. The event didn't use to be fired at all, so the workspace would appear on the wrong bar. That fix works fine until a monitor is disconnected and then connected. At that point the newly bar has each workspace twice. I think there's another race condition where the Move events are being fired before the Init event, but I didn't have time to figure out why. Maybe something jumps to your mind immediately?
<!-- gh-comment-id:3406981081 --> @azertyfun commented on GitHub (Oct 15, 2025): Sound good. A couple other things I noticed while experimenting and forgot to mention: * When there are zero outputs available and we destroy all bars, `ironbar` exits. I assume that's just the event loop closing since there's nothing to manage, and it's fine when running with `Restart=Always`, but probably not intended behavior. * I have fixed [workspace moves on Hyprland](https://github.com/azertyfun/ironbar/commit/bab6f570b6b770de3f45419421021bd7b456a57e) breaking across monitors. The event didn't use to be fired at all, so the workspace would appear on the wrong bar. That fix works fine *until* a monitor is disconnected and then connected. At that point the newly bar has each workspace twice. I think there's another race condition where the `Move` events are being fired before the `Init` event, but I didn't have time to figure out why. Maybe something jumps to your mind immediately?
Author
Owner

@JakeStanger commented on GitHub (Oct 15, 2025):

but probably not intended behavior

I can't say I've ever considered a scenario in which there are no monitors connected but the bar needs to be kept alive. I guess that could occur if you're rewiring your desktop. That could be GTK closing the application when no windows are left? No idea.

Maybe something jumps to your mind immediately?

It's possible. Everything happening here occurs after the channel is opened, and before the data is sent. There's a good few calls out the IPC in that period.

github.com/jakestanger/ironbar@bab6f570b6/src/clients/compositor/hyprland.rs (L419-L430)

If that is the case, moving the rx creation further down should resolve that.

I've definitely run into the workspace move issue before myself plenty of times though, and so I'll happily take the fix as-is. I will say I am looking to migrate to the new ext-workspace-v1 wayland protocol and stop relying on IPCs for workspaces soon ™️ so I'd hold off doing anything too drastic there. The Hyprland IPC has been nothing but trouble since day one.

<!-- gh-comment-id:3407036546 --> @JakeStanger commented on GitHub (Oct 15, 2025): > but probably not intended behavior I can't say I've ever considered a scenario in which there are no monitors connected but the bar needs to be kept alive. I guess that could occur if you're rewiring your desktop. That could be GTK closing the application when no windows are left? No idea. > Maybe something jumps to your mind immediately? It's possible. Everything happening here occurs after the channel is opened, and before the data is sent. There's a good few calls out the IPC in that period. https://github.com/jakestanger/ironbar/blob/bab6f570b6b770de3f45419421021bd7b456a57e/src/clients/compositor/hyprland.rs#L419-L430 If that is the case, moving the `rx` creation further down should resolve that. I've definitely run into the workspace move issue before myself plenty of times though, and so I'll happily take the fix as-is. I will say I am looking to migrate to the new `ext-workspace-v1` wayland protocol and stop relying on IPCs for workspaces soon :tm: so I'd hold off doing anything too drastic there. The Hyprland IPC has been nothing but trouble since day one.
Author
Owner

@azertyfun commented on GitHub (Oct 16, 2025):

I can't say I've ever considered a scenario in which there are no monitors connected but the bar needs to be kept alive

I discovered that because my work setup has my laptop plugged in to a docking station with the lid closed, and I was unplugging the docking station to easily disconnect monitors and reproduce the issue, which momentarily left the laptop without any active outputs. I suppose "there's always at least one active output" should be added to a future "List of falsehoods programmers believe about Desktop Environments" lol

<!-- gh-comment-id:3409608041 --> @azertyfun commented on GitHub (Oct 16, 2025): > I can't say I've ever considered a scenario in which there are no monitors connected but the bar needs to be kept alive I discovered that because my work setup has my laptop plugged in to a docking station with the lid closed, and I was unplugging the docking station to easily disconnect monitors and reproduce the issue, which momentarily left the laptop without any active outputs. I suppose "there's always at least one active output" should be added to a future "List of falsehoods programmers believe about Desktop Environments" lol
Author
Owner

@JakeStanger commented on GitHub (Oct 16, 2025):

The most surprising part of that is that you use Ironbar for work lol. That does make sense though, that's definitely something I have done myself enough times.

<!-- gh-comment-id:3410080598 --> @JakeStanger commented on GitHub (Oct 16, 2025): The most surprising part of that is that you use Ironbar for work lol. That does make sense though, that's definitely something I have done myself enough times.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
JakeStanger/ironbar#3179
No description provided.