From d270ac6ff725dfa1b49c4c1d0cfc8668af093022 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 4 Aug 2026 22:08:44 +0800 Subject: [PATCH] Namespace Hyprland events under bread.hyprland.*, keep legacy names via [compat] normalize_hyprland() dual-emits each of its 10 flat event names (bread.workspace.changed, bread.monitor.connected, bread.window.opened, etc.) alongside a bread.hyprland. equivalent, so portable automation can subscribe to bread.hyprland.* and be guaranteed compositor-specific coverage without also matching genuinely cross-backend events like bread.power.*. The bread.hyprland.event fallback was already namespaced and is unaffected. Gated behind [compat] legacy_hyprland_event_names (default true during the deprecation window) so the old names keep firing until modules migrate; set to false to emit only the namespaced names. EventNormalizer::with_legacy_hyprland_event_names(bool) is a builder on top of the existing ::new(dedup_window_ms), threaded through from config in main.rs. Bumps API_VERSION to 1.5.0 (additive), updates Documentation.md's Hyprland event reference with Deprecated/Since markers and the new [compat] option, refreshes the README config example and the dock-workflow/git-branch-widget examples to the namespaced names, and adds DEPRECATIONS.md tracking the deferred full removal once the window closes. --- DEPRECATIONS.md | 40 +++ Documentation.md | 45 ++- README.md | 5 +- breadd/src/core/config.rs | 49 +++ breadd/src/core/normalizer.rs | 476 +++++++++++++++++++++---- breadd/src/ipc/mod.rs | 2 +- breadd/src/main.rs | 5 +- examples/modules/dock-workflow.lua | 4 +- examples/modules/git-branch-widget.lua | 4 +- 9 files changed, 535 insertions(+), 95 deletions(-) create mode 100644 DEPRECATIONS.md diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md new file mode 100644 index 0000000..cb9941c --- /dev/null +++ b/DEPRECATIONS.md @@ -0,0 +1,40 @@ +# Deprecations + +Tracks API surface currently in its deprecation window per +[`Documentation.md`'s API Stability & Versioning](Documentation.md#api-stability--versioning) +policy — marked `Deprecated` and still functioning, pending removal in a +future major version. + +## Hyprland legacy flat event names (since v1.5) + +**What's deprecated:** the 10 pre-namespace Hyprland event names emitted by +`normalize_hyprland()` in `breadd/src/core/normalizer.rs`: + +- `bread.workspace.changed`, `bread.workspace.created`, `bread.workspace.destroyed` +- `bread.monitor.connected`, `bread.monitor.disconnected` +- `bread.window.focus.changed`, `bread.window.focused`, `bread.window.opened`, + `bread.window.closed`, `bread.window.moved` + +**Recommended form going forward:** their `bread.hyprland.` equivalents +(e.g. `bread.hyprland.workspace.changed`), which make explicit that these +events are Hyprland-specific rather than portable across a future second +compositor backend — see `Documentation.md`'s +[Hyprland event reference](Documentation.md#hyprland) for the full mapping. + +**Current behavior:** both names fire by default (`[compat] +legacy_hyprland_event_names = true`). Setting that flag to `false` suppresses +the legacy names; only `bread.hyprland.*` fires. + +**Deferred follow-up (not yet scheduled):** + +1. Flip `[compat] legacy_hyprland_event_names`'s *default* to `false` in a + later minor release, once downstream modules have had a full deprecation + window to migrate. +2. Remove the legacy flat names and the `[compat]` flag entirely in the next + major version (v2) — at that point `normalize_hyprland()` only ever + produces `bread.hyprland.*` names and the dual-emit machinery + (`emit_hyprland_dual` in `normalizer.rs`) can be deleted. + +Neither step is scheduled yet; this file exists so the removal isn't +forgotten once the window closes. No issue tracker is wired up to this repo, +so this note is the tracking mechanism until one exists. diff --git a/Documentation.md b/Documentation.md index 1520ab5..a367e24 100644 --- a/Documentation.md +++ b/Documentation.md @@ -909,19 +909,42 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre #### Hyprland +*Since: v1.5 — the `bread.hyprland.*` namespaced forms below. Bread's event vocabulary is meant to be portable across a future second compositor backend; a flat `bread.workspace.*`/`bread.monitor.*`/`bread.window.*` name gave no way to tell a genuinely cross-backend event (like `bread.power.*`) apart from one that is Hyprland-specific. The 10 rows marked `Deprecated: v1.5` are unaffected functionally — they keep firing — but new automation should subscribe to their `bread.hyprland.*` sibling instead.* + +Every Hyprland-sourced event below is dual-emitted: the daemon fires both the legacy flat name and its `bread.hyprland.` equivalent with identical `data`/`timestamp`/`source`, unless `[compat] legacy_hyprland_event_names = false` is set (see below), in which case only the namespaced name fires. A module that subscribes only to `bread.hyprland.*` always gets full workspace/monitor/window coverage regardless of that setting. + | Event | Data | |-------|------| -| `bread.workspace.changed` | raw payload | -| `bread.workspace.created` | `{ workspace }` | -| `bread.workspace.destroyed` | `{ workspace }` | -| `bread.monitor.connected` | raw payload | -| `bread.monitor.disconnected` | raw payload | -| `bread.window.focus.changed` | raw payload | -| `bread.window.focused` | `{ address }` | -| `bread.window.opened` | `{ address, workspace, class, title }` | -| `bread.window.closed` | `{ address }` | -| `bread.window.moved` | `{ address, workspace }` | -| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) | +| `bread.workspace.changed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.changed`)* | raw payload | +| `bread.hyprland.workspace.changed` *(Since: v1.5)* | raw payload | +| `bread.workspace.created` *(Deprecated: v1.5 — use `bread.hyprland.workspace.created`)* | `{ workspace }` | +| `bread.hyprland.workspace.created` *(Since: v1.5)* | `{ workspace }` | +| `bread.workspace.destroyed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.destroyed`)* | `{ workspace }` | +| `bread.hyprland.workspace.destroyed` *(Since: v1.5)* | `{ workspace }` | +| `bread.monitor.connected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.connected`)* | raw payload | +| `bread.hyprland.monitor.connected` *(Since: v1.5)* | raw payload | +| `bread.monitor.disconnected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.disconnected`)* | raw payload | +| `bread.hyprland.monitor.disconnected` *(Since: v1.5)* | raw payload | +| `bread.window.focus.changed` *(Deprecated: v1.5 — use `bread.hyprland.window.focus.changed`)* | raw payload | +| `bread.hyprland.window.focus.changed` *(Since: v1.5)* | raw payload | +| `bread.window.focused` *(Deprecated: v1.5 — use `bread.hyprland.window.focused`)* | `{ address }` | +| `bread.hyprland.window.focused` *(Since: v1.5)* | `{ address }` | +| `bread.window.opened` *(Deprecated: v1.5 — use `bread.hyprland.window.opened`)* | `{ address, workspace, class, title }` | +| `bread.hyprland.window.opened` *(Since: v1.5)* | `{ address, workspace, class, title }` | +| `bread.window.closed` *(Deprecated: v1.5 — use `bread.hyprland.window.closed`)* | `{ address }` | +| `bread.hyprland.window.closed` *(Since: v1.5)* | `{ address }` | +| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` | +| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` | +| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) | + +##### Compatibility: `[compat]` config + +```toml +[compat] +legacy_hyprland_event_names = true # default during the deprecation window +``` + +Set to `false` to suppress the 10 legacy flat names above and emit only their `bread.hyprland.*` equivalents. This defaults to `true` for now; per the [API Stability & Versioning](#api-stability--versioning) deprecation-window policy, the default will flip to `false` in a later release once the window closes. Removing the legacy names entirely is a further, separate follow-up — see the note in `DEPRECATIONS.md`. #### Power diff --git a/README.md b/README.md index 0cab0db..944febb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Instead of scattering behavior across shell scripts, compositor configs, udev ru Bread runs a long-lived daemon (`breadd`) that: 1. Ingests raw signals from your compositor, hardware, and OS -2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.monitor.connected`, etc.) +2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.hyprland.monitor.connected`, etc.) 3. Maintains a live model of your desktop state 4. Delivers those events to Lua modules that implement your automation @@ -144,6 +144,9 @@ enabled = true [events] dedup_window_ms = 100 +[compat] +legacy_hyprland_event_names = true # dual-emits bread.hyprland.* alongside legacy flat names; see Documentation.md + [notifications] default_timeout_ms = 5000 default_urgency = "normal" diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 3ce75c5..7cb3526 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -19,6 +19,8 @@ pub struct Config { pub notifications: NotificationsConfig, #[serde(default)] pub events: EventsConfig, + #[serde(default)] + pub compat: CompatConfig, } #[derive(Debug, Clone, Deserialize)] @@ -119,6 +121,22 @@ pub struct EventsConfig { pub dedup_window_ms: u64, } +/// Deprecation-window toggles for backwards compatibility with pre-namespace +/// event names. See `[compat]` in `breadd.toml` / `Documentation.md`'s +/// Hyprland event reference for the migration this gates. +#[derive(Debug, Clone, Deserialize)] +pub struct CompatConfig { + /// When `true` (the default during the deprecation window), the Hyprland + /// adapter dual-emits both its legacy flat event names (e.g. + /// `bread.workspace.changed`) and their namespaced `bread.hyprland.*` + /// equivalents (e.g. `bread.hyprland.workspace.changed`). Set to `false` + /// to suppress the legacy names and emit only the namespaced ones — this + /// will become the default in a later release once the deprecation + /// window closes. + #[serde(default = "default_true")] + pub legacy_hyprland_event_names: bool, +} + #[derive(Debug, Clone, Deserialize)] pub struct NotificationsConfig { #[serde(default = "default_notify_timeout")] @@ -218,6 +236,14 @@ impl Default for NotificationsConfig { } } +impl Default for CompatConfig { + fn default() -> Self { + Self { + legacy_hyprland_event_names: default_true(), + } + } +} + impl Config { pub fn load() -> Result { let path = config_path(); @@ -379,6 +405,7 @@ mod tests { assert_eq!(cfg.notifications.notify_send_path, "notify-send"); assert!(cfg.modules.builtin); assert!(cfg.modules.disable.is_empty()); + assert!(cfg.compat.legacy_hyprland_event_names); } #[test] @@ -394,6 +421,7 @@ mod tests { let cfg: Config = toml::from_str("").unwrap(); assert_eq!(cfg.daemon.log_level, "info"); assert!(cfg.adapters.hyprland.enabled); + assert!(cfg.compat.legacy_hyprland_event_names); } #[test] @@ -435,6 +463,9 @@ dedup_window_ms = 250 default_timeout_ms = 1000 default_urgency = "critical" notify_send_path = "/usr/local/bin/notify-send" + +[compat] +legacy_hyprland_event_names = false "#; let cfg: Config = toml::from_str(raw).unwrap(); assert_eq!(cfg.daemon.log_level, "debug"); @@ -453,6 +484,7 @@ notify_send_path = "/usr/local/bin/notify-send" assert_eq!(cfg.events.dedup_window_ms, 250); assert_eq!(cfg.notifications.default_timeout_ms, 1000); assert_eq!(cfg.notifications.default_urgency, "critical"); + assert!(!cfg.compat.legacy_hyprland_event_names); } #[test] @@ -466,6 +498,23 @@ log_level = "trace" // Untouched sections still get their defaults. assert!(cfg.adapters.hyprland.enabled); assert_eq!(cfg.events.dedup_window_ms, 100); + assert!(cfg.compat.legacy_hyprland_event_names); + } + + #[test] + fn compat_section_defaults_legacy_hyprland_names_to_true() { + let cfg = Config::default(); + assert!(cfg.compat.legacy_hyprland_event_names); + } + + #[test] + fn compat_section_can_disable_legacy_hyprland_names() { + let raw = r#" +[compat] +legacy_hyprland_event_names = false +"#; + let cfg: Config = toml::from_str(raw).unwrap(); + assert!(!cfg.compat.legacy_hyprland_event_names); } #[test] diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 96e40c8..8065b92 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -14,6 +14,11 @@ pub struct EventNormalizer { /// fired within the current window, so subsequent child-node events from the /// same plug-in are suppressed at the normalizer level. seen_devices: RwLock>, + /// Mirrors `[compat] legacy_hyprland_event_names` in `breadd.toml`. When + /// `true` (the default during the deprecation window), `normalize_hyprland` + /// dual-emits both its legacy flat event names and their namespaced + /// `bread.hyprland.*` equivalents. See `with_legacy_hyprland_event_names`. + legacy_hyprland_event_names: bool, } impl EventNormalizer { @@ -22,9 +27,21 @@ impl EventNormalizer { dedup_window_ms, recent: RwLock::new(HashMap::new()), seen_devices: RwLock::new(HashMap::new()), + legacy_hyprland_event_names: true, } } + /// Overrides whether the Hyprland adapter's legacy flat event names + /// (`bread.workspace.changed` etc.) keep firing alongside their + /// `bread.hyprland.*` equivalents. Defaults to `true` via `new`, mirroring + /// `[compat] legacy_hyprland_event_names`'s documented default during the + /// deprecation window; `main.rs` overrides this from + /// `config.compat.legacy_hyprland_event_names`. + pub fn with_legacy_hyprland_event_names(mut self, enabled: bool) -> Self { + self.legacy_hyprland_event_names = enabled; + self + } + pub fn normalize(&self, raw: &RawEvent) -> Vec { let mut out = match &raw.source { AdapterSource::Udev => self.normalize_udev(raw), @@ -169,87 +186,67 @@ impl EventNormalizer { .unwrap_or(""); match kind { - "workspace" | "workspacev2" => vec![BreadEvent { - event: "bread.workspace.changed".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: raw.payload.clone(), - }], - "createworkspace" => vec![BreadEvent { - event: "bread.workspace.created".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ "workspace": data }), - }], - "destroyworkspace" => vec![BreadEvent { - event: "bread.workspace.destroyed".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ "workspace": data }), - }], - "monitoradded" => vec![BreadEvent { - event: "bread.monitor.connected".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ "name": data }), - }], - "monitorremoved" => vec![BreadEvent { - event: "bread.monitor.disconnected".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ "name": data }), - }], - "activewindow" => vec![BreadEvent { - event: "bread.window.focus.changed".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: raw.payload.clone(), - }], + "workspace" | "workspacev2" => { + self.emit_hyprland_dual("bread.workspace.changed", raw.payload.clone(), raw) + } + "createworkspace" => { + self.emit_hyprland_dual("bread.workspace.created", json!({ "workspace": data }), raw) + } + "destroyworkspace" => self.emit_hyprland_dual( + "bread.workspace.destroyed", + json!({ "workspace": data }), + raw, + ), + "monitoradded" => { + self.emit_hyprland_dual("bread.monitor.connected", json!({ "name": data }), raw) + } + "monitorremoved" => { + self.emit_hyprland_dual("bread.monitor.disconnected", json!({ "name": data }), raw) + } + "activewindow" => { + self.emit_hyprland_dual("bread.window.focus.changed", raw.payload.clone(), raw) + } "activewindowv2" => { let fields = split_hyprland_fields(data); - vec![BreadEvent { - event: "bread.window.focused".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ + self.emit_hyprland_dual( + "bread.window.focused", + json!({ "address": fields.first().unwrap_or(&"") }), - }] + raw, + ) } "openwindow" => { let fields = split_hyprland_fields(data); - vec![BreadEvent { - event: "bread.window.opened".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ + self.emit_hyprland_dual( + "bread.window.opened", + json!({ "address": fields.first().unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""), "class": fields.get(2).unwrap_or(&""), "title": fields.get(3).unwrap_or(&""), }), - }] + raw, + ) } "closewindow" => { let fields = split_hyprland_fields(data); - vec![BreadEvent { - event: "bread.window.closed".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ "address": fields.first().unwrap_or(&"") }), - }] + self.emit_hyprland_dual( + "bread.window.closed", + json!({ "address": fields.first().unwrap_or(&"") }), + raw, + ) } "movewindow" => { let fields = split_hyprland_fields(data); - vec![BreadEvent { - event: "bread.window.moved".to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: json!({ + self.emit_hyprland_dual( + "bread.window.moved", + json!({ "address": fields.first().unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""), }), - }] + raw, + ) } _ => vec![BreadEvent { event: "bread.hyprland.event".to_string(), @@ -260,6 +257,40 @@ impl EventNormalizer { } } + /// Emits a Hyprland event under its namespaced `bread.hyprland.` + /// name — always — plus, when `legacy_hyprland_event_names` is enabled + /// (the default during the deprecation window), a second `BreadEvent` + /// under the pre-namespace flat name (e.g. `bread.workspace.changed`). + /// This is the single mechanism behind Workstream C: the two names carry + /// identical `data`/`timestamp`/`source`, so a module can migrate to + /// `bread.hyprland.*` at its own pace without missing events either way. + fn emit_hyprland_dual( + &self, + legacy_event: &str, + data: Value, + raw: &RawEvent, + ) -> Vec { + let rest = legacy_event.strip_prefix("bread.").unwrap_or(legacy_event); + let namespaced_event = format!("bread.hyprland.{rest}"); + + let mut out = Vec::with_capacity(2); + if self.legacy_hyprland_event_names { + out.push(BreadEvent { + event: legacy_event.to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: data.clone(), + }); + } + out.push(BreadEvent { + event: namespaced_event, + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data, + }); + out + } + fn normalize_power(&self, raw: &RawEvent) -> Vec { let mut events = Vec::new(); @@ -710,6 +741,11 @@ mod tests { } // ─── Hyprland ───────────────────────────────────────────────────────── + // + // `EventNormalizer::new` defaults `legacy_hyprland_event_names` to `true` + // (mirrors `[compat]`'s documented default), so by default every mapped + // Hyprland kind dual-emits: the legacy flat name plus its namespaced + // `bread.hyprland.*` sibling. See `emit_hyprland_dual`. #[test] fn hyprland_workspace_change() { @@ -721,8 +757,11 @@ mod tests { 1, ); let out = n.normalize(&ev); - assert_eq!(out.len(), 1); - assert_eq!(out[0].event, "bread.workspace.changed"); + assert_eq!(out.len(), 2); + assert!(out.iter().any(|e| e.event == "bread.workspace.changed")); + assert!(out + .iter() + .any(|e| e.event == "bread.hyprland.workspace.changed")); } #[test] @@ -735,9 +774,14 @@ mod tests { 1, ); let out = n.normalize(&ev); - assert_eq!(out.len(), 1); - assert_eq!(out[0].event, "bread.window.focused"); - assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef"); + assert_eq!(out.len(), 2); + assert!(out.iter().any(|e| e.event == "bread.window.focused")); + assert!(out + .iter() + .any(|e| e.event == "bread.hyprland.window.focused")); + for ev in &out { + assert_eq!(ev.data.get("address").unwrap(), "0xdeadbeef"); + } } #[test] @@ -750,17 +794,25 @@ mod tests { 1, ); let out = n.normalize(&ev); - assert_eq!(out.len(), 1); - assert_eq!(out[0].event, "bread.window.opened"); - let d = &out[0].data; - assert_eq!(d.get("address").unwrap(), "0xabc"); - assert_eq!(d.get("workspace").unwrap(), "2"); - assert_eq!(d.get("class").unwrap(), "firefox"); - assert_eq!(d.get("title").unwrap(), "Mozilla Firefox"); + assert_eq!(out.len(), 2); + assert!(out.iter().any(|e| e.event == "bread.window.opened")); + assert!(out + .iter() + .any(|e| e.event == "bread.hyprland.window.opened")); + for ev in &out { + let d = &ev.data; + assert_eq!(d.get("address").unwrap(), "0xabc"); + assert_eq!(d.get("workspace").unwrap(), "2"); + assert_eq!(d.get("class").unwrap(), "firefox"); + assert_eq!(d.get("title").unwrap(), "Mozilla Firefox"); + } } #[test] fn hyprland_unknown_kind_falls_through_to_generic_event() { + // The `bread.hyprland.event` fallback is already namespaced, so it's + // exempt from dual-emit — it never had a legacy flat name to begin + // with. let n = EventNormalizer::new(0); let ev = raw( AdapterSource::Hyprland, @@ -788,9 +840,274 @@ mod tests { json!({"kind": "monitorremoved", "data": "HDMI-A-1"}), 2, )); - assert_eq!(added[0].event, "bread.monitor.connected"); - assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1"); - assert_eq!(removed[0].event, "bread.monitor.disconnected"); + assert_eq!(added.len(), 2); + assert!(added.iter().any(|e| e.event == "bread.monitor.connected")); + assert!(added + .iter() + .any(|e| e.event == "bread.hyprland.monitor.connected")); + for ev in &added { + assert_eq!(ev.data.get("name").unwrap(), "HDMI-A-1"); + } + + assert_eq!(removed.len(), 2); + assert!(removed + .iter() + .any(|e| e.event == "bread.monitor.disconnected")); + assert!(removed + .iter() + .any(|e| e.event == "bread.hyprland.monitor.disconnected")); + } + + /// (a) With the default config (`legacy_hyprland_event_names = true`), + /// every one of the 10 dual-emit mappings fires both its legacy flat + /// name and its namespaced `bread.hyprland.*` sibling, with identical + /// data on each. + #[test] + fn hyprland_dual_emit_covers_all_ten_mappings_by_default() { + let n = EventNormalizer::new(0); + let cases: &[(&str, &str, &str, &str)] = &[ + ( + "workspace", + "2", + "bread.workspace.changed", + "bread.hyprland.workspace.changed", + ), + ( + "workspacev2", + "2,name", + "bread.workspace.changed", + "bread.hyprland.workspace.changed", + ), + ( + "createworkspace", + "3", + "bread.workspace.created", + "bread.hyprland.workspace.created", + ), + ( + "destroyworkspace", + "3", + "bread.workspace.destroyed", + "bread.hyprland.workspace.destroyed", + ), + ( + "monitoradded", + "HDMI-A-1", + "bread.monitor.connected", + "bread.hyprland.monitor.connected", + ), + ( + "monitorremoved", + "HDMI-A-1", + "bread.monitor.disconnected", + "bread.hyprland.monitor.disconnected", + ), + ( + "activewindow", + "firefox,Mozilla Firefox", + "bread.window.focus.changed", + "bread.hyprland.window.focus.changed", + ), + ( + "activewindowv2", + "0xdead", + "bread.window.focused", + "bread.hyprland.window.focused", + ), + ( + "openwindow", + "0xabc>>2>>firefox>>Mozilla Firefox", + "bread.window.opened", + "bread.hyprland.window.opened", + ), + ( + "closewindow", + "0xabc", + "bread.window.closed", + "bread.hyprland.window.closed", + ), + ( + "movewindow", + "0xabc,2", + "bread.window.moved", + "bread.hyprland.window.moved", + ), + ]; + + for (kind, data, legacy_event, namespaced_event) in cases { + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": kind, "data": data}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 2, "kind {kind} should dual-emit exactly 2 events"); + assert!( + out.iter().any(|e| &e.event == legacy_event), + "kind {kind} missing legacy event {legacy_event}" + ); + assert!( + out.iter().any(|e| &e.event == namespaced_event), + "kind {kind} missing namespaced event {namespaced_event}" + ); + let legacy_data = out.iter().find(|e| &e.event == legacy_event).unwrap().data.clone(); + let namespaced_data = out + .iter() + .find(|e| &e.event == namespaced_event) + .unwrap() + .data + .clone(); + assert_eq!( + legacy_data, namespaced_data, + "kind {kind}: legacy and namespaced events should carry identical data" + ); + } + } + + /// (b) With `legacy_hyprland_event_names = false`, only the namespaced + /// `bread.hyprland.*` names fire — the legacy flat names are fully + /// suppressed, not just relegated to a secondary slot. + #[test] + fn hyprland_legacy_names_suppressed_when_compat_disabled() { + let n = EventNormalizer::new(0).with_legacy_hyprland_event_names(false); + let cases: &[(&str, &str, &str, &str)] = &[ + ( + "workspace", + "2", + "bread.workspace.changed", + "bread.hyprland.workspace.changed", + ), + ( + "createworkspace", + "3", + "bread.workspace.created", + "bread.hyprland.workspace.created", + ), + ( + "destroyworkspace", + "3", + "bread.workspace.destroyed", + "bread.hyprland.workspace.destroyed", + ), + ( + "monitoradded", + "HDMI-A-1", + "bread.monitor.connected", + "bread.hyprland.monitor.connected", + ), + ( + "monitorremoved", + "HDMI-A-1", + "bread.monitor.disconnected", + "bread.hyprland.monitor.disconnected", + ), + ( + "activewindow", + "firefox,Mozilla Firefox", + "bread.window.focus.changed", + "bread.hyprland.window.focus.changed", + ), + ( + "activewindowv2", + "0xdead", + "bread.window.focused", + "bread.hyprland.window.focused", + ), + ( + "openwindow", + "0xabc>>2>>firefox>>Mozilla Firefox", + "bread.window.opened", + "bread.hyprland.window.opened", + ), + ( + "closewindow", + "0xabc", + "bread.window.closed", + "bread.hyprland.window.closed", + ), + ( + "movewindow", + "0xabc,2", + "bread.window.moved", + "bread.hyprland.window.moved", + ), + ]; + + for (kind, data, legacy_event, namespaced_event) in cases { + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": kind, "data": data}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!( + out.len(), + 1, + "kind {kind} should emit exactly 1 event when legacy names are disabled" + ); + assert_eq!( + out[0].event, *namespaced_event, + "kind {kind} should emit only the namespaced event" + ); + assert!( + !out.iter().any(|e| &e.event == legacy_event), + "kind {kind} leaked legacy event {legacy_event} despite being disabled" + ); + } + + // The already-namespaced fallback is unaffected either way. + let fallback = n.normalize(&raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "submap", "data": "resize"}), + 1, + )); + assert_eq!(fallback.len(), 1); + assert_eq!(fallback[0].event, "bread.hyprland.event"); + } + + /// (c) A module that subscribes only to `bread.hyprland.*` gets full + /// coverage of workspace, monitor, and window activity — regardless of + /// the `[compat]` setting — since the namespaced name is unconditional. + /// This is the actual promise Workstream C makes: "portable automation" + /// only means something if the namespaced form alone is a complete feed. + #[test] + fn hyprland_namespace_only_subscriber_gets_full_coverage_regardless_of_compat() { + let kinds_and_data: &[(&str, &str)] = &[ + ("workspace", "2"), + ("createworkspace", "3"), + ("destroyworkspace", "3"), + ("monitoradded", "HDMI-A-1"), + ("monitorremoved", "HDMI-A-1"), + ("activewindow", "firefox,Mozilla Firefox"), + ("activewindowv2", "0xdead"), + ("openwindow", "0xabc>>2>>firefox>>Mozilla Firefox"), + ("closewindow", "0xabc"), + ("movewindow", "0xabc,2"), + ]; + + for legacy_enabled in [true, false] { + let n = EventNormalizer::new(0).with_legacy_hyprland_event_names(legacy_enabled); + for (kind, data) in kinds_and_data { + let out = n.normalize(&raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": kind, "data": data}), + 1, + )); + let namespaced_hits = out + .iter() + .filter(|e| e.event.starts_with("bread.hyprland.")) + .count(); + assert_eq!( + namespaced_hits, 1, + "kind {kind} (legacy_enabled={legacy_enabled}) should always emit exactly \ + one bread.hyprland.* event, matching a bread.hyprland.* subscriber's view" + ); + } + } } // ─── Power ───────────────────────────────────────────────────────────── @@ -1053,9 +1370,14 @@ mod tests { json!({"kind": "workspace", "data": "2"}), 1100, ); - assert_eq!(n.normalize(&a).len(), 1); + // Hyprland's "workspace" kind dual-emits (legacy + namespaced name, + // see the Hyprland test section below), so each distinct payload + // produces 2 events rather than 1 — but the point of this test is + // that neither call is suppressed by dedup, since the payloads + // differ and thus so does the dedup key. + assert_eq!(n.normalize(&a).len(), 2); // Different payloads = different dedup key - assert_eq!(n.normalize(&b).len(), 1); + assert_eq!(n.normalize(&b).len(), 2); } #[test] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index e132770..10cdfdf 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -27,7 +27,7 @@ use crate::lua::RuntimeHandle; /// something new-but-additive (a binding, an event, an IPC param); bump the /// major version only for a breaking change, which should not happen inside /// this daemon's v1 lifetime per that section's stated policy. -const API_VERSION: &str = "1.4.0"; +const API_VERSION: &str = "1.5.0"; #[derive(Clone)] pub struct Server { diff --git a/breadd/src/main.rs b/breadd/src/main.rs index 8ca1c67..14b4c4b 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -52,7 +52,10 @@ async fn main() -> Result<()> { shutdown_rx.clone(), )); - let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms)); + let normalizer = Arc::new( + EventNormalizer::new(config.events.dedup_window_ms) + .with_legacy_hyprland_event_names(config.compat.legacy_hyprland_event_names), + ); { let normalizer = normalizer.clone(); let normalized_tx = normalized_tx.clone(); diff --git a/examples/modules/dock-workflow.lua b/examples/modules/dock-workflow.lua index 4a1bba2..259c5a8 100644 --- a/examples/modules/dock-workflow.lua +++ b/examples/modules/dock-workflow.lua @@ -21,7 +21,7 @@ bread.workflow.define("dock-connected", function() bread.workflow.step("waiting for monitor") local event = bread.wait_any( - { "bread.monitor.connected", "bread.hyprland.event" }, + { "bread.hyprland.monitor.connected", "bread.hyprland.event" }, { timeout = 5000 } ) if not event then @@ -35,7 +35,7 @@ bread.workflow.define("dock-connected", function() bread.profile.activate("docked") bread.workflow.step("waiting for workspace") - bread.wait("bread.workspace.changed", { timeout = 3000 }) + bread.wait("bread.hyprland.workspace.changed", { timeout = 3000 }) bread.workflow.step("notifying") bread.notify("Dock connected", { title = "bread" }) diff --git a/examples/modules/git-branch-widget.lua b/examples/modules/git-branch-widget.lua index 8e971ba..80ca5ce 100644 --- a/examples/modules/git-branch-widget.lua +++ b/examples/modules/git-branch-widget.lua @@ -146,8 +146,8 @@ function M.on_load() -- catch a branch switch inside the same still-focused tab (e.g. `git -- checkout` run without ever changing window focus), which produces no -- focus event at all. - bread.on("bread.window.focused", update) - bread.on("bread.window.focus.changed", update) + bread.on("bread.hyprland.window.focused", update) + bread.on("bread.hyprland.window.focus.changed", update) bread.every(2000, update) end