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

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),
@ -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.<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> {
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]

View file

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

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