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.
326 lines
13 KiB
Lua
326 lines
13 KiB
Lua
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- Monitors
|
|
--
|
|
-- Kuycon P20 (matched by description): 4500x3000 @ 60Hz, 1.5x fractional scale.
|
|
-- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean"
|
|
-- fractional scale and Hyprland will not complain.
|
|
--
|
|
-- On HDR --------------------------------------------------------------------
|
|
-- GNOME runs this panel in bt2100/HDR. Hyprland CAN do that (cm = "hdr"), but
|
|
-- on 0.56.x it currently breaks screencopy: grim screenshots come back empty,
|
|
-- and OBS/Sunshine capture and hyprlock's blurred background go with them.
|
|
-- Root cause is still open upstream. Since screen capture matters more here
|
|
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
|
|
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
|
|
--
|
|
-- To try full-time HDR anyway, set shipped_cm = "hdr" below (or pick HDR for
|
|
-- this display in Settings, which saves it as the display's colorProfile and
|
|
-- wins over the shipped value) and read the notes in overrides.lua first.
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
local prefs = require("prefs")
|
|
|
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
|
-- { ["DP-2"] = {
|
|
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
|
-- x = 0, y = 0, primary = true,
|
|
-- vrrMode = -1, colorProfile = "auto", bitdepth = 10,
|
|
-- sdrBrightness = 1, sdrSaturation = 1, mirrorOf = "",
|
|
-- } }
|
|
--
|
|
-- The fields past `primary` are optional: records written before they existed
|
|
-- carry none of them, and the shipped values below stand instead. That is the
|
|
-- relationship in both directions -- what is written here is what a saved
|
|
-- record inherits, and what Settings saves is what overrides it, so the two
|
|
-- stop fighting over the same monitor rule.
|
|
--
|
|
-- Geometry and colour fail differently on purpose. A half-written position is
|
|
-- refused outright (below), because guessing one can strand an output where
|
|
-- nothing can reach it. An unreadable colour, VRR or mirror value is dropped
|
|
-- on its own and the shipped default stands: the worst it costs is a wrong
|
|
-- shade, and taking the whole record down with it would cost the arrangement.
|
|
local displays = prefs.get("displays", {})
|
|
if type(displays) ~= "table" then
|
|
displays = {}
|
|
end
|
|
|
|
local function mode_dimensions(mode)
|
|
if type(mode) ~= "string" then
|
|
return nil, nil
|
|
end
|
|
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
|
if width == nil then
|
|
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
|
end
|
|
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
|
if width == nil or height == nil or refresh == nil
|
|
or width <= 0 or height <= 0 or refresh <= 0 then
|
|
return nil, nil
|
|
end
|
|
return width, height
|
|
end
|
|
|
|
local function valid_mode(mode)
|
|
local width = mode_dimensions(mode)
|
|
return width ~= nil
|
|
end
|
|
|
|
local function valid_scale(mode, scale)
|
|
local width, height = mode_dimensions(mode)
|
|
if width == nil or type(scale) ~= "number" or scale ~= scale
|
|
or scale <= 0 or scale > 4 then
|
|
return false
|
|
end
|
|
local logical_width = width / scale
|
|
local logical_height = height / scale
|
|
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
|
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
|
end
|
|
|
|
local function valid_transform(transform)
|
|
return type(transform) == "number"
|
|
and transform == math.floor(transform)
|
|
and transform >= 0
|
|
and transform <= 3
|
|
end
|
|
|
|
local function valid_coordinate(value)
|
|
return type(value) == "number"
|
|
and value == value
|
|
and value == math.floor(value)
|
|
and value >= -100000
|
|
and value <= 100000
|
|
end
|
|
|
|
local function valid_position(entry)
|
|
return valid_coordinate(entry.x) and valid_coordinate(entry.y)
|
|
end
|
|
|
|
local function valid_primary(entry)
|
|
return type(entry.primary) == "boolean"
|
|
end
|
|
|
|
local function has_layout_fields(entry)
|
|
return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil
|
|
end
|
|
|
|
local function display_entry(output)
|
|
if type(output) ~= "string" or output == ""
|
|
or output:match("^[%w_.-]+$") == nil then
|
|
return nil
|
|
end
|
|
local entry = displays[output]
|
|
if type(entry) ~= "table" then
|
|
return nil
|
|
end
|
|
if not valid_mode(entry.mode)
|
|
or not valid_scale(entry.mode, entry.scale)
|
|
or not valid_transform(entry.transform) then
|
|
return nil
|
|
end
|
|
-- Legacy records have none of the layout fields and keep automatic
|
|
-- placement. A partially written extended record is unsafe: accepting its
|
|
-- mode but guessing its position could overlap or strand another output.
|
|
if has_layout_fields(entry)
|
|
and (not valid_position(entry) or not valid_primary(entry)) then
|
|
return nil
|
|
end
|
|
return entry
|
|
end
|
|
|
|
local function display_position(entry, fallback)
|
|
if entry ~= nil and has_layout_fields(entry) then
|
|
return string.format("%dx%d", entry.x, entry.y)
|
|
end
|
|
return fallback
|
|
end
|
|
|
|
local color_profiles = { auto = true, srgb = true, wide = true, hdr = true }
|
|
|
|
local function color_profile(entry, fallback)
|
|
local value = entry ~= nil and entry.colorProfile or nil
|
|
if type(value) == "string" and color_profiles[value] then
|
|
return value
|
|
end
|
|
return fallback
|
|
end
|
|
|
|
local function bitdepth_value(entry, fallback)
|
|
local value = entry ~= nil and entry.bitdepth or nil
|
|
if value == 8 or value == 10 then
|
|
return value
|
|
end
|
|
return fallback
|
|
end
|
|
|
|
-- -1 means the display follows the global misc.vrr policy, which is said by
|
|
-- leaving the key out. 3 is the global policy's own value and not a per-display
|
|
-- choice, so it is not accepted here either.
|
|
local function vrr_value(entry)
|
|
local value = entry ~= nil and entry.vrrMode or nil
|
|
if type(value) ~= "number" or value ~= math.floor(value)
|
|
or value < 0 or value > 2 then
|
|
return nil
|
|
end
|
|
return value
|
|
end
|
|
|
|
-- Neutral is 1.0, and the neutral value is left out rather than written: a rule
|
|
-- that names it pins the display to it, which is not the same as leaving the
|
|
-- trim alone.
|
|
local function sdr_value(value, minimum, maximum)
|
|
if type(value) ~= "number" or value ~= value
|
|
or value < minimum or value > maximum
|
|
or math.abs(value - 1) < 0.001 then
|
|
return nil
|
|
end
|
|
return value
|
|
end
|
|
|
|
-- A mirror needs a target that is not itself and not another mirror -- Hyprland
|
|
-- has no chain to follow -- and the primary may not mirror at all, since the
|
|
-- arrangement is anchored on it.
|
|
local function mirror_value(entry, output)
|
|
local value = entry ~= nil and entry.mirrorOf or nil
|
|
if type(value) ~= "string" or value == "" or value == output
|
|
or value:match("^[%w_.-]+$") == nil
|
|
or entry.primary == true then
|
|
return nil
|
|
end
|
|
local target = displays[value]
|
|
if type(target) == "table" and type(target.mirrorOf) == "string"
|
|
and target.mirrorOf ~= "" then
|
|
return nil
|
|
end
|
|
return value
|
|
end
|
|
|
|
-- Colour, VRR and mirroring layered onto a rule that already carries
|
|
-- mode/position/scale/transform. A monitor rule replaces the previous rule for
|
|
-- that output whole, so the shipped defaults are passed in here rather than
|
|
-- written in a rule of their own. `connector` is the output name the record was
|
|
-- saved under, which is not always the rule's own output: the Kuycon rule
|
|
-- matches by description.
|
|
local function with_display_fields(rule, entry, connector, default_bitdepth, default_cm)
|
|
rule.bitdepth = bitdepth_value(entry, default_bitdepth)
|
|
rule.cm = color_profile(entry, default_cm)
|
|
rule.vrr = vrr_value(entry)
|
|
rule.sdrbrightness = entry ~= nil and sdr_value(entry.sdrBrightness, 0.8, 2.0) or nil
|
|
rule.sdrsaturation = entry ~= nil and sdr_value(entry.sdrSaturation, 0.8, 1.2) or nil
|
|
|
|
-- A mirror shows its target's picture in its target's place, so the saved
|
|
-- position is not the compositor's to honour or ours to ask for.
|
|
local mirror = mirror_value(entry, connector)
|
|
if mirror ~= nil then
|
|
rule.mirror = mirror
|
|
rule.position = "auto"
|
|
end
|
|
return rule
|
|
end
|
|
|
|
-- Every connected output uses the same validated per-output store. Automatic
|
|
-- placement and the compositor's normal color policy unless the entry says
|
|
-- otherwise.
|
|
for output, _ in pairs(displays) do
|
|
local entry = display_entry(output)
|
|
if entry ~= nil then
|
|
hl.monitor(with_display_fields({
|
|
output = output,
|
|
mode = entry.mode,
|
|
position = display_position(entry, "auto"),
|
|
scale = entry.scale,
|
|
transform = entry.transform,
|
|
}, entry, output, nil, nil))
|
|
end
|
|
end
|
|
|
|
-- The Kuycon P20, matched by what it is rather than where it is plugged in.
|
|
-- This used to be a rule for connector DP-2 outright, which handed the panel's
|
|
-- 4500x3000 mode and 1.5 scale to whatever monitor a stranger's machine had on
|
|
-- its most common DisplayPort connector. Emitted after the prefs loop so a
|
|
-- saved entry for its connector still carries the mode/scale/position, while
|
|
-- this rule holds the shipped defaults and the panel-specific color policy.
|
|
local shipped_mode = "4500x3000@60"
|
|
local shipped_scale = 1.5
|
|
local shipped_transform = 0
|
|
|
|
-- 8-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
|
-- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset
|
|
-- retrains it and blanks the screen. 8bpc keeps headroom on the link.
|
|
--
|
|
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
|
|
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
|
|
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
|
|
-- format, and on amdgpu a format change is a full modeset. Compositing always
|
|
-- keeps the format fixed, so the link never retrains mid-game.
|
|
--
|
|
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
|
|
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
|
|
-- from it anyway.
|
|
local shipped_bitdepth = 8
|
|
|
|
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
|
local shipped_cm = "auto"
|
|
|
|
local kuycon = display_entry("DP-2")
|
|
hl.monitor(with_display_fields({
|
|
output = "desc:GVT Kuycon P20",
|
|
mode = kuycon and kuycon.mode or shipped_mode,
|
|
position = display_position(kuycon, "0x0"),
|
|
scale = kuycon and kuycon.scale or shipped_scale,
|
|
transform = kuycon and kuycon.transform or shipped_transform,
|
|
}, kuycon, "DP-2", shipped_bitdepth, shipped_cm))
|
|
|
|
-- Any monitor not named above: sane defaults rather than nothing.
|
|
hl.monitor({
|
|
output = "",
|
|
mode = "preferred",
|
|
position = "auto",
|
|
scale = "auto",
|
|
})
|
|
|
|
-- ── Workspaces on the primary display only ──────────────────────────────────
|
|
--
|
|
-- GNOME offered one workspace choice worth reproducing: whether the other
|
|
-- screens join in. Off, every monitor has its own workspaces and switching
|
|
-- affects whichever one has focus -- Hyprland's own behaviour, so it needs no
|
|
-- rules at all. On, workspaces 1-10 are pinned to the primary display and a
|
|
-- second screen keeps a workspace of its own that stays put.
|
|
--
|
|
-- Ten because that is how many the keybinds reach: ALT+1 through ALT+0 in
|
|
-- keybinds.lua. Binding more would pin workspaces nothing can navigate to, and
|
|
-- binding fewer would leave the last few behaving differently from the rest for
|
|
-- no reason a person could see.
|
|
--
|
|
-- The rules are emitted here rather than written live because Hyprland reads
|
|
-- them at config time and offers no way to remove one afterwards: writing an
|
|
-- empty monitor leaves the previous binding in place. So the config is the only
|
|
-- honest source, and applying a change is a reload.
|
|
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
|
-- Only a record display_entry accepts counts. A half-written entry is one
|
|
-- the monitor rules above already refuse, so pinning ten workspaces to it on
|
|
-- the strength of a `primary` flag nothing else trusts would put them on a
|
|
-- screen that never got a rule of its own.
|
|
local primaries = {}
|
|
for output, _ in pairs(displays) do
|
|
local entry = display_entry(output)
|
|
if entry ~= nil and entry.primary == true then
|
|
primaries[#primaries + 1] = output
|
|
end
|
|
end
|
|
|
|
-- Without a primary there is nothing to pin to, and guessing one would move
|
|
-- every workspace onto whichever screen happened to sort first. Two records
|
|
-- both claiming primary is the same problem wearing a different hat: pairs()
|
|
-- has no order, so picking one of them would pin the workspaces to a
|
|
-- different screen from one reload to the next. Neither case guesses.
|
|
if #primaries == 1 then
|
|
local primary = primaries[1]
|
|
for i = 1, 10 do
|
|
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
|
end
|
|
end
|
|
end
|
|
|
|
return true
|