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:
parent
96639516b1
commit
d270ac6ff7
9 changed files with 535 additions and 95 deletions
40
DEPRECATIONS.md
Normal file
40
DEPRECATIONS.md
Normal 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.
|
||||
|
|
@ -909,19 +909,42 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
|
|||
|
||||
#### Hyprland
|
||||
|
||||
*Since: v1.5 — the `bread.hyprland.*` namespaced forms below. Bread's event vocabulary is meant to be portable across a future second compositor backend; a flat `bread.workspace.*`/`bread.monitor.*`/`bread.window.*` name gave no way to tell a genuinely cross-backend event (like `bread.power.*`) apart from one that is Hyprland-specific. The 10 rows marked `Deprecated: v1.5` are unaffected functionally — they keep firing — but new automation should subscribe to their `bread.hyprland.*` sibling instead.*
|
||||
|
||||
Every Hyprland-sourced event below is dual-emitted: the daemon fires both the legacy flat name and its `bread.hyprland.<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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!(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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue