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

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!(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,
@ -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]