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.<rest> 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.
This commit is contained in:
Breadway 2026-08-04 22:08:44 +08:00
parent 96639516b1
commit d270ac6ff7
9 changed files with 535 additions and 95 deletions

40
DEPRECATIONS.md Normal file
View file

@ -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.<rest>` 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.

View file

@ -909,19 +909,42 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
#### Hyprland #### 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.<rest>` 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 | | Event | Data |
|-------|------| |-------|------|
| `bread.workspace.changed` | raw payload | | `bread.workspace.changed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.changed`)* | raw payload |
| `bread.workspace.created` | `{ workspace }` | | `bread.hyprland.workspace.changed` *(Since: v1.5)* | raw payload |
| `bread.workspace.destroyed` | `{ workspace }` | | `bread.workspace.created` *(Deprecated: v1.5 — use `bread.hyprland.workspace.created`)* | `{ workspace }` |
| `bread.monitor.connected` | raw payload | | `bread.hyprland.workspace.created` *(Since: v1.5)* | `{ workspace }` |
| `bread.monitor.disconnected` | raw payload | | `bread.workspace.destroyed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.destroyed`)* | `{ workspace }` |
| `bread.window.focus.changed` | raw payload | | `bread.hyprland.workspace.destroyed` *(Since: v1.5)* | `{ workspace }` |
| `bread.window.focused` | `{ address }` | | `bread.monitor.connected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.connected`)* | raw payload |
| `bread.window.opened` | `{ address, workspace, class, title }` | | `bread.hyprland.monitor.connected` *(Since: v1.5)* | raw payload |
| `bread.window.closed` | `{ address }` | | `bread.monitor.disconnected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.disconnected`)* | raw payload |
| `bread.window.moved` | `{ address, workspace }` | | `bread.hyprland.monitor.disconnected` *(Since: v1.5)* | raw payload |
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) | | `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 #### Power

View file

@ -15,7 +15,7 @@ Instead of scattering behavior across shell scripts, compositor configs, udev ru
Bread runs a long-lived daemon (`breadd`) that: Bread runs a long-lived daemon (`breadd`) that:
1. Ingests raw signals from your compositor, hardware, and OS 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 3. Maintains a live model of your desktop state
4. Delivers those events to Lua modules that implement your automation 4. Delivers those events to Lua modules that implement your automation
@ -144,6 +144,9 @@ enabled = true
[events] [events]
dedup_window_ms = 100 dedup_window_ms = 100
[compat]
legacy_hyprland_event_names = true # dual-emits bread.hyprland.* alongside legacy flat names; see Documentation.md
[notifications] [notifications]
default_timeout_ms = 5000 default_timeout_ms = 5000
default_urgency = "normal" default_urgency = "normal"

View file

@ -19,6 +19,8 @@ pub struct Config {
pub notifications: NotificationsConfig, pub notifications: NotificationsConfig,
#[serde(default)] #[serde(default)]
pub events: EventsConfig, pub events: EventsConfig,
#[serde(default)]
pub compat: CompatConfig,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -119,6 +121,22 @@ pub struct EventsConfig {
pub dedup_window_ms: u64, 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)] #[derive(Debug, Clone, Deserialize)]
pub struct NotificationsConfig { pub struct NotificationsConfig {
#[serde(default = "default_notify_timeout")] #[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 { impl Config {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let path = config_path(); let path = config_path();
@ -379,6 +405,7 @@ mod tests {
assert_eq!(cfg.notifications.notify_send_path, "notify-send"); assert_eq!(cfg.notifications.notify_send_path, "notify-send");
assert!(cfg.modules.builtin); assert!(cfg.modules.builtin);
assert!(cfg.modules.disable.is_empty()); assert!(cfg.modules.disable.is_empty());
assert!(cfg.compat.legacy_hyprland_event_names);
} }
#[test] #[test]
@ -394,6 +421,7 @@ mod tests {
let cfg: Config = toml::from_str("").unwrap(); let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.daemon.log_level, "info"); assert_eq!(cfg.daemon.log_level, "info");
assert!(cfg.adapters.hyprland.enabled); assert!(cfg.adapters.hyprland.enabled);
assert!(cfg.compat.legacy_hyprland_event_names);
} }
#[test] #[test]
@ -435,6 +463,9 @@ dedup_window_ms = 250
default_timeout_ms = 1000 default_timeout_ms = 1000
default_urgency = "critical" default_urgency = "critical"
notify_send_path = "/usr/local/bin/notify-send" notify_send_path = "/usr/local/bin/notify-send"
[compat]
legacy_hyprland_event_names = false
"#; "#;
let cfg: Config = toml::from_str(raw).unwrap(); let cfg: Config = toml::from_str(raw).unwrap();
assert_eq!(cfg.daemon.log_level, "debug"); 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.events.dedup_window_ms, 250);
assert_eq!(cfg.notifications.default_timeout_ms, 1000); assert_eq!(cfg.notifications.default_timeout_ms, 1000);
assert_eq!(cfg.notifications.default_urgency, "critical"); assert_eq!(cfg.notifications.default_urgency, "critical");
assert!(!cfg.compat.legacy_hyprland_event_names);
} }
#[test] #[test]
@ -466,6 +498,23 @@ log_level = "trace"
// Untouched sections still get their defaults. // Untouched sections still get their defaults.
assert!(cfg.adapters.hyprland.enabled); assert!(cfg.adapters.hyprland.enabled);
assert_eq!(cfg.events.dedup_window_ms, 100); 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] #[test]

View file

@ -14,6 +14,11 @@ pub struct EventNormalizer {
/// fired within the current window, so subsequent child-node events from the /// fired within the current window, so subsequent child-node events from the
/// same plug-in are suppressed at the normalizer level. /// same plug-in are suppressed at the normalizer level.
seen_devices: RwLock<HashMap<String, u64>>, seen_devices: RwLock<HashMap<String, u64>>,
/// 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 { impl EventNormalizer {
@ -22,9 +27,21 @@ impl EventNormalizer {
dedup_window_ms, dedup_window_ms,
recent: RwLock::new(HashMap::new()), recent: RwLock::new(HashMap::new()),
seen_devices: 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<BreadEvent> { pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut out = match &raw.source { let mut out = match &raw.source {
AdapterSource::Udev => self.normalize_udev(raw), AdapterSource::Udev => self.normalize_udev(raw),
@ -169,87 +186,67 @@ impl EventNormalizer {
.unwrap_or(""); .unwrap_or("");
match kind { match kind {
"workspace" | "workspacev2" => vec![BreadEvent { "workspace" | "workspacev2" => {
event: "bread.workspace.changed".to_string(), self.emit_hyprland_dual("bread.workspace.changed", raw.payload.clone(), raw)
timestamp: raw.timestamp, }
source: AdapterSource::Hyprland, "createworkspace" => {
data: raw.payload.clone(), self.emit_hyprland_dual("bread.workspace.created", json!({ "workspace": data }), raw)
}], }
"createworkspace" => vec![BreadEvent { "destroyworkspace" => self.emit_hyprland_dual(
event: "bread.workspace.created".to_string(), "bread.workspace.destroyed",
timestamp: raw.timestamp, json!({ "workspace": data }),
source: AdapterSource::Hyprland, raw,
data: json!({ "workspace": data }), ),
}], "monitoradded" => {
"destroyworkspace" => vec![BreadEvent { self.emit_hyprland_dual("bread.monitor.connected", json!({ "name": data }), raw)
event: "bread.workspace.destroyed".to_string(), }
timestamp: raw.timestamp, "monitorremoved" => {
source: AdapterSource::Hyprland, self.emit_hyprland_dual("bread.monitor.disconnected", json!({ "name": data }), raw)
data: json!({ "workspace": data }), }
}], "activewindow" => {
"monitoradded" => vec![BreadEvent { self.emit_hyprland_dual("bread.window.focus.changed", raw.payload.clone(), raw)
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(),
}],
"activewindowv2" => { "activewindowv2" => {
let fields = split_hyprland_fields(data); let fields = split_hyprland_fields(data);
vec![BreadEvent { self.emit_hyprland_dual(
event: "bread.window.focused".to_string(), "bread.window.focused",
timestamp: raw.timestamp, json!({
source: AdapterSource::Hyprland,
data: json!({
"address": fields.first().unwrap_or(&"") "address": fields.first().unwrap_or(&"")
}), }),
}] raw,
)
} }
"openwindow" => { "openwindow" => {
let fields = split_hyprland_fields(data); let fields = split_hyprland_fields(data);
vec![BreadEvent { self.emit_hyprland_dual(
event: "bread.window.opened".to_string(), "bread.window.opened",
timestamp: raw.timestamp, json!({
source: AdapterSource::Hyprland,
data: json!({
"address": fields.first().unwrap_or(&""), "address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""),
"class": fields.get(2).unwrap_or(&""), "class": fields.get(2).unwrap_or(&""),
"title": fields.get(3).unwrap_or(&""), "title": fields.get(3).unwrap_or(&""),
}), }),
}] raw,
)
} }
"closewindow" => { "closewindow" => {
let fields = split_hyprland_fields(data); let fields = split_hyprland_fields(data);
vec![BreadEvent { self.emit_hyprland_dual(
event: "bread.window.closed".to_string(), "bread.window.closed",
timestamp: raw.timestamp, json!({ "address": fields.first().unwrap_or(&"") }),
source: AdapterSource::Hyprland, raw,
data: json!({ "address": fields.first().unwrap_or(&"") }), )
}]
} }
"movewindow" => { "movewindow" => {
let fields = split_hyprland_fields(data); let fields = split_hyprland_fields(data);
vec![BreadEvent { self.emit_hyprland_dual(
event: "bread.window.moved".to_string(), "bread.window.moved",
timestamp: raw.timestamp, json!({
source: AdapterSource::Hyprland,
data: json!({
"address": fields.first().unwrap_or(&""), "address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""),
}), }),
}] raw,
)
} }
_ => vec![BreadEvent { _ => vec![BreadEvent {
event: "bread.hyprland.event".to_string(), event: "bread.hyprland.event".to_string(),
@ -260,6 +257,40 @@ impl EventNormalizer {
} }
} }
/// Emits a Hyprland event under its namespaced `bread.hyprland.<rest>`
/// 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<BreadEvent> {
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<BreadEvent> { fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
@ -710,6 +741,11 @@ mod tests {
} }
// ─── Hyprland ───────────────────────────────────────────────────────── // ─── 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] #[test]
fn hyprland_workspace_change() { fn hyprland_workspace_change() {
@ -721,8 +757,11 @@ mod tests {
1, 1,
); );
let out = n.normalize(&ev); let out = n.normalize(&ev);
assert_eq!(out.len(), 1); assert_eq!(out.len(), 2);
assert_eq!(out[0].event, "bread.workspace.changed"); assert!(out.iter().any(|e| e.event == "bread.workspace.changed"));
assert!(out
.iter()
.any(|e| e.event == "bread.hyprland.workspace.changed"));
} }
#[test] #[test]
@ -735,9 +774,14 @@ mod tests {
1, 1,
); );
let out = n.normalize(&ev); let out = n.normalize(&ev);
assert_eq!(out.len(), 1); assert_eq!(out.len(), 2);
assert_eq!(out[0].event, "bread.window.focused"); assert!(out.iter().any(|e| e.event == "bread.window.focused"));
assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef"); assert!(out
.iter()
.any(|e| e.event == "bread.hyprland.window.focused"));
for ev in &out {
assert_eq!(ev.data.get("address").unwrap(), "0xdeadbeef");
}
} }
#[test] #[test]
@ -750,17 +794,25 @@ mod tests {
1, 1,
); );
let out = n.normalize(&ev); let out = n.normalize(&ev);
assert_eq!(out.len(), 1); assert_eq!(out.len(), 2);
assert_eq!(out[0].event, "bread.window.opened"); assert!(out.iter().any(|e| e.event == "bread.window.opened"));
let d = &out[0].data; 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("address").unwrap(), "0xabc");
assert_eq!(d.get("workspace").unwrap(), "2"); assert_eq!(d.get("workspace").unwrap(), "2");
assert_eq!(d.get("class").unwrap(), "firefox"); assert_eq!(d.get("class").unwrap(), "firefox");
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox"); assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
} }
}
#[test] #[test]
fn hyprland_unknown_kind_falls_through_to_generic_event() { 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 n = EventNormalizer::new(0);
let ev = raw( let ev = raw(
AdapterSource::Hyprland, AdapterSource::Hyprland,
@ -788,9 +840,274 @@ mod tests {
json!({"kind": "monitorremoved", "data": "HDMI-A-1"}), json!({"kind": "monitorremoved", "data": "HDMI-A-1"}),
2, 2,
)); ));
assert_eq!(added[0].event, "bread.monitor.connected"); assert_eq!(added.len(), 2);
assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1"); assert!(added.iter().any(|e| e.event == "bread.monitor.connected"));
assert_eq!(removed[0].event, "bread.monitor.disconnected"); 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 ───────────────────────────────────────────────────────────── // ─── Power ─────────────────────────────────────────────────────────────
@ -1053,9 +1370,14 @@ mod tests {
json!({"kind": "workspace", "data": "2"}), json!({"kind": "workspace", "data": "2"}),
1100, 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 // Different payloads = different dedup key
assert_eq!(n.normalize(&b).len(), 1); assert_eq!(n.normalize(&b).len(), 2);
} }
#[test] #[test]

View file

@ -27,7 +27,7 @@ use crate::lua::RuntimeHandle;
/// something new-but-additive (a binding, an event, an IPC param); bump the /// 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 /// major version only for a breaking change, which should not happen inside
/// this daemon's v1 lifetime per that section's stated policy. /// 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)] #[derive(Clone)]
pub struct Server { pub struct Server {

View file

@ -52,7 +52,10 @@ async fn main() -> Result<()> {
shutdown_rx.clone(), 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 normalizer = normalizer.clone();
let normalized_tx = normalized_tx.clone(); let normalized_tx = normalized_tx.clone();

View file

@ -21,7 +21,7 @@ bread.workflow.define("dock-connected", function()
bread.workflow.step("waiting for monitor") bread.workflow.step("waiting for monitor")
local event = bread.wait_any( local event = bread.wait_any(
{ "bread.monitor.connected", "bread.hyprland.event" }, { "bread.hyprland.monitor.connected", "bread.hyprland.event" },
{ timeout = 5000 } { timeout = 5000 }
) )
if not event then if not event then
@ -35,7 +35,7 @@ bread.workflow.define("dock-connected", function()
bread.profile.activate("docked") bread.profile.activate("docked")
bread.workflow.step("waiting for workspace") 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.workflow.step("notifying")
bread.notify("Dock connected", { title = "bread" }) bread.notify("Dock connected", { title = "bread" })

View file

@ -146,8 +146,8 @@ function M.on_load()
-- catch a branch switch inside the same still-focused tab (e.g. `git -- catch a branch switch inside the same still-focused tab (e.g. `git
-- checkout` run without ever changing window focus), which produces no -- checkout` run without ever changing window focus), which produces no
-- focus event at all. -- focus event at all.
bread.on("bread.window.focused", update) bread.on("bread.hyprland.window.focused", update)
bread.on("bread.window.focus.changed", update) bread.on("bread.hyprland.window.focus.changed", update)
bread.every(2000, update) bread.every(2000, update)
end end