53 Commits
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 0821b1013e Settings: HANDS_SWAP_SIDES defaults to auto, and old untouched configs move to it
frametop.conf.example said HANDS_SWAP_SIDES=0, and desktops.sh copies it on a
fresh install, so every new install forced ft-camd's side camera names and
turned the hand tracker's own side check off. Some SteamVR restarts swap those
names, and then hands land beside their cutouts and recordings are mislabelled.

The example now says auto. scripts/conf-migrate.sh, run by install.sh and
hands/rec/install.sh, replaces the old line only where it's still exactly as
the example wrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:03 -06:00
DeeJanuzandClaude Opus 5.5 6df6f97937 Session: AT-SPI watcher checks its buses every 5 seconds
Each check starts two gdbus processes (about 5.5 ms of CPU each on the
Frame), so checking once a second cost about 1% of a core for as long as
the desktop ran. On SteamOS 0.3.0 native activation fails, so the watcher
always runs. A registry left behind now goes within 5 seconds instead of 1.

design.md also says where the watcher matters: started from the VR
launcher, the desktop runs in steam.service, which doesn't stop with it,
so keep-apps.sh and the unit's stop don't clean up there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:23:51 -06:00
JakeGreen2145 5e3323c188 [verified] merge experimental into nested AT-SPI fix 2026-10-04 17:38:33 -04:00
JakeGreen2145 25504e0ed4 [verified] fix(session): start nested AT-SPI registry 2026-10-04 16:13:03 -04:00
DeeJanuzandClaude Opus 5.5 d995b815f8 Session: bring back a taskbar saved on a screen the desktop doesn't have
Plasma 6.2.5 keeps a panel on a screen number and never moves one whose
number is past the screen count, so a taskbar saved on a spare output
(#18, lastScreen=8 with three screens) or on a screen a smaller layout
dropped stayed hidden. Before Plasma starts, session/fix-panels.py moves
such a panel and its tray's containment to screen 0 (the primary),
keeping its widgets, unless screen 0 already has a panel on that edge.
doctor.sh checks the panels' screens, and report.sh lists them with the
live outputs and panels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:32:04 -06:00
DeeJanuzandClaude Opus 5.5 b7dfe4759e Session: don't autostart Discover's notifier or IBus in the desktop
The nested Plasma session runs the system's XDG autostart entries. Discover's update
notifier (/etc/xdg/autostart/org.kde.discover.notifier.desktop) started
plasma-discover --mode update inside it, 520-620 MB resident and about 9% of a core,
with flatpak-system-helper and AppStream downloads behind it. IBus started a nested
ibus-daemon with kimpanel and ibus-extension-gtk3, which no app in the desktop can use:
KWin's input method is ft-textinput (zwp_input_method_v1, focus reports only; the VR
keyboard types through ft-screens' seat), and the session already drops QT_IM_MODULE,
GTK_IM_MODULE and XMODIFIERS. Nothing in Frametop talks to IBus.

Before Plasma starts, the session script copies both entries into
$XDG_CONFIG_HOME/autostart with Hidden=true, which plasma-session honours for that
desktop only. It does this once ([Defaults] autostart=1 in frametoprc) and skips a name
the user already has a file for, so deleting the copy brings the program back. The
geoclue demo agent stays (it answers apps' location requests outside GNOME and idles at
0%), and orca's entry is OnlyShowIn GNOME-family desktops, so it never ran. Tested
against a temporary XDG_CONFIG_HOME, including an existing user ibus.desktop left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:18:31 -06:00
DeeJanuzandClaude Opus 5.5 5b6cfcd746 Session: blur, background contrast and animations off by default
The nested kwinrc had no [Plugins] group, so KWin ran its default blur and background
contrast effects, and kdeglobals had no AnimationDurationFactor, so animations ran at
full length. KWin renders through zink on Turnip, on the GPU vrcompositor needs, and
blur re-renders what's behind every translucent panel and menu; each animation frame is
another frame for KWin and ft-screens.

Before KWin starts, the session script now writes [Plugins] blurEnabled=false and
contrastEnabled=false to $XDG_CONFIG_HOME/kwinrc and [KDE] AnimationDurationFactor=0 to
its kdeglobals, each only if the desktop's own file has no value for it. It does this
once and records that in $XDG_CONFIG_HOME/frametoprc ([Defaults] effects=1), because
System Settings deletes a key put back to its default: without the marker, turning blur
back on wouldn't survive a restart. The ids blur and contrast are the built-in effects
of KWin 6.2.5 on SteamOS (both enabled by default in its plugin metadata). Tested
against a temporary XDG_CONFIG_HOME: fresh config, an existing blurEnabled=true kept,
and a deleted key not rewritten.

README and docs/reference.md say how to turn them back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:17:44 -06:00
DeeJanuzandClaude Opus 5.5 e1ee5ef395 Remote desktop: read the layout only after it changes
While FreeRDP ran, vnc-bridge.sh called ft-layout remote-view every 5 s, which scans
all of /proc for plasmashell and runs kscreen-doctor -j: about 4.4% of a core for a
layout that rarely changes.

It now stats the two files the answer depends on, the nested KWin's
~/.config/frametop/kwinoutputconfig.json (positions, scales, primary) and
~/.config/frametop-layout.json (screen sizes), once a second while FreeRDP runs. After
either changes it reads the layout every 2 s for 10 s, since KWin's outputs follow the
file a few seconds later; otherwise once a minute, in case a change touched neither.
With no VNC viewer connected nothing runs (previous commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:14:43 -06:00
DeeJanuzandClaude Opus 5.5 3e7248a04e Remote desktop: connect FreeRDP only while a VNC viewer is connected
vnc-bridge.sh kept FreeRDP connected to krdpserver from the moment remote desktop
started, so krdp captured and H.264-encoded every KWin redraw in software (openh264)
with nobody watching: krdpserver 55-78% of a core, xfreerdp 16-27%, Xvnc 6-11%, with 0
clients on :5900. krdp 6.7 creates its screencast session per RDP connection and drops
it when the connection closes, so krdpserver itself idles without one and stays up.

The bridge now counts established connections to Xvnc's port with ss, starts FreeRDP
when a viewer appears (the desktop shows about 3 s later; the VNC screen is black until
then) and stops it 45 s after the last one leaves (VNC_IDLE_SEC). Xvnc has no client
hook, so its log output, which it writes for every connection, wakes the bridge early;
otherwise it looks every 5 s while idle (0.1% of a core measured, against 0.9% for ss
once a second) and every second while FreeRDP runs. The layout check runs only while
FreeRDP runs.

While a viewer is connected the bridge sends "watch 15" to ft-screens (@ft_screens) at
once and every 5 s, so screens at a reduced frame rate (out of view, headset on a
stand) stream at full rate; it lapses by itself if the bridge dies, and an ft-screens
without the command just answers an error. The window search after starting FreeRDP
now ends when FreeRDP exits instead of polling for 30 s.

krdp on 127.0.0.1 with a fresh password, VNC on the tailnet address with VncAuth, and
remote-ctl.sh start/stop (pause and resume) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:14:25 -06:00
DeeJanuzandClaude Opus 5.5 12ad93d24c Gaze: install our eye tracker, prefer it, and say why a calibration dot wasn't taken
A user on a fresh install got "Calibration failed: only 0 of 21 dots" with
no reason. The installer never installed our tracker, so gaze used
SteamVR's, and the only way SteamVR's tracker rejects a dot is losing an
eye for most of the look. The panel just showed a red ring.

- install.sh: step 9/10 installs our tracker (gaze/tracker/install.sh)
  after gaze mode, yes by default; it needs sudo, so --yes runs it only
  when sudo won't prompt. If it fails, gaze keeps SteamVR's tracker.
  Configs that still say GAZE_TRACKER=steam (the old template) are asked
  whether to switch.
- GAZE_TRACKER=auto, the new default: ours when it's installed (the
  frame grabber, its unit, and ft-eyes' Python), else SteamVR's.
  ft-gazed rechecks every second, so installing it switches over. Input
  Settings lists Own tracker first as recommended, and says how to
  install it when it's missing (checking the host's /etc through
  /run/host from the dev container).
- The calibration panel has a note line, orange over the instructions:
  why a dot wasn't taken (an eye lost, a blink, the eyes disagreeing for
  SteamVR's tracker, from steady_samples' new drop counts; ft-eyes' reply
  for ours), what a click is still waiting for after 1.5 s, and a failed
  calibration's most common reason, which the Gaze page shows too.
  steady_samples keeps the same samples as before (checked on 2037
  windows of recordings); a lost eye is named before a blink, since its
  openness reads 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:48:52 -06:00
DeeJanuzandClaude Opus 5.5 fadc0f467c Shortcuts: Steam menu, commands, and modifier taps
Key combinations (and mouse and controller buttons) get two new actions:
- steam_menu: Open Steam menu / close dashboard (steam/ft-steam menu).
- command:CMD: run CMD with sh -c, as the relay's service, with layout/,
  float/ and steam/ on its PATH. Input Settings offers it for key
  combinations as Run a command...
Both work without pointer mode.

A modifier on its own is now a key combination too: a tap, pressed and
released with no other key, mouse button, or scroll in between. A bound
tap sends the desktop F24 before the release, so Plasma's launcher stays
shut. The defaults gain a Meta tap for the Steam menu; this replaces
META_DASHBOARD, which only worked in pointer mode.

input/test/keys-test.py runs the relay against fake devices with every
outgoing socket renamed, so it's safe next to the live relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:06:30 -06:00
DeeJanuzandClaude Opus 5.5 12c42cd455 Bring the docs up to date with the code
- floating-windows.md and profiles.md describe what's built, with what
  isn't listed as such; the plans, phases, and branch notes are gone
- hands-migration.md is gone: the move is done; its open items are in
  hands/README.md's Known issues
- reference.md: Layout & profiles, every action, key combinations,
  floating windows, the gaze pointer, and hand tracking as they are
- design.md gets the KWin findings from floating-windows.md
- README, gaze/README, AGENTS, hazards, gaze-controllers, and the
  example config catch up with gaze, hands, and the stuck-key fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:47:53 -06:00
DeeJanuzandClaude Opus 5.5 20e8d6254a Merge gaze-calibration into experimental
The gaze checks and calibration in a headset panel (quick, five, full, headset
fit; shared-buffer drawing, click-to-capture), keyboard and mouse gaze clicks
(Meta+J/K, the mouse's buttons alike, mouse moves only while a button is held,
double right/Meta+K tilts a drag), one 55 degree learning limit with a quick
check past it, eye presence for "the headset went on", the gaze probe as a
development tool, and the relay releasing keys the desktop has down that no
keyboard holds.

Conflicts with the profiles: the relay keeps known_action/needs_pointer and
the gaze defaults (Meta+J/K and the float key); Input Settings keeps the
profile actions everywhere and the gaze actions in key combinations only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:55:04 -06:00
DeeJanuzandClaude Opus 5.5 a2a5a4c98e Gaze mode: the mouse only corrects, and left-then-right right-clicks
POINTER_GAZE_MOUSE_MOVE=held, the default (the Gaze page's Mouse movement
switch, with why it's on): while the gaze has the pointer, moving the mouse
does nothing; it moves the pointer only while a button is held, as a
correction. A bumped or drifting mouse can't pull the pointer off what you're
looking at, and every mouse move is a correction, so lessons aren't polluted by
mouse moves to somewhere else. With the gaze stale for a second, in a game, or
with the headset off, the mouse moves the pointer as usual. "free" is the old
behaviour.

Pressing the right button while the left one's press is held back right-clicks
where the pointer is instead (correct with the left, then right-click); both
releases are then nothing. Meta+J then Meta+K stays a drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:56:33 -06:00
DeeJanuzandClaude Opus 5.5 8998fc1ddd Gaze learning limit: 55 degrees, past it a quick check; a solid panel
Drops the quick check's second step (3629681): the user didn't want it, and it
re-entered itself every tick, re-placing and redrawing the panel, which
flickered in the headset.

POINTER_GAZE_NUDGE_MAX is the one learning limit, now 55 degrees by default:
half of the 109 the Frame shows across. The helper measures the correction
itself (raw gaze to click), not the mouse's path; past the limit it isn't
learned and the helper sends "recheck" for the quick check, for the mouse and
keyboard alike. ft-gazed's own limits (8 and 25) follow the setting.

Test, our tracker's 409 clicks since its Sep 29 calibration: a one-dot check
set from any one of them puts the next 2 minutes' clicks within 15 degrees (99%
within 4.2) and the next 10 minutes' within 25, so it gets well under 55.

The panel's dot is still and its capture ring fills in quarters, so it's drawn
again only a few times per dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:48:57 -06:00
DeeJanuzandClaude Opus 5.5 362968198b Quick check: put the pointer on the dot, and one limit for learning
After the quick check's capture, in gaze mode, the dot stays where it is in the
room (the panel's new "lock") and the gaze has the pointer again. You put the
pointer on the dot as you'd correct a click, with the mouse or Meta+J and the
head. That click clicks nothing: the helper sends it as "calverify", learned
whatever its size. Over POINTER_GAZE_NUDGE_MAX, the capture runs again, up to
3 times. A right click or Meta+K skips it.

POINTER_GAZE_NUDGE_MAX is now the one limit: 15 degrees by default (was 8),
and ft-gazed's own limits (8 for SteamVR's tracker, 25 for ours) follow it. A
keyboard correction past it isn't learned but opens a quick check ("recheck"),
in place of the 30 degree keyboard limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:41:52 -06:00
DeeJanuzandClaude Opus 5.5 4c46d69b0c Check what Frametop needs from SteamOS after an update
On the Frame, a SteamOS update replaces SteamVR, KWin, and gamescope with the
rest of the OS image. scripts/update-check.py, run by doctor.sh and report.sh,
checks what Frametop uses from it: the OpenVR interface versions the installed
programs were built against, the vrcmd --overlays format, the eye tracker's
shared memory layout, host files, services, sockets, and the driver
registration. doctor.sh --mark-good records the package versions once things
work, and later runs say what changed and what to try by hand.

The session no longer stops when mesavars.sh or flatpak.sh is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:51:09 -06:00
DeeJanuzandClaude Opus 5.5 858dbe1562 Profiles: named layouts that open apps
A profile is a named layout plus the screens it hides and its apps' windows
(docs/profiles.md). ft-layout save captures them (ft-floatd's "windows",
after the KWin script reports every window as it is now); use opens them:
the screens move, open windows of each app go to their places (on a screen,
maximized or not, or floating), and missing apps start, once and then again
for each window still missing 3 s after the first. Nothing closes.

The desktop starts in FT_PROFILE or default_profile (ft-layout start, from
the session script). Each profile gets a launcher entry (Frametop: NAME, in
SteamVR's Launch a program list) that switches to it or starts the desktop
in it. The relay's profile:NAME action and Input Settings' "Open profile"
entries put one on a key, mouse button, or controller button. Display
Settings' Layout page becomes Layout & profiles: Save as profile, Open
profile, the profile's apps, and Start in profile. Plasma's own session
restore is off in the Frametop session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:32:10 -06:00
DeeJanuzandClaude Opus 5.5 38843a4717 Launch apps floating, remember where they floated, and Launch as Standalone
ft-float launch APP / run COMMAND: ft-floatd starts the app and floats its
first window (matched by process or desktop file name for 30 s) where that
app last floated, or in front of you at the primary screen's density. Each
app's place (pose relative to the primary screen, size in pixels, scale) is
kept in ~/.config/frametop-float.json whenever one of its windows stops
floating; the scale isn't applied yet, since rescaling can loop (written up
in docs/floating-windows.md, Known problems).

Launch as Standalone (float/ft_apps.py): the session writes copies of the
apps' desktop files with that action and puts them first in XDG_DATA_DIRS,
so it shows in the Application Launcher's and the taskbar's right-click menus
in the Frametop desktop only. ft-floatd rewrites them when apps change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:20:36 -06:00
DeeJanuzandClaude Opus 5.5 c4c10e6d92 Gaze checks and calibration in a panel fixed to the headset
ft-gazepanel (gaze/panel) is a SteamVR overlay that stays put in your view and
shows dots at known head-relative directions; the gaze service runs it and
drives it (gaze/gazecheck.py):
- a one-dot quick check 3 s after the headset goes on, when our tracker asks
  for a click (reseat), at most every 2 minutes, and from Quick check on the
  Gaze page; five dots follow if the next 3 lessons are still over 2 degrees off
- the full calibration (the probe's three rounds, dark to bright) when gaze mode
  comes on without one, or from Calibrate; quitting it with still no calibration
  turns gaze mode off
Each dot takes the gaze once it has held still for 0.6 s; a left click or Meta+J
takes it at once, a right click or Meta+K closes the panel (the pointer helper
hides its dot and passes those presses on while "calpanel" lasts). Our tracker
gets clicks and its own calibration; SteamVR's gets lessons per eye, or a new
calibration.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:12:26 -06:00
DeeJanuzandClaude Opus 5.5 00dfd61396 Title bar button: fixes from trying it in the live desktop
- The decoration's "bottom" property clashed with Item's (KWin refused it).
- No On All Desktops button with one virtual desktop, like Breeze.
- decoration/apply.sh finds the session's D-Bus through plasmashell (KWin's
  environment isn't readable), and installs each try under a new name: KWin
  keeps a decoration's QML by name until it restarts.
- ft-floatd starts its script with Scripting.start: after a reload, the new
  script gets the old one's id while the old one is still being deleted, so
  run() on /Scripting/Script<id> went to the old script and the new one never
  ran (a second ft-floatd start left floating windows without their script).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:11:29 -06:00
DeeJanuzandClaude Opus 5.5 f32187824a A float button in every window's title bar
Frametop's own window decoration (decoration/), a QML decoration for KWin's
Aurorae engine drawn like Breeze, puts a float button left of Close. It's the
Keep Below button with its own glyph: the KWin script floats a window when
keep-below is set and docks it when it's cleared, and keeps the flag set on
every floating window, so the button shows "back to the desktop" there. The
session script installs the decoration for the Frametop desktop only;
decoration/apply.sh switches a running desktop to it or back to Breeze.

Not yet tried in a running KWin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:03:41 -06:00
DeeJanuzandClaude Opus 5.5 e16b7588e2 Gaze mode is a mouse feature: drop its controller parts
Steam reads the Frame controllers itself, outside SteamVR's bindings, and every
press and release it sees takes SteamVR out of laser mode, so controller clicks
at the gaze can't be done cleanly (docs/gaze-controllers.md, from the gaze-first
branch's tests).

- Gaze precision and Gaze drag take a mouse button or a key combination only;
  the controller source, its aim steering, and POINTER_PRECISION_GAIN,
  POINTER_PRECISION_DEADZONE and POINTER_GAZE_DRAG_GAIN are gone.
- The gaze actions (including Gaze pointer on/off) can't be mapped to controller
  buttons: the relay ignores them there and doesn't ask the helper for those
  buttons, and Input Settings no longer offers them.
- Last used wins in gaze mode too: picking up a controller hands it the laser,
  as docs/design.md already said.
- The gaze dot shows all the time (POINTER_GAZE_DOT=always, the default;
  moving brings back the old behaviour), with a switch on the Gaze page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:11:24 -06:00
DeeJanuzandClaude Opus 5.5 d5c2ec65d7 Take the user, host, and home from the machine, not this Frame
The local RDP login between krdp and the VNC bridge uses the account's
own name instead of "steamos", its certificate the hostname instead of
"steam-frame", and the ft_pointer driver installs under the user's home
instead of /home/steamos. The remote-access address already came from
the tailnet (tailscale0 and tailscaled's local API).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:57:33 -06:00
DeeJanuzandClaude Opus 5.5 1f19732aaf Add Frametop Remote Access, a settings app for the VNC view
A GTK 4 / libadwaita app (remote/ft-remote-settings, on the host's own
Python like the gaze probe): remote access on and off (REMOTE, applied at
once when the desktop allows it), the tailnet name and address to connect
to, and the VNC password shown, copied, or replaced. The password stays
random and made on the Frame, in ~/.config/frametop-remote; none is in
the code.

session/remote-ctl.sh starts, stops, and reports remote access; the
session uses it and leaves a remote-capable marker, since KWin allows the
capture only in a desktop that started with REMOTE=1. The password
between krdp and the VNC bridge (local only, but on krdp's command line)
is now new at every start. The installer adds the app to the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:55:49 -06:00
DeeJanuz 9820e7996f Serve only the desktop's primary screen over VNC
krdp streams every screen, so the VNC screen is now the primary's size and the
FreeRDP window is shifted so the primary fills it (ft-layout remote-view gives
the offset). It resizes and reconnects when the layout changes. With remote
access on, KWin's D-Bus screenshot interface is open too, for scripts that look
at the screens without the headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6f5a23c80f2ecd26e6a96c86b102be36d518ee25)
2026-09-30 15:50:46 -06:00
DeeJanuzandClaude Opus 5.5 97f853130d Merge eye-tracking into experimental
Our own eye tracker joins the gaze folder: ft-eyegrab (the root frame
grabber, frametop-eyegrab.service) and ft-eyes, run by the gaze service
when GAZE_TRACKER=own, with its lab tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:45:38 -06:00
DeeJanuzandClaude Opus 5.5 55d92b411b Input Settings: drop the pointer role choice
The hand role the pointer takes isn't something to choose by hand. The
helper still reads POINTER_ROLE (right by default) for experiments.

Tried and reverted (not committed): in gaze mode, taking the stylus role
by reconnecting. SteamVR gave the device no role at all (hint 5, role
none) and kept the dashboard laser on the held controllers, so the mouse
couldn't click either. The dashboard laser needs a hand role, and a held
Frame controller takes its hand's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:35:08 -06:00
DeeJanuzandClaude Opus 5.5 d666fa031f Gaze first: precision and drag buttons, key combinations, pointer role
Two new actions for mouse buttons, controller buttons, and key
combinations: gaze_precision (hold: the pointer stops where you look and
the button's device steers it, a controller by where it points at
POINTER_PRECISION_GAIN, the mouse by its moves; release: click there) and
gaze_drag (the same with a real press at once, dragging until the
release). The relay sends "precision|gazedrag <source> 1|0" to the
helper, which runs them through the same holds as pinches and grips.

- Key combinations on any keyboard ("key_bindings" in the rules, e.g.
  Ctrl+Alt+G for gaze on/off): the last key isn't typed.
- POINTER_GAZE_MOUSE: in gaze mode the left button is a precision button
  (the default, as before) or clicks right away (direct).
- In gaze mode a moving controller no longer takes the pointer away.
- POINTER_ROLE (right, left, stylus): the driver takes a "role" command,
  so the pointer can stay off the hand holding the precision controller,
  which would otherwise take the role back. Needs the rebuilt driver.
- Input Settings: the actions on the Controllers and Buttons pages; on the
  Gaze page the mouse choice, the role, the precision sliders, and key
  combinations (captured from any keyboard).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:14:57 -06:00
DeeJanuzandClaude Opus 5.5 c2739cb0df Hands: park hand tracking behind ft-handsctl
Hand tracking no longer starts with SteamVR: hands/run.sh install leaves
the units disabled and links hands/ft-handsctl into ~/.local/bin, which
turns it on and off (on | off | status | log | cutouts on|off | gestures).
With it on, hands show through the screens; pinches and grips move the
pointer only with POINTER_HANDS=1, now off by default. ft-camd's service
runs the mono cameras only: while the headset is worn, the colour module
writes just a half-size image into the top-left quarter of its buffers,
which ft-camd can't use yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:08:54 -06:00
DeeJanuzandClaude Opus 5.5 5ca4160a7e Hands: fixes from the second headset test (pinches)
- The palm-down filter is off by default: the user's deliberate pinches,
  hand raised, read 0.90-0.99, like typing.
- Typing is told apart by the keyboard instead: the input relay sends the
  pointer helper "typing" on key presses, and it takes no pinch within
  POINTER_PINCH_TYPING (1 s) of one.
- Grips are still held to hands raised (POINTER_GRIP_BELOW, 0.35 m below
  the eyes); pinches aren't, since the user's own sat 0.35-0.45 m below,
  elbow resting.
- A grip doesn't begin with the thumb on the index tip: that's a pinch
  with the other fingers curled, which was taken for a grip.
- A pinch's point is the index and middle knuckles: the tips' midpoint
  moved 1-2 cm as the pinch opened, dragging every release off its press.
- ft-hands --gesture-log prints what the detectors measure, 10 times a
  second.

Pinches still aren't reliable enough to use; hand tracking is parked for
now in favour of the controllers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:07:58 -06:00
DeeJanuzandClaude Opus 5.5 50c14545ed Hands: pick the cameras by the light, and detect grips
ft-hands tracks with the mono IR cameras in dim light and with every
camera (or the colour pair, HANDS_BRIGHT) in bright light, going by the
colour frames' mean brightness with hysteresis and a 2 s hold
(HANDS_CAMERAS=auto, the default; mono, color and all fix it). Colour
frames are placed on the mono cameras' clock by their dequeue time, and a
view in a camera a step lacks waits for that camera's next frame.

A grip (a closed hand) is a second gesture next to the pinch, in version 2
of the gestures file: every fingertip curled toward the wrist, beginning
only on a hand seen open within a second and held up in front. On the
2026-09-30 lit recording that leaves 6 false grips of 14, all with the
hands on the desk; pinch counts are unchanged. ft-handreplay logs grips
and finger curl, watch_gestures.py shows them, and tools/cut_sets.py
copies a few sets out of a recording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:31:17 -06:00
DeeJanuzandClaude Opus 5.5 8bc6739dca Merge hands-migration into experimental
Hand tracking (ft-camd, ft-hands, and their tools) joins the desktop. The
hands file and ring move to /run/user/UID/frametop-hands/, which ft-screens'
hand cutouts now read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:02:01 -06:00
DeeJanuzandClaude Opus 5.5 4493b789fd Merge floating-windows into experimental
Floating windows join the keyboard and the hand cutouts. Floating panels
don't get cutouts yet: their panel and popups show crops of the client
buffer (texture bounds), which the side-by-side cutout buffer doesn't
match. KWin gets both the spare outputs and our input method, and the
pointer helper's frametop. prefix already covers the float panels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:53:28 -06:00
DeeJanuzandClaude Opus 5.5 5714978e65 Add a keyboard for text fields on the desktop
Fixes #7: SteamVR's keyboard never came up for the desktop's apps, and
opening it for our panels doesn't work well on the Frame (it's Steam's own
panel, mounted in the dashboard's scene, it follows the laser between
panels, and it takes the controllers over to SteamVR's laser).

- KWin starts input/ft-textinput as the desktop's input method. It tells
  the input relay when a text field gains or loses focus, and the relay
  asks ft-screens to open or close the keyboard. The session drops the
  QT_IM_MODULE=xim and GTK_IM_MODULE=xim that the gamescope session sets,
  or Qt and GTK apps never report text fields.
- The keyboard is ft-screens' own panel (screens/keyboard.cpp): a US laptop
  layout, typed with a controller's laser or the 3D mouse. It opens 0.7 m
  in front of you, below your eyes and facing you. It has a grab bar to
  move it, a Close key, latching Shift, Ctrl and Alt, and repeat on a held
  key. It's drawn into shared DMA-BUFs, so it doesn't flicker. Its keys
  reach the focused screen as key presses, so every app takes them.
- It steps aside while the Steam menu or Steam's own keyboard is up and
  comes back after. A layout reset closes it, and it doesn't open without
  a head pose.
- Frametop Input Settings has a Keyboard page: open it for every text
  field, only while no keyboard is connected (the default), only from a
  mapped button (the new Open/close keyboard action, for mice and
  controllers), or never. A switch keeps it open until you close it.
- The pointer helper treats every frametop.* overlay as a real panel. The
  keyboard's shared texture reports 0x0 like SteamVR's scene-graph
  controls, and the helper had given it their wide catch radius.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:41:47 -06:00
DeeJanuzandClaude Opus 5.5 ed75614f62 Bring our own eye tracker into the gaze folder, run by the gaze service
frame-eyes, a separate project until now, becomes gaze/tracker:
- ft-eyegrab (was fe-bufprobe) copies the eye-camera frames, read-only, out of
  SteamVR's eyetracking process. It runs as the system service
  frametop-eyegrab.service, which gaze/tracker/install.sh installs to
  /etc/frametop with sudo. It keeps only CAP_SYS_PTRACE, CAP_DAC_READ_SEARCH, and
  CAP_CHOWN, and copies frames only while /dev/shm/frametop-eyes-want is fresh,
  holding none of the tracker's buffers otherwise.
- ft-eyes (was fe-trackd) runs under ft-gazed in the dev container, with
  build/venv's pinned numpy and OpenCV: while Eye tracker is Own tracker, or on
  the probe's lease ("eyes SECONDS"). No sudo password or fe-live script at
  run time any more.
- lab/ holds the research tools (ft-eyes-score, -e2e, -record, -replay,
  -session) and findings.md. Recordings live outside the repo, in
  ~/.local/share/frametop/eyes/captures; .gitignore catches stray frame dumps.

Its socket is now @ft_eyes, its output /dev/shm/frametop-eyes-gaze, and its state
~/.local/state/frametop/gaze/eyes. On practice1 -> practice2 the whole live path
(ft-eyes-e2e) gives 1.30 deg median and 3.18 for the worst tenth, as before the
move (1.30, 3.21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:00:07 -06:00
DeeJanuzandClaude Opus 5.5 499035216c Make hand tracking a Frametop component
- Programs: ft-camd (the camera broker), ft-hands (the tracker), and
  ft-handreplay and ft-ringplay for recordings, built by hands/build.sh
  into hands/build/ with one Makefile. The first build fetches ncnn at
  frame-hands' pinned tag and builds it with the same options.
- ft-camd gets its privileges from file capabilities (CAP_SYS_PTRACE,
  CAP_PERFMON, CAP_DAC_READ_SEARCH) that hands/run.sh install sets with
  sudo, and drops them once set up. It still works under sudo. It runs
  on the host, linked statically, as frametop-camd.service. ft-hands
  runs in the dev container as frametop-hands.service. Both start and
  stop with SteamVR.
- Files move to /run/user/UID/frametop/ (cam-ring, hands, gestures),
  not $XDG_RUNTIME_DIR, which a terminal in the Frametop desktop has
  its own of. SIGUSR1 recordings go to ~/.local/share/frametop/hands.
- The calibration is read through /run/host in the container.
- Settings: HANDS_SWAP_SIDES and HANDS_CPUS in frametop.conf.
- install.sh offers hand tracking as an optional last step.
- The container gets jsoncpp-devel, glibc-static, and NumPy and OpenCV
  for the Python tools.
- tools/ring.py reads the ring, and models/NOTICE credits the
  Apache-2.0 models.

Checked: ft-handreplay gives identical summaries and byte-identical
depth dumps to frame-hands' fh-replay on both 2026-09-29 recordings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:15:07 -06:00
DeeJanuzandClaude Opus 5.5 5565a25444 Let the gaze pointer use our own eye tracker, and weight the eyes
Frametop Input Settings' Gaze page gets two settings, saved in frametop.conf and
read again by ft-gazed when the file changes: Eye tracker (GAZE_TRACKER: SteamVR's
or our own, frame-eyes' fe-trackd) and Eye bias (GAZE_EYE: auto, left, right).

ft-gazed now combines the eyes, each calibrated on its own: SteamVR's set 2 eyes
with the probe's Left eye and Right eye calibrations, or our tracker's eyes as
they come. Without per-eye calibrations, or with --source, it keeps the older
one-source path. A pointer nudge finds its look from the raw gaze the helper
echoes back; with our tracker it goes to fe-trackd as a click.

The bias leans instead of choosing (gazecal.EyeWeights): on 306 live clicks the
eyes' errors partly cancelled, both together 0.65 deg off against 0.96 and 1.11
for either alone. Left or Right counts that eye twice; auto weights each eye by
its RMS miss at its last 20 nudges, since the calibration's fit picked the wrong
eye on SteamVR's test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:11:54 -06:00
DeeJanuzandClaude Opus 5.5 c2e5e861c8 Show floating windows as panels of their own
ft-screens makes a panel for each spare output (frametop.float.N), hidden
until ft-floatd floats a window on it. The panel shows only the window's
rectangle of the buffer at the density of the screen it came from, its
popups and dialogs get small panels over it, pressing its title bar
carries it while KWin's pointer stays put, the corner tab resizes the
window in pixels, and two more buttons close it and put it back on the
desktop. The session adds FLOAT_SLOTS spare outputs to KWin and starts
ft-floatd; ft-layout arranges only the screens' outputs, and the pointer
helper treats the new panels like screens. Not yet tried in the headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:26:20 -06:00
DeeJanuzandClaude Opus 5.5 9c04207bb9 Let the pointer pass through panels you pick, like a performance overlay
A head-locked performance overlay kept catching the 3D mouse. It has no
input method, so SteamVR's laser passes through it, but the helper hit
tests every visible overlay with ComputeOverlayIntersection, and the dot
stuck to it whenever it crossed that corner of the view.

POINTER_IGNORE in frametop.conf now lists overlay keys the helper leaves
out of the collision, comma-separated shell patterns, so "vendor.app*"
covers a whole app, including panels it opens later. The laser starts
just before the cursor point, so an ignored panel nearer to you doesn't
catch it either.

Frametop Input Settings has a new Ignored panels page. It asks the
helper for SteamVR's overlays ("overlays", answered from the list's
thread once vrcmd has run again, even while the pointer is off), groups
them by app, and has a checkbox per panel and one for the whole app.
Frametop's own screens aren't offered, since ignoring one would leave
nothing to click the app on with the mouse. Entries for apps that
aren't open are listed so they can be removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:13:34 -06:00
DeeJanuzandClaude Opus 5.5 89522e1894 Let Flatpak apps save and upload files in the desktop
The file picker hands a sandboxed app the host path of the document
portal, which the desktop's private runtime directory moves to
$runtime/doc. Inside the sandbox that path is an empty private folder,
so Brave finished downloads into it and they were lost when the session
cleaned up. Link it to /run/flatpak/doc in each installed app's
runtime folder before Plasma starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:13:46 -06:00
DeeJanuzandClaude Opus 5.5 b99fb97f32 Turn the displays off while the headset isn't used, and keep it awake on a charger
A display mount that covers the proximity sensor makes the headset seem
worn, so SteamVR never turned its displays off and they stayed on all
night. The new power service, ft-powerd (frametop-power.service), goes by
use instead: after DISPLAY_OFF_MIN minutes in which the headset and
controllers didn't move and no input device was used, it turns the
backlight off, and the next movement or input turns it back on.

The new Power tab in Frametop Display Settings sets that time and has a
Stay awake while plugged in switch. The switch sets Steam's own "When
Plugged In and Idle -> Sleep after" to Never through Steam's UI, so the
Frame stays reachable remotely while the power button still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:34:34 -06:00
DeeJanuzandClaude Opus 5.5 0857a54fab Don't let resting controllers take the laser from the mouse
A controller released the 3D mouse's pointer on a single pose sample
faster than 0.35 m/s or 2 rad/s, once the mouse had been still for
500 ms. The helper polls about every 8 ms, so one noisy sample was
enough: a knock on the desk, or a tracking jump when the headset's
cameras pick a resting controller up again (#1).

A controller now has to stay over the limit for 100 ms in a row, and
only samples with a normal tracking result (Running_OK) count. The new
POINTER_CONTROLLER_PICKUP setting (1 by default, 0.5 to 5) scales both
limits; it's a slider on the Pointer page of Frametop Input Settings
and applies live. A controller picked up for real still gets the laser
back through SteamVR's hand role, which follows its touch sensors. The
release log now records the speed and spin that triggered it, to tune
the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:31:22 -06:00
DeeJanuzandClaude Opus 5.5 0d50efec45 Map the Frame controllers' buttons; make gaze clicks correctable
The Frame controllers aren't input devices on the host, so the pointer
helper reads them with SteamVR input (vrbuttons.h, actions/), one action
set per button, and sends presses to the relay, which does the mapped
action like for a mouse button. Only mapped buttons are taken, at an
overlay-global priority (SteamVR's experimental "Enable global input from
overlays"), and only outside games unless In games is on. Frametop Input
Settings gets a Controllers page to map them.

Gaze mode: a left press while the gaze has the pointer isn't sent at
once. The pointer stops, you drag it onto what you meant with the button
held, and the release clicks there; a press held still for
POINTER_GAZE_HOLD (0.5 s) becomes a real press, for drags. The dot shows
only while the mouse moves it (POINTER_GAZE_SHOW), while a press is held,
and as a pulse per click. Outside games the pointer stays on while gaze
mode is on. ft-gazed takes a third of each lesson's offset instead of all
of it: in the first live test one 6 degree lesson moved everything and put
the next target 7 degrees off. Frametop Input Settings gets a Gaze page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:26:24 -06:00
DeeJanuzandClaude Opus 5.5 10f21273a6 Add gaze mode to the pointer: it goes where you look
MAGIC pointing (Zhai et al. 1999), off by default: with POINTER_GAZE=1,
"gaze on", or a button mapped to the new gaze_toggle action, the cursor ray
is the corrected gaze from ft-gazed while the gaze has the pointer. Moving
the mouse takes the pointer from where the gaze left it; looking more than
POINTER_GAZE_RETAKE (5 deg) away with the mouse still gives it back. The
pointer is aimed at the gaze, never steered toward it, and only fresh gaze
moves it, so a stopped service or a blink leaves it where it is. A mouse
nudge of up to POINTER_GAZE_NUDGE_MAX (8 deg) before a click is sent to
ft-gazed as a lesson in the eye tracker's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:51:51 -06:00
DeeJanuzandClaude Opus 5.5 9addd8c669 Move the head-follow cursor only after the head passes the leash
Easing the reference toward the head all the time moved the cursor on
every small head movement, so a cursor parked in a corner of the view
drifted. Now nothing moves while the head stays within the leash. Once
the head has been past it for POINTER_LEASH_DELAY (0.2 s, so a glance
out and back doesn't count), the reference eases all the way to where
you face (POINTER_LEASH_RETURN) and the cursor lands back in its place
in the view, then the leash waits again. Leaning inside the leash no
longer moves the cursor either. Leash 0 stays head-locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:52:21 -06:00
DeeJanuzandClaude Opus 5.5 9f4b61729d Settle head follow back on where you look; let the mouse reach 70 degrees
The leash only moved its reference when the head reached the leash's end,
so after turning back it sat up to the leash off, and recentring meant
overshooting with the head. The reference now eases toward the head's
facing (POINTER_LEASH_RETURN, 0.2 s) and never lags more than the leash,
so the cursor settles back to its place in the view once the head stops.
The mouse can now move the cursor up to POINTER_FOLLOW_REACH (70 degrees)
from the middle of the view, up from a fixed 40. Both are sliders on the
Pointer page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:52:21 -06:00
DeeJanuzandClaude Opus 5.5 137705cbfd Add head follow: the pointer comes along when you turn your head
Off by default. With POINTER_FOLLOW=1 (a switch on the Pointer page of
Frametop Input Settings, or a mouse button mapped to Head follow on/off),
the cursor rides on a reference direction leashed POINTER_LEASH_DEG (10)
from where you face. Within the leash it stays put in the room; past it,
it turns with your head and keeps its offset. A leash of 0 locks it to
your view. Head roll is ignored, the cursor stays within 40 degrees of
the reference, and it holds still in the room while the left button is
down so the head can't nudge a click or a drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:52:21 -06:00
DeeJanuzandClaude Opus 5.5 8f051db083 Send typing to the panel clicked last
An ungrabbed keyboard reached both sides at once: gamescope, which in VR
reads every input device itself (the SteamOS build's InputStealer), typed
into its focused app, and ft-screens typed into the desktop. Space in the
desktop paused Spotify on the dashboard, including every space frame-voice
dictated. And while the SteamVR dashboard was open, the desktop got no
keys at all.

Typing now follows the last click. ft-screens sees clicks on its own
screens; the pointer helper reports the panel under the dot on each mouse
press, so a click on any other panel sends typing to Steam. While typing
goes to the desktop and the screens are showing, ft-screens tells the
relay every second, and the relay grabs pass-through keyboards. A grab
waits until no key is down, and the relay lets go if ft-screens stops
reporting. Controller clicks on other panels aren't visible to overlay
apps, so they don't move typing (noted in the README).

A program that reads every keyboard for a hotkey loses a grabbed one.
Repeating the keys on another input device doesn't work, since gamescope
reads that too, so with SHARE_KEYS=1 the relay sends them to
@frametop_keys as datagrams with the device name. It's off by default:
any local process that binds that name first would get every key typed
into the desktop.

The docs now say gamescope reads keyboards and the headset's buttons
itself; they said SteamVR passed keys on to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:52:21 -06:00
DeeJanuzandClaude Opus 5.5 de2823c360 Turn off the Meta tap dashboard shortcut by default
A Meta tap on a pass-through keyboard toggled the SteamVR dashboard, which
got in the way of using Meta on its own. It's now off unless
META_DASHBOARD=1 is in ~/.config/frametop.conf. Meta as a modifier
(Meta+Shift+R, Meta+Shift+H) is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:12:14 -06:00