Files
Panama/config/dot/hypr/rules.lua
T
Gabriel Brown 39a2e8e9ca Fix: Stop the display link retraining and blanking
This panel's DP link is marginal. 4500x3000@60 at 10bpc is around 24 Gbps,
right at the edge of DP 1.4 HBR3 and reliant on DSC, so every modeset
retrains the link and blanks the screen. 8bpc keeps headroom.

Steam games are the other trigger. Everything Hyprland does only for a real
fullscreen window (direct scanout, VRR, tearing, auto HDR) forces that
retrain, so steam_app windows get fullscreen_state "1 2": maximized
internally while the game believes it is fullscreen, which is what
borderless windowed looks like from the game's side.

Recorded alongside the related Panama settings already at 0,
directScanoutPolicy and vrrPolicy.
2026-09-14 14:36:35 -04:00

340 lines
13 KiB
Lua

-- ─────────────────────────────────────────────────────────────────────────────
-- Window, workspace and layer rules
--
-- Rules evaluate top to bottom, last match wins -- but every NAMED rule is
-- evaluated before every anonymous one, so a named rule can never override an
-- anonymous rule further down. Names are used sparingly here for that reason.
--
-- Matching is Google RE2 (no backtracking). Prefix a pattern with "negative:"
-- to invert it.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── Upstream sanity rules ───────────────────────────────────────────────────
hl.window_rule({
name = "suppress-maximize-events",
match = { class = ".*" },
suppress_event = "maximize",
})
hl.window_rule({
name = "fix-xwayland-drags",
match = {
class = "^$",
title = "^$",
xwayland = true,
float = true,
fullscreen = false,
pin = false,
},
no_focus = true,
})
-- ── Floating apps ───────────────────────────────────────────────────────────
-- Carried over from the Forge windows.json override list, minus entries for
-- apps that are no longer installed.
hl.window_rule({
match = {
class = "^(jetbrains-toolbox|zoom|Cider|Hidamari|com\\.mattjakeman\\.ExtensionManager)$",
},
float = true,
})
hl.window_rule({
match = { class = "^([Bb]itwarden|com\\.bitwarden\\.desktop)$" },
float = true,
})
hl.window_rule({
match = { class = "^(com\\.nextcloud\\.desktopclient\\.nextcloud|Nextcloud)$" },
float = true,
})
hl.window_rule({
match = { class = "^(org\\.gnome\\.Calculator|gnome-calculator)$" },
float = true,
size = { 400, 600 },
center = true,
})
hl.window_rule({
match = { class = "^(mpv|io\\.mpv\\.Mpv)$" },
float = true,
-- Video should never be blurred or dimmed behind another window.
no_blur = true,
no_dim = true,
})
hl.window_rule({
match = { class = "^(com\\.spotify\\.Client|Spotify)$" },
float = true,
})
-- Settings-style utility windows and pickers: float and center, like GNOME did.
hl.window_rule({
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
float = true,
size = { "monitor_w * 0.45", "monitor_h * 0.6" },
center = true,
})
-- Quick Look. The GNOME previewer is what the file manager opens on space,
-- and it is an overlay rather than a window someone manages: tiled, it shoves
-- the file manager aside and has to be dismissed before the list is usable
-- again. Sized generously because a preview that needs zooming is not a
-- preview; it still gets a margin so the file underneath stays visible.
hl.window_rule({
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
float = true,
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
center = true,
})
-- Portal dialogs (file chooser, screen share picker) should always float.
hl.window_rule({
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
float = true,
center = true,
})
hl.window_rule({
match = { title = "^(Open File|Save File|Save As|Open Folder|Select a File|Choose Files)$" },
float = true,
center = true,
})
-- Polkit prompt.
hl.window_rule({
match = { class = "^(hyprpolkitagent|polkit-gnome-authentication-agent-1)$" },
float = true,
center = true,
})
-- ── Gaming ──────────────────────────────────────────────────────────────────
-- content = "game" is the keystone: misc.vrr = 3, render.direct_scanout = 2 and
-- cursor.no_break_fs_vrr = 2 all key off it. Blur, animation, shadow and dim are
-- all disabled so the compositor gets out of the way entirely.
hl.window_rule({
match = { class = "^(steam_app_\\d+|gamescope|lutris|net\\.lutris\\.Lutris|com\\.heroicgameslauncher\\.hgl|Minecraft.*|moonlight|com\\.moonlight_stream\\.Moonlight)$" },
content = "game",
immediate = true,
no_blur = true,
no_anim = true,
no_shadow = true,
no_dim = true,
})
-- Steam games never get true fullscreen. Everything Hyprland does only for a
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
-- window maximized internally while the game believes it is fullscreen, which
-- is what borderless windowed looks like from the game's side.
hl.window_rule({
match = { class = "^steam_app_\\d+$" },
fullscreen_state = "1 2",
})
-- Steam itself is a normal window, but its transient popups are a mess.
hl.window_rule({
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
float = true,
})
-- Fullscreen video in a browser also benefits from no blur / no dim.
hl.window_rule({
match = { fullscreen = true },
no_blur = true,
no_dim = true,
})
-- ── Per-application rules the user wrote ────────────────────────────────────
--
-- `windowRules` in settings.json, edited from Settings' Windows page. Each
-- entry is data and nothing else:
--
-- { class, label, float, center, size = {w, h}, workspace, noAnim, game,
-- noDim, pin }
--
-- `class` is matched LITERALLY. Hyprland matches with RE2, so a class typed
-- into a text field is a regular expression unless something escapes it -- and
-- "org.gnome.Files" as a pattern also matches "orgxgnomexFiles", while a
-- half-typed "(" is a pattern error rather than a rule that matches nothing.
-- Escaped and anchored here, so what the user typed is what gets matched.
--
-- Emitted AFTER the shipped rules and deliberately WITHOUT a name. Hyprland
-- evaluates every named rule before every anonymous one, so a named user rule
-- would silently outrank the anonymous shipped rules above it -- the opposite
-- of the intended precedence. Anonymous, last, is what "the user's rule wins"
-- actually means here.
--
-- An entry that fails any check is skipped whole rather than emitted with the
-- bad field dropped: a rule that half-applies is harder to understand than one
-- that is not there, and the settings page can see the same thing is wrong.
local function escape_regex(value)
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
end
-- The shell's own surfaces are layers, not windows -- but Quickshell's helper
-- windows are not, and a rule that floats or moves one of them would be a user
-- breaking their own desktop from the Windows page. Refused at both ends; this
-- is the end that matters, because the file is hand-editable.
local function reserved_class(class)
local lowered = class:lower()
return lowered:match("^quickshell") ~= nil or lowered:match("^qs%-") ~= nil
end
local function positive_integer(value, low, high)
if type(value) ~= "number" or value ~= math.floor(value) then
return nil
end
if value < low or value > high then
return nil
end
return value
end
for _, entry in ipairs(prefs.get("windowRules", {})) do
if type(entry) == "table" and type(entry.class) == "string" then
local class = entry.class
local rule = nil
if #class >= 1 and #class <= 128 and not reserved_class(class) then
rule = { match = { class = "^" .. escape_regex(class) .. "$" } }
if entry.float == true then rule.float = true end
if entry.center == true then rule.center = true end
if entry.noAnim == true then rule.no_anim = true end
if entry.noDim == true then rule.no_dim = true end
if entry.pin == true then rule.pin = true end
-- The keystone the gaming rules above use: misc.vrr,
-- render.direct_scanout and cursor.no_break_fs_vrr all key off it.
if entry.game == true then rule.content = "game" end
if entry.size ~= nil then
local size = entry.size
local width = type(size) == "table" and positive_integer(size[1], 50, 10000) or nil
local height = type(size) == "table" and positive_integer(size[2], 50, 10000) or nil
if width == nil or height == nil then
rule = nil
else
rule.size = { width, height }
end
end
if rule ~= nil and entry.workspace ~= nil then
local workspace = positive_integer(entry.workspace, 1, 10)
if workspace == nil then
rule = nil
else
rule.workspace = workspace
end
end
end
if rule ~= nil then
hl.window_rule(rule)
end
end
end
-- ── Workspace rules ─────────────────────────────────────────────────────────
-- Deliberately NO "smart gaps".
--
-- The usual trick is to strip gaps, border and rounding when a workspace holds
-- a single tiled window (`workspace = "w[tv1]"` + matching window rules). It
-- was here and it was wrong for this setup: the common case is one window on a
-- workspace, so the desktop spent most of its time square-cornered and
-- edge-to-edge -- the opposite of the intended look. Gaps and rounding are
-- constant now, however many windows are open.
-- ── Layer rules (Quickshell surfaces) ───────────────────────────────────────
-- Namespaces are set in QML via WlrLayershell.namespace; keep these regexes and
-- those strings in sync. `hyprctl layers` lists what is actually live.
--
-- The bar intentionally has no rule: it is a transparent edge-to-edge input
-- surface, not glass. Its individual controls paint their own hover feedback.
-- Keeping a full-width blur rule here would spend compositor work on pixels
-- the bar does not draw.
hl.layer_rule({
name = "qs-dock",
match = { namespace = "^qs-dock$" },
blur = true,
-- The dock's context menu and its window previews are xdg-popups of this
-- surface, not layers of their own, so they are only blurred if the rule
-- says to blur the popups too. Without it they are flat panes over the
-- wallpaper while everything else on the desktop is glass.
blur_popups = true,
ignore_alpha = 0.3,
})
-- Popovers: calendar, quick settings, notification center, tray menus.
hl.layer_rule({
name = "qs-popover",
match = { namespace = "^qs-popover" },
blur = true,
blur_popups = true,
ignore_alpha = 0.2,
})
-- Overview and local screen-reading UI dim the desktop behind them.
hl.layer_rule({
name = "qs-overlay",
match = { namespace = "^qs-(overview|screen-intelligence)$" },
blur = true,
ignore_alpha = 0.4,
dim_around = true,
no_screen_share = true,
})
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
-- all, into every screenshot.
hl.layer_rule({
name = "qs-capture",
match = { namespace = "^qs-capture$" },
no_anim = true,
no_screen_share = true,
})
-- Notification toasts. Blurred like every other shell surface -- without this
-- the cards are a near-transparent fill sitting directly on the wallpaper and
-- read as washed out rather than as glass.
--
-- no_anim because the toasts animate themselves in QML, and no_screen_share so
-- they don't leak into OBS or Sunshine streams.
hl.layer_rule({
name = "qs-notifications",
match = { namespace = "^qs-notifications$" },
blur = true,
ignore_alpha = 0.15,
no_anim = true,
no_screen_share = true,
})
-- Signal Glass provides its own event-driven motion, so the compositor only
-- supplies the material blur and privacy behavior.
hl.layer_rule({
name = "qs-signal-glass",
match = { namespace = "^qs-signal-glass$" },
blur = true,
ignore_alpha = 0.15,
no_anim = true,
no_screen_share = true,
})
-- The launcher blurs and dims whatever is behind it.
hl.layer_rule({
name = "launcher",
match = { namespace = "^(vicinae|wofi)$" },
blur = true,
ignore_alpha = 0.5,
dim_around = true,
})
return true