hypr: reassert joplin workspace on window open

Joplin (Wayland-native Electron) reasserts its own window right after it
maps, escaping the map-time `silent` workspace rule, so it opened on the
focused workspace instead of its app-home (ws 4 / HDMI-A-2).

Add a data-driven `reassert` placement flag: rules.lua emits a window.open
handler that re-applies the assignment by address (follow = false, silent)
for flagged apps. Only Joplin opts in; the static rule stays as a fallback
and the other app-homes are untouched. Document the flag in CLAUDE.md and
AGENTS.md.
This commit is contained in:
2026-06-04 15:53:43 +02:00
parent 873801262e
commit 7b6ba7bee6
3 changed files with 22 additions and 2 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ The data model separates concerns:
- **`compositor`** — string, `"hyprland"` or `"niri"`. Branch with `eq .chezmoi.config.data.compositor "hyprland"`.
- **`apps`** — list of optional programs present on this machine (e.g. `["steam", "spotify", "jellyfin"]`). Gate with `has "steam" .chezmoi.config.data.apps`.
- **`data.monitors`** — array (size varies per machine). Each has `name`, optional `primary = true`, and a `role` (`"primary"`/`"left"`/`"right"`) used by placement.
- **`data.placement`** — array of app-home workspaces: `id`, `name`, `role`, `apps` (window classes routed here), `silent`, optional `layout`/`layoutopts`/`on_created_empty`/`requires`. A `role` resolves to the monitor carrying it, falling back to the primary monitor when absent (single-monitor machines "just work"). `requires` drops the entry unless that program is in `apps`. Consumed by `hypr/hyprland.d.lua/workspaces.lua.tmpl` (workspace_rule) and `rules.lua.tmpl` (window_rule).
- **`data.placement`** — array of app-home workspaces: `id`, `name`, `role`, `apps` (window classes routed here), `silent`, optional `layout`/`layoutopts`/`on_created_empty`/`requires`/`reassert`. A `role` resolves to the monitor carrying it, falling back to the primary monitor when absent (single-monitor machines "just work"). `requires` drops the entry unless that program is in `apps`. `reassert` re-applies the workspace assignment by address via a `window.open` handler in `rules.lua.tmpl`, for apps (e.g. Joplin) whose window escapes the map-time silent rule and opens on the focused workspace. Consumed by `hypr/hyprland.d.lua/workspaces.lua.tmpl` (workspace_rule) and `rules.lua.tmpl` (window_rule).
- **`data.microphones`** — array; only meaningful when `pipewire = true`.
## Architecture
+1 -1
View File
@@ -13,7 +13,7 @@ Hyprland configuration uses the v0.55+ **Lua** config: entry point `hyprland.lua
## Chezmoi Config (chezmoi.toml)
- Source of truth: `~/.config/chezmoi/chezmoi.toml` (not tracked here).
- Data model: `tags` (booleans: `laptop`, `touchscreen`, `pipewire`, `dev`, `bluetooth` — no `desktop`, use `not laptop`), `compositor` (`"hyprland"`/`"niri"`), `apps` (optional programs, gate with `has "x" .apps`), `data.monitors` (array; each `name` + optional `primary` + `role`), `data.placement` (app-home workspaces), `data.microphones`.
- **Workspace placement is data-driven.** `data.placement[]` entries define the app-home workspaces (`id`, `name`, monitor `role`, `apps` = window classes routed there, `silent`, optional `layout`/`layoutopts`/`on_created_empty`/`requires`). `workspaces.lua.tmpl` turns each into a `workspace_rule` (resolving `role` → monitor name, falling back to the `primary` monitor when that role is absent), and `rules.lua.tmpl` turns each `apps` class into a `window_rule` routing to that workspace. To move an app or add a new app-home, edit `data.placement` — do **not** hardcode monitor names in the templates.
- **Workspace placement is data-driven.** `data.placement[]` entries define the app-home workspaces (`id`, `name`, monitor `role`, `apps` = window classes routed there, `silent`, optional `layout`/`layoutopts`/`on_created_empty`/`requires`/`reassert`). `workspaces.lua.tmpl` turns each into a `workspace_rule` (resolving `role` → monitor name, falling back to the `primary` monitor when that role is absent), and `rules.lua.tmpl` turns each `apps` class into a `window_rule` routing to that workspace. `reassert = true` additionally emits a `window.open` handler that re-applies the assignment by address, for apps (e.g. Joplin) that ignore the map-time silent rule and open on the focused workspace. To move an app or add a new app-home, edit `data.placement` — do **not** hardcode monitor names in the templates.
- Reference structure (current machine):
```toml
[data]
@@ -59,6 +59,26 @@ hl.window_rule({ match = { class = "^({{ join "|" $classes }})$" }, workspace =
{{- end }}
{{- end }}
-- App-home reassertion: some apps (Joplin) ignore the static silent workspace
-- rule at map time and open on the focused workspace instead. For placements
-- flagged `reassert`, re-apply the assignment by address when the window opens.
local reassert_homes = {
{{- range $p := .placement }}
{{- if and (index $p "reassert" | default false) (or (not (hasKey $p "requires")) (has $p.requires $apps)) }}
{{- range $c := $p.apps }}
["{{ $c }}"] = "{{ $p.id }}",
{{- end }}
{{- end }}
{{- end }}
}
hl.on("window.open", function(w)
if not (w and w.class and w.address) then return end
local ws = reassert_homes[w.class]
if ws then
hl.dispatch(hl.dsp.window.move({ workspace = ws, window = "address:" .. w.address, follow = false }))
end
end)
-- Game Content Bypass (Option A - per-monitor CTM bypass)
-- Automatically mark Steam games as "game" content type
hl.window_rule({ match = { class = "^steam_app_%d+$" }, content = "game" })