Merge feature/hyprland-event-namespace (Workstream C)

# Conflicts:
#	breadd/src/core/normalizer.rs
This commit is contained in:
Breadway 2026-08-04 22:37:09 +08:00
commit c8f6a72331
8 changed files with 538 additions and 114 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

@ -915,19 +915,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.<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 |
|-------|------|
| `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

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:
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"

View file

@ -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<Self> {
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]

View file

@ -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<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 {
@ -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<BreadEvent> {
let mut out = match &raw.source {
AdapterSource::Udev => self.normalize_udev(raw),
@ -188,107 +205,67 @@ impl EventNormalizer {
.unwrap_or("");
match kind {
"workspace" | "workspacev2" => vec![BreadEvent {
event: "bread.workspace.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}],
"createworkspace" => vec![BreadEvent {
event: "bread.workspace.created".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "workspace": data }),
}],
"destroyworkspace" => vec![BreadEvent {
event: "bread.workspace.destroyed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "workspace": data }),
}],
"monitoradded" => vec![BreadEvent {
event: "bread.monitor.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "name": data }),
}],
"monitorremoved" => vec![BreadEvent {
event: "bread.monitor.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "name": data }),
}],
"activewindow" => vec![BreadEvent {
event: "bread.window.focus.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
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,
id: bread_shared::new_event_id(),
caused_by: None,
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,
id: bread_shared::new_event_id(),
caused_by: None,
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,
id: bread_shared::new_event_id(),
caused_by: None,
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,
id: bread_shared::new_event_id(),
caused_by: None,
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(),
@ -301,6 +278,44 @@ 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,
id: bread_shared::new_event_id(),
caused_by: None,
data: data.clone(),
});
}
out.push(BreadEvent {
event: namespaced_event,
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data,
});
out
}
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut events = Vec::new();
@ -781,6 +796,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() {
@ -792,8 +812,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]
@ -806,9 +829,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]
@ -821,17 +849,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!(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,
@ -859,9 +895,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 ─────────────────────────────────────────────────────────────
@ -1124,9 +1425,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]

View file

@ -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();

View file

@ -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" })

View file

@ -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