270 lines
13 KiB
Bash
Executable File
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
|