# Host-only: ideapad tablet ergonomics — touchscreen calibration, IIO sensors, virtual keyboard, # touch-controller resume fix, and per-session helper daemons (tablet-mode toggle + auto-rotation # via iio-sensor-proxy) for both Niri and Hyprland. Lives at the NixOS layer because the hardware # bits are system-wide; the per-compositor autostart hooks are gated on `chiasson.desktop..enable` # so they stay dormant if you pick the other session at the greeter. # # Hyprland uses CW transforms via `hyprctl`; Niri uses CCW transforms via `niri msg output`. { 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 ` 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 — identity matrix is correct: hardware coordinates are already aligned # with the panel-native frame, and per-orientation rotation is handled by `niri msg output`, # not by re-tuning this matrix. Rotate the *output*, never this matrix. services.udev.extraRules = '' SUBSYSTEM=="input", ENV{ID_INPUT_TOUCHSCREEN}=="1", ENV{LIBINPUT_CALIBRATION_MATRIX}="1 0 0 0 1 0" ''; # ─────────────────────── Touch controller resume fix ─────────────────────── # The hid-over-i2c touch controller at i2c bus 4-0001 wedges across S3 suspend: after resume it # re-enumerates with the correct capabilities but reports zero events on touch. Cycling the # `i2c_hid_of` driver (unbind + bind) un-wedges it. systemd-sleep runs every executable in # `/etc/systemd/system-sleep/` with `$1 = pre|post`; we only act on `post`. Driver name is # discovered at runtime so a future kernel rename to `i2c_hid` doesn't break this. environment.etc."systemd/system-sleep/ideapad-touch-rebind".source = pkgs.writeShellScript "ideapad-touch-rebind" '' set -eu [ "$1" = post ] || exit 0 dev=4-0001 dev_dir=/sys/bus/i2c/devices/$dev [ -L "$dev_dir/driver" ] || exit 0 drv=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/readlink "$dev_dir/driver")") echo "ideapad-touch-rebind: cycling $drv for $dev" >&2 echo "$dev" > "/sys/bus/i2c/drivers/$drv/unbind" || true ${pkgs.coreutils}/bin/sleep 0.3 echo "$dev" > "/sys/bus/i2c/drivers/$drv/bind" ''; # ─────────────────────── 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"; # Required for logind's `HandlePowerKey` in `_private/platform.nix` to take effect: otherwise # niri grabs a `block` inhibitor on `handle-power-key` and suspends via D-Bus, including on # the EC's wake-from-suspend KEY_POWER event → instant re-suspend loop. # https://github.com/niri-wm/niri/issues/2233 input."disable-power-key-handling" = _: { }; # 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" ]; }; }