Rebuild the PicoCalc F-key lane per JP's spec: five fixed 9-cell chips (F1 DESCRI, "10" standing in for the overflow F10 label), gray fill on the plain F1-F5 bank and green while Shift's bank is showing, white text on both. DEFAULT_LANE moves to an adjacent-plain-key priority model: commit/leave take the easiest- to-reach F4/F5 with no Shift needed, jump-to-top/end take F1/F2 with paging as their Shift companions, and F3 stays the one lone slot a screen's own lane reaches for first. FPair's Shift half is now optional, so a lone slot renders blank in the shifted bank instead of forcing an unrelated opposite action onto it. Also bridges the on-device quirk where the MCU's F6-F10 translation can appear to release Shift an instant before the translated code lands, flipping the lane back mid-chord: modifier_watch now latches shift_down() true for a short grace window after any F6-F10 press resolves, on top of the raw watcher state. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
141 lines
5.7 KiB
Python
141 lines
5.7 KiB
Python
"""The optional Shift-state watcher behind the PicoCalc F-key lane's live flip.
|
||
|
||
The PicoCalc keyboard's MCU translates Shift+F1..F5 into plain F6–F10 keycodes, so the
|
||
terminal never sees the Shift that produced them — but the kernel input device does, and
|
||
the ``LSHIFT DOWN`` event arrives *before* the translated F-code (measured in P0). This
|
||
watcher reads the raw evdev stream and tracks whether a Shift key is physically held, so
|
||
the footer lane can flip to the F6–F10 bank's labels while the user is mid-chord.
|
||
|
||
On-device use (JP, 2026-08-01) turned up a follow-on quirk: the lane can flip back to its
|
||
unshifted bank the instant an F6–F10 code lands, even with Shift still physically held —
|
||
consistent with the MCU's key-matrix scan releasing/re-asserting Shift around the chord
|
||
rather than holding it down continuously through the translation. :func:`note_shift_bank_key`
|
||
bridges that: the session calls it whenever an F6–F10 press actually resolves, and
|
||
:func:`shift_down` latches ``True`` for a short grace window after, so a momentary dip in
|
||
the raw signal can't flip the display mid-keystroke. The window is generous enough to
|
||
outlast the flicker without noticeably outlasting the actual key release — the lane still
|
||
self-corrects within one idle repaint (the 2 s picocalc tick) either way.
|
||
|
||
Strictly an experiment layered over a working static lane, and built to disappear: it
|
||
only ever engages when the platform asks for it (``Platform.modifier_watch``), the input
|
||
device exists, and it is readable (the deploy user is in the ``input`` group on the device) — an ssh
|
||
session on any other machine, a permissions change, or any read error at all just means
|
||
:func:`shift_down` stays ``False`` and the lane stays static. Hand-rolled 30-line evdev
|
||
reader instead of the ``keyboard`` library: no dependency to build on the stripped image,
|
||
and we need exactly two keycodes.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import struct
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Callable, Optional
|
||
|
||
#: struct input_event on 32-bit ARM: struct timeval (2 × long = 8 bytes), then
|
||
#: __u16 type, __u16 code, __s32 value.
|
||
_EVENT_FORMAT = "llHHi"
|
||
_EVENT_SIZE = struct.calcsize(_EVENT_FORMAT)
|
||
|
||
_EV_KEY = 0x01
|
||
_KEY_LEFTSHIFT = 42
|
||
_KEY_RIGHTSHIFT = 54
|
||
|
||
#: Where the PicoCalc's keyboard MCU registers (same i2c chip as the battery; P0).
|
||
_SYS_INPUT = Path("/sys/class/input")
|
||
|
||
_shift_down = False
|
||
_thread: Optional[threading.Thread] = None
|
||
|
||
#: How long a resolved F6–F10 keycode keeps :func:`shift_down` latched ``True`` after the
|
||
#: raw signal drops — long enough to bridge the MCU's release/re-assert flicker around a
|
||
#: chord, short enough that letting Shift go for real still reads as released well within
|
||
#: one held keypress. See :func:`note_shift_bank_key`.
|
||
_SHIFT_BANK_GRACE_S = 0.6
|
||
|
||
_last_shift_bank_at: Optional[float] = None
|
||
|
||
|
||
def shift_down() -> bool:
|
||
"""Whether a Shift key is physically held right now (``False`` when not watching).
|
||
|
||
Also ``True`` for :data:`_SHIFT_BANK_GRACE_S` after the last F6–F10 press resolved
|
||
(see :func:`note_shift_bank_key`), bridging a firmware quirk where the raw signal can
|
||
dip mid-chord even though Shift never actually came up.
|
||
"""
|
||
if _shift_down:
|
||
return True
|
||
return (
|
||
_last_shift_bank_at is not None
|
||
and time.monotonic() - _last_shift_bank_at < _SHIFT_BANK_GRACE_S
|
||
)
|
||
|
||
|
||
def note_shift_bank_key() -> None:
|
||
"""Record that an F6–F10 keycode just resolved — proof Shift was physically down.
|
||
|
||
The MCU only ever emits these codes while Shift is held, so their arrival is stronger
|
||
evidence than the watcher's own raw state (see the module docstring). Call this from
|
||
wherever an F-key press resolves against the Shift bank (:meth:`TuiSession._dispatch`).
|
||
"""
|
||
global _last_shift_bank_at
|
||
_last_shift_bank_at = time.monotonic()
|
||
|
||
|
||
def _find_keyboard() -> Optional[Path]:
|
||
"""The PicoCalc keyboard's event device, or ``None`` when it isn't this machine."""
|
||
try:
|
||
for entry in sorted(_SYS_INPUT.glob("event*")):
|
||
name = (entry / "device" / "name").read_text().strip().lower()
|
||
if "picocalc" in name:
|
||
return Path("/dev/input") / entry.name
|
||
except OSError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def start(on_change: Callable[[], None]) -> bool:
|
||
"""Start the watcher thread if this machine has the keyboard; ``True`` if it engaged.
|
||
|
||
Args:
|
||
on_change: Called (from the watcher thread) whenever the Shift state flips —
|
||
the session wraps this in a thread-safe repaint request.
|
||
"""
|
||
global _thread
|
||
if _thread is not None:
|
||
return True
|
||
device = _find_keyboard()
|
||
if device is None:
|
||
return False
|
||
try:
|
||
stream = open(device, "rb", buffering=0)
|
||
except OSError:
|
||
return False
|
||
|
||
def _watch() -> None:
|
||
global _shift_down
|
||
held = {_KEY_LEFTSHIFT: False, _KEY_RIGHTSHIFT: False}
|
||
while True:
|
||
try:
|
||
data = stream.read(_EVENT_SIZE)
|
||
except OSError:
|
||
break
|
||
if not data or len(data) < _EVENT_SIZE:
|
||
break
|
||
_, _, etype, code, value = struct.unpack(_EVENT_FORMAT, data)
|
||
if etype != _EV_KEY or code not in held:
|
||
continue
|
||
held[code] = value != 0 # 1 down, 2 auto-repeat, 0 up
|
||
now = any(held.values())
|
||
if now != _shift_down:
|
||
_shift_down = now
|
||
try:
|
||
on_change()
|
||
except Exception: # noqa: BLE001 - a repaint hiccup must not kill the watch
|
||
pass
|
||
|
||
_thread = threading.Thread(target=_watch, name="modifier-watch", daemon=True)
|
||
_thread.start()
|
||
return True
|