-- Monitor configuration and automatic and manual switching-- ------------------ ---- PROFILES ---- ------------------ -- Profiles: -- Key of the profile is the name of the profile -- Profiles are ordered by priority, higher number takes precedence over smaller number -- First (by priority) profile that matches will be applied, all others will be ignored -- -- A profile matches the current setup if _ALL_ of its match criteria are fulfilled -- If there are no match criteria, it will match always local PROFILES = { -- The most basic fallback if nothing else ever works fallback = { priority = -999, matches = {}, monitors = { { output = "", mode = "preferred", position = "auto", scale = 1, disabled = false, }, }, }, laptop = { priority = 0, matches = {{ output = "eDP-1" }}, monitors = { { output = "eDP-1", mode = "1920x1200@120", position = "0x0", scale = 1, disabled = false, }, }, }, home_docked = { priority = 10, matches = { { output = "desc:GIGA-BYTE TECHNOLOGY CO. LTD. M34WQ 0x00000B52" }, { output = "desc:Dell Inc. DELL U2414H 292K475V26JL" }, { output = "desc:Dell Inc. DELL U2724D DL7R834" }, }, monitors = { { output = "eDP-1", mode = "preferred", position = "0x0", scale = 1, disabled = true, }, { output = "desc:GIGA-BYTE TECHNOLOGY CO. LTD. M34WQ 0x00000B52", mode = "3440x1440@60", position = "0x0", scale = 1, disabled = false, }, { output = "desc:Dell Inc. DELL U2414H 292K475V26JL", mode = "1920x1080@60", position = "760x-1080", scale = 1, disabled = false, }, { output = "desc:Dell Inc. DELL U2724D DL7R834", mode = "2560x1440@60", position = "3440x0", scale = 1, disabled = false, }, }, postprocess = function() debug_notify("now applying workspace rules") for i = 1,5 do hl.workspace_rule({ workspace = string.format("%d", i), monitor = "desc:GIGA-BYTE TECHNOLOGY CO. LTD. M34WQ 0x00000B52"}) end hl.workspace_rule({ workspace = "1", default = true, persistent = true}) for i = 6,9 do hl.workspace_rule({ workspace = string.format("%d", i), monitor = "desc:Dell Inc. DELL U2724D DL7R834"}) end hl.workspace_rule({ workspace = "9", default = true, persistent = true}) hl.workspace_rule({ workspace = "10", monitor = "desc:Dell Inc. DELL U2414H 292K475V26JL", default = true, persistent = true}) hl.dispatch(hl.dsp.focus({workspace = 10})) hl.dispatch(hl.dsp.focus({workspace = 9})) hl.dispatch(hl.dsp.focus({workspace = 1})) end }, } local function refresh_monitors() hl.exec_cmd("hyprctl monitors all -j | jq -r '.[] | [.name, .description] | @tsv' > /tmp/hypr_monitors.tsv") end local function get_monitors() -- FIXME: Use hl.get_monitors({all=true}) as soon as it is released local handle = io.open("/tmp/hypr_monitors.tsv", "r") if handle ~= nil then local out = handle:read("*a") handle:close() local result = {} for line in out:gmatch("[^\n]+") do local name, desc = line:match("([^\t]+)\t(.+)") table.insert(result, { name = name, description = desc }) end return result end return {} end local function get_matching_profile() local monitors = get_monitors() local profiles = {} or {} local monitor_debug = map(monitors, function(monitor) return monitor.name .. ": "..monitor.description end) debug_notify("Currently " .. #monitors .. " connected: " .. table.concat(monitor_debug, "; ")) for profile_name, profile in pairs(PROFILES) do profile.name = profile_name or "unknown" table.insert(profiles, profile) end table.sort(profiles, function(a, b) return (a.priority or 0) > (b.priority or 0) end) for _, profile in ipairs(profiles) do local matches = profile.matches or {} local has_matched = {} for _, match in ipairs(matches) do if starts_with(match.output, "desc:") then table.insert(has_matched, any(monitors, function(m) return "desc:" .. m.description == match.output end)) else table.insert(has_matched, any(monitors, function(m) return m.name == match.output end)) end end if #has_matched == 0 then debug_notify("Monitor profile " .. profile.name .. " is active because it does not require any matches (fallback)") return profile elseif all(has_matched, function(v) return v end) then return profile else local failed = map(has_matched, function(v, i) if not v then return matches[i].output else return "" end end) local failedmsg = table.concat(failed, " ") debug_notify("Monitor profile " .. profile.name .. " does not match, could not match " .. failedmsg) end end return nil end local PROFILE_CHANGE_ONGOING = false local PROFILE_CHANGE_RESET_TIMER = nil local PROFILE_CHANGE_START_TIMER = nil local POSTPROCESS_TIMER = nil local function apply_profile(profile) if PROFILE_CHANGE_ONGOING then debug_notify("Monitor profile change is inprogress (debounce)") return end if profile == nil then debug_notify("No monitor profile available") return end debug_notify("Applying monitor profile " .. profile.name) PROFILE_CHANGE_ONGOING = true -- delay application of the profile for some time, as other monitor change events might still be arriving PROFILE_CHANGE_START_TIMER = hl.timer(function() -- after we have started applying our profile, we will generate monitor change events that we do not -- want to process. Assume 5 seconds of non-interesting monitor events that we do not want to process -- afterwards, reset the PROFILE_CHANGE_ONGOING flag for the next time we actually want to change -- monitor profiles PROFILE_CHANGE_RESET_TIMER = hl.timer(function() PROFILE_CHANGE_ONGOING = false debug_notify("Monitor Profile Change Debounce Finished") PROFILE_CHANGE_RESET_TIMER = nil end, { timeout = 5000, type = "oneshot" }) local monitors = profile.monitors for _, monitor in ipairs(monitors) do hl.monitor(monitor) end debug_notify("Monitor Profile " .. profile.name .. " successfully applied") -- start any post processing if profile.postprocess ~= nil then POSTPROCESS_TIMER = hl.timer(function() if profile.postprocess ~= nil then profile.postprocess() end PROFILE_CHANGE_START_TIMER = nil POSTPROCESS_TIMER = nil end, { timeout = 200, type = "oneshot" }) else PROFILE_CHANGE_START_TIMER = nil end end, { timeout = 500, type = "oneshot" }) end local function apply_current_profile() apply_profile(get_matching_profile()) end local function force_refresh_profile() if PROFILE_CHANGE_RESET_TIMER ~= nil then PROFILE_CHANGE_RESET_TIMER:set_enabled(false) end if POSTPROCESS_TIMER ~= nil then POSTPROCESS_TIMER:set_enabled(false) end if PROFILE_CHANGE_START_TIMER ~= nil then PROFILE_CHANGE_START_TIMER:set_enabled(false) end PROFILE_CHANGE_RESET_TIMER = nil POSTPROCESS_TIMER = nil PROFILE_CHANGE_START_TIMER = nil PROFILE_CHANGE_ONGOING = false debug_notify("Force applying monitor profile") refresh_monitors() apply_current_profile() end local function on_lid_close() debug_notify("Lid was closed") refresh_monitors() hl.monitor({ output = "eDP-1", disabled = true }) end local function on_lid_open() debug_notify("Lid was opened") refresh_monitors() apply_current_profile() end local function on_monitor_added(_event) debug_notify("A monitor was added") refresh_monitors() apply_current_profile() end local function on_monitor_removed(_event) debug_notify("A monitor was removed") refresh_monitors() apply_current_profile() end hl.bind("switch:on:Lid Switch", on_lid_close, { locked = true }) hl.bind("switch:off:Lid Switch", on_lid_open, { locked = true }) hl.bind("SUPER + P", function() force_refresh_profile() end, { locked = true, description = "Force Refresh Monitor Profile" }) hl.on("monitor.added", on_monitor_added) hl.on("monitor.removed", on_monitor_removed) hl.on("hyprland.start", function() refresh_monitors() end)