Files
Panama/config/dot/quickshell/scripts/panama-lid
T

270 lines
13 KiB
Bash
Executable File

#!/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("^(?<w>[0-9]+)x(?<h>[0-9]+)@(?<r>[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