Rebase to flake parts #9

This commit is contained in:
2026-05-10 01:45:16 -03:00
parent 34b89af77f
commit f02606902c
46 changed files with 2382 additions and 166 deletions
@@ -0,0 +1,321 @@
# Host-only: ideapad tablet ergonomics — touchscreen calibration, IIO sensors, virtual keyboard,
# and per-session helper daemons (tablet-mode toggle + auto-rotation via iio-sensor-proxy) for both
# Niri and Hyprland.
#
# Why all of this lives at the *NixOS* layer (not the home-manager catalog under wisdom/):
# - The hardware bits (`hardware.sensor.iio.enable`, the udev calibration matrix) are system-wide
# and tied to this exact device, so they belong with the host module.
# - The compositor helpers run via session-specific autostart hooks (Niri `spawn-at-startup`,
# Hyprland `exec-once`); the wiring is gated on the matching `chiasson.desktop.<wm>.enable`,
# so picking a different session at the greeter just leaves them dormant.
#
# Two compositor flavours of each daemon:
# - Hyprland (CW transforms via `hyprctl`) — original; matches the old NixOS-New setup.
# - Niri (CCW transforms via `niri msg output`) — needed because Niri is the V2 default.
{
config,
lib,
pkgs,
...
}:
let
# ─────────────────────── Hyprland helpers ───────────────────────
ideapadTabletModeDaemon = pkgs.writeShellScriptBin "ideapad-tablet-mode-daemon" ''
#!/usr/bin/env bash
set -euo pipefail
HYPRCTL="${pkgs.hyprland}/bin/hyprctl"
JQ="${pkgs.jq}/bin/jq"
PKILL="${pkgs.procps}/bin/pkill"
PGREP="${pkgs.procps}/bin/pgrep"
WVKBD="${pkgs.wvkbd}/bin/wvkbd-mobintl"
monitor_name="DSI-1"
keyboard_scale="1.25"
tablet_scale="1.6"
state_file="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}/ideapad-input-mode.state"
current_transform() {
"$HYPRCTL" -j monitors 2>/dev/null | "$JQ" -r --arg mon "$monitor_name" '
([.[] | select(.name == $mon)][0].transform // 1 | tostring)
' 2>/dev/null || echo "1"
}
has_attached_pogo_dock() {
"$HYPRCTL" -j devices 2>/dev/null | "$JQ" -e '
any((([.keyboards[]?.name] + [.mice[]?.name])[]?); (ascii_downcase | test("google-inc\\.-hammer")))
' >/dev/null
}
# wvkbd is no longer auto-spawned at session start this daemon owns its lifecycle so we
# only have a virtual keyboard surface in memory when the pogo cover is detached.
start_wvkbd() {
"$PGREP" -x wvkbd-mobintl >/dev/null 2>&1 && return 0
"$WVKBD" --non-exclusive -H 520 -L 360 --fn 'DejaVu Sans 18' >/dev/null 2>&1 &
}
stop_wvkbd() {
"$PKILL" -x wvkbd-mobintl >/dev/null 2>&1 || true
}
apply_mode() {
mode="$1"
transform="$(current_transform)"
if [ "$mode" = "tablet" ]; then
"$HYPRCTL" keyword monitor "$monitor_name,1200x2000@60.0,0x0,$tablet_scale, transform, $transform" >/dev/null 2>&1 || true
start_wvkbd
else
"$HYPRCTL" keyword monitor "$monitor_name,1200x2000@60.0,0x0,$keyboard_scale, transform, $transform" >/dev/null 2>&1 || true
stop_wvkbd
fi
"$HYPRCTL" keyword input:touchdevice:output "$monitor_name" >/dev/null 2>&1 || true
"$HYPRCTL" keyword input:touchdevice:transform "$transform" >/dev/null 2>&1 || true
printf "%s\n" "$mode" > "$state_file"
}
# Always reapply on startup the cached state file may lie (e.g., session restart while
# pogo state changed) and wvkbd needs to be (re)spawned since nothing else launches it now.
previous_mode=""
while true; do
if has_attached_pogo_dock; then
mode="keyboard"
else
mode="tablet"
fi
if [ "$mode" != "$previous_mode" ]; then
apply_mode "$mode"
previous_mode="$mode"
fi
sleep 2
done
'';
ideapadAutoRotateDaemon = pkgs.writeShellScriptBin "ideapad-autorotate-daemon" ''
#!/usr/bin/env bash
set -euo pipefail
HYPRCTL="${pkgs.hyprland}/bin/hyprctl"
MONITOR_SENSOR="${pkgs.iio-sensor-proxy}/bin/monitor-sensor"
monitor_name="DSI-1"
keyboard_scale="1.25"
tablet_scale="1.6"
state_file="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}/ideapad-input-mode.state"
scale_for_mode() {
mode="$(cat "$state_file" 2>/dev/null || echo keyboard)"
if [ "$mode" = "tablet" ]; then
printf "%s\n" "$tablet_scale"
else
printf "%s\n" "$keyboard_scale"
fi
}
transform_for_orientation() {
orientation="$1"
case "$orientation" in
normal) printf "0\n" ;;
bottom-up) printf "2\n" ;;
left-up) printf "1\n" ;;
right-up) printf "3\n" ;;
*) return 1 ;;
esac
}
apply_orientation() {
orientation="$1"
transform="$(transform_for_orientation "$orientation")" || return 0
scale="$(scale_for_mode)"
"$HYPRCTL" keyword monitor "$monitor_name,1200x2000@60.0,0x0,$scale, transform, $transform" >/dev/null 2>&1 || true
"$HYPRCTL" keyword input:touchdevice:output "$monitor_name" >/dev/null 2>&1 || true
"$HYPRCTL" keyword input:touchdevice:transform "$transform" >/dev/null 2>&1 || true
}
while true; do
"$MONITOR_SENSOR" --accel 2>/dev/null | while IFS= read -r line; do
case "$line" in
*normal*) apply_orientation "normal" ;;
*bottom-up*) apply_orientation "bottom-up" ;;
*left-up*) apply_orientation "left-up" ;;
*right-up*) apply_orientation "right-up" ;;
esac
done
sleep 2
done
'';
# ─────────────────────── Niri helpers ───────────────────────
# `niri msg output DSI-1 transform <T>` accepts: normal | 90 | 180 | 270.
#
# Empirical mapping for this exact device + ACCEL_MOUNT_MATRIX (lenovo-wormdingler family):
# physical position iio orientation correct niri transform
# ────────────────── ──────────────── ──────────────────────
# keyboard down (LS) left-up normal
# keyboard up (LS) right-up 180
# keyboard right (P) bottom-up 90
# keyboard left (P) normal 270
# iio's "normal" is *not* the natural landscape pose here — the panel-mount matrix in the
# mobile-nixos device file biases it toward portrait-with-keyboard-left. Don't trust the
# textbook iio→Wayland convention; trust the table above.
ideapadNiriTabletModeDaemon = pkgs.writeShellScriptBin "ideapad-niri-tablet-mode-daemon" ''
#!/usr/bin/env bash
set -euo pipefail
NIRI="${pkgs.niri}/bin/niri"
JQ="${pkgs.jq}/bin/jq"
PKILL="${pkgs.procps}/bin/pkill"
PGREP="${pkgs.procps}/bin/pgrep"
WVKBD="${pkgs.wvkbd}/bin/wvkbd-mobintl"
output_name="DSI-1"
keyboard_scale="1.25"
tablet_scale="1.6"
state_file="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}/ideapad-input-mode.state"
has_attached_pogo_dock() {
# Niri doesn't expose input devices via IPC, so we look in `/dev/input/by-id` for the
# USB-class enumeration of the Google Hammer / Whiskers pogo keyboard. The directory
# contains symlinks; presence of either string means the cover is attached.
# NOTE: `find` ships in findutils, not coreutils using the wrong package here makes
# the pipeline fail silently (stderr is dropped) and pogo always reads as detached.
${pkgs.findutils}/bin/find /dev/input/by-id -maxdepth 1 -type l 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -i -E 'google.*hammer|google.*whiskers' >/dev/null
}
# wvkbd is owned by this daemon: spawn on pogo detach, kill on pogo attach. The DMS bar
# `pkill -SIGRTMIN -x wvkbd-mobintl` toggle still works while wvkbd is running (i.e., in
# tablet mode). When the pogo cover is on, the pill is a no-op that's intentional, the
# physical keyboard is the input.
start_wvkbd() {
"$PGREP" -x wvkbd-mobintl >/dev/null 2>&1 && return 0
"$WVKBD" --non-exclusive -H 520 -L 360 --fn 'DejaVu Sans 18' >/dev/null 2>&1 &
}
stop_wvkbd() {
"$PKILL" -x wvkbd-mobintl >/dev/null 2>&1 || true
}
apply_mode() {
mode="$1"
if [ "$mode" = "tablet" ]; then
"$NIRI" msg output "$output_name" scale "$tablet_scale" >/dev/null 2>&1 || true
start_wvkbd
else
"$NIRI" msg output "$output_name" scale "$keyboard_scale" >/dev/null 2>&1 || true
stop_wvkbd
fi
printf "%s\n" "$mode" > "$state_file"
}
# Always reapply on startup see Hyprland daemon's identical comment.
previous_mode=""
while true; do
if has_attached_pogo_dock; then
mode="keyboard"
else
mode="tablet"
fi
if [ "$mode" != "$previous_mode" ]; then
apply_mode "$mode"
previous_mode="$mode"
fi
sleep 2
done
'';
ideapadNiriAutoRotateDaemon = pkgs.writeShellScriptBin "ideapad-niri-autorotate-daemon" ''
#!/usr/bin/env bash
set -euo pipefail
NIRI="${pkgs.niri}/bin/niri"
MONITOR_SENSOR="${pkgs.iio-sensor-proxy}/bin/monitor-sensor"
output_name="DSI-1"
transform_for_orientation() {
orientation="$1"
case "$orientation" in
normal) printf "270\n" ;;
bottom-up) printf "90\n" ;;
left-up) printf "normal\n" ;;
right-up) printf "180\n" ;;
*) return 1 ;;
esac
}
apply_orientation() {
orientation="$1"
transform="$(transform_for_orientation "$orientation")" || return 0
"$NIRI" msg output "$output_name" transform "$transform" >/dev/null 2>&1 || true
}
while true; do
"$MONITOR_SENSOR" --accel 2>/dev/null | while IFS= read -r line; do
case "$line" in
*normal*) apply_orientation "normal" ;;
*bottom-up*) apply_orientation "bottom-up" ;;
*left-up*) apply_orientation "left-up" ;;
*right-up*) apply_orientation "right-up" ;;
esac
done
sleep 2
done
'';
in
{
# ─────────────────────── Hardware ───────────────────────
hardware.sensor.iio.enable = true;
# Touchscreen calibration — solved empirically with `niri msg output DSI-1 transform normal`
# in the natural kb-down pose. The panel's touch hardware reports raw coordinates already
# aligned with the panel-native frame (HW(visual_top_left) = (0,0), etc.), so identity is
# correct. `niri input.touch.map-to-output = "DSI-1"` then handles per-orientation rotation
# on top — never re-tune this matrix per orientation; rotate the *output* instead.
services.udev.extraRules = ''
SUBSYSTEM=="input", ENV{ID_INPUT_TOUCHSCREEN}=="1", ENV{LIBINPUT_CALIBRATION_MATRIX}="1 0 0 0 1 0"
'';
# ─────────────────────── User-facing tools ───────────────────────
# System-wide so any user session (Niri or Hyprland) can launch wvkbd / hyprctl / niri-msg helpers.
environment.systemPackages = [
pkgs.wvkbd
pkgs.iio-sensor-proxy
ideapadTabletModeDaemon
ideapadAutoRotateDaemon
ideapadNiriTabletModeDaemon
ideapadNiriAutoRotateDaemon
];
# ─────────────────────── Niri session autostart ───────────────────────
# Set on the NixOS layer; the `desktopNiri` HM module merges this into per-user `niri/config.kdl`.
# Touch input is glued to DSI-1 so it follows whatever transform the autorotate daemon sets.
# wvkbd is intentionally NOT spawned here — `ideapad-niri-tablet-mode-daemon` starts/kills it
# in response to pogo-cover attach/detach so we don't keep a virtual-keyboard surface alive
# while a physical keyboard is plugged in.
chiasson.desktop.niri.extraSettings = lib.mkIf config.chiasson.desktop.niri.enable {
input.touch.map-to-output = "DSI-1";
# wrapper-modules schema: each entry is a `command argv` list of strings (or a single string).
spawn-at-startup = [
[ "ideapad-niri-autorotate-daemon" ]
[ "ideapad-niri-tablet-mode-daemon" ]
];
};
# ─────────────────────── Hyprland session autostart ───────────────────────
# Same lifecycle policy as Niri: the tablet-mode daemon owns wvkbd, no exec-once for it.
chiasson.desktop.hyprland.settings = lib.mkIf config.chiasson.desktop.hyprland.enable {
exec-once = lib.mkAfter [
"${pkgs.procps}/bin/pkill -x ideapad-tablet-mode-daemon >/dev/null 2>&1 || true; ideapad-tablet-mode-daemon &"
"${pkgs.procps}/bin/pkill -x ideapad-autorotate-daemon >/dev/null 2>&1 || true; ideapad-autorotate-daemon &"
];
bind = lib.mkAfter [
"Super, K, exec, pkill -SIGRTMIN -x wvkbd-mobintl"
];
monitor = lib.mkAfter [
"DSI-1,1200x2000@60.0,0x0,1.25, transform, 1"
];
};
}