#!/usr/bin/env bash # What closing the lid should do. # # panama-lid status what this machine would do right now, as JSON # panama-lid guard hold the inhibitor while clamshell use is possible # # The rule is one sentence: closing the lid should suspend, UNLESS there is an # external monitor, in which case the machine is docked and should keep working. # # The implementation is deliberately not a lid watcher. logind already handles # the lid perfectly well; what it cannot do is notice that an external display # makes a closed lid mean something different, because its own "docked" test # looks for an ACPI docking station that modern hardware does not have. # # So Panama does not take the lid over. It holds a `handle-lid-switch` # inhibitor while an external monitor is connected, and releases it when the # last one goes away. logind does everything else, which has three properties # worth having: # # * Nothing has to watch the lid, and no polling loop exists. # * If this ever fails or is killed, the inhibitor is released and the # machine goes back to logind's default. The failure mode is "a docked # laptop suspends", not "the lid does nothing at all", which is the failure # mode a logind drop-in would have. # * Locking before sleep is already handled: hypridle's before_sleep_cmd # runs `loginctl lock-session`, so a suspend from a closed lid is a locked # suspend without anything here being involved. # # Started and stopped by services/LidPolicy.qml on display topology changes. # # NOT YET VERIFIED ON A LAPTOP. The logic and the inhibitor are testable on any # machine and are covered by tests/setup/lid-contract, but the end-to-end # behavior of closing a real lid on a docked machine needs a machine with a # lid. Everything here fails toward logind's default, so the untested path is # "keeps working while docked" rather than anything that could strand a session. set -uo pipefail PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" HW="$PANAMA_PATH/bin/panama-hw" answer() { "$@" && printf 'true' || printf 'false'; } cmd_status() { local inhibited=false systemd-inhibit --list 2>/dev/null | grep -q 'panama-lid' && inhibited=true printf '{"laptop":%s,"lidClosed":%s,"externalMonitor":%s,"clamshell":%s,"inhibited":%s}\n' \ "$(answer "$HW" laptop)" \ "$(answer "$HW" lid-closed)" \ "$(answer "$HW" external-monitor)" \ "$(answer "$HW" clamshell)" \ "$inhibited" } # Holds the inhibitor for as long as it runs. Exits immediately, holding # nothing, when this machine has no reason to want one -- a desktop has no lid # to inhibit and an undocked laptop should suspend normally. cmd_guard() { "$HW" laptop || exit 0 "$HW" external-monitor || exit 0 exec systemd-inhibit \ --what=handle-lid-switch \ --who=panama-lid \ --why="An external display is connected, so a closed lid is a docked machine rather than one being put away" \ --mode=block \ sleep infinity } # The internal panel's connector name, from the compositor: eDP, LVDS and # DSI are built in, everything else arrived through a cable -- the same rule # panama-hw and LidPolicy use. internal_connector() { hyprctl -j monitors all 2>/dev/null \ | jq -r '[.[] | select(.name | test("^(eDP|LVDS|DSI)"; "i"))][0].name // empty' } # Bound to the lid switch by keybinds.lua. Closing a DOCKED lid turns the # internal panel off, so nothing renders inside a closed shell and no # workspace strands on an invisible output; an undocked lid does nothing here # because logind is already suspending the machine. Failure direction: if the # disable never runs, the panel stays lit inside the shell -- wasteful, not # harmful. cmd_close() { "$HW" laptop || exit 0 "$HW" external-monitor || exit 0 local internal internal="$(internal_connector)" [[ -n "$internal" ]] || exit 0 hyprctl eval "hl.monitor({ output = \"$internal\", disable = true })" >/dev/null } # The stored display record for one output, rendered as hl.monitor arguments. # # This exists because the four-key version it replaces was a clobber. Opening # the lid emitted mode, position="auto" and scale and nothing else -- and an # hl.monitor call REPLACES the rule for that output whole, so every other field # the person had chosen for the panel went with it. Transform, VRR, bit depth, # colour profile, SDR trim, mirroring and, worst of all, the position: a panel # arranged to the left of an external display jumped back to automatic # placement, taking its workspaces with it, every time the lid was opened. # # So the rule is rebuilt from the same store config/dot/hypr/monitors.lua reads # at startup, with the same validation, so that opening the lid produces the # rule a reload would have produced. Mirroring that file field for field is the # point; the two must not be able to disagree about what a saved record means. # # The two failure directions are deliberately different, exactly as they are # there: # # * mode, scale or transform unreadable -- or a half-written position, where # a record carries some layout fields but not a valid pair -- refuses the # WHOLE record. Nothing is printed and the caller falls back to the panel's # preferred mode. Guessing a position can strand an output where no cursor # can reach it. # * an unreadable colour, VRR, SDR or mirror value drops only itself. The # geometry survives, and the worst it costs is a wrong shade. # # Every value that reaches the emitted string is validated first -- modes and # positions against a numeric pattern, connector names against the same # `[%w_.-]` class monitors.lua uses, colour profiles against a fixed set -- so # nothing from the settings file can carry a quote into the Lua that is built # from it. monitor_rule() { local name="$1" settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" [[ -r "$settings" ]] || return 0 jq -r --arg name "$name" ' def numeric: if type == "number" and (isnan | not) and (isinfinite | not) then . else null end; def whole: numeric | if . != null and . == floor then . else null end; . as $root | ($root.displays[$name] // null) as $e | if ($e | type) != "object" then "" else # ── Geometry: all of it, or none of it ─────────────────────────────── ($e.mode | if type == "string" and test("^[0-9]+x[0-9]+@[0-9]+(\\.[0-9]+)?$") then . else null end) as $mode | ($mode | if . == null then null else capture("^(?[0-9]+)x(?[0-9]+)@(?[0-9.]+)$") | [(.w | tonumber), (.h | tonumber), (.r | tonumber)] end) as $dim | (if $dim == null or $dim[0] <= 0 or $dim[1] <= 0 or $dim[2] <= 0 then null else $mode end) as $mode # A scale is only valid if it divides the mode into whole logical # pixels; Hyprland refuses the rest, and monitors.lua refuses them here # first so the two agree about which records are usable. | ($e.scale | numeric | if . != null and . > 0 and . <= 4 then . else null end) as $rawScale | (if $mode == null or $rawScale == null then null else ($dim[0] / $rawScale) as $lw | ($dim[1] / $rawScale) as $lh | if ((($lw - ($lw | round)) | fabs) < 0.0001) and ((($lh - ($lh | round)) | fabs) < 0.0001) then $rawScale else null end end) as $scale | ($e.transform | whole | if . != null and . >= 0 and . <= 3 then . else null end) as $transform # Legacy records carry no layout fields at all and keep automatic # placement. A record that carries SOME of them and gets one wrong is # refused outright rather than half-honoured. | (($e.x != null) or ($e.y != null) or ($e.primary != null)) as $hasLayout | ($e.x | whole | if . != null and . >= -100000 and . <= 100000 then . else null end) as $x | ($e.y | whole | if . != null and . >= -100000 and . <= 100000 then . else null end) as $y | ($hasLayout and ($x == null or $y == null or ($e.primary | type) != "boolean")) as $layoutBroken # ── Extended fields: each one drops on its own ─────────────────────── | ($e.bitdepth | if . == 8 or . == 10 then . else null end) as $bitdepth | ($e.colorProfile | if . == "auto" or . == "srgb" or . == "wide" or . == "hdr" then . else null end) as $cm # -1 means "follow the global VRR policy", which is said by leaving the # key out; 3 is the global policy value and not a per-display choice. | ($e.vrrMode | whole | if . != null and . >= 0 and . <= 2 then . else null end) as $vrr # Neutral is 1.0 and is left out rather than written: naming it pins the # display to it, which is not the same as leaving the trim alone. | ($e.sdrBrightness | numeric | if . != null and . >= 0.8 and . <= 2.0 and (((. - 1) | fabs) >= 0.001) then . else null end) as $sdrBrightness | ($e.sdrSaturation | numeric | if . != null and . >= 0.8 and . <= 1.2 and (((. - 1) | fabs) >= 0.001) then . else null end) as $sdrSaturation # 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. | ($e.mirrorOf | if type == "string" and . != "" and . != $name and test("^[A-Za-z0-9_.-]+$") then . else null end) as $mirrorName | (if $mirrorName == null or $e.primary == true then null else ($root.displays[$mirrorName] // null) as $target | if ($target | type) == "object" and ($target.mirrorOf | type) == "string" and $target.mirrorOf != "" then null else $mirrorName end end) as $mirror # A mirror shows its target picture in its target place, so the saved # position is not ours to ask for. | (if $mirror != null then "auto" elif $hasLayout then "\($x)x\($y)" else "auto" end) as $position | if $mode == null or $scale == null or $transform == null or $layoutBroken then "" else ([ "mode = \"\($mode)\"", "position = \"\($position)\"", "scale = \($scale)", "transform = \($transform)" ] + (if $bitdepth == null then [] else ["bitdepth = \($bitdepth)"] end) + (if $cm == null then [] else ["cm = \"\($cm)\""] end) + (if $vrr == null then [] else ["vrr = \($vrr)"] end) + (if $sdrBrightness == null then [] else ["sdrbrightness = \($sdrBrightness)"] end) + (if $sdrSaturation == null then [] else ["sdrsaturation = \($sdrSaturation)"] end) + (if $mirror == null then [] else ["mirror = \"\($mirror)\""] end) ) | join(", ") end end ' "$settings" 2>/dev/null || true } # Re-enable on open, restoring what the person chose for this panel in Settings # -- the whole record, not the three fields that used to survive -- and falling # back to the panel's preferred mode when there is no usable record. cmd_open() { "$HW" laptop || exit 0 local internal rule internal="$(internal_connector)" [[ -n "$internal" ]] || exit 0 # The connector name is interpolated into Lua too, and it comes from # hyprctl rather than from us. Same class monitors.lua accepts. [[ "$internal" =~ ^[A-Za-z0-9_.-]+$ ]] || exit 0 rule="$(monitor_rule "$internal")" [[ -n "$rule" ]] || rule='mode = "preferred", position = "auto", scale = "auto"' hyprctl eval "hl.monitor({ output = \"$internal\", $rule })" >/dev/null } case "${1:-status}" in status) cmd_status ;; guard) cmd_guard ;; close) cmd_close ;; open) cmd_open ;; -h|--help) cat <<'USAGE' usage: panama-lid [status|guard|close|open] status what closing the lid would do right now, as JSON guard hold a handle-lid-switch inhibitor while an external display is connected; exits immediately on a machine that needs none close docked lid closed: turn the internal panel off (bound to the lid switch by keybinds.lua); does nothing undocked open lid opened: turn the internal panel back on, restoring the whole display record chosen in Settings -- position, mode, scale, transform, and the colour, VRR and mirror fields when they are set USAGE ;; *) printf 'panama-lid: unknown command: %s\n' "$1" >&2; exit 2 ;; esac