From 7b6ba7bee68ac22f3deed77d566a1a2f96ed1b83 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Thu, 4 Jun 2026 15:53:43 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 2 +- dot_config/hypr/AGENTS.md | 2 +- dot_config/hypr/hyprland.d.lua/rules.lua.tmpl | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0164de2..e3c2f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/dot_config/hypr/AGENTS.md b/dot_config/hypr/AGENTS.md index 5f33387..c0a0385 100644 --- a/dot_config/hypr/AGENTS.md +++ b/dot_config/hypr/AGENTS.md @@ -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] diff --git a/dot_config/hypr/hyprland.d.lua/rules.lua.tmpl b/dot_config/hypr/hyprland.d.lua/rules.lua.tmpl index a1360ef..cacb44c 100644 --- a/dot_config/hypr/hyprland.d.lua/rules.lua.tmpl +++ b/dot_config/hypr/hyprland.d.lua/rules.lua.tmpl @@ -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" })