Files
Panama/config/dot/hypr/monitors.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

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