diff --git a/README.md b/README.md index 6dcd5a638..e040cafeb 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ User-mode driver, mapper and GTK3 based GUI for Steam Controller, DS4 and many o ## Features - Allows to setup, configure and use the Steam Controller without ever launching Steam -- Connect multiple controllers at the same time +- Connect multiple controllers at the same time, each with its own remembered profile - Supports profiles switchable in GUI or with controller button - Stick, Pads and Gyroscope input +- Steam Controller 2 (2026) support, including its capacitive stick-touch and grip sensors — bind actions to them directly, or use them as conditions in mode-shift combinations - Haptic Feedback and in-game Rumble support - OSD, Menus, On-Screen Keyboard for desktop *and* in games. - Automatic profile switching based on active window. @@ -23,6 +24,50 @@ User-mode driver, mapper and GTK3 based GUI for Steam Controller, DS4 and many o Based on [Standalone Steam Controller Driver](https://github.com/ynsta/steamcontroller) by [Ynsta](https://github.com/ynsta). +## Using multiple controllers + +SC Controller can drive several controllers at once — Steam Controllers (v1 and +v2), a DualShock 4 and others can all be connected together. + +- **One window, two bars: pick the controller, then its profile.** Just connect + them: a controller-selector bar lists every connected controller (by type, + numbered when you have more than one of the same model) together with its + current profile; choosing one shows it on the big controller image, and a + second bar sets that controller's profile. There is no separate window per + device. The controller that connected *first* is the primary one — it is the + one drawn by default and the target when a command (a menu, the OSD) does not + name a specific controller. +- **Each controller keeps its own profile.** Selecting a controller and setting + a profile applies only to that controller. The choice is remembered and + restored automatically the next time that controller connects, so you do not + have to re-pick it every session. +- **Disconnecting is safe.** Turning one controller off (or letting it go idle) + leaves the window and the other controllers untouched; when it comes back it + returns to its remembered profile. + +![SC Controller with three controllers connected](docs/multiple-controllers.jpg?raw=true) + +*Two Steam Controller v1s and a Steam Controller v2 connected at once: the +selector at the top lists each controller — numbered when there are duplicates — +alongside its current profile.* + +### Telling controllers apart + +How a controller is identified — and therefore which remembered profile and +per-controller settings it gets — is governed by **Use Serial Numbers to +Identify Controllers** in *Settings*: + +- **Off (default):** controllers are identified by connection order (first + connected, second connected, …). This is simplest for a fixed setup, but if + you change which controller powers on first they will swap profiles. +- **On:** each controller is identified by its own hardware serial number, so + its profile and settings follow the physical device no matter what order + things connect in. + +Turn this **on** when you regularly use more than one controller — especially +two of the same model, such as two Steam Controllers — and want each to reliably +keep its own profile. + ## Like what I'm doing? You can check out the ways to donate on [my website](https://rys.rs/donate), or just go straight to my [Ko-Fi](https://ko-fi.com/martinrys). @@ -40,6 +85,23 @@ Linux: - **Void Linux:** Packaged as [sc-controller](https://github.com/void-linux/void-packages/blob/master/srcpkgs/sc-controller/template) - Run `xbps-install -S sc-controller` in a terminal, points to archived Ryochan7's fork at the time of writing - **Others:** You can attempt to use one of the AppImages (try all, AppImages built on older distributions tend to work better), or a package meant for your parent distribution if applicable. Flatpak is planned. +### AppImage: install the udev rules + +The AppImage is self-contained but **cannot install the udev rules** it needs (those live in a system directory). Without them your user can't access the controller and SC Controller can't create the virtual gamepad (`/dev/uinput`), so a detected controller appears to "do nothing". Distro packages install these rules for you; **AppImage users must do it once, by hand:** + +1. Download `69-sc-controller.rules` from the [latest release](https://github.com/Patola/sc-controller-cc/releases/latest). +2. Copy it into place — this needs `sudo`: + ```sh + sudo cp 69-sc-controller.rules /etc/udev/rules.d/69-sc-controller.rules + ``` +3. Reload and re-apply the rules: + ```sh + sudo udevadm control --reload-rules && sudo udevadm trigger + ``` +4. Unplug and replug the controller (or its wireless dongle) — or reboot. + +Only the AppImage needs this; the Arch and other distro packages already ship these rules. **The Steam Deck doesn't need it either** — SteamOS already ships udev rules for Steam devices, so the AppImage works out of the box there. + Windows: - It should be possible to get it running as per the [wiki](https://github.com/C0rn3j/sc-controller/wiki/Running-SC-Controller-on-Windows), but this is untested and might be broken, report a bug if so @@ -80,3 +142,32 @@ docker build -o build-output --build-arg BASE_CODENAME=noble . - Optionally checkout a branch or a tag, like `main`(default) or `v0.6.2` - Execute `./run.sh`, this automatically builds the project into a venv called `.venv`, activates it and runs sc-controller, which in turn runs scc-daemon if one does not run already - If you are debugging an issue, running `./run.sh daemon` first will launch the daemon in debug mode, allowing you to launch sc-controller in another terminal with `./run.sh` - note that sc-controller launched via `run.sh` always runs in debug mode too. + +### Regenerating controller artwork (for contributors) + +Some SVG assets under `images/` are **generated** from source drawings by scripts +in `tools/`, so edit the source and rerun the script rather than hand-editing the +committed output. All scripts run from the repository root and optimise their +output with [`svgo`](https://github.com/svg/svgo) when it is on `PATH` (optional; +without it the SVGs are just left un-minified). The `svgo` config +(`tools/svgo.config.js`) deliberately preserves the element ids, `` +geometry, `viewBox` and `display:none` layers that the GUI relies on. + +- **`tools/gen_sc2_image.py`** — builds the Steam Controller v2 GUI artwork + (`images/controller-images/sc2.svg`, the face-button glyphs and side-panel + icons) from the traced sources in `tools/` (`sc2-source.svg`, `sc2-assets/`). + +- **`tools/gen_binding_display.py`** — builds the per-controller *Display Current + Bindings* templates in `images/binding-display/.svg`. Instead of + a hand-drawn asset per controller, it derives each template straight from that + controller's GUI drawing (`images/controller-images/.svg`): it scales the + drawing into the OSD canvas, recolours it into the binding-display palette + (green outlines over two greys on a dark backdrop) and drops a marker ring at + each control's `AREA_*` anchor so the binding boxes can draw connector lines to + them. The OSD then picks the file up automatically via the controller's gui + `background` name (see `scc/osd/binding_display.py`, `_resolve_image`). + + To add a controller: give it an entry in the script's `CONTROLLERS` table (its + source drawing + which `AREA_*` anchors each binding box points at) and a + matching box layout in `LAYOUTS` in `scc/osd/binding_display.py`, then rerun the + script. Controllers that share a physical control set can share a layout. diff --git a/TODO.md b/TODO.md index 7d5070afd..2882e92b1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,135 @@ List of (possibly) planned features in no particular order: +- Selectable output device: virtual Xbox (today's default), virtual DS4/DS5, + or NO virtual controller at all. The current "Xbox 360 pad" is just a + generic uinput device wearing an X360 identity defined entirely in + config["output"] (scc/config.py: vendor/product/name/buttons/axes), so the + architecture already treats identity as data. Three tiers of work: + - no-controller mode (keyboard+mouse only) ALREADY EXISTS as the + undocumented SCC_NOGAMEPAD env var (scc/mapper.py create_gamepad); + promote it to a config key + GUI toggle (trivial), or per-profile + (moderate: create/destroy the uinput gamepad on profile switch). + Essential for games that refuse mouse/keyboard input while any + controller is detected; + - evdev-level DS4/DS5 identity presets (Sony VID/PID + the button/axis + layout SDL's gamecontrollerdb expects on the classic path): preset + table + GUI dropdown; gives PlayStation glyphs in most games, but no + gyro/touchpad/lightbar (those ride on hidraw, which uinput cannot + fake; SDL HIDAPI just falls back to evdev); + - faithful DS4/DS5 emulation via /dev/uhid: present a real HID device so + the kernel's hid-playstation binds and exposes motion/touchpad nodes + -> native in-game gyro from ANY supported controller. Big: a uhid + backend beside uinput, authentic report descriptors/streams, a udev + rule for /dev/uhid. Prior art exists (fake-DS4-over-uhid projects). + +- DualShock 4 / DualSense (ds4/ds5) polish. The HID driver is now functional + (mapper rstick/dpad guards, touchpad coordinate scaling + click highlight), but + rough edges remain: + - the two analog sticks are asymmetric in Input Test -- the left and right + stick brighten / behave differently from each other; + - the input icons drawn around the controller are the generic ones, not the + DualShock face symbols (cross / circle / square / triangle); + - the DS4 gyro works as relative and absolute (host-side euler integration in + _step_orientation, verified on hardware) but the absolute path is gyro-only, + so it DRIFTS and gimbals on large combined rotations. The clean next step is + accelerometer drift-correction: fuse gyro + accel (complementary filter) to + pin pitch/roll to the gravity vector -- the raw accel is still in q1-q3 right + after decode, before _step_orientation overwrites them (yaw has no absolute + reference without a magnetometer, so it drifts inherently); + - gyro -> MOUSE routes to the stick instead: an Axes/Rels IntEnum value + collision (ABS_X == REL_X) makes a mouse axis serialize/round-trip as a stick + axis. The gyro editor labels + chooser display are fixed, but the save/reload + path still needs a proper Axes-vs-Rels disambiguation; use gamepad axes for + gyro meanwhile; + - no rumble: neither DS4Controller nor DS4HidRawController drives the pad (the + DS5 driver does) -- port the DS5/kernel output report; over Bluetooth the + DS4HidRawController is input-only, so rumble + lightbar there need output + reports with the BT CRC32 wrapper (mirror DS5HidRawController); + - the lightbar LED is not driven (no DS4-specific set_led); + - DS5 is UNVERIFIED (no DualSense hardware here): its HID touchpad scaling was + added by analogy to the DS4 (DualSense pad assumed 1920x1080) and not tested; + the DS5HidRawController touchpad is still unscaled and stores cpad as unsigned + c_uint16, which can't hold the signed scaled range -- it needs a field type + change as well as scaling. - Multiple on-screen menus (and possibly keyboards) when using multiple controllers - Injecting emulated xbox controller into wine +- mnuImage right-click "change background" menu has no `sc2` entry (the v2 + image is selected automatically via sc2.config.json `gui.background`, but + it can't be picked manually from that menu yet). +- Custom small (24px) controller icons per supported controller. Today only the + Steam Controller v1 (sc-*) and v2 (sc2-*) have bespoke top-down glyphs; every + other type (deck, ds4, ds5, evdev, hid, scbt, fake) reuses the same generic + silhouette, just recolored. Draw a distinct glyph per type so each controller + is recognisable at a glance. The v2 glyph could also be refined further (its + trackpads are necessarily small at 24px). +- Steam Controller v1 GET_SERIAL reliability (nicety). The flaky v1 serial read + is now handled gracefully - usb.py retries a stalled control request instead of + tearing the dongle down, and sc_dongle falls back to a generated id if it never + reads - so multiple v1s with "Use Serial Numbers" on are detected reliably. + Remaining nicety: investigate *why* GET_SERIAL stalls, so a v1 always ends up + with its real serial (today a persistent stall yields a positional id instead). +- Continuous "HD rumble" for the Steam Controller v2 (and v1/Deck). The SC pads + are LRA voice-coil actuators, not ERM spin-motors. We already drive single + pulses (v1: FEEDBACK report 0x8F; v2: interrupt-OUT report 0x82, effect 0x01 = + one click) which suit pad/scroll detents but NOT sustained, amplitude/ + frequency-modulated game rumble. Gap: the v2's continuous-rumble report is + unknown (see sc2.py feedback(): "sustained game rumble may need another report, + not yet found"); the v2 uses its own report scheme (interrupt-OUT 0x82), + distinct from the Deck's feature-report commands, so it needs confirming for + the v2 specifically. Approach (do NOT brute-force the HID space by trial and + error - a wrong report just does nothing and gives no signal): + 1. Read the canonical implementations: SDL's hidapi Steam driver + (SDL_hidapi_steam.c / SDL_hidapi_steamdeck.c - ID_TRIGGER_RUMBLE_CMD plus + the left/right gain "magic numbers") and the Linux kernel + drivers/hid/hid-steam.c (FF play_effect, derived from SDL's Deck code). + 2. Check first whether SDL3 already rumbles the v2 by its VID/PID - if so, + its source *is* the v2 report format and no capture is needed. + 3. Otherwise capture ground truth: run Steam Input on the v2, trigger rumble + (Steam's controller rumble test, or a rumbling game) and capture the USB + OUTPUT reports with usbmon + Wireshark; decode the continuous-rumble + report Steam actually sends. + 4. Replicate it in sc2.py feedback() and diff the emitted bytes against the + capture to confirm. + 5. Map the emulated gamepad's FF_RUMBLE strong/weak magnitudes to the LRA's + amplitude/frequency/gain and tune for feel (LRA != ERM, so a curve is + needed). + Plumbing already exists (emulated gamepad FF -> controller.feedback()); the + missing piece is the v2 continuous-rumble report itself. Refs: SDL hidapi steam + driver, kernel hid-steam.c, and Alice Mikhaylenko's "Steam Deck, HID, and + libmanette adventures" writeup. +- Deck OSD menu fixes. (a) "Display Current Bindings..." and "Run Program..." + ship disabled in the menu settings; once enabled they appear in the OSD, but + selecting them does nothing - their shell() actions (scc-osd-show-bindings, + scc-osd-launcher) don't actually run/work on the Deck. Make them functional. + (b) Remove "Turn Controller OFF" from the Deck's OSD menu - the Deck's + built-in controller can't be powered off (today it shows and does nothing). + Entries defined in scc/gui/global_settings.py (~L45-58, e.g. + "Turn Controller OFF" -> osd(turnoff())); menu data in + default_menus/Default.menu. +- Generalize the OSD "Turn Controller OFF" hiding. It's currently hidden only + for the Deck's built-in controls (controller type == "deck", checked in + scc/osd/menu.py against the --controller-type the daemon passes). Replace that + hardcoded type check with a per-controller capability (a ControllerFlags bit or + a controller.can_turnoff()) so any controller that can't be powered off + remotely hides the entry, not just the Deck. +- Deck tray/status icon not visible. On the Steam Deck the status (tray) icon + doesn't appear even with the option enabled - works on desktop now that + libdbusmenu is bundled, so this is a Deck/gamescope SNI-tray-host issue to + investigate. +- Rebrand the AppImage desktop app-id. app_info.id in AppImageBuilder.yml / + AppImageBuilder.debian.yml is still org.c0rn3j.sc-controller (upstream), so the + installed .desktop carries the upstream id, and the after_bundle step symlinks + it as org.c0rn3j.sc-controller.desktop. Switch both to org.patola.sc-controller-cc + once the fork is stable and we have committed/PR'd to upstream. +- Replace the last deprecated GTK stock-icon calls. macro_editor.py (the + up/down/delete buttons) and modeshift_editor.py (the clear button) still call + `Gtk.Image.new_from_stock("gtk-go-up" / "gtk-go-down" / "gtk-delete", ...)`. + They render today (GTK maps the stock id to an icon internally) but the stock + API is deprecated; move them to `Gtk.Image.new_from_icon_name` with freedesktop + names (go-up / go-down / edit-delete, or the -symbolic variants - all present in + Adwaita/Breeze). Same class as the profile_switcher.py save/edit buttons, which + were actively blank because `new_from_icon_name` was handed the stock ids + "gtk-save"/"gtk-edit" (now document-save / document-edit). Hard stuff: - Injecting emulated xbox controller into PlayOnLinux @@ -10,7 +138,22 @@ Very hard stuff: - Visual feedback in binding editor ( [what this guy says](https://www.reddit.com/r/linux_gaming/comments/5pcdmr/sc_controller_use_steam_controller_without_steam/dcqpvf4/) ) **Done** stuff: +- "Act on release" (inverted button): a general InvertedButtonModifier plus a + checkbox in the button action editor (next to Toggle/Repeat) that fires a + binding on *release* instead of press - for always-on sensors like the + capacitive grips. Round-trips with the Custom Action `inverted(...)` token. +- Dedicated v2 controller artwork: traced SVG (tools/sc2-source.svg) wired by + tools/gen_sc2_image.py into controller-images/sc2.svg + v2 face-overlay + glyphs (button-images/sc2_*.svg, lifted from the drawn symbols so the face + buttons are blank in the art -> no duplication, monochrome ABXY, round Steam, + single dots) + v2 side-panel icons (images/sc2/*.svg, per-controller override + added in app.apply_gui_config_buttons). Control-name ids on sticks/pads/dpad/ + bumpers + grip-touch shapes so everything highlights on hover; darker body + (#b8b8b8). sc2.config.json points at it all. Replaces the borrowed Deck image. - Multicontroller support +- Per-controller profile memory: each controller's profile is remembered by id + (config["controllers"][id]["profile"]) and restored on (re)connect - follows + the physical device with "Use Serial Numbers" on, per-slot otherwise. - Configurable gamepad type (e.g. 4 axes and 16 buttons) - Steam Profile import - Radial Menu for the Joystick/Trackpad diff --git a/docs/actions.md b/docs/actions.md index fa49425e8..5b3b8dfa8 100755 --- a/docs/actions.md +++ b/docs/actions.md @@ -325,6 +325,14 @@ A button whenever physical button is pressed. #### released(action) Creates action that occurs for brief moment when button is released. +#### inverted(action) +Acts on release: swaps press and release, so the wrapped action is held while +the physical button is NOT pressed and released while it is. Meant for +always-on sensors such as the capacitive handle grips, which read "on" the +whole time the controller is held - inverting them fires the action when you +let go. Unlike `pressed`/`released`, which emit a momentary tap, this is a +true held inversion of the button state. + #### pressed(action) Creates action that occurs for brief moment when finger touches pad. diff --git a/docs/multiple-controllers.jpg b/docs/multiple-controllers.jpg new file mode 100644 index 000000000..788ebfa1c Binary files /dev/null and b/docs/multiple-controllers.jpg differ diff --git a/glade/action_editor.glade b/glade/action_editor.glade index cf7025d6a..ecb228bed 100644 --- a/glade/action_editor.glade +++ b/glade/action_editor.glade @@ -161,7 +161,6 @@ True dialog - diff --git a/glade/ae/buttons.glade b/glade/ae/buttons.glade index d4b4f3a31..2a3f4746b 100644 --- a/glade/ae/buttons.glade +++ b/glade/ae/buttons.glade @@ -58,6 +58,27 @@ 1 + + + Act on release + 150 + True + True + False + start + end + 0 + True + Fire when the button or sensor is released instead of pressed. Useful for always-on sensors like the capacitive handle grips, which read "on" while the controller is held. + + + + False + True + end + 2 + + False diff --git a/glade/ae/dpad.glade b/glade/ae/dpad.glade index b2f674f6d..4a45820a2 100644 --- a/glade/ae/dpad.glade +++ b/glade/ae/dpad.glade @@ -86,6 +86,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x @@ -118,6 +126,22 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPAD diff --git a/glade/ae/special_action.glade b/glade/ae/special_action.glade index f5b2368f5..e981a3791 100644 --- a/glade/ae/special_action.glade +++ b/glade/ae/special_action.glade @@ -127,6 +127,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x @@ -159,6 +167,22 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPAD diff --git a/glade/app.glade b/glade/app.glade index 08cd62ee0..2d241af2a 100644 --- a/glade/app.glade +++ b/glade/app.glade @@ -2,264 +2,6 @@ - - False - 2 - False - True - True - False - False - False - False - - - True - False - 5 - 5 - 5 - 5 - - - 75 - True - False - - - True - False - gtk-missing-image - 3 - - - False - True - 5 - 0 - - - - - True - False - Exit - - - - - - - False - True - 1 - - - - - False - True - 5 - 0 - - - - - 75 - False - True - - - True - False - gtk-missing-image - 3 - - - False - True - 5 - 0 - - - - - True - False - Activate - - - - - - - False - True - 1 - - - - - False - True - 5 - 1 - - - - - 75 - True - False - - - True - False - gtk-missing-image - 3 - - - False - True - 5 - 0 - - - - - True - False - OK - - - - - - - False - True - 1 - - - - - False - True - 5 - 2 - - - - - 75 - False - True - - - True - False - gtk-missing-image - 3 - - - False - True - 5 - 0 - - - - - True - False - Close - - - - - - - False - True - 1 - - - - - False - True - 5 - 3 - - - - - 75 - True - False - - - True - False - gtk-missing-image - 3 - - - False - True - 5 - 0 - - - - - True - False - Save - - - - - - - False - True - 1 - - - - - False - True - 5 - 4 - - - - - True - False - - - True - True - 5 - - - - - True - False - - - True - True - 6 - - - - - @@ -644,8 +386,9 @@ - + 170 + True True True @@ -659,9 +402,8 @@ - + 170 - True True True @@ -735,8 +477,9 @@ - + 170 + True True True @@ -750,9 +493,8 @@ - + 170 - True True True @@ -809,6 +551,21 @@ False 12 bottom + + + 220 + True + True + + + + + + False + True + 0 + + 220 @@ -900,6 +657,21 @@ 6 + + + 220 + True + True + + + + + + False + True + 8 + + 0 diff --git a/glade/controller_settings.glade b/glade/controller_settings.glade index 778ff96b4..fd46e43db 100644 --- a/glade/controller_settings.glade +++ b/glade/controller_settings.glade @@ -77,6 +77,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x @@ -109,6 +117,22 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPAD diff --git a/glade/modeshift_editor.glade b/glade/modeshift_editor.glade index c9268994c..d17f192b7 100644 --- a/glade/modeshift_editor.glade +++ b/glade/modeshift_editor.glade @@ -658,6 +658,101 @@ False + + + True + False + vertical + + + True + False + 10 + 10 + 10 + 5 + 15 + + + 150 + True + False + When touched + 0 + + + 0 + 0 + + + + + True + True + True + True + + + + 1 + 0 + + + + + True + True + True + + + + True + False + gtk-clear + + + + + 2 + 0 + + + + + False + True + 0 + + + + + True + False + 10 + True + Action triggered when the capacitive sensor under the stick is touched (no press needed). + True + + + False + True + 1 + + + + + + + True + False + True + Touch + + + 3 + False + + 0 diff --git a/glade/simple_chooser.glade b/glade/simple_chooser.glade index bce863de3..98f4d2696 100644 --- a/glade/simple_chooser.glade +++ b/glade/simple_chooser.glade @@ -17,7 +17,14 @@ False True - + + Clear + True + True + True + Unset this axis + + diff --git a/images/binding-display/deck.svg b/images/binding-display/deck.svg new file mode 100644 index 000000000..27f17985a --- /dev/null +++ b/images/binding-display/deck.svg @@ -0,0 +1 @@ +L4L5R5R4X \ No newline at end of file diff --git a/images/binding-display/ds4.svg b/images/binding-display/ds4.svg new file mode 100644 index 000000000..5184b84fa --- /dev/null +++ b/images/binding-display/ds4.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/binding-display/ds5.svg b/images/binding-display/ds5.svg new file mode 100644 index 000000000..2501e08e2 --- /dev/null +++ b/images/binding-display/ds5.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/binding-display/sc2.svg b/images/binding-display/sc2.svg new file mode 100644 index 000000000..95d5b91e8 --- /dev/null +++ b/images/binding-display/sc2.svg @@ -0,0 +1 @@ +R4R5L5L4X \ No newline at end of file diff --git a/images/binding-display/x360.svg b/images/binding-display/x360.svg new file mode 100644 index 000000000..96821e2dd --- /dev/null +++ b/images/binding-display/x360.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/button-images/DOTS.svg b/images/button-images/DOTS.svg index 1aa42bda3..e084c592e 100644 --- a/images/button-images/DOTS.svg +++ b/images/button-images/DOTS.svg @@ -51,7 +51,7 @@ inkscape:groupmode="layer" id="layer1"> - - - - - - + + + + + - + image/svg+xml - + - - - - + + + + STEAM - + \ No newline at end of file diff --git a/images/button-images/ELIPSE.svg b/images/button-images/ELIPSE.svg index b421c8dac..7a36d7303 100644 --- a/images/button-images/ELIPSE.svg +++ b/images/button-images/ELIPSE.svg @@ -1,76 +1,18 @@ - - - - - - - + + + + + - + image/svg+xml - + - - - - + + + + STEAM - + \ No newline at end of file diff --git a/images/button-images/sc_C.svg b/images/button-images/sc_C.svg new file mode 100644 index 000000000..2253a099e --- /dev/null +++ b/images/button-images/sc_C.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/controller-images/deck.svg b/images/controller-images/deck.svg index fdf7910e0..84fbe78be 100644 --- a/images/controller-images/deck.svg +++ b/images/controller-images/deck.svg @@ -1,741 +1 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +L4L5R5R4 \ No newline at end of file diff --git a/images/controller-images/ds4.svg b/images/controller-images/ds4.svg index 02d6fedcf..17cfa8680 100644 --- a/images/controller-images/ds4.svg +++ b/images/controller-images/ds4.svg @@ -330,9 +330,9 @@ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:1;fill:#00000a;fill-opacity:0.04313725;fill-rule:evenodd;stroke:#00b400;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate" id="AREA_LPADTEST" width="90.008965" - height="1" + height="90.008965" x="46" - y="105" + y="60.495517" ry="0" /> @@ -344,9 +344,9 @@ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:1;fill:#00000a;fill-opacity:0.04313725;fill-rule:evenodd;stroke:#00b400;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate" id="AREA_RSTICKTEST" width="63.352852" - height="0.25254059" + height="63.352852" x="46.561966" - y="79.079262" + y="47.529106" ry="0" /> - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/controller-images/snes.svg b/images/controller-images/snes.svg index e720c95fd..b12d15c0e 100644 --- a/images/controller-images/snes.svg +++ b/images/controller-images/snes.svg @@ -203,9 +203,9 @@ ry="0" /> @@ -317,9 +317,9 @@ ry="0" /> diff --git a/images/controller-images/x360.svg b/images/controller-images/x360.svg index 3ff1d0609..07cecb931 100644 --- a/images/controller-images/x360.svg +++ b/images/controller-images/x360.svg @@ -427,9 +427,9 @@ ry="0" /> @@ -451,17 +451,17 @@ ry="0" /> diff --git a/images/deck/BACK.svg b/images/deck/BACK.svg new file mode 100644 index 000000000..d818d4ce4 --- /dev/null +++ b/images/deck/BACK.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/C.svg b/images/deck/C.svg new file mode 100644 index 000000000..0e06f2248 --- /dev/null +++ b/images/deck/C.svg @@ -0,0 +1 @@ +STEAM \ No newline at end of file diff --git a/images/deck/DOTS.svg b/images/deck/DOTS.svg new file mode 100644 index 000000000..fc0f4b4cb --- /dev/null +++ b/images/deck/DOTS.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/LGRIP.svg b/images/deck/LGRIP.svg new file mode 100644 index 000000000..cff736a50 --- /dev/null +++ b/images/deck/LGRIP.svg @@ -0,0 +1 @@ +L5 \ No newline at end of file diff --git a/images/deck/LGRIP2.svg b/images/deck/LGRIP2.svg new file mode 100644 index 000000000..119da8e4c --- /dev/null +++ b/images/deck/LGRIP2.svg @@ -0,0 +1 @@ +L4 \ No newline at end of file diff --git a/images/deck/LPAD.svg b/images/deck/LPAD.svg new file mode 100644 index 000000000..5cc901816 --- /dev/null +++ b/images/deck/LPAD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/RGRIP.svg b/images/deck/RGRIP.svg new file mode 100644 index 000000000..0849c27e8 --- /dev/null +++ b/images/deck/RGRIP.svg @@ -0,0 +1 @@ +R5 \ No newline at end of file diff --git a/images/deck/RGRIP2.svg b/images/deck/RGRIP2.svg new file mode 100644 index 000000000..8c93744eb --- /dev/null +++ b/images/deck/RGRIP2.svg @@ -0,0 +1 @@ +R4 \ No newline at end of file diff --git a/images/deck/RPAD.svg b/images/deck/RPAD.svg new file mode 100644 index 000000000..5cc901816 --- /dev/null +++ b/images/deck/RPAD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/START.svg b/images/deck/START.svg new file mode 100644 index 000000000..252773da4 --- /dev/null +++ b/images/deck/START.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc-config.json b/images/sc-config.json index ee0f62cd1..a6b060113 100644 --- a/images/sc-config.json +++ b/images/sc-config.json @@ -1,12 +1,13 @@ { - "_ " : "Currently, this file is used only when changing controller image", - "__" : "manually from context menu", + "_ ": "Loaded for the Steam Controller (v1) via SCController.get_gui_config_file", + "__": "and on manual image change. GUI section only: button/axis sets stay at", + "___": "their defaults; this just points the Steam (C) button at the logo image.", "gui": { "background": "sc", "buttons": [ "A", "B", "X", "Y", "BACK", - "C", "START", "LB", "RB", "LT", "RT", + "sc_C", "START", "LB", "RB", "LT", "RT", "STICK", "LPAD", "RPAD", "LG", "RG" ] }, diff --git a/images/sc/C.svg b/images/sc/C.svg new file mode 100644 index 000000000..5bb1a60a9 --- /dev/null +++ b/images/sc/C.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/LB.svg b/images/sc2/LB.svg new file mode 100644 index 000000000..db34ce7ee --- /dev/null +++ b/images/sc2/LB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/LT.svg b/images/sc2/LT.svg new file mode 100644 index 000000000..0c9322547 --- /dev/null +++ b/images/sc2/LT.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/RB.svg b/images/sc2/RB.svg new file mode 100644 index 000000000..d9a7fa85d --- /dev/null +++ b/images/sc2/RB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/RT.svg b/images/sc2/RT.svg new file mode 100644 index 000000000..c18d7c04d --- /dev/null +++ b/images/sc2/RT.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scc/actions.py b/scc/actions.py index c81e9886e..ab5023cb5 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -15,6 +15,7 @@ from scc.mapper import Mapper import inspect import logging +import operator import sys from enum import IntEnum from math import atan2, copysign, cos, sin, sqrt @@ -387,11 +388,21 @@ class RangeOP: OPS = ("<", ">", "<=", ">=") + # An analog axis parked near a modeshift threshold jitters across it, and + # every crossing runs ModeModifier's switch path -- which releases held + # buttons and recenters gyro references. With a hair-trigger comparison a + # trigger held at "70%" could re-center an absolute gyro several times a + # second, leaving it permanently at its neutral output. Once the condition + # holds, the value has to travel this far (as a fraction of the axis range) + # back past the threshold before it stops holding. + HYSTERESIS = 0.05 + def __init__(self, what, op, value): """Raises ValueError if 'what' or 'op' is not supported value""" self.what = what self.op = op self.value = value + self.held = False self.min = float(TRIGGER_MIN) self.max = float(TRIGGER_MAX) @@ -441,41 +452,43 @@ def __init__(self, what, op, value): def cmp_or(self, mapper: Mapper): return any([x(mapper) for x in self.children]) - def cmp_gt(self, mapper: Mapper): + def _cmp(self, mapper: Mapper, op, state, rising: bool) -> bool: + """Compares 'state' against the threshold with HYSTERESIS applied, and + latches the result. 'rising' says which side of the threshold satisfies + the operator, so the band always widens in the holding direction. + """ + margin = -RangeOP.HYSTERESIS if self.held else RangeOP.HYSTERESIS + self.held = op(state, self.value + (margin if rising else -margin)) + return self.held + + def _state(self, mapper: Mapper): if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state > self.value + return None + return float(getattr(mapper.state, self.axis_name)) / self.max + + def cmp_gt(self, mapper: Mapper): + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.gt, state, True) def cmp_lt(self, mapper: Mapper): - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state < self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.lt, state, False) def cmp_ge(self, mapper: Mapper): - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state >= self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.ge, state, True) def cmp_le(self, mapper: Mapper): - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state <= self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.le, state, False) def cmp_labs(self, mapper: Mapper): - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return abs(state) < self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.lt, abs(state), False) def cmp_gabs(self, mapper: Mapper): - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return abs(state) > self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.gt, abs(state), True) def __call__(self, mapper: Mapper): return self.op_method(mapper) @@ -797,7 +810,10 @@ class MouseAction(WholeHapticAction, Action): def __init__(self, axis=None, speed=None): Action.__init__(self, *strip_none(axis, speed)) WholeHapticAction.__init__(self) - self._mouse_axis = axis or None + # NOT `axis or None`: Rels.REL_X has integer value 0, which is falsy, so + # `or None` would collapse REL_X to None -- describing it as "Mouse" (not + # "Mouse X") and moving both axes instead of just X. + self._mouse_axis = axis if axis is not None else None self._old_pos = None if speed: self.speed = (speed, speed) @@ -909,10 +925,16 @@ def whole(self, mapper: Mapper, x, y, what): self._old_pos = None def gyro(self, mapper: Mapper, pitch, yaw, roll, *a): + # Use the same per-gyro-axis rate signs as the Per-Axis mouse paths + # (GyroAction.MOUSE_RATE_SIGN, hw-verified on DS4 + SC2). The old + # hardcoded negation of ALL axes predates the drivers' rate-convention + # normalization; with normalized rates it inverted pitch (screen Y), + # while its yaw/roll negation happened to match the sign table. + sp, sy, sr = GyroAction.MOUSE_RATE_SIGN if self._mouse_axis == YAW: - mapper.mouse_move(yaw * -self.speed[0], pitch * -self.speed[1]) + mapper.mouse_move(yaw * sy * self.speed[0], pitch * sp * self.speed[1]) else: - mapper.mouse_move(roll * -self.speed[0], pitch * -self.speed[1]) + mapper.mouse_move(roll * sr * self.speed[0], pitch * sp * self.speed[1]) def trigger(self, mapper: Mapper, position, old_position): delta = position - old_position @@ -1157,15 +1179,49 @@ class GyroAction(Action): """Uses *relative* gyroscope position as input for emulated axes""" COMMAND = "gyro" + # Mouse-path tuning, shared with GyroAbsAction. RATE_SIGN is per gyro axis + # (pitch, yaw, roll), hw-verified on the DS4. MOUSE_FACTOR scales the + # lean-to-turn cursor velocity into a sane default range. + MOUSE_RATE_SIGN = (1.0, -1.0, -1.0) + MOUSE_FACTOR = 0.01 def __init__(self, axis1, axis2=None, axis3=None): Action.__init__(self, axis1, *strip_none(axis2, axis3)) self.axes = [axis1, axis2, axis3] self.speed = (1.0, 1.0, 1.0) + # lean-to-turn neutral reference (per gyro axis, radians); captured on + # the first gyro event after (re)activation or a Recenter Gyro action. + self._lean_ref = [None, None, None] + + def reset(self): + """Re-captures the lean-to-turn neutral pose on the next gyro event. + Called by mapper.reset_gyros (the Recenter Gyro special action) and by + ModeModifier when a gated gyro deactivates.""" + self._lean_ref = [None, None, None] def get_compatible_modifiers(self): return Action.MOD_SENSITIVITY | Action.MOD_SENS_Z + @staticmethod + def emit_axis(mapper: Mapper, axis, value): + """Sends a stick-range gyro value (STICK_PAD_MIN..STICK_PAD_MAX) to a + gamepad axis, rescaled to that axis' own range. + + Triggers need the rescale: they are unipolar (TRIGGER_MIN..TRIGGER_MAX + == 0..255), so merely clamping a stick-range value into them -- what + this used to do -- discarded the whole negative half and saturated the + positive half within about 0.7 deg of rotation. A gyro-driven trigger + behaved like a button. Mapping the positive half across the full + trigger travel keeps the neutral pose at "released" (which is what a + trigger has to rest at) and full pull at the same rotation that would + fully deflect a stick; bind the opposite rotation with a negative + sensitivity or the 'inverted' modifier. + """ + if axis in AxisAction.Z: + value = value * TRIGGER_MAX / STICK_PAD_MAX + mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, value)) + mapper.syn_list.add(mapper.gamepad) + def set_speed(self, x: float, y: float, z: float): self.speed = (x, y, z) @@ -1173,26 +1229,57 @@ def get_speed(self): return self.speed def gyro(self, mapper: Mapper, *pyr): + angles = None for i in (0, 1, 2): axis = self.axes[i] - # 'gyro' cannot map to mouse, but 'mouse' does that. - if axis in Axes.__members__.values() or type(axis) is int: - mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, pyr[i] * self.speed[i] * -10)) - mapper.syn_list.add(mapper.gamepad) + # isinstance, not `in Axes.__members__.values()`: Rels and Axes are + # IntEnums with overlapping values (REL_X == ABS_X == 0), so the + # membership test would misroute a mouse axis here as a gamepad axis. + if isinstance(axis, Axes) or type(axis) is int: + GyroAction.emit_axis(mapper, axis, pyr[i] * self.speed[i] * -10) + # Relative mouse = lean-to-turn: cursor VELOCITY is proportional to + # the held tilt angle -- lean and the cursor keeps moving, return to + # level and it stops. (For laser-pointer tracking, where the cursor + # follows the rotation and stops with it, check Absolute.) + elif axis in (Rels.REL_X, Rels.REL_Y) and len(pyr) >= 7: + if angles is None: + q1, q2, q3, q4 = pyr[3:7] + if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: + angles = (q1 / 10430.37, q2 / 10430.37, q3 / 10430.37) # 2**15 / PI + else: + angles = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + # The lean is measured against the neutral reference captured on + # the first event after (re)activation or Recenter Gyro -- NOT + # against the driver's absolute zero, whose yaw is an arbitrary + # power-on orientation (and would make yaw-lean unusable). + if self._lean_ref[i] is None: + self._lean_ref[i] = angles[i] + lean = anglediff(self._lean_ref[i], angles[i]) + # saturate at +-90 deg, then scale to a sane default velocity + v = clamp(STICK_PAD_MIN, lean * (2**15) * 2 / PI, STICK_PAD_MAX) + v = v * GyroAction.MOUSE_FACTOR * self.speed[i] + if axis == Rels.REL_X: + mapper.mouse_move(v, 0) + else: + # screen Y grows downward (sign hw-verified on the DS4) + mapper.mouse_move(0, -v) def describe(self, context): if self.name: return self.name rv = [] - - if self.axes[0] in Rels.__members__.values(): - return _("Mouse") - for x in self.axes: - if x: + # `is not None`, not truthiness: Rels.REL_X / Axes.ABS_X have value 0 + # (falsy) yet are valid axes. isinstance keeps mouse (Rels) apart from + # stick (Axes) -- their integer values collide (REL_X == ABS_X == 0). + if x is None: + continue + if isinstance(x, Rels): + s = MouseAction(x).describe(context) + else: s, trash, trash = AxisAction.get_axis_description(x) - if s not in rv: - rv.append(s) + if s not in rv: + rv.append(s) return "\n".join(rv) @@ -1200,12 +1287,15 @@ class GyroAbsAction(HapticEnabledAction, GyroAction): """Uses *absolute* gyroscope position as input for emulated axes""" COMMAND = "gyroabs" - MOUSE_FACTOR = 0.01 # Just random number to put default sensitivity into sane range def __init__(self, *blah): GyroAction.__init__(self, *blah) HapticEnabledAction.__init__(self) - self.ir = [0, 0, None, 0] # Initial rotation, last has to be determined + # Orientation reference: None = capture at the next gyro event. All + # axes capture-at-first-event, so the very first activation centers at + # the current pose exactly like every later one (re-centered by + # reset() on deactivation / Recenter Gyro). + self.ir = [None, None, None, None] self._was_oor = False self._deadzone_fn = None @@ -1225,13 +1315,25 @@ def get_previewable(self) -> bool: GYROAXES = (0, 1, 2) def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): + rates = (pitch, yaw, roll) # raw angular rates, used for the mouse axes if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: pyr = [q1 / 10430.37, q2 / 10430.37, q3 / 10430.37] # 2**15 / PI else: pyr = list(quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0)) for i in self.GYROAXES: - self.ir[i] = self.ir[i] or pyr[i] - pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[2] * 2 / PI + # explicit None test, not `or`: a legitimate 0.0 reference is falsy + # and would re-capture on every event + if self.ir[i] is None: + self.ir[i] = pyr[i] + # speed[i], not speed[2]: sensitivity is per gyro axis, and the Z + # one used to be applied to all three -- and then applied a SECOND + # time, per-axis, down in the output loop, so a sensitivity of 2 + # came out as 4. Scaling here rather than there also means the + # clamp and the out-of-range haptic below see the value that is + # actually emitted: sensitivity now moves the rotation needed for + # full deflection, instead of merely scaling an already saturated + # reading. + pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[i] * 2 / PI if self.haptic: oor = False # oor - Out Of Range for i in self.GYROAXES: @@ -1253,17 +1355,25 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): pyr[i] = int(clamp(STICK_PAD_MIN, pyr[i], STICK_PAD_MAX)) for i in self.GYROAXES: axis = self.axes[i] - if axis in Axes.__members__.values() or type(axis) == int: - val = AxisAction.clamp_axis(axis, pyr[i] * self.speed[i]) + # isinstance, not `in Axes.__members__.values()`: REL_X == ABS_X == 0 + # and REL_Y == ABS_Y == 1 by IntEnum value, so the membership test + # swallowed the mouse axes into this gamepad branch (the elifs below + # never ran) -- gyro->mouse moved the stick instead of the cursor. + if isinstance(axis, Axes) or type(axis) == int: + val = pyr[i] if self._deadzone_fn: - val, trash = self._deadzone_fn(val, 0, STICK_PAD_MAX) - val = int(val) - mapper.gamepad.axisEvent(axis, val) - mapper.syn_list.add(mapper.gamepad) + # deadzone works in stick range, before the axis rescale + val, trash = self._deadzone_fn(clamp(STICK_PAD_MIN, val, STICK_PAD_MAX), 0, STICK_PAD_MAX) + GyroAction.emit_axis(mapper, axis, val) + # Absolute mouse = laser pointer: the angular RATE is the move delta + # (like MouseAction.gyro); the rate integrates to the rotation angle, + # so the cursor tracks the controller's absolute orientation and stops + # when the rotation stops (Steam's gyro-mouse behavior). For + # angle-proportional cursor velocity (lean-to-turn), uncheck Absolute. elif axis == Rels.REL_X: - mapper.mouse_move(AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i]), 0) + mapper.mouse_move(rates[i] * GyroAction.MOUSE_RATE_SIGN[i] * self.speed[i], 0) elif axis == Rels.REL_Y: - mapper.mouse_move(0, AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i])) + mapper.mouse_move(0, rates[i] * GyroAction.MOUSE_RATE_SIGN[i] * self.speed[i]) class ResetGyroAction(Action): @@ -1349,7 +1459,19 @@ def get_compatible_modifiers(self): def gyro(self, mapper: Mapper, *pyr): q1, q2, q3, q4 = pyr[-4:] - pyr = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: + # q1-q3 already hold euler angles in 2**15/PI fixed point (q4 unused). + # Feeding them into quat2euler as if they were a quaternion computes + # atan2 of near-zero noise products -> arbitrary full-range angles that + # constantly cross MIN and fire tilt actions with the pad at rest. + # + # Slot order below is (front down/up, TILTED left/right, ROTATED + # left/right) = (pitch, roll, yaw), so swap yaw/roll into that order; + # pitch and yaw are negated to match the slot firing directions against + # the DS4 integration conventions (all three axes hw-verified). + pyr = (-q1 / 10430.37, q3 / 10430.37, -q2 / 10430.37) + else: + pyr = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) for j in (0, 1, 2): i = j * 2 if self.actions[i]: diff --git a/scc/config.py b/scc/config.py index dd1cfaf45..38a1df86a 100644 --- a/scc/config.py +++ b/scc/config.py @@ -14,6 +14,25 @@ log = logging.getLogger("Config") +def _is_steam_deck() -> bool: + """Return True when running on Steam Deck hardware (LCD reports DMI product + name 'Jupiter', OLED 'Galileo'). + + Used to flip the 'autokill_daemon' default on: the Deck has no built-in + keyboard, so a daemon left running after the GUI closes keeps emulating and + locks the user out of the device's own controls (only the touchscreen still + works). Desktop machines are unaffected. + """ + for path in ("/sys/class/dmi/id/product_name", "/sys/class/dmi/id/board_name"): + try: + with open(path) as f: + if f.read().strip() in ("Jupiter", "Galileo"): + return True + except OSError: + pass + return False + + class Config: DEFAULTS = { "autoswitch_osd": True, # True to show OSD message when profile is autoswitched @@ -33,6 +52,7 @@ class Config: "sc_by_cable": True, "sc_by_bt": True, "steamdeck": True, + "sc2": True, # new Steam Controller (2026) "fake": False, # Used for developement "hiddrv": True, "evdevdrv": True, @@ -46,7 +66,7 @@ class Config: "enable_status_icon": False, "minimize_to_status_icon": True, "minimize_on_start": False, - "autokill_daemon": False, + "autokill_daemon": _is_steam_deck(), # default ON on the Steam Deck (no built-in keyboard) "news": { # Controls "new in this version" message "enabled": True, # if disabled, no querying is done @@ -125,6 +145,10 @@ class Config: "menu_control": "STICK", "menu_confirm": "A", "menu_cancel": "B", + # Remembered profile name, restored when this controller (re)connects. + # None means "use the global default". See SCCDaemon.add_controller and + # SCCDaemon._remember_controller_profile. + "profile": None, } def __init__(self) -> None: diff --git a/scc/drivers/sc_dongle.py b/scc/drivers/sc_dongle.py index 7c4e0693e..c12a55954 100644 --- a/scc/drivers/sc_dongle.py +++ b/scc/drivers/sc_dongle.py @@ -7,16 +7,19 @@ from __future__ import annotations import logging +import os import struct +import time from enum import IntEnum -from math import cos, sin +from math import asin, atan2, cos, sin, sqrt from math import pi as PI from typing import TYPE_CHECKING, NamedTuple from scc.config import Config -from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, SCButtons +from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, ControllerFlags, SCButtons from scc.controller import Controller from scc.drivers.usb import SCUSBDevice, register_hotplug_device +from scc.tools import quat2euler if TYPE_CHECKING: from usb1 import USBDevice, USBDeviceHandle @@ -27,6 +30,27 @@ from scc.drivers.steamdeck import Deck from scc.sccdaemon import SCCDaemon +_EUREL_SCALE = 32768.0 / PI # radians -> the 2**15/PI fixed point EUREL_GYROS wants + + +def _quat_to_eurel(q1: int, q2: int, q3: int, q4: int) -> tuple[int, int, int]: + """Convert the SC1 hardware quaternion (q1=w q2=x q3=y q4=z, unit * 32767) + to DS4/SC2-convention EUREL euler angles (pitch, yaw, roll) in 2**15/PI fixed + point. Measured from held poses: the SC1 and SC2 use the identical quaternion + convention -- x=pitch (nose-up +), y=roll (roll-right +), z=yaw (yaw-left +) -- + so this is byte-for-byte the sc2.parse_input mapping. Feeding these to the + mapper (with EUREL_GYROS set) puts SC1 on the same single gyro code path as + the DS4/SC2, where all the axis/sign conventions are hardware-verified.""" + w, x, y, z = q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0 + pitch = atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y)) + roll = asin(max(-1.0, min(1.0, 2.0 * (w * y - z * x)))) + yaw = atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + return ( + int(-pitch * _EUREL_SCALE), + int(-yaw * _EUREL_SCALE), + int(roll * _EUREL_SCALE), + ) + class ControllerInput(NamedTuple): """Based on INPUT_FORMAT except anything starting with "ukn_""" @@ -92,7 +116,39 @@ class ControllerInput(NamedTuple): log = logging.getLogger("SCDongle") - +_CALIB = bool(os.environ.get("SCC_GYRO_CALIB")) +if _CALIB: + # The daemon leaves the root logger at WARNING; opt this logger into INFO so + # the IMU-calibration dump is visible. + log.setLevel(logging.INFO) +_calib_last_t = 0.0 + + +def _log_imu_calib(idata) -> None: + """Throttled IMU dump for Steam Controller v1 gyro calibration (gate: env + SCC_GYRO_CALIB=1). Logs the accel gravity vector, the raw hardware + quaternion, what quat2euler currently makes of it, and the raw rates -- so + held poses reveal the quaternion's real axis/sign convention vs the + EUREL convention the DS4/SC2 use.""" + global _calib_last_t + now = time.time() + if now - _calib_last_t < 0.25: + return + _calib_last_t = now + ax, ay, az = idata.accel_x, idata.accel_y, idata.accel_z + mag = sqrt(ax * ax + ay * ay + az * az) or 1.0 + q = (idata.q1 / 32767.0, idata.q2 / 32767.0, idata.q3 / 32767.0, idata.q4 / 32767.0) + qnorm = sqrt(sum(c * c for c in q)) * 32767.0 + e = quat2euler(*q) + deg = 180.0 / PI + log.info( + "IMU-CALIB accel unit=(% .2f % .2f % .2f) |a|=%6.0f | quat=(% 6d % 6d % 6d % 6d) |q|=%6.0f | " + "quat2euler deg=(% 6.1f % 6.1f % 6.1f) | rates(gpitch,groll,gyaw)=(% 5d % 5d % 5d)", + ax / mag, ay / mag, az / mag, mag, + idata.q1, idata.q2, idata.q3, idata.q4, qnorm, + e[0] * deg, e[1] * deg, e[2] * deg, + idata.gpitch, idata.groll, idata.gyaw, + ) class Dongle(SCUSBDevice): MAX_ENDPOINTS = 4 @@ -186,6 +242,10 @@ class SCConfigType(IntEnum): class SCController(Controller): + # The SC1 quaternion is converted to euler host-side (input()) and handed to + # the mapper in q1-q3 as EUREL angles, so SC1 shares the DS4/SC2 gyro path. + flags = ControllerFlags.EUREL_GYROS + def __init__(self, driver: Deck | Dongle | SCByBt | SCByCable | SC2Device, ccidx: int, endpoint: int) -> None: Controller.__init__(self) self._driver: Deck | Dongle | SCByBt | SCByCable | SC2Device = driver @@ -204,6 +264,11 @@ def __init__(self, driver: Deck | Dongle | SCByBt | SCByCable | SC2Device, ccidx def get_type(self) -> str: return "sc" + def get_gui_config_file(self) -> str: + # Steam Controller (v1): GUI-only config that puts the Steam logo on the + # C button (image + side icon). Inherited by SCByCable / SCByBt. + return "sc-config.json" + def __repr__(self) -> str: return f"" @@ -264,6 +329,15 @@ def input(self, idata: ControllerInput) -> None: idata.q4, ) + if _CALIB: + _log_imu_calib(idata) # logs the RAW quaternion, before conversion + # Convert the hardware quaternion (q1-q4) to EUREL euler angles in + # q1-q3 (q4 unused) so the mapper's gyro paths -- absolute, tilt, + # lean-to-turn -- match the DS4/SC2 exactly. Only when the gyro is + # streaming (a nonzero quaternion); disabled, q1-q4 are 0. + if idata.q1 or idata.q2 or idata.q3 or idata.q4: + p, y, r = _quat_to_eurel(idata.q1, idata.q2, idata.q3, idata.q4) + idata = idata._replace(q1=p, q2=y, q3=r, q4=0) self.mapper.input(self, old_state, idata) def _generate_id(self) -> str: @@ -300,8 +374,17 @@ def cb(rawserial) -> None: self._driver.make_request( self._ccidx, cb, struct.pack(">BBB61x", SCPacketType.GET_SERIAL, SCPacketLength.GET_SERIAL, 0x01), + on_giveup=self._on_serial_giveup, ) + def _on_serial_giveup(self) -> None: + """Called when the GET_SERIAL request kept stalling. Add the controller + with a generated id anyway, so it still appears (it just won't have a + stable serial-based identity).""" + log.warning("GET_SERIAL kept stalling for SC on endpoint %s; using a generated id", self._endpoint) + self.generate_serial() + self.on_serial_got() + def generate_serial(self) -> None: """Called only if ignore_serials is enabled""" if len(self._driver._available_serials) > 0: @@ -316,7 +399,13 @@ def on_serial_got(self) -> None: except UnicodeDecodeError: log.debug("Failed to decode wireless SC serial") self._serial = self._driver._available_serials.pop() - self._id = str(self._serial) + serial = str(self._serial).strip() + if not serial or serial in self._driver.daemon.get_active_ids(): + # A blank or already-used id would make two controllers collapse into + # one in the GUI (it keys controllers by id). Keep them distinct by + # falling back to a generated positional id. + serial = self._generate_id() + self._id = serial self._driver.daemon.add_controller(self) def apply_config(self, config: dict) -> None: diff --git a/scc/drivers/steamdeck.py b/scc/drivers/steamdeck.py index e28198178..c6c21e719 100644 --- a/scc/drivers/steamdeck.py +++ b/scc/drivers/steamdeck.py @@ -221,8 +221,8 @@ def _on_input(self, endpoint: int, data: bytearray) -> None: 0 | ((self._input.buttons & DIRECTLY_TRANSLATABLE_BUTTONS) << 8) | map_button(self._input, DeckButton.DOTS, SCButtons.DOTS) - # | map_button(self._input, DeckButton.RSTICKTOUCH, ....) // not mapped - # | map_button(self._input, DeckButton.LSTICKTOUCH, ....) // not mapped + | map_button(self._input, DeckButton.RSTICKTOUCH, SCButtons.RSTICKTOUCH) + | map_button(self._input, DeckButton.LSTICKTOUCH, SCButtons.LSTICKTOUCH) | map_button(self._input, DeckButton.LSTICKPRESS, SCButtons.STICKPRESS) | map_button(self._input, DeckButton.RSTICKPRESS, SCButtons.RSTICKPRESS) | map_button(self._input, DeckButton.LGRIP2, SCButtons.LGRIP2) diff --git a/scc/drivers/usb.py b/scc/drivers/usb.py index 12adfcfc6..a77000b6d 100644 --- a/scc/drivers/usb.py +++ b/scc/drivers/usb.py @@ -20,6 +20,8 @@ import usb1 if TYPE_CHECKING: + from collections.abc import Callable + from usb1 import USBContext, USBDevice, USBDeviceHandle, USBTransfer from scc.drivers.hiddrv import HIDDrvFakeDaemon @@ -27,6 +29,11 @@ log = logging.getLogger("USB") +# How many times to retry a stalling control request (notably the flaky Steam +# Controller v1 GET_SERIAL) on successive flushes before giving up, instead of +# letting the stall propagate and tear down the whole device. +REQUEST_MAX_ATTEMPTS = 20 + class SCUSBDevice: """Base class for all handled usb devices.""" @@ -97,10 +104,13 @@ def overwrite_control(self, index, data) -> None: break self.send_control(index, data) - def make_request(self, index, callback, data, size=64) -> None: + def make_request(self, index: int, callback: Callable[[bytes], None], data: bytes, + size: int = 64, on_giveup: Callable[[], None] | None = None) -> None: """Schedule a synchronous request that requires response. Request is done ASAP and provided callback is called with received data. + If the control transfer keeps stalling it is retried on later flushes, + and 'on_giveup' (if given) is called once the retries are exhausted. """ self._rmsg.append( ( @@ -114,26 +124,53 @@ def make_request(self, index, callback, data, size=64) -> None: index, size, callback, + on_giveup, + 0, # stall-retry attempts so far ), ) def flush(self) -> None: - """Flush all prepared control messages to the device.""" + """Flush all prepared control messages to the device. + + A control-endpoint stall (USBErrorPipe) is recovered from rather than + propagated: letting it reach the mainloop would tear down the whole + device and drop its controllers. Notably the Steam Controller v1 + GET_SERIAL request is flaky and can stall (more so with several dongles + connected at once); such a request is retried on a later flush. + """ while len(self._cmsg): msg = self._cmsg.pop() - self.handle.controlWrite(*msg) + try: + self.handle.controlWrite(*msg) + except usb1.USBErrorPipe: + # Config command stalled; drop it (it is re-sent on the next + # configure) instead of tearing the whole device down. + pass + requeue = [] while len(self._rmsg): - msg, index, size, callback = self._rmsg.pop() - self.handle.controlWrite(*msg) - data = self.handle.controlRead( - 0xA1, # request_type - 0x01, # request - 0x0300, # value - index, - size, - ) + msg, index, size, callback, on_giveup, attempts = self._rmsg.pop() + try: + self.handle.controlWrite(*msg) + data = self.handle.controlRead( + 0xA1, # request_type + 0x01, # request + 0x0300, # value + index, + size, + ) + except usb1.USBErrorPipe: + # Control protocol stall; it clears on the next SETUP, so retry on + # a later flush rather than letting it close the whole device. + if attempts + 1 < REQUEST_MAX_ATTEMPTS: + requeue.append((msg, index, size, callback, on_giveup, attempts + 1)) + else: + log.warning("Control request to %s kept stalling; giving up after %d tries", self, attempts + 1) + if on_giveup: + on_giveup() + continue callback(data) + self._rmsg.extend(requeue) def force_restart(self) -> None: """Restart device, close handle and try to re-grab it again. diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index 54caf1543..e340e46ee 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -183,9 +183,6 @@ def setup_widgets(self): ), ) - if self.app.osd_mode: - self.builder.get_object("entName").set_sensitive(False) - def load_components(self): """Loads list of editor components""" # Import and load components @@ -217,10 +214,6 @@ def on_Dialog_destroy(self, *a): if self._selected_component is not None: self._selected_component.hidden() - def on_Dialog_key_press_event(self, window, event): - if self.app.osd_mode and event.keyval == 65471: - self.on_btOK_clicked() - def set_osd_enabled(self, value): """Sets value of OSD modifier checkbox, without firing any more events.""" self._recursing = True diff --git a/scc/gui/ae/__init__.py b/scc/gui/ae/__init__.py index 70c3e54d6..2c545adb9 100644 --- a/scc/gui/ae/__init__.py +++ b/scc/gui/ae/__init__.py @@ -10,11 +10,68 @@ from gi.repository import Gdk, GLib, Gtk from scc.actions import Action, NoAction, XYAction +from scc.constants import SCButtons from scc.gui.editor import ComboSetter from scc.tools import _, ensure_size log = logging.getLogger("AE") +# Rear-paddle display names. On controllers whose four rear paddles are +# physically labeled L4/L5/R4/R5 (Steam Controller 2, Steam Deck) the generic +# "Left Grip (2)" naming is confusing -- especially on the SC2, where "grip" +# reads as the capacitive handle sensor instead. The Steam Controller v1's +# LGRIP/RGRIP are its squeeze grips and keep the "Left/Right Grip" naming. +PADDLE_TYPES = ("sc2", "deck") +PADDLE_NAMES = { + SCButtons.LGRIP: "L4", + SCButtons.RGRIP: "R4", + SCButtons.LGRIP2: "L5", + SCButtons.RGRIP2: "R5", +} + + +def button_label(app, button, default): + """Per-controller display name for a button: the rear paddles are + L4/L5/R4/R5 on paddle controllers (sc2/deck), the default (grip) name + elsewhere. `app` may be None (falls back to the default label).""" + ctype = None + try: + c = app.profile_switchers[0].get_controller() if app else None + ctype = c.get_type() if c else None + except Exception: + pass + if ctype in PADDLE_TYPES and button in PADDLE_NAMES: + return PADDLE_NAMES[button] + return default + + +# Buttons that only SOME controllers have and whose presence is reliably +# recorded in the controller's gui config "buttons" capability list (sc2 and +# deck ship configs; everything else falls back to DEFAULT_BUTTONS, which +# correctly lacks these). Only these are ever filtered out of button lists -- +# filtering arbitrary buttons against the capability list would wrongly hide +# valid entries on configless controllers (e.g. the DS4's stick presses). +OPTIONAL_BUTTONS = { + SCButtons.LGRIP2, SCButtons.RGRIP2, + SCButtons.LGRIPTOUCH, SCButtons.RGRIPTOUCH, + SCButtons.LSTICKTOUCH, SCButtons.RSTICKTOUCH, +} + + +def button_available(app, button) -> bool: + """True unless `button` is an optional extra (OPTIONAL_BUTTONS) that the + currently displayed controller's capability list does not include.""" + if button not in OPTIONAL_BUTTONS: + return True + try: + available = app.background.get_config()["buttons"] if app else None + except Exception: + available = None + if not available: + return True + from scc.tools import nameof + return nameof(button) in available + class AEComponent(ComboSetter): GLADE = None diff --git a/scc/gui/ae/buttons.py b/scc/gui/ae/buttons.py index d0dda7dfb..1e1b499c0 100644 --- a/scc/gui/ae/buttons.py +++ b/scc/gui/ae/buttons.py @@ -15,6 +15,7 @@ from scc.gui.key_grabber import KeyGrabber from scc.gui.parser import InvalidAction from scc.macros import Cycle, Macro, PressAction, ReleaseAction +from scc.modifiers import InvertedButtonModifier from scc.tools import _ from scc.uinput import Keys, Rels @@ -50,18 +51,22 @@ def load(self): if not self.loaded: AEComponent.load(self) self.setup_image() - if self.app.osd_mode: - self.builder.get_object("btnGrabKey").set_sensitive(False) - self.builder.get_object("btnGrabAnother").set_sensitive(False) def area_action_selected(self, area, action): self.set_active_area(area) + if self.builder.get_object("cbActOnRelease").get_active(): + action = InvertedButtonModifier(action) self.editor.set_action(action) def set_action(self, mode, action): cbToggle = self.builder.get_object("cbToggle") cbRepeat = self.builder.get_object("cbRepeat") + cbActOnRelease = self.builder.get_object("cbActOnRelease") if self.handles(mode, action): + # "Act on release" wraps the action in an InvertedButtonModifier + is_inverted = isinstance(action, InvertedButtonModifier) + if is_inverted: + action = action.action self.keys = set() is_togle, is_repeat = False, False if isinstance(action, MultiAction): @@ -80,6 +85,7 @@ def set_action(self, mode, action): is_togle = True cbToggle.set_active(is_togle) cbRepeat.set_active(is_repeat) + cbActOnRelease.set_active(is_inverted) area = action_to_area(action) if area is not None: self.set_active_area(area) @@ -90,6 +96,10 @@ def get_button_title(self): return _("Key or Button") def handles(self, mode, action): + # "Act on release" wraps the real action in an InvertedButtonModifier; + # look through it to the wrapped action. + if isinstance(action, InvertedButtonModifier): + return self.handles(mode, action.action) # Handles ButtonAction and MultiAction if all subactions are ButtonAction if isinstance(action, (ButtonAction, NoAction, InvalidAction)): return True @@ -127,8 +137,11 @@ def modifiers_first(key): def apply_keys(self, *a): """Common part of on_*key_grabbed""" + if not self.keys: + return cbToggle = self.builder.get_object("cbToggle") cbRepeat = self.builder.get_object("cbRepeat") + cbActOnRelease = self.builder.get_object("cbActOnRelease") keys = sorted(self.keys, key=ButtonsComponent.modifiers_first) action = ButtonAction(keys[0]) if len(keys) > 1: @@ -139,6 +152,8 @@ def apply_keys(self, *a): action.repeat = True elif cbToggle.get_active(): action = Cycle(PressAction(action), ReleaseAction(action)) + if cbActOnRelease.get_active(): + action = InvertedButtonModifier(action) self.editor.set_action(action) def on_btnGrabKey_clicked(self, *a): @@ -166,6 +181,9 @@ def on_cbRepeat_toggled(self, cbRepeat): cbToggle.set_active(False) self.apply_keys() + def on_cbActOnRelease_toggled(self, cb: Gtk.CheckButton) -> None: + self.apply_keys() + def hide_toggle(self): """Hides 'set as toggle button' option""" cbToggle = self.builder.get_object("cbToggle") diff --git a/scc/gui/ae/first_page.py b/scc/gui/ae/first_page.py index 562b83090..7203c6115 100644 --- a/scc/gui/ae/first_page.py +++ b/scc/gui/ae/first_page.py @@ -6,7 +6,8 @@ import logging from scc.actions import Action -from scc.gui.ae import AEComponent +from scc.constants import SCButtons +from scc.gui.ae import AEComponent, button_label from scc.tools import _, nameof log = logging.getLogger("AE.1st") @@ -96,6 +97,8 @@ def load(self): "RPAD": _("Right Pad"), "LGRIP": _("Left Grip"), "RGRIP": _("Right Grip"), + "LGRIP2": _("Left Grip 2"), + "RGRIP2": _("Right Grip 2"), "LB": _("Left Bumper"), "RB": _("Right Bumper"), "LEFT": _("Left Trigger"), @@ -104,8 +107,12 @@ def load(self): "RSTICK": _("Right Stick"), } + name = nameof(self.editor.get_id()) + what = long_names.get(name, name.title()) + # Per-controller paddle naming (L4/L5/R4/R5 on sc2/deck) + what = button_label(self.app, getattr(SCButtons, name, None), what) markup = markup % { - "what": long_names.get(nameof(self.editor.get_id()), nameof(self.editor.get_id()).title()), + "what": what, } self.builder.get_object("lblMarkup").set_markup(markup.strip(" \r\n\t")) return True diff --git a/scc/gui/ae/gyro.py b/scc/gui/ae/gyro.py index 78c4e46fd..6c4ab9794 100644 --- a/scc/gui/ae/gyro.py +++ b/scc/gui/ae/gyro.py @@ -5,8 +5,9 @@ import itertools import logging -from scc.actions import Action, AxisAction, GyroAbsAction, GyroAction, MultiAction, NoAction, RangeOP +from scc.actions import Action, AxisAction, GyroAbsAction, GyroAction, MouseAction, MultiAction, NoAction, RangeOP from scc.constants import STICK, SCButtons +from scc.uinput import Rels from scc.gui.ae import AEComponent, describe_action from scc.gui.ae.gyro_action import TRIGGERS, fill_buttons, is_gyro_enable from scc.gui.simple_chooser import SimpleChooser @@ -36,7 +37,7 @@ def load(self): cbGyroButton = self.builder.get_object("cbGyroButton") self._recursing = True cbGyroButton = self.builder.get_object("cbGyroButton") - fill_buttons(cbGyroButton) + fill_buttons(cbGyroButton, self.app) self._recursing = False self.buttons = [self.builder.get_object(x) for x in ("btPitch", "btYaw", "btRoll")] self.cbs = [self.builder.get_object(x) for x in ("cbPitchAbs", "cbYawAbs", "cbRollAbs")] @@ -88,14 +89,25 @@ def on_select_axis(self, source, *a): i = self.buttons.index(source) def cb(action): - self.axes[i] = action.parameters[0] + self.axes[i] = None if isinstance(action, NoAction) else action.parameters[0] self.update() self.send() b = SimpleChooser(self.app, "axis", cb) b.set_title(_("Select Axis")) - b.hide_mouse() - b.display_action(Action.AC_STICK, AxisAction(self.axes[i])) + b.show_clear() + # Mouse is a valid target here in both modes: Absolute checked gives + # laser-pointer tracking (cursor follows the rotation, Steam-style, + # rate-based); unchecked gives lean-to-turn (cursor velocity follows the + # held tilt angle). hide_mouse() wrongly blocked it, greying out the + # mouse arrows. + # + # Show the current axis with the matching action class, or the chooser + # highlights the stick for a mouse axis (Axes/Rels value collision) and + # re-confirming would silently retarget the binding to the stick. + axis = self.axes[i] + current = MouseAction(axis) if isinstance(axis, Rels) else AxisAction(axis) + b.display_action(Action.AC_STICK, current) b.show(self.editor.window) def on_abs_changed(self, source, *a): @@ -143,7 +155,13 @@ def on_sclSoftLevel_format_value(self, scale, value): def update(self, *a): for i in range(3): - self.labels[i].set_label(describe_action(Action.AC_STICK, AxisAction, self.axes[i])) + # A mouse axis (REL_*) must be described as a MouseAction; forcing it + # through AxisAction mislabels "Mouse X/Y" as "LStick X/Y". Use an + # isinstance check, not "in Rels.values()" -- Axes and Rels are IntEnums + # with overlapping values, so ABS_X == REL_X and membership misfires. + axis = self.axes[i] + cls = MouseAction if isinstance(axis, Rels) else AxisAction + self.labels[i].set_label(describe_action(Action.AC_STICK, cls, axis)) def send(self, *a): if self._recursing: diff --git a/scc/gui/ae/gyro_action.py b/scc/gui/ae/gyro_action.py index 7b6775cd1..d2b09023d 100644 --- a/scc/gui/ae/gyro_action.py +++ b/scc/gui/ae/gyro_action.py @@ -6,7 +6,7 @@ from scc.actions import Action, GyroAbsAction, GyroAction, MouseAbsAction, MouseAction, MultiAction, NoAction, RangeOP from scc.constants import ROLL, STICK, YAW, SCButtons -from scc.gui.ae import AEComponent +from scc.gui.ae import AEComponent, button_available, button_label from scc.gui.parser import GuiActionParser from scc.modifiers import ModeModifier, SensitivityModifier from scc.special_actions import CemuHookAction @@ -38,8 +38,17 @@ class GyroActionComponent(AEComponent): (SCButtons.LPAD, _("Left Pad Pressed")), (SCButtons.RPAD, _("Right Pad Pressed")), (None, None), + # The rear paddles. Labels are per-controller (see button_label): + # L4/R4/L5/R5 on sc2/deck, Left/Right Grip (2) elsewhere. (SCButtons.LGRIP, _("Left Grip")), (SCButtons.RGRIP, _("Right Grip")), + (SCButtons.LGRIP2, _("Left Grip 2")), + (SCButtons.RGRIP2, _("Right Grip 2")), + # Capacitive handle sensors (Steam Controller 2). On the SC2 the + # entries above are the rear paddles; the handle TOUCH sensors are + # separate buttons -- and the natural "gyro while gripping" enabler. + (SCButtons.LGRIPTOUCH, _("Left Grip Touched")), + (SCButtons.RGRIPTOUCH, _("Right Grip Touched")), (STICK, _("Stick Tilted")), (None, None), (SCButtons.A, _("A")), @@ -66,7 +75,7 @@ def load(self) -> None: AEComponent.load(self) self._recursing = True cbGyroButton = self.builder.get_object("cbGyroButton") - fill_buttons(cbGyroButton) + fill_buttons(cbGyroButton, self.app) self._recursing = False def set_action(self, mode: int, action) -> None: @@ -312,9 +321,13 @@ def is_gyro_enable(modemod) -> bool: return False -def fill_buttons(cb): +def fill_buttons(cb, app=None): cb.set_row_separator_func(lambda model, iter: model.get_value(iter, 1) is None) model = cb.get_model() for button, text in GyroActionComponent.BUTTONS: + if button is not None: + if not button_available(app, button): + continue # e.g. grip-touch / GRIP2 on controllers without them + text = button_label(app, button, text) model.append((None if button is None else nameof(button), text)) cb.set_active(0) diff --git a/scc/gui/app.py b/scc/gui/app.py index f9ae0820f..8fa90e852 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -12,7 +12,7 @@ import sys from urllib.parse import unquote -from gi.repository import Gdk, Gio, GLib, Gtk +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk from scc.actions import NoAction from scc.config import Config @@ -34,6 +34,7 @@ from scc.tools import ( _, check_access, + find_controller_icon, find_gksudo, find_profile, get_profile_name, @@ -50,6 +51,26 @@ log = logging.getLogger("App") +# Human-friendly default names per controller type (controller.get_type()). +# Used when the user has not given the controller a custom name. Wrapped in +# _() at lookup time so they remain translatable. +CONTROLLER_TYPE_NAMES = { + "sc": "Steam Controller v1", + "scbt": "Steam Controller v1 (Bluetooth)", + "sc2": "Steam Controller v2", + "deck": "Steam Deck", + "ds4": "DualShock 4", + "ds4evdev": "DualShock 4", + "ds4bt_hidraw": "DualShock 4", + "ds5": "DualSense", + "ds5evdev": "DualSense", + "ds5bt_hidraw": "DualSense", + "hid": "HID Controller", + "evdev": "Controller", + "rpad": "Remote Pad", + "fake": "Fake Controller", +} + class App(Gtk.Application, UserDataManager, BindingEditor): """Main application / window.""" @@ -58,7 +79,6 @@ class App(Gtk.Application, UserDataManager, BindingEditor): OBSERVE_COLOR = "#FF60A0FF" # ARGB CONFIG = "scc.config.json" RELEASE_URL = "https://github.com/C0rn3j/sc-controller/releases/tag/v%s" - OSD_MODE_PROF_NAME = ".scc-osd.profile_editor" def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/share/scc/images"): Gtk.Application.__init__( @@ -92,8 +112,7 @@ def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/sha self.status = "unknown" self.context_menu_for = None self.daemon_changed_profile = False - self.osd_mode = False # In OSD mode, only active profile can be editted - self.osd_mode_mapper = None + self.osk_edit_mode = False # --osd: open only the OSD-keyboard bindings editor self.background = None self.outdated_version = None self.profile_switchers: list[ProfileSwitcher] = [] @@ -127,6 +146,15 @@ def setup_widgets(self) -> None: ps.connect("new-clicked", self.on_new_clicked) ps.connect("save-clicked", self.on_save_clicked) + # Controller selector: shown above the profile switcher only when more + # than one controller is connected. Lets the user pick which controller + # the editor shows (replacing the old stack of one profile bar per + # controller + the per-bar "switch-to" pen button). Each row is the + # controller's icon + name, with its current profile as dim secondary + # text; selecting a row makes that controller the active/edited one. + self._selector_recursing = False + self.controller_selector = self._build_controller_selector() + # Drag&drop target self.builder.get_object("content").drag_dest_set( Gtk.DestDefaults.ALL, @@ -165,11 +193,6 @@ def setup_widgets(self) -> None: self.main_area.put(self.dpad_test, 40, 90) self.main_area.put(self.cpad_test, 150, 90) - # OSD mode (if used) - if self.osd_mode: - self.builder.get_object("btDaemon").set_sensitive(False) - self.window.set_title(_("Edit Profile")) - # Headerbar headerbar(self.builder.get_object("hbWindow")) @@ -215,6 +238,9 @@ def apply_gui_config_buttons(self, config) -> None: btDPAD = self.builder.get_object("btDPAD") btGYRO = self.builder.get_object("btGYRO") btC = self.builder.get_object("btC") + btLGRIPTOUCH = self.builder.get_object("btLGRIPTOUCH") + btRGRIPTOUCH = self.builder.get_object("btRGRIPTOUCH") + btRSTICK = self.builder.get_object("btRSTICK") buttons = ControllerImage.get_names(config.get("buttons", {})) axes = ControllerImage.get_names(config.get("axes", {})) @@ -226,10 +252,18 @@ def apply_gui_config_buttons(self, config) -> None: if w: w.set_sensitive(nameof(b) in buttons) # Buttons (as GTK Widgets) + # A controller may ship its own side-panel icons under + # images//.svg (e.g. images/sc2/); use them when + # present, else fall back to the shared default icon. + bg = config.get("gui", {}).get("background") for b in self.button_widgets: try: w = self.button_widgets[b] icon, trash = ControllerManager.get_button_icon(config, b, True) + if bg: + cand = os.path.join(self.imagepath, bg, nameof(b) + ".svg") + if os.path.exists(cand): + icon = cand w.icon.set_from_file(icon) except Exception: pass @@ -252,45 +286,59 @@ def apply_gui_config_buttons(self, config) -> None: # TODO: Maybe actual detection w.set_sensitive(gyros) - for w in (btC, btCPAD, btDPAD, btGYRO): - w.set_visible(w.get_sensitive()) + for w in (btC, btCPAD, btDPAD, btGYRO, btLGRIPTOUCH, btRGRIPTOUCH, btRSTICK): + if w: + w.set_visible(w.get_sensitive()) # Re-layout if needed expected_layout = "default" - if len(axes) >= 8 and btC.get_sensitive(): + if "rstick_x" in axes and btC.get_sensitive(): expected_layout = "deck" if expected_layout != self.current_ui_layout: self.apply_ui_layout(expected_layout) + # The Steam Deck maps its lower back paddles to LGRIP/RGRIP (labelled + # L5/R5) and the upper ones to LGRIP2/RGRIP2 (L4/R4) - the reverse of the + # SC2. Left alone the side panel reads L5/R5 above L4/R4; reorder the + # paddle buttons so they match the device (L4/R4 above L5/R5). Every other + # controller (incl. the SC2, whose LGRIP=L4) keeps the .glade order. + if bg == "deck": + grip_pairs = (("btLGRIP2", "btLGRIP"), ("btRGRIP2", "btRGRIP")) + else: + grip_pairs = (("btLGRIP", "btLGRIP2"), ("btRGRIP", "btRGRIP2")) + for above, below in grip_pairs: + wa = self.builder.get_object(above) + wb = self.builder.get_object(below) + if wa and wb and wa.get_parent() is wb.get_parent(): + kids = wa.get_parent().get_children() + if kids.index(wa) > kids.index(wb): + wa.get_parent().reorder_child(wa, kids.index(wb)) + stckEditor.set_visible_child(grEditor) GLib.idle_add(self.on_c_size_allocate) def apply_ui_layout(self, layout) -> None: """Changes layout of ui elements to fit additional buttons needed for Deck""" if layout == "deck": - # Move 'C' button bellow LGRIP - btRGRIP = self.builder.get_object("btRGRIP") + btLGRIP = self.builder.get_object("btLGRIP") + # Put 'DPAD' at the top of the left column (above the back paddles), + # to mirror the Deck's physical layout: D-Pad, L4, L5, View, Steam. + btDPAD = self.builder.get_object("btDPAD") + btDPAD.get_parent().remove(btDPAD) + btLGRIP.get_parent().pack_start(btDPAD, False, True, 6) + btLGRIP.get_parent().reorder_child(btDPAD, 2) + # Move 'C' (Steam) to the bottom of the LEFT column (was the right) btC = self.builder.get_object("btC") btC.get_parent().remove(btC) btC.set_margin_right(0) - btRGRIP.get_parent().pack_start(btC, False, True, 0) - btRGRIP.get_parent().reorder_child(btC, 5) + btLGRIP.get_parent().pack_start(btC, False, True, 0) # Move 'GYRO' button to middle of image (where C was) btGYRO = self.builder.get_object("btGYRO") btGYRO.get_parent().remove(btGYRO) vbC = self.builder.get_object("vbC") vbC.pack_start(btGYRO, False, True, 0) btGYRO.set_margin_top(30) - # Resize buttons at bottom - # for w in ['btSTICK', 'btRSTICK', 'btLPAD', 'btRPAD']: - # w.set_size_request(150, -1) - # Move 'DPAD' bellow 'LGRIP' - btLGRIP = self.builder.get_object("btLGRIP") - btDPAD = self.builder.get_object("btDPAD") - btDPAD.get_parent().remove(btDPAD) - btLGRIP.get_parent().pack_start(btDPAD, False, True, 6) - btLGRIP.get_parent().reorder_child(btDPAD, 5) def setup_statusicon(self) -> None: if self.statusicon is None: @@ -550,6 +598,10 @@ def on_mnuEditPress_activate(self, *a): id = self.context_menu_for if id == STICK: id = nameof(SCButtons.STICKPRESS) + elif id == Profile.RSTICK: + id = nameof(SCButtons.RSTICKPRESS) + elif id == Profile.CPAD: + id = nameof(SCButtons.CPADPRESS) self.show_editor(getattr(SCButtons, id)) def on_mnuGlobalSettings_activate(self, *a): @@ -715,12 +767,6 @@ def on_switch_to_clicked(self, ps, *a) -> None: def on_profile_saved(self, giofile: Gio.File, send: bool = True) -> None: """Called when selected profile is saved to disk""" - if self.osd_mode: - # Special case, profile shouldn't be changed while in osd_mode - if not giofile.get_path().endswith(".mod"): - self.profile_switchers[0].set_profile_modified(False, self.current.is_template) - return - if giofile.get_path().endswith(".mod"): # Special case, this one is saved only to be sent to daemon # and user doesn't need to know about it @@ -734,8 +780,9 @@ def on_profile_saved(self, giofile: Gio.File, send: bool = True) -> None: self.profile_switchers[0].set_profile_modified(False, self.current.is_template) if send and self.dm.is_alive() and not self.daemon_changed_profile: - for ps in self.profile_switchers: - controller = ps.get_controller() + # Re-send to every controller currently running this profile (not + # just the active one), so a saved profile reloads on all of them. + for controller in self.dm.get_controllers(): if controller: active = controller.get_profile() if active.endswith(".mod"): @@ -857,7 +904,7 @@ def on_exiting_n_daemon_killed(self, *a): self.quit() def on_mnuExit_activate(self, *a): - if not self.osd_mode and self.app.config["gui"]["autokill_daemon"]: + if self.app.config["gui"]["autokill_daemon"]: log.debug("Terminating scc-daemon") for x in ("content", "mnuEmulationEnabled", "mnuEmulationEnabledTray"): w = self.builder.get_object(x) @@ -885,49 +932,42 @@ def on_daemon_alive(self, *a) -> None: if not self.release_notes_visible(): self.hide_error() self.just_started = False - if self.osd_mode: - self.enable_osd_mode() - elif self.profile_switchers[0].get_file() is not None and not self.just_started: + if self.profile_switchers[0].get_file() is not None and not self.just_started: self.dm.set_profile(self.current_file.get_path()) GLib.timeout_add_seconds(1, self.check) self.enable_test_mode() def on_daemon_ccunt_changed(self, daemon, count: int) -> None: - if self.controller_count == 0: - # First controller connected - # - # 'event' signal should be connected only on first controller, - # so this block is executed only when number of connected - # controllers changes from 0 to 1 - if len(self.dm.get_controllers()) > 0: - c = self.dm.get_controllers()[0] - self.load_gui_config_for_controller(c, first=True) - if count > self.controller_count: - # Controller added - while len(self.profile_switchers) < count: - s = self.add_switcher() - elif count < self.controller_count: - # Controller removed - while len(self.profile_switchers) > max(1, count): - s = self.profile_switchers.pop() - s.set_controller(None) - self.remove_switcher(s) - - # Assign controllers to widgets - for i in range(count): - c = self.dm.get_controllers()[i] - self.profile_switchers[i].set_controller(c) - - if count == 0: - # Special case, no controllers are connected, but one widget has to stay on screen - self.profile_switchers[0].set_controller(None) - # First load, default controller decided by _ensure_config() in controller_image.py - if not self._controller_shown: - self.load_gui_config_for_controller(None, first=True) + # A single profile switcher always shows the *active* controller; any + # others are reachable through the controller selector above it (built + # in setup_widgets). So here we only keep the active controller in the + # switcher and rebuild the selector. + controllers = list(self.dm.get_controllers()) + ps0 = self.profile_switchers[0] + first_connect = self.controller_count == 0 and count >= 1 + + if count >= 1: + active = ps0.get_controller() + if active not in controllers: + # No active controller yet (first connect) or the active one was + # disconnected: fall back to the first connected controller and + # switch the editor image to it. + active = controllers[0] + ps0.set_controller(active) + self.load_gui_config_for_controller(active, first=first_connect) else: - self.enable_test_mode(self.profile_switchers[0].get_controller()) + # No controllers connected, but one switcher has to stay on screen + ps0.set_controller(None) + self.load_gui_config_for_controller(None, first=True) + self.rebuild_controller_selector() self.controller_count = count + if count >= 1: + # Re-arm Input Test on the (now) active controller. Without this, a + # controller connected *after* startup is never observed -- the + # enable at 'alive' ran while no controller was present -- so Input + # Test stays blank until the user toggles it off and on again. + self.enable_test_mode() def new_profile(self, profile: Profile, name: str) -> None: filename = os.path.join(get_profiles_path(), name + ".sccprofile") @@ -964,9 +1004,6 @@ def add_switcher(self, margin_left=24, margin_right=24) -> ProfileSwitcher: sepSwitchers.set_visible(True) vbSwitchers.show_all() - if self.osd_mode: - ps.set_allow_switch(False) - if len(self.profile_switchers) > 0: ps.set_profile_list(self.profile_switchers[0].get_profile_list()) ps.set_switch_to_enabled(True) @@ -985,12 +1022,119 @@ def remove_switcher(self, s): if len(vbSwitchers.get_children()) == 2: sepSwitchers.set_visible(False) + def _build_controller_selector(self) -> Gtk.ComboBox: + """Creates the 'which controller' combo and packs it above the profile + switcher. Hidden until 2+ controllers are connected.""" + # model columns: controller object, icon pixbuf, name, current profile + model = Gtk.ListStore(object, GdkPixbuf.Pixbuf, str, str) + combo = Gtk.ComboBox.new_with_model(model) + rPix, rName, rProf = Gtk.CellRendererPixbuf(), Gtk.CellRendererText(), Gtk.CellRendererText() + rProf.set_property("foreground", "#888888") + combo.pack_start(rPix, False) + combo.pack_start(rName, True) + combo.pack_start(rProf, False) + combo.add_attribute(rPix, "pixbuf", 1) + combo.add_attribute(rName, "text", 2) + combo.add_attribute(rProf, "text", 3) + combo.set_margin_left(12) + combo.set_margin_right(12) + combo.set_margin_top(4) + combo.connect("changed", self.on_controller_selected) + combo.connect("notify::popup-shown", self._refresh_selector_profiles) + vbSwitchers = self.builder.get_object("vbSwitchers") + vbSwitchers.pack_start(combo, False, False, 0) + # Layout top-to-bottom: [ selector ][ separator ][ profile switcher ] + vbSwitchers.reorder_child(combo, 0) + vbSwitchers.reorder_child(self.builder.get_object("sepSwitchers"), 1) + combo.set_no_show_all(True) + combo.set_visible(False) + return combo + + def _load_controller_pixbuf(self, c: ControllerManager) -> GdkPixbuf.Pixbuf | None: + """Loads the 24px icon for a controller, or None if unavailable.""" + try: + iconname = self.config.get_controller_config(c.get_id()).get("icon") + if iconname: + path = find_controller_icon(iconname) + if path and os.path.exists(path): + return GdkPixbuf.Pixbuf.new_from_file_at_size(path, 24, 24) + except Exception as e: + log.debug("No selector icon for %s: %s", c.get_id(), e) + return None + + def controller_display_name(self, c: ControllerManager) -> str: + """Human-friendly controller name: the user's custom name if one was set + in controller settings, otherwise a per-type label (e.g. 'Steam + Controller v2') rather than the raw internal id (e.g. 'sc1' / '3:4').""" + name = self.config.get_controller_config(c.get_id())["name"] + if name and name != c.get_id(): + return name # user-customised + return _(CONTROLLER_TYPE_NAMES.get(c.get_type(), "Controller")) + + def rebuild_controller_selector(self) -> None: + """Refills the controller selector from the connected controllers and + shows it only when more than one is connected.""" + combo = self.controller_selector + controllers = list(self.dm.get_controllers()) + active = self.profile_switchers[0].get_controller() + # Friendly names, disambiguating duplicates of the same type with #N + # (e.g. two 'Steam Controller v1' become '... #1' and '... #2'). + names = [self.controller_display_name(c) for c in controllers] + dupes = {n for n in names if names.count(n) > 1} + seen = {} + labels = [] + for n in names: + if n in dupes: + seen[n] = seen.get(n, 0) + 1 + labels.append("%s #%d" % (n, seen[n])) + else: + labels.append(n) + self._selector_recursing = True + model = combo.get_model() + model.clear() + active_iter = None + for c, label in zip(controllers, labels): + prof = get_profile_name(c.get_profile() or "") or "" + it = model.append((c, self._load_controller_pixbuf(c), label, prof)) + if c is active: + active_iter = it + if active_iter is not None: + combo.set_active_iter(active_iter) + self._selector_recursing = False + multi = len(controllers) >= 2 + combo.set_visible(multi) + self.builder.get_object("sepSwitchers").set_visible(multi) + + def _refresh_selector_profiles(self, combo: Gtk.ComboBox, *a: object) -> None: + """Refreshes each row's profile subtext when the dropdown is opened.""" + if not combo.get_property("popup-shown"): + return + for row in combo.get_model(): + row[3] = get_profile_name(row[0].get_profile() or "") or "" + + def on_controller_selected(self, combo: Gtk.ComboBox) -> None: + """Makes the chosen controller the active (edited) one, with the same + image transition the old switch-to button used.""" + if self._selector_recursing: + return + it = combo.get_active_iter() + if it is None: + return + c = combo.get_model().get_value(it, 0) + ps0 = self.profile_switchers[0] + if c is None or c is ps0.get_controller(): + return + ps0.set_controller(c) + ps0.set_profile(c.get_profile()) + self.load_gui_config_for_controller(c, False) + self.enable_test_mode() + def enable_test_mode(self, controller: ControllerManager | None = None) -> None: """Disables and re-enables Input Test mode. If sniffing is disabled in daemon configuration, 2nd call fails and logs error. """ - if self.dm.is_alive() and not self.osd_mode: + if self.dm.is_alive(): if self.test_mode_controller: self.test_mode_controller.unlock_all() if controller is None: @@ -1039,62 +1183,6 @@ def enable_test_mode(self, controller: ControllerManager | None = None) -> None: ) self.test_mode_controller = c - def enable_osd_mode(self): - # TODO: Support for multiple controllers here - self.osd_mode_controller = 0 - osd_mode_profile = Profile(GuiActionParser()) - osd_mode_profile.load(find_profile(App.OSD_MODE_PROF_NAME)) - try: - c = self.dm.get_controllers()[self.osd_mode_controller] - except IndexError: - log.error("osd_mode: Controller not connected") - self.quit() - return - - def on_lock_failed(*a): - log.error("osd_mode: Locking failed") - self.quit() - - def on_lock_success(*a): - log.debug("osd_mode: Locked everything") - from scc.gui.osd_mode import OSDModeMapper, OSDModeMappings - - self.osd_mode_mapper = OSDModeMapper(self, osd_mode_profile) - self.osd_mode_mapper.set_target_window(self.window.get_window()) - self.builder.get_object("btUndo").set_visible(False) - self.builder.get_object("btRedo").set_visible(False) - - m = OSDModeMappings(self, self.osd_mode_mapper, self.builder.get_object("OsdmodeMappings")) - m.set_controller(self.profile_switchers[0].get_controller()) - m.show() - - # Locks everything but pads. Pads are emulating mouse and this is - # better left in daemon - involving socket in mouse controls - # adds too much lags. - c.lock( - on_lock_success, - on_lock_failed, - "A", - "B", - "X", - "Y", - "START", - "BACK", - "LB", - "RB", - "C", - "STICK", - "LGRIP", - "RGRIP", - "LT", - "RT", - "STICKPRESS", - ) - - # Ask daemon to temporaly reconfigure pads for mouse emulation - c.replace(DaemonManager.nocallback, on_lock_failed, LEFT, osd_mode_profile.pads[LEFT]) - c.replace(DaemonManager.nocallback, on_lock_failed, RIGHT, osd_mode_profile.pads[RIGHT]) - def on_observe_failed(self, error): log.debug("Failed to enable test mode: %s", error) @@ -1113,8 +1201,7 @@ def on_daemon_version(self, daemon, version): # and we can check if there is anything new to inform user about elif self.app.config["gui"]["news"]["last_version"] != App.get_release(): if self.app.config["gui"]["news"]["enabled"]: - if not self.osd_mode: - self.check_release_notes() + self.check_release_notes() def on_daemon_error(self, daemon, error): log.debug("Daemon reported error '%s'", error) @@ -1147,16 +1234,16 @@ def on_daemon_error(self, daemon, error): return # If check() fails to find error reason, error message is displayed as it is - if self.osd_mode: - self.quit() - self.show_error(msg) self.set_daemon_status("error", True) def on_daemon_event_observer(self, daemon, c, what, data) -> None: - if self.osd_mode_mapper: - self.osd_mode_mapper.handle_event(daemon, what, data) - elif what in (LEFT, RIGHT, STICK, RSTICK, DPAD, CPAD): + # Only react to the controller Input Test is observing. Other connected + # controllers also emit events; without this, their input would show on + # the selected controller's image. + if c is not self.test_mode_controller: + return + if what in (LEFT, RIGHT, STICK, RSTICK, DPAD, CPAD): widget, area = { LEFT: (self.lpad_test, "LPADTEST"), RIGHT: (self.rpad_test, "RPADTEST"), @@ -1169,16 +1256,34 @@ def on_daemon_event_observer(self, daemon, c, what, data) -> None: if data[0] == data[1] == 0: widget.hide() return + # Grab values. The controller image may not define a test area for + # this input (e.g. deck.svg has no STICKTEST); skip silently rather + # than crashing the GUI and spamming the log with ValueError. + try: + ax, ay, aw, ah = self.background.get_area_position(area) + except ValueError: + widget.hide() + return + # Area coords are in SVG document space, but the cursor is a GTK + # overlay placed in image pixels. Shift by the viewBox origin so a + # non-zero origin (e.g. sc2.svg's "0 -45 ..." trigger headroom) + # doesn't push every cursor up/left. Origin is (0,0) for the other + # controllers, so they are unaffected. + vbx, vby, _vbw, _vbh = self.background.get_viewbox() + ax -= vbx + ay -= vby if not widget.is_visible(): widget.show() - # Grab values - ax, ay, aw, trash = self.background.get_area_position(area) cw = widget.get_allocation().width - # Compute center - x, y = ax + aw * 0.5 - cw * 0.5, ay + 1.0 - cw * 0.5 - # Add pad position + ch = widget.get_allocation().height + # Rest position = centre of the area on BOTH axes (the old code + # used 'ay + 1.0' for Y, pinning the cursor to the top of the area + # so it sat half a control too high until the stick/pad was pushed). + x = ax + aw * 0.5 - cw * 0.5 + y = ay + ah * 0.5 - ch * 0.5 + # Add pad/stick position x += data[0] * aw / STICK_PAD_MAX * 0.5 - y -= data[1] * aw / STICK_PAD_MAX * 0.5 + y -= data[1] * ah / STICK_PAD_MAX * 0.5 # Move circle self.main_area.move(widget, x, y) elif what in ("LT", "RT", "STICKPRESS"): @@ -1289,10 +1394,11 @@ def on_btRenameProfile_clicked(self, *a) -> None: controllers = list(self.dm.get_controllers()) for c in controllers: if get_profile_name(c.get_profile()) == old_name: - ps = self.profile_switchers[controllers.index(c)] - ps.set_profile(new_name, True) c.set_profile(new_name) + if c is self.profile_switchers[0].get_controller(): + self.profile_switchers[0].set_profile(new_name, True) self.load_profile_list() + self.rebuild_controller_selector() dlg.hide() def on_mnuProfileDelete_activate(self, *a) -> None: @@ -1338,8 +1444,6 @@ def on_window_key_press_event(self, window, event) -> None: if (event.state & Gdk.ModifierType.CONTROL_MASK) != 0: if event.keyval == 115: self.on_save_clicked() - elif self.osd_mode and event.keyval == 65471: - self.on_save_clicked() def show_error(self, message, ribar=None): if self.ribar is None or self.ribar.get_label() is None: @@ -1364,8 +1468,15 @@ def hide_error(self, *a): def on_daemon_reconfigured(self, *a) -> None: log.debug("Reloading config...") self.config.reload() + # If Input Test was just turned off, drop any highlights left over from + # the last observed press (e.g. a held grip sensor): with sniffing off no + # release event arrives to clear them, so they'd stay stuck on the image. + if not self.config["enable_sniffing"] and self.hilights[App.OBSERVE_COLOR]: + self.hilights[App.OBSERVE_COLOR].clear() + self._update_background() for ps in self.profile_switchers: ps.set_controller(ps.get_controller()) + self.rebuild_controller_selector() def on_daemon_dead(self, *a): if self.just_started: @@ -1374,12 +1485,14 @@ def on_daemon_dead(self, *a): self.set_daemon_status("unknown", True) return - if self.osd_mode: - self.quit() - for ps in self.profile_switchers: ps.set_controller(None) ps.on_daemon_dead() + self._selector_recursing = True + self.controller_selector.get_model().clear() + self._selector_recursing = False + self.controller_selector.set_visible(False) + self.builder.get_object("sepSwitchers").set_visible(False) self.set_daemon_status("dead", False) def on_mnuEmulationEnabled_toggled(self, cb): @@ -1401,13 +1514,17 @@ def do_startup(self, *a) -> None: Gtk.Application.do_startup(self, *a) self.load_profile_list() self.setup_widgets() - if self.app.config["gui"]["enable_status_icon"]: + # No tray icon for the transient OSD-keyboard bindings editor launch. + if self.app.config["gui"]["enable_status_icon"] and not self.osk_edit_mode: self.setup_statusicon() self.set_daemon_status("unknown", True) def do_local_options(self, trash, lo): set_logging_level(lo.contains("verbose"), lo.contains("debug")) - self.osd_mode = lo.contains("osd") + # --osd opens the standalone OSD-keyboard bindings editor (do_activate) - + # the same dialog as Settings > Menus & Keyboard > Advanced. Used on both + # X11 and Wayland for a consistent, reliable single-window experience. + self.osk_edit_mode = lo.contains("osd") return -1 def do_command_line(self, cl: ApplicationCommandLine) -> int: @@ -1434,12 +1551,49 @@ def i_told_you_to_quit(*a) -> Never: return 0 def do_activate(self, *a) -> None: + if self.osk_edit_mode: + # "Edit Bindings" (OSD menu): show only the OSD-keyboard bindings + # editor, never the main window, and quit when it is closed - so it + # can't pile up duplicate main windows. + if not getattr(self, "_osk_editor", None): + self.open_osk_editor() + return self.builder.get_object("window").show() if self.config["gui"]["minimize_on_start"] and self.statusicon and self.statusicon.get_property("active"): self.builder.get_object("window").hide() else: self.builder.get_object("window").show() + def open_osk_editor(self) -> None: + """Opens the standalone OSD-keyboard bindings editor (the same window + reachable from Settings > Menus & Keyboard > Advanced) as the only + window, quitting the app when it closes. Backs the OSD menu's + 'Edit Bindings' item.""" + import fcntl + + import scc.osd.osk_actions + from scc.actions import Action + from scc.gui.osk_binding_editor import OSKBindingEditor + # Single-instance: each "Edit Bindings" is its own process (the app is + # NON_UNIQUE), so without this a repeated launch would stack a second + # editor window. Hold an exclusive lock for our lifetime; if another + # launch already holds it, just quit instead of opening a duplicate. + try: + self._osk_lock = open(os.path.join(get_config_path(), ".osk-editor.lock"), "w") + fcntl.flock(self._osk_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + log.info("OSD-keyboard bindings editor already open; not opening another") + self.quit() + return + except Exception: + log.exception("OSK editor single-instance lock failed; opening anyway") + # The OSD-keyboard profile uses OSK.* actions; register them so the + # editor can parse it (GlobalSettings does the same before opening it). + Action.register_all(scc.osd.osk_actions, prefix="OSK") + self._osk_editor = OSKBindingEditor(self) + self._osk_editor.window.connect("destroy", lambda *a: self.quit()) + self._osk_editor.show(None) + def remove_dot_profile(self) -> None: """Checks if first profile in list begins with dot and if yes, removes it. This is done to undo automatic addition that is done when daemon reports @@ -1523,7 +1677,7 @@ def aso(long_name, short_name, description, arg=None, flags=GLib.OptionFlags.IN_ aso("verbose", b"v", "Be verbose") aso("debug", b"d", "Be more verbose (debug mode)") - aso("osd", b"o", "OSD mode (OSD-controllable editor for current profile)") + aso("osd", b"o", "Open the OSD-keyboard bindings editor") def save_profile_selection(self, path) -> None: """Saves name of profile into config file""" diff --git a/scc/gui/area_to_action.py b/scc/gui/area_to_action.py index 870bd8aa7..2987043df 100644 --- a/scc/gui/area_to_action.py +++ b/scc/gui/area_to_action.py @@ -80,10 +80,15 @@ Rels.REL_Y, 1, ), - "MOUSE_X": (MouseAction, Rels.REL_X, 1), - "MOUSE_Y": (MouseAction, Rels.REL_Y, 1), - "MOUSE_WHEEL": (MouseAction, Rels.REL_WHEEL, 1), - "MOUSE_HWHEEL": (MouseAction, Rels.REL_HWHEEL, 1), + # Full-axis (whole) mouse entries are bare, mirroring the bare stick entries + # (ABS_X etc.) -- the whole X axis is MouseAction(REL_X), no direction. The + # trailing "1" made these 2-param, so action_to_area could never match the + # 1-param MouseAction the editors store (gyro Mouse X/Y went un-highlighted), + # and it duplicated the directional MOUSE_LEFT/RIGHT/UP/DOWN entries anyway. + "MOUSE_X": (MouseAction, Rels.REL_X), + "MOUSE_Y": (MouseAction, Rels.REL_Y), + "MOUSE_WHEEL": (MouseAction, Rels.REL_WHEEL), + "MOUSE_HWHEEL": (MouseAction, Rels.REL_HWHEEL), # Mouse buttons "MOUSE1": (ButtonAction, Keys.BTN_LEFT), "MOUSE2": (ButtonAction, Keys.BTN_MIDDLE), diff --git a/scc/gui/binding_editor.py b/scc/gui/binding_editor.py index b1469d89a..3bd737fcc 100644 --- a/scc/gui/binding_editor.py +++ b/scc/gui/binding_editor.py @@ -84,7 +84,10 @@ def set_action(self, profile, id, action): self.button_widgets[id.name].update() elif id in BUTTONS: before, profile.buttons[id] = profile.buttons[id], action - self.button_widgets[id].update() + # Some buttons (e.g. the stick-touch sensors, set via the Touch tab) + # have no on-screen widget; just store the action for those. + if id in self.button_widgets: + self.button_widgets[id].update() elif id in TRIGGERS: # TODO: Use LT and RT in profile as well side = LEFT if id == "LT" else RIGHT diff --git a/scc/gui/global_settings.py b/scc/gui/global_settings.py index 677da6f9d..f53876f3f 100644 --- a/scc/gui/global_settings.py +++ b/scc/gui/global_settings.py @@ -331,9 +331,13 @@ def on_cbShowOSD_toggled(self, cb): def on_btRestartEmulation_clicked(self, *a): rvRestartWarning = self.builder.get_object("rvRestartWarning") - self.app.dm.stop() rvRestartWarning.set_reveal_child(False) - GLib.timeout_add_seconds(1, self.app.dm.start) + # Use the daemon's built-in restart (stop-and-wait, *then* start) rather + # than a manual stop + fixed 1s delay + start. The old approach raced the + # daemon's own shutdown: with several USB devices the new daemon could + # start before the old one had died and released them, so it hit a stale + # pidfile / still-claimed devices and the controllers stayed undetected. + self.app.dm.restart() def on_restarting_checkbox_toggled(self, *a): if self._recursing: diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index df79e41e6..886b970d5 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -10,6 +10,7 @@ from scc.actions import Action, NoAction, RangeOP from scc.constants import HapticPos, SCButtons +from scc.gui.ae import button_available, button_label from scc.gui.controller_widget import PADS, STICKS from scc.gui.dwsnc import headerbar from scc.gui.editor import Editor @@ -34,6 +35,8 @@ class ModeshiftEditor(Editor): (None, None), (SCButtons.LGRIP, _("Left Grip")), (SCButtons.RGRIP, _("Right Grip")), + (SCButtons.LGRIP2, _("Left Grip 2")), + (SCButtons.RGRIP2, _("Right Grip 2")), (SCButtons.LB, _("Left Bumper")), (SCButtons.RB, _("Right Bumper")), (None, None), @@ -48,6 +51,10 @@ class ModeshiftEditor(Editor): (SCButtons.RPAD, _("Right Pad Pressed")), (SCButtons.LPADTOUCH, _("Left Pad Touched")), (SCButtons.RPADTOUCH, _("Right Pad Touched")), + (SCButtons.LSTICKTOUCH, _("Left Stick Touched")), + (SCButtons.RSTICKTOUCH, _("Right Stick Touched")), + (SCButtons.LGRIPTOUCH, _("Left Grip Touched")), + (SCButtons.RGRIPTOUCH, _("Right Grip Touched")), ) def __init__(self, app, callback): @@ -60,6 +67,10 @@ def __init__(self, app, callback): self.current_page = 0 self.actions = ([], [], []) self.nomods = [NoAction(), NoAction(), NoAction()] + # Touch tab: bound to the capacitive stick-touch sensor (a separate + # button), only shown when editing a stick-press action. + self.touch_id = None + self.touch_action = NoAction() self.setup_widgets() def setup_widgets(self): @@ -92,6 +103,11 @@ def _fill_button_chooser(self, *a): if any([x[0] == item for x in self.actions[self.current_page]]): # Skip already added buttons continue + if isinstance(item, SCButtons): + if not button_available(self.app, item): + continue # optional button this controller doesn't have + # Per-controller paddle naming (L4/L5/R4/R5 on sc2/deck) + text = button_label(self.app, item, text) if type(item) is str: # Special case for soft pull items button = getattr(SCButtons, item.split(" ")[-1]) @@ -197,6 +213,10 @@ def on_clearb_clicked(self, trash, index, button): model.clear() # Fill it again for button, text in self.BUTTONS: + if isinstance(button, SCButtons): + if not button_available(self.app, button): + continue # optional button this controller doesn't have + text = button_label(self.app, button, text) model.append((None if button is None else nameof(button), text)) if button is not None: if nameof(button) == active: @@ -240,9 +260,16 @@ def on_chosen(id, action): def on_ntbMore_switch_page(self, ntb, box, index): self.current_page = index + cb = self.builder.get_object("cbButtonChooser") + add = self.builder.get_object("btAddAction") + if index >= len(self.actions): + # Touch tab: no mode-shift grid / button chooser here + cb.set_sensitive(False) + add.set_sensitive(False) + return self._fill_button_chooser() - self.builder.get_object("cbButtonChooser").set_sensitive(box.get_sensitive()) - self.builder.get_object("btAddAction").set_sensitive(box.get_sensitive()) + cb.set_sensitive(box.get_sensitive()) + add.set_sensitive(box.get_sensitive()) def on_nomodbt_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] @@ -260,6 +287,20 @@ def on_nomodclear_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] actionButton.set_label(self.nomods[self.current_page].describe(self.mode)) + def on_btTouch_clicked(self, *a: object) -> None: + """'Touch' tab: edit the action bound to the stick-touch sensor.""" + def on_chosen(id: str, action: Action) -> None: + self.touch_action = action + self.builder.get_object("btTouch").set_label(action.describe(self.mode)) + + ae = self._choose_editor(self.touch_action, on_chosen) + ae.set_input(self.touch_id, self.touch_action, mode=Action.AC_BUTTON) + ae.show(self.window) + + def on_btClearTouch_clicked(self, *a: object) -> None: + self.touch_action = NoAction() + self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) + def on_btAddAction_clicked(self, *a): cbButtonChooser = self.builder.get_object("cbButtonChooser") item = cbButtonChooser.get_model().get_value(cbButtonChooser.get_active_iter(), 0) @@ -303,6 +344,9 @@ def on_btOK_clicked(self, *a): """Handler for OK button""" if self.ac_callback is not None: self.ac_callback(self.id, self._make_action()) + if self.touch_id is not None: + # Touch tab maps to a separate input (the stick-touch sensor) + self.ac_callback(self.touch_id, self.touch_action) self.close() def _make_action(self): @@ -409,3 +453,24 @@ def set_input(self, id, action, mode=None): if mode != Action.AC_BUTTON: for w in ("vbHold", "vbDoubleClick", "lblHold", "lblDoubleClick"): self.builder.get_object(w).set_sensitive(False) + + # Touch tab: only for the stick-press inputs; binds the capacitive + # stick-touch sensor (a separate button). Hidden for everything else. + # (Editor.show() uses window.show(), not show_all, so hiding sticks.) + touch_for = {SCButtons.STICKPRESS: SCButtons.LSTICKTOUCH, + SCButtons.RSTICKPRESS: SCButtons.RSTICKTOUCH} + self.touch_id = touch_for.get(id) if id in SCButtons.__members__.values() else None + vbTouch = self.builder.get_object("vbTouch") + lblTouch = self.builder.get_object("lblTouch") + if self.touch_id is not None: + try: + self.touch_action = self.app.current.buttons[self.touch_id] or NoAction() + except (KeyError, TypeError): + self.touch_action = NoAction() + self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) + vbTouch.set_visible(True) + lblTouch.set_visible(True) + else: + self.touch_action = NoAction() + vbTouch.set_visible(False) + lblTouch.set_visible(False) diff --git a/scc/gui/osd_mode.py b/scc/gui/osd_mode.py deleted file mode 100644 index 5f50e0385..000000000 --- a/scc/gui/osd_mode.py +++ /dev/null @@ -1,191 +0,0 @@ -"""SC Controller - OSD Mode Mapper - -Very special case of mapper used when main application is launched in "odd mode". -That means it's drawn in OSD layer, cannot be clicked and cannot react to -keyboard. This mapper emulates input events on it using GTK methods. - -Mouse movement (but not buttons) are passed to uinput as usuall. -""" - -import logging - -from gi.repository import Gdk, GLib, Gtk - -from scc.constants import SCButtons -from scc.gui.gdk_to_key import KEY_TO_GDK, KEY_TO_KEYCODE -from scc.osd.slave_mapper import SlaveMapper -from scc.uinput import Keys - -log = logging.getLogger("OSDModMapper") - - -class OSDModeMapper(SlaveMapper): - def __init__(self, app, profile): - SlaveMapper.__init__(self, profile, None, keyboard="osd", mouse="osd") - self.app = app - self.set_special_actions_handler(self) - self.target_window = None - - def on_sa_restart(self, *a): - """Restart / exit handler""" - self.app.quit() - - def set_target_window(self, w): - self.target_window = w - - def create_keyboard(self, name): - return OSDModeKeyboard(self) - - def create_mouse(self, name): - return OSDModeMouse(self) - - -class OSDModeKeyboard: - """Emulates uinput keyboard emulator""" - - def __init__(self, mapper): - self.mapper = mapper - self.display = Gdk.Display.get_default() - self.manager = self.display.get_device_manager() - self.device = [ - x for x in self.manager.list_devices(Gdk.DeviceType.MASTER) if x.get_source() == Gdk.InputSource.KEYBOARD - ][0] - - def pressEvent(self, keys): - for k in keys: - event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = KEY_TO_KEYCODE[k] - event.keyval = KEY_TO_GDK[k] - event.window = self.mapper.target_window - event.set_device(self.device) - Gtk.main_do_event(event) - - def releaseEvent(self, keys=[]): - for k in keys: - event = Gdk.Event.new(Gdk.EventType.KEY_RELEASE) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = KEY_TO_KEYCODE[k] - event.keyval = KEY_TO_GDK[k] - event.window = self.mapper.target_window - event.set_device(self.device) - Gtk.main_do_event(event) - - -class OSDModeMouse: - """Emulates uinput keyboard emulator too""" - - def __init__(self, mapper): - self.mapper = mapper - self.display = Gdk.Display.get_default() - self.manager = self.display.get_device_manager() - self.device = [ - x for x in self.manager.list_devices(Gdk.DeviceType.MASTER) if x.get_source() == Gdk.InputSource.MOUSE - ][0] - - def synEvent(self, *a): - pass - - def keyEvent(self, key, val) -> None: - tp = Gdk.EventType.BUTTON_PRESS if val else Gdk.EventType.BUTTON_RELEASE - event = Gdk.Event.new(tp) - event.button = int(key) - Keys.BTN_LEFT + 1 - window, event.x, event.y = Gdk.Window.at_pointer() - screen, x, y, mask = Gdk.Display.get_default().get_pointer() - event.x_root, event.y_root = x, y - - gtk_window = None - for w in Gtk.Window.list_toplevels(): - if w.get_window(): - if window.get_toplevel().get_xid() == w.get_window().get_xid(): - gtk_window = w - break - if gtk_window: - if gtk_window.get_type_hint() == Gdk.WindowTypeHint.COMBO: - # Special case, clicking on combo does nothing, so - # pressing "space" is emulated instead. - if not val: - return - event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = 65 - event.keyval = Gdk.KEY_space - event.window = self.mapper.target_window - event.time = Gtk.get_current_event_time() - event.window = window - event.set_device(self.device) - Gtk.main_do_event(event) - - -class OSDModeMappings: - ICONS = { - "imgOsdmodeAct": SCButtons.A, - "imgOsdmodeClose": SCButtons.B, - "imgOsdmodeExit": SCButtons.C, - "imgOsdmodeSave": SCButtons.Y, - "imgOsdmodeOK": SCButtons.Y, - } - - MAIN_WINDOW_BUTTONS = {"vbOsdmodeExit", "vbOsdmodeSave"} - OTHER_WINDOW_BUTTONS = {"vbOsdmodeExit", "vbOsdmodeAct", "vbOsdmodeClose", "vbOsdmodeOK"} - - def __init__(self, app, mapper, window): - self.app = app - self.mapper = mapper - self.window = window - self.parent = app.window - self.first_window = None - GLib.timeout_add(10, self.move_around) - self.app.window.connect("focus-in-event", self.on_main_window_focus_in_event) - self.app.window.connect("focus-out-event", self.on_main_window_focus_out_event) - self.on_main_window_focus_in_event() - - def set_controller(self, c): - config = c.load_gui_config(self.app.imagepath or {}) - for name in OSDModeMappings.ICONS: - w = self.app.builder.get_object(name) - icon, trash = c.get_button_icon(config, OSDModeMappings.ICONS[name]) - w.set_from_file(icon) - - def on_main_window_focus_in_event(self, *a): - for x in self.OTHER_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(False) - for x in self.MAIN_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(True) - - def on_main_window_focus_out_event(self, *a): - for x in self.MAIN_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(False) - for x in self.OTHER_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(True) - - def get_target_position(self): - pos = self.first_window.get_position() - size = self.first_window.get_geometry() - my_size = self.window.get_window().get_geometry() - tx = pos.x + 0.5 * (size.width - my_size.width) - ty = pos.y + size.height + 100 - return tx, ty - - def show(self): - self.window.show() - self.window.get_window().set_override_redirect(True) - - def move_around(self, *a): - if self.first_window is None: - active = self.window.get_window().get_screen().get_active_window() - if active is None: - return None - self.first_window = active - - tx, ty = self.get_target_position() - self.window.get_window().move(tx, ty) - return True - - -def direction(x): - if x >= 1: - return 1 - if x <= -1: - return -1 - return 0 diff --git a/scc/gui/simple_chooser.py b/scc/gui/simple_chooser.py index 074ceebd0..ae33f868b 100644 --- a/scc/gui/simple_chooser.py +++ b/scc/gui/simple_chooser.py @@ -8,6 +8,7 @@ import importlib import logging +from scc.actions import NoAction from scc.gui.ae import AEComponent from scc.gui.dwsnc import headerbar from scc.gui.editor import Editor @@ -61,3 +62,11 @@ def hide_axes(self): def hide_mouse(self): """Prevents user from selecting mouse-related stuff""" self.component.hide_mouse() + + def show_clear(self): + """Reveals a 'Clear' button. The caller's callback must accept a NoAction + (i.e. handle the unset case).""" + self.builder.get_object("btClear").set_visible(True) + + def on_btClear_clicked(self, *a): + self.set_action(NoAction()) diff --git a/scc/gui/svg_widget.py b/scc/gui/svg_widget.py index f85d4a0bc..fadd322c4 100644 --- a/scc/gui/svg_widget.py +++ b/scc/gui/svg_widget.py @@ -94,8 +94,8 @@ def on_mouse_click(self, trash, event): def on_mouse_moved(self, trash, event): """Not actual signal handler, just called from App.""" x_offset = (self.get_allocation().width - self.image_width) / 2 - x = event.x - x_offset - y = event.y + x = event.x - x_offset + vbx + y = event.y + vby for a in self.areas: # *TEST areas exist only to bound the Input Test cursor (looked up # by id via get_area_position), not as hover targets. Skip them so diff --git a/scc/lib/xwrappers.py b/scc/lib/xwrappers.py index 26107ec3d..8511c44bf 100644 --- a/scc/lib/xwrappers.py +++ b/scc/lib/xwrappers.py @@ -18,6 +18,7 @@ from ctypes import ( CDLL, + CFUNCTYPE, POINTER, Structure, byref, @@ -50,6 +51,21 @@ def _load_lib(*names): libX11 = _load_lib("libX11.so", "libX11.so.6") libXext = _load_lib("libXext.so", "libXext.so.6") +# By default Xlib aborts the whole process on an X protocol error (e.g. BadWindow +# when querying a stale/invalid window id - which happens under XWayland, where +# the focused window may not be an X window). Install a no-op error handler so the +# offending call fails quietly (callers already cope with missing data) instead of +# killing the process (this previously crashed scc-osd-daemon from Autoswitch). +_XErrorHandler = CFUNCTYPE(c_int, c_void_p, c_void_p) + + +def _ignore_x_error(display: int, error: int) -> int: + return 0 + + +_x_error_handler = _XErrorHandler(_ignore_x_error) # keep a reference so it isn't GC'd +libX11.XSetErrorHandler(_x_error_handler) + # Types XID = c_ulong diff --git a/scc/mapper.py b/scc/mapper.py index c596b9e7d..a0c5b25a4 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -6,7 +6,7 @@ import traceback from typing import TYPE_CHECKING -from scc.actions import ButtonAction, GyroAbsAction +from scc.actions import ButtonAction, GyroAction from scc.aliases import ALL_AXES, ALL_BUTTONS from scc.config import Config from scc.constants import ( @@ -340,6 +340,17 @@ def set_button(self, button, state): button = SCButtons.LPADTOUCH elif button == RIGHT: button = SCButtons.RPADTOUCH + elif button == RSTICK: + button = SCButtons.RSTICKTOUCH + elif button == STICK: + button = SCButtons.LSTICKTOUCH + elif button == CPAD: + button = SCButtons.CPADTOUCH + elif isinstance(button, str): + # Sources like DPAD/GYRO have no button bit to toggle; skip rather + # than crash on ~button. A ModeModifier on the (v2) right stick used + # to pass the RSTICK string here -> "bad operand type for unary ~". + return if state: self.buttons |= button @@ -356,6 +367,15 @@ def set_was_pressed(self, button, state): button = SCButtons.LPADTOUCH elif button == RIGHT: button = SCButtons.RPADTOUCH + elif button == RSTICK: + button = SCButtons.RSTICKTOUCH + elif button == STICK: + button = SCButtons.LSTICKTOUCH + elif button == CPAD: + button = SCButtons.CPADTOUCH + elif isinstance(button, str): + # See set_button: skip non-button sources instead of crashing. + return if state: self.old_buttons |= button @@ -379,8 +399,12 @@ def cancel_all(self): a.cancel(self) def reset_gyros(self): + # GyroAction covers GyroAbsAction (subclass): absolute actions re-capture + # their orientation reference (ir), relative ones their lean-to-turn + # neutral pose. Rate-based outputs (laser-pointer mouse, relative stick) + # have no reference by nature, so recentering rightly leaves them alone. for a in self.profile.get_all_actions(): - if isinstance(a, GyroAbsAction): + if isinstance(a, GyroAction): a.reset() def input(self, controller: Controller, old_state, state) -> None: @@ -423,7 +447,14 @@ def input(self, controller: Controller, old_state, state) -> None: elif not self.buttons & SCButtons.LPADTOUCH: if FE_STICK in fe or self.old_state.lpad_x != state.lpad_x or self.old_state.lpad_y != state.lpad_y: self.profile.stick.whole(self, state.lpad_x, state.lpad_y, STICK) - if self.controller.flags & ControllerFlags.HAS_RSTICK: + # HAS_RSTICK controllers store the right stick either as a real rstick + # (Steam Controller 2 / Deck) or, for gamepads on the generic HID decoder + # (DS4/DS5), as the right pad (pads[RIGHT]). The latter's state struct + # (HIDControllerInput) has no rstick_* fields, so guard the access: + # without it every event raised AttributeError here, which aborted the + # rest of input processing (right pad, triggers, touchpad) -- the reason + # those controls were dead on the DS4. + if self.controller.flags & ControllerFlags.HAS_RSTICK and hasattr(state, "rstick_x"): if ( FE_STICK in fe or self.old_state.rstick_x != state.rstick_x diff --git a/scc/menu_data.py b/scc/menu_data.py index 5b37ba217..bf19371ae 100644 --- a/scc/menu_data.py +++ b/scc/menu_data.py @@ -5,6 +5,9 @@ import json import os +import logging + +log = logging.getLogger("menu_data") from scc.actions import Action from scc.tools import _ @@ -25,7 +28,10 @@ def generate(self, menuhandler): items = [] for i in self: if isinstance(i, MenuGenerator): - items.extend(i.generate(menuhandler)) + try: + items.extend(i.generate(menuhandler)) + except Exception: + log.exception("Menu generator %r failed", getattr(i, "GENERATOR_NAME", i)) else: items.append(i) return MenuData(*items) diff --git a/scc/modifiers.py b/scc/modifiers.py index 6ecf1aaf9..ccf7e985a 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from __future__ import annotations + import inspect import itertools import logging @@ -13,7 +15,7 @@ from collections import OrderedDict, deque from math import atan2, copysign, cos, sin, sqrt from math import pi as PI -from typing import Self +from typing import TYPE_CHECKING, Self from scc.actions import ( Action, @@ -48,6 +50,9 @@ from scc.tools import clamp, nameof from scc.uinput import Axes, Rels +if TYPE_CHECKING: + from scc.mapper import Mapper + log = logging.getLogger("Modifiers") _ = lambda x: x @@ -340,6 +345,39 @@ def button_release(self, mapper): mapper.schedule(0.02, self._release) +class InvertedButtonModifier(Modifier): + """Acts on button release instead of press ("Act on release"). + + Swaps press and release, so the wrapped action is held while the physical + button is NOT pressed. Meant for always-on sensors such as the Steam + Controller's capacitive handle grips, which read "on" the whole time the + controller is held - inverting them lets the action fire when you let go. + Unlike PressedModifier/ReleasedModifier (which emit a momentary tap), this + is a true held inversion of the button state. + """ + COMMAND = "inverted" + + def describe(self, context: int) -> str: + if context in (Action.AC_STICK, Action.AC_PAD): + return _("(act on release)") + "\n" + self.action.describe(context) + return _("(act on release)") + " " + self.action.describe(context) + + def strip(self) -> Action: + return self.action.strip() + + def compress(self) -> Action: + self.action = self.action.compress() + return self + + def button_press(self, mapper: Mapper) -> None: + # Physical press -> the wrapped action is released + self.action.button_release(mapper) + + def button_release(self, mapper: Mapper) -> None: + # Physical release -> the wrapped action is pressed + self.action.button_press(mapper) + + class BallModifier(Modifier, WholeHapticAction): """Emulates ball-like movement with inertia and friction. @@ -999,7 +1037,24 @@ def gyro(self, mapper, pitch, yaw, roll, *q): sel = self.select(mapper) if sel is not self.old_action: if self.old_action: + # Neutralize the outgoing gyro action so its output axis doesn't stay + # stuck when the enable button is released. Relative GyroAction zeroes + # on (0,0,0), but GyroAbsAction ignores pitch/yaw/roll (it tracks + # q1-q4), so it would emit its last orientation and leave the axis + # deflected. Reset the reference first so the neutralizing call below + # emits 0. Covers MultiAction (mixed relative+absolute) via .actions. + for a in getattr(self.old_action, "actions", None) or (self.old_action,): + if hasattr(a, "reset"): + a.reset() self.old_action.gyro(mapper, 0, 0, 0, *q) + # That neutralizing call just re-captured the reference at the + # RELEASE pose (reset -> first-event capture). Reset once more so + # the next activation captures ITS OWN pose: the gyro re-centers + # on every engage (Steam-like), instead of carrying a stale + # offset accumulated while deactivated. + for a in getattr(self.old_action, "actions", None) or (self.old_action,): + if hasattr(a, "reset"): + a.reset() self.old_action = sel return sel.gyro(mapper, pitch, yaw, roll, *q) diff --git a/scc/osd/__init__.py b/scc/osd/__init__.py index 6f5f73725..b7babea56 100644 --- a/scc/osd/__init__.py +++ b/scc/osd/__init__.py @@ -158,6 +158,7 @@ def _add_arguments(self) -> None: Use negative value to specify as distance from bottom side (default: -20)""", ) self.argparser.add_argument("--controller", type=str, help="""id of controller to use""") + self.argparser.add_argument("--controller-type", type=str, help="""type of controller to use""") self.argparser.add_argument("-d", action="store_true", help="""display debug messages""") def choose_controller(self, daemonmanager: DaemonManager) -> ControllerManager: diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index f79f3c94a..37f1a89ff 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -5,20 +5,23 @@ Reuses styles from OSD Menu and OSD Dialog """ +from __future__ import annotations import base64 import logging import os +import re import sys +from collections.abc import Callable from enum import IntEnum -from typing import Self +from typing import TYPE_CHECKING, Self from gi.repository import Gtk from scc.actions import Action, AxisAction, DPadAction, MouseAction, MultiAction, XYAction from scc.config import Config -from scc.constants import SCButtons -from scc.gui.daemon_manager import DaemonManager +from scc.constants import DPAD, LEFT, RIGHT, SCButtons +from scc.gui.daemon_manager import ControllerManager, DaemonManager from scc.gui.svg_widget import SVGEditor, SVGWidget from scc.modifiers import DoubleclickModifier, ModeModifier from scc.osd import OSDWindow @@ -29,6 +32,9 @@ from scc.tools import _, nameof from scc.uinput import Rels +if TYPE_CHECKING: + from xml.etree import ElementTree as ET + log = logging.getLogger("osd.binds") @@ -45,6 +51,7 @@ def __init__(self, config=None): self.group = None self.limits = {} self.background = None + self._layout_key = None # gui "background" name -> per-controller LAYOUTS self._eh_ids = [] self._stick = 0, 0 @@ -53,8 +60,22 @@ def __init__(self, config=None): self.c.set_name("osd-keyboard-container") def on_profile_changed(self, daemon: DaemonManager, filename: str): - profile = Profile(TalkingActionParser()).load(filename) - Generator(SVGEditor(self.background), profile) + self._draw_profile(filename) + + def _draw_profile(self, filename: str) -> None: + """(Re)draws the binding boxes for the given profile onto the current + background. No-op until the background image has been built, which + happens in on_daemon_connected() once the connected controller (and + thus which per-controller image to load) is known. + """ + if self.background is None or not filename: + return + try: + profile = Profile(TalkingActionParser()).load(filename) + except Exception: + log.exception("Failed to load profile %s", filename) + return + Generator(SVGEditor(self.background), profile, self._layout_key) def use_daemon(self, d): """Allows (re)using already existing DaemonManager instance in same process.""" @@ -70,19 +91,30 @@ def _add_arguments(self): ) def compute_position(self): - """Unlike other OSD windows, this one is scaled to 80% of screen size and centered in on active screen.""" - x, y = 10, 10 + """Fit the (per-controller) binding image to the active screen and centre + it. Unlike other OSD windows this one is nearly screen-sized, so it is + capped to 80% of the screen in BOTH dimensions and scaled down to fit.""" iw, ih = self.background.image_width, self.background.image_height geometry = self.get_active_screen_geometry() - if geometry: - width, height = iw, ih - if width > geometry.width * 0.8: - width = geometry.width * 0.8 - height = int(float(ih) / float(iw) * float(width)) - self.background.resize(width, height) - self.background.hilight({}) - x = geometry.x + ((geometry.width - width) / 2) - y = geometry.y + ((geometry.height - height) / 2) + if geometry is None: + # The Steam Deck (gamescope) reports no active window; fall back to the + # primary monitor so the image is still fitted instead of shown at full + # 1280x720, which overflows the Deck's 1280x800 screen. + screen = self.get_window().get_screen() + geometry = screen.get_monitor_geometry(screen.get_primary_monitor()) + if geometry is None: + return 10, 10 + # Cap to 80% of the screen in BOTH dimensions: width-only scaling left the + # window overflowing on screens barely larger than the image (the Deck's + # 1280x800 vs the 1280x720 image), and off-screen boxes made their + # connector lines appear to shoot outside the window. + scale = min(1.0, geometry.width * 0.8 / iw, geometry.height * 0.8 / ih) + width, height = int(iw * scale), int(ih * scale) + if scale < 1.0: + self.background.resize(width, height) + self.background.hilight({}) + x = geometry.x + (geometry.width - width) // 2 + y = geometry.y + (geometry.height - height) // 2 return x, y def parse_arguments(self, argv): @@ -114,6 +146,16 @@ def success(*a): self.on_failed_to_lock("Controller not connected") return + # The binding-display image is per-controller, so it can only be + # resolved now that we know which controller is connected. show() left + # the window unbuilt; build it here and draw the profile the controller + # already has (the daemon may have reported the profile before the + # window existed, so relying on the profile-changed signal alone would + # leave the boxes blank until the next profile change). + if self.background is None: + self._build_and_show(self._resolve_image(c)) + self._draw_profile(c.get_profile()) + self._eh_ids += [ (c, c.connect("event", self.on_event)), (c, c.connect("lost", self.on_controller_lost)), @@ -123,6 +165,55 @@ def success(*a): locks = ["RB", "LB", self.args.cancel_with] c.lock(success, self.on_failed_to_lock, *locks) + def _resolve_image(self, controller: ControllerManager) -> str: + """Picks the binding-display SVG for the connected controller. + + Order of preference: + 1. an explicit image given on the command line + 2. "binding_display" filename set in the controller's gui config + 3. convention: binding-display/.svg + (looked up in ~/.config/scc first, then the bundled images dir) + 4. the generic binding-display.svg (user override, then bundled) + The generic fallback keeps controllers without a dedicated layout + working - they just render on the old template, as before. + """ + images_path = os.path.join(get_share_path(), "images") + config_path = get_config_path() + candidates = [] + # 1. explicit command-line image (default equals self.bdisplay) + cli = getattr(self.args, "image", None) + if cli and cli != self.bdisplay: + candidates.append(cli) + # 2./3. per-controller, from the controller's gui config + try: + config = controller.load_gui_config(images_path) + except Exception: + log.exception("Failed to load controller gui config") + config = None + gui = (config or {}).get("gui") or {} + explicit = gui.get("binding_display") + if explicit: + if "/" in explicit: + candidates.append(explicit) + else: + candidates.append(os.path.join(config_path, explicit)) + candidates.append(os.path.join(images_path, explicit)) + background = gui.get("background") + self._layout_key = background # selects the per-controller box layout + if background: + # per-controller templates live in the binding-display/ subdir, with + # an optional user override under ~/.config/scc/binding-display/. + fname = os.path.join("binding-display", "%s.svg" % (background,)) + candidates.append(os.path.join(config_path, fname)) + candidates.append(os.path.join(images_path, fname)) + # 4. generic fallback (already user-override-then-bundled) + candidates.append(self.bdisplay) + for path in candidates: + if path and os.path.exists(path): + log.debug("Using binding-display image: %s", path) + return path + return self.bdisplay + def quit(self, code=-1): if self.get_controller(): self.get_controller().unlock_all() @@ -132,13 +223,21 @@ def quit(self, code=-1): OSDWindow.quit(self, code) def show(self, *a): - if self.background is None: - self.realize() - self.background = SVGWidget(self.args.image, init_hilighted=True) - self.c.add(self.background) - self.add(self.c) - - OSDWindow.show(self, *a) + # The background image is per-controller and only known once the daemon + # reports the connected controller, so the real show is deferred to + # on_daemon_connected() -> _build_and_show(). Until the background + # exists this is a no-op (run() calls show() before the daemon is up). + if self.background is not None: + OSDWindow.show(self, *a) + self.move(*self.compute_position()) + + def _build_and_show(self, image: str) -> None: + """Builds the window around the given background image and shows it.""" + self.realize() + self.background = SVGWidget(image, init_hilighted=True) + self.c.add(self.background) + self.add(self.c) + OSDWindow.show(self) self.move(*self.compute_position()) def on_event(self, daemon, what, data): @@ -199,8 +298,10 @@ class Box: SPACING = 2 MIN_WIDTH = 100 MIN_HEIGHT = 50 + MIN_SCALE = 0.4 # smallest font shrink before lines may overflow anyway - def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_height=MIN_HEIGHT, max_width=999999): + def __init__(self, anchor_x: int, anchor_y: int, align: Align, name: str, min_width: int = MIN_WIDTH, + min_height: int = MIN_HEIGHT, max_width: int = 999999, max_height: int = 999999) -> None: self.name = name self.lines = [] self.anchor = anchor_x, anchor_y @@ -209,6 +310,7 @@ def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_hei self.x, self.y = 0, 0 self.min_width = min_width self.max_width = max_width + self.max_height = max_height self.min_height = min_height def to_string(self): @@ -237,7 +339,7 @@ def add(self, icon, context, action): action = action.strip() if isinstance(action, MenuAction): - if self.name == "bcs" and action.menu_id == "Default.menu": + if self.name in ("bcs", "system") and action.menu_id == "Default.menu": # Special case, this action is expected in every profile, # so there is no need to draw it here return LineCollection() @@ -277,6 +379,17 @@ def calculate(self, gen): self.width += 2 * self.PADDING + self.icount * (gen.line_height + self.SPACING) self.width = min(self.width, self.max_width) self.height = max(self.height, self.min_height) + # Auto-scale the font for this box so all its lines fit within max_height. + # place() draws every line regardless of box height, so without this a + # crowded box (e.g. a stick bound to a big radial/dpad, or mode-heavy face + # buttons) overflows downward off the box and the screen. + content = self.height - 2 * self.PADDING + avail = self.max_height - 2 * self.PADDING + if content > avail and content > 0: + self.scale = max(self.MIN_SCALE, avail / content) + self.height = self.max_height + else: + self.scale = 1.0 anchor_x, anchor_y = self.anchor if (self.align & Align.TOP) != 0: @@ -307,9 +420,12 @@ def place(self, gen, root): y=self.y, ) + scale = getattr(self, "scale", 1.0) + lh = gen.line_height * scale + text_style = gen.label_style(scale) y = self.y + self.PADDING for line in self.lines: - h = gen.line_height + h = lh x = self.x + self.PADDING for icon in line.icons: image = find_image(icon) @@ -327,12 +443,12 @@ def place(self, gen, root): x += h + self.SPACING x = self.x + self.PADDING + self.icount * (h + self.SPACING) y += h - txt = SVGEditor.add_element(root, "text", x=x, y=y, style=gen.label_template.attrib["style"]) - max_line_width = self.max_width - gen.line_height - self.PADDING - while line.text and line.get_size(gen)[0] > max_line_width: + txt = SVGEditor.add_element(root, "text", x=x, y=y, style=text_style) + max_line_width = self.max_width - lh - self.PADDING + while line.text and line.get_size(gen)[0] * scale > max_line_width: line.text = line.text[:-1] SVGEditor.set_text(txt, line.text) - y += self.SPACING + y += self.SPACING * scale def place_marker(self, gen, root): x1, y1 = self.x, self.y @@ -380,19 +496,133 @@ def place_marker(self, gen, root): ) +# --- per-controller binding-display layouts -------------------------------- +# Which controls go in which box is semantic, so it lives here rather than in +# the template SVG (which only supplies marker positions + the canvas). Keyed by +# the controller's gui "background" name; controllers without an entry fall back +# to the v1 layout (Generator._build_v1). Box positions are auto-placed from the +# Align flags; size caps are fractions of the canvas. Only bound controls draw a +# line and a box with no bound controls is hidden (Generator._build_layout), so +# every variant (grip-squeeze, stick/pad touch & press, ...) can be listed and +# simply stays invisible until the user binds it. +_B, _T, _P, _S = Action.AC_BUTTON, Action.AC_TRIGGER, Action.AC_PAD, Action.AC_STICK + + +def _btn(name: str) -> Callable[[Profile], Action | None]: + return lambda p: p.buttons.get(SCButtons[name]) + + +def _pad(side: str) -> Callable[[Profile], Action | None]: + return lambda p: p.pads.get(side) + + +def _trig(side: str) -> Callable[[Profile], Action | None]: + return lambda p: p.triggers.get(side) + + +def _stick(p: Profile) -> Action: + return p.stick + + +def _rstick(p: Profile) -> Action | None: + return getattr(p, "rstick", None) + + +LAYOUTS = { + # Steam Controller v2 (2026) -- Steam Deck control set: two sticks, a D-pad, + # two trackpads, four system buttons, back paddles + grip-squeeze sensors. + # Six boxes: four corners + top/bottom centre, controller art in the middle. + "sc2": [ + dict(name="system", align=Align.TOP, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("BACK", _B, _btn("BACK")), ("C", _B, _btn("C")), + ("START", _B, _btn("START")), ("DOTS", _B, _btn("DOTS"))]), + dict(name="lshoulder", align=Align.LEFT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("LT", _T, _trig(LEFT)), ("LB", _B, _btn("LB")), + ("LGRIP", _B, _btn("LGRIP")), ("LGRIP2", _B, _btn("LGRIP2")), + ("LGRIPTOUCH", _B, _btn("LGRIPTOUCH"))]), + dict(name="rshoulder", align=Align.RIGHT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RT", _T, _trig(RIGHT)), ("RB", _B, _btn("RB")), + ("RGRIP", _B, _btn("RGRIP")), ("RGRIP2", _B, _btn("RGRIP2")), + ("RGRIPTOUCH", _B, _btn("RGRIPTOUCH"))]), + dict(name="lthumb", align=Align.LEFT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("STICK", _S, _stick), ("DPAD", _P, _pad(DPAD)), + ("LPAD", _P, _pad(LEFT))]), + dict(name="rthumb", align=Align.RIGHT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RSTICK", _S, _rstick), ("RPAD", _P, _pad(RIGHT))]), + dict(name="face", align=Align.BOTTOM, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("A", _B, _btn("A")), ("B", _B, _btn("B")), + ("X", _B, _btn("X")), ("Y", _B, _btn("Y"))]), + ], +} + +# The Steam Deck's built-in controller shares the v2's physical control set (same +# boxes, same controls), so it reuses the v2 layout. Controls the Deck lacks stay +# unbound and their boxes render nothing, so the shared list is safe. +LAYOUTS["deck"] = LAYOUTS["sc2"] + +# Standard gamepads (DualShock 4, DualSense, Xbox 360). Their control model +# differs from the Steam controllers: the right stick is the right pad +# (pads[RIGHT], not rstick) and the d-pad is the left pad (pads[LEFT]); there are +# no trackpads or grip-touch. Same six-box frame. ds4/ds5/x360 are physically +# alike, so they share one layout. +_GAMEPAD_LAYOUT = [ + dict(name="system", align=Align.TOP, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("BACK", _B, _btn("BACK")), ("C", _B, _btn("C")), + ("START", _B, _btn("START"))]), + dict(name="lshoulder", align=Align.LEFT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("LT", _T, _trig(LEFT)), ("LB", _B, _btn("LB")), + ("LGRIP", _B, _btn("LGRIP"))]), + dict(name="rshoulder", align=Align.RIGHT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RT", _T, _trig(RIGHT)), ("RB", _B, _btn("RB")), + ("RGRIP", _B, _btn("RGRIP"))]), + dict(name="lthumb", align=Align.LEFT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("STICK", _S, _stick), ("DPAD", _P, _pad(LEFT))]), + dict(name="rthumb", align=Align.RIGHT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RSTICK", _P, _pad(RIGHT))]), + dict(name="face", align=Align.BOTTOM, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("A", _B, _btn("A")), ("B", _B, _btn("B")), + ("X", _B, _btn("X")), ("Y", _B, _btn("Y"))]), +] +LAYOUTS["ds4"] = LAYOUTS["ds5"] = LAYOUTS["x360"] = _GAMEPAD_LAYOUT + + class Generator: PADDING = 10 - def __init__(self, editor, profile): + def __init__(self, editor: SVGEditor, profile: Profile, layout_key: str | None = None) -> None: background = SVGEditor.get_element(editor, "background") self.label_template = SVGEditor.get_element(editor, "label_template") self.line_height = int(float(self.label_template.attrib.get("height") or 8)) self.char_width = int(float(self.label_template.attrib.get("width") or 8)) self.full_width = int(float(background.attrib.get("width") or 800)) self.full_height = int(float(background.attrib.get("height") or 800)) + self._label_style = self.label_template.attrib.get("style", "") + m = re.search(r"font-size:\s*([\d.]+)", self._label_style) + self.font_size = float(m.group(1)) if m else self.line_height * 1.45 + root = SVGEditor.get_element(editor, "root") + + layout = LAYOUTS.get(layout_key) + if layout is None: + self._build_v1(profile, root) + else: + self._build_layout(profile, root, layout) + editor.commit() + + def _build_v1(self, profile: Profile, root: ET.Element) -> None: + """The original 5-box layout (Steam Controller v1: one stick, no D-pad, + three system buttons). Used for v1 and any controller without a dedicated + entry in LAYOUTS.""" boxes = [] - box_bcs = Box(0, self.PADDING, Align.TOP, "bcs") + box_bcs = Box(0, self.PADDING, Align.TOP, "bcs", max_height=self.full_height * 0.25) box_bcs.add("BACK", Action.AC_BUTTON, profile.buttons.get(SCButtons.BACK)) box_bcs.add("C", Action.AC_BUTTON, profile.buttons.get(SCButtons.C)) box_bcs.add("START", Action.AC_BUTTON, profile.buttons.get(SCButtons.START)) @@ -406,6 +636,7 @@ def __init__(self, editor, profile): min_height=self.full_height * 0.5, min_width=self.full_width * 0.2, max_width=self.full_width * 0.275, + max_height=self.full_height * 0.85, ) box_left.add("LEFT", Action.AC_TRIGGER, profile.triggers.get(profile.LEFT)) box_left.add("LB", Action.AC_BUTTON, profile.buttons.get(SCButtons.LB)) @@ -421,6 +652,7 @@ def __init__(self, editor, profile): min_height=self.full_height * 0.5, min_width=self.full_width * 0.2, max_width=self.full_width * 0.275, + max_height=self.full_height * 0.85, ) box_right.add("RIGHT", Action.AC_TRIGGER, profile.triggers.get(profile.RIGHT)) box_right.add("RB", Action.AC_BUTTON, profile.buttons.get(SCButtons.RB)) @@ -429,7 +661,8 @@ def __init__(self, editor, profile): boxes.append(box_right) box_abxy = Box( - 4 * self.PADDING, self.PADDING, Align.RIGHT | Align.BOTTOM, "abxy", max_width=self.full_width * 0.45, + 4 * self.PADDING, self.PADDING, Align.RIGHT | Align.BOTTOM, "abxy", + max_width=self.full_width * 0.45, max_height=self.full_height * 0.25, ) box_abxy.add("A", Action.AC_BUTTON, profile.buttons.get(SCButtons.A)) box_abxy.add("B", Action.AC_BUTTON, profile.buttons.get(SCButtons.B)) @@ -438,15 +671,12 @@ def __init__(self, editor, profile): boxes.append(box_abxy) box_stick = Box( - 4 * self.PADDING, self.PADDING, Align.LEFT | Align.BOTTOM, "stick", max_width=self.full_width * 0.45, + 4 * self.PADDING, self.PADDING, Align.LEFT | Align.BOTTOM, "stick", + max_width=self.full_width * 0.45, max_height=self.full_height * 0.25, ) box_stick.add("STICK", Action.AC_STICK, profile.stick) boxes.append(box_stick) - w = int(float(background.attrib.get("width") or 800)) - h = int(float(background.attrib.get("height") or 800)) - - root = SVGEditor.get_element(editor, "root") for b in boxes: b.calculate(self) @@ -465,7 +695,46 @@ def __init__(self, editor, profile): for b in boxes: b.place(self, root) - editor.commit() + def _build_layout(self, profile: Profile, root: ET.Element, layout: list[dict]) -> None: + """Builds boxes from a per-controller LAYOUTS spec. Box positions are + auto-placed from the Align flags; size caps come from canvas fractions. + Boxes whose controls are all unbound draw nothing and are dropped.""" + boxes = [] + for spec in layout: + box = Box( + spec.get("ax", self.PADDING), + spec.get("ay", self.PADDING), + spec["align"], + spec["name"], + min_width=spec.get("min_width_f", 0) * self.full_width or Box.MIN_WIDTH, + min_height=spec.get("min_height_f", 0) * self.full_height or Box.MIN_HEIGHT, + max_width=spec.get("max_width_f", 1.0) * self.full_width, + max_height=spec.get("max_height_f", 1.0) * self.full_height, + ) + for icon, context, getter in spec["controls"]: + box.add(icon, context, getter(profile)) + boxes.append(box) + + for b in boxes: + b.calculate(self) + # Hide boxes that ended up with no bound controls. + boxes = [b for b in boxes if b.lines] + + for b in boxes: + b.place_marker(self, root) + for b in boxes: + b.place(self, root) + + def label_style(self, scale: float) -> str: + """Label text style with font-size scaled by `scale` (used to shrink a + crowded box so its lines fit). Returns the template style unchanged at + scale 1.0.""" + if scale >= 0.999: + return self._label_style + fs = self.font_size * scale + if re.search(r"font-size:\s*[\d.]+px", self._label_style): + return re.sub(r"font-size:\s*[\d.]+px", "font-size:%.1fpx" % (fs,), self._label_style) + return "font-size:%.1fpx;%s" % (fs, self._label_style) def equal_width(self, *boxes): """Sets width of all passed boxes to width of widest box""" diff --git a/scc/osd/grid_menu.py b/scc/osd/grid_menu.py index 1c608cef9..41e593cee 100644 --- a/scc/osd/grid_menu.py +++ b/scc/osd/grid_menu.py @@ -25,6 +25,9 @@ def __init__(self, cls="osd-menu"): Menu.__init__(self, cls) self.ipr = 1 # items per row + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: + return parent # grid menus manage their own fixed layout + def create_parent(self): g = Gtk.Grid() g.set_name("osd-menu") diff --git a/scc/osd/keyboard.py b/scc/osd/keyboard.py index e240e9b14..6ec47da88 100644 --- a/scc/osd/keyboard.py +++ b/scc/osd/keyboard.py @@ -367,7 +367,7 @@ def use_daemon(self, d: DaemonManager) -> None: self._cononect_handlers() self.on_daemon_connected(self.daemon) - def redraw_background(self, *a) -> None: + def redraw_background(self, *a: object) -> None: """Forces a repaint of the keyboard background image. Called by the OSD daemon after recolor()/update_labels() when the diff --git a/scc/osd/menu.py b/scc/osd/menu.py index dedfaa491..5b209d25f 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -16,6 +16,7 @@ from scc.lib import xwrappers as X from scc.menu_data import MenuData, Separator, Submenu from scc.osd import OSDWindow, StickController, menu_generators +from scc.parser import TalkingActionParser from scc.paths import get_share_path from scc.tools import _, circle_to_square, clamp, find_icon, find_menu from scc.x11 import autoswitcher @@ -55,7 +56,7 @@ def __init__(self, cls="osd-menu", layer=None): self.parent = self.create_parent() self.f = Gtk.Fixed() - self.f.add(self.parent) + self.f.add(self.scroll_wrap(self.parent)) self.add(self.f) self._submenu = None @@ -66,6 +67,14 @@ def __init__(self, cls="osd-menu", layer=None): self._menuid = None self._use_cursor = False self._eh_ids = [] + # Reveal-after-lock state: the menu is not made visible until inputs are + # locked, so a button press can't reach the normal mapping before the + # (asynchronous) lock lands. A fallback reveals anyway if the lock is + # slow, so the menu can never get stuck invisible. + self._inputs_locked = False + self._show_pending = False + self._reveal_timer = None + self._quit_done = False self._control_with = STICK self._control_with_dpad = False self._confirm_with = "A" @@ -82,6 +91,63 @@ def create_parent(self): v.set_name("osd-menu") return v + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: + """Wrap the vertical item list in a scrolled viewport capped to the screen + height, so very long menus (e.g. hundreds of profiles) don't run off-screen. + Overridden to a no-op by grid/radial/horizontal menus.""" + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + try: + sw.set_shadow_type(Gtk.ShadowType.NONE) + except Exception: + pass + sw.add(parent) + self._scrollwindow = sw + return sw + + def _max_menu_height(self) -> int: + """Largest the menu may grow before scrolling (monitor height minus margin).""" + try: + display = Gdk.Display.get_default() + monitor = display.get_primary_monitor() or display.get_monitor(0) + return max(240, monitor.get_geometry().height - 80) + except Exception: + return 720 + + def _fit_scroll(self) -> None: + """Size the scrolled viewport to the packed items, capped to the screen. + The item box is empty when scroll_wrap() runs and a GtkFixed won't expand + the viewport afterwards, so the size is set here once items are present.""" + sw = getattr(self, "_scrollwindow", None) + if sw is None: + return + self.parent.show_all() + nath = self.parent.get_preferred_height()[1] + natw = self.parent.get_preferred_width()[1] + cap = self._max_menu_height() + if nath > cap: + sw.set_size_request(natw + 24, cap) + else: + sw.set_size_request(natw, nath) + + def _ensure_visible(self, widget: Gtk.Widget) -> None: + """Scroll the viewport (if any) so the selected item stays on screen.""" + sw = getattr(self, "_scrollwindow", None) + if sw is None or widget is None: + return + adj = sw.get_vadjustment() + if adj is None: + return + alloc = widget.get_allocation() + page = adj.get_page_size() + if alloc.height <= 0 or page <= 0: + return + val = adj.get_value() + if alloc.y < val: + adj.set_value(alloc.y) + elif alloc.y + alloc.height > val + page: + adj.set_value(alloc.y + alloc.height - page) + def pack_items(self, parent, items): for item in items: parent.pack_start(item.widget, True, True, 0) @@ -94,6 +160,14 @@ def use_daemon(self, d): if not self._is_submenu: self._connect_handlers() self.on_daemon_connected(self.daemon) + else: + # A submenu reuses the parent menu's input lock (the parent forwards + # events to it), so it never requests its own lock and + # _on_inputs_locked() is never called. Treat its inputs as already + # locked; otherwise the reveal-when-locked gate added for the + # dead-menu fix keeps the submenu window permanently hidden (it is + # navigable via forwarded input but never shown). + self._inputs_locked = True def use_config(self, c): """Allows reusing already existin Config instance in same process. @@ -167,10 +241,15 @@ def _get_on_screen_position(w): return a.x, a.y def parse_menu(self): + # Parse actions only when we may need to filter items by what they do + # (dropping "Turn Controller OFF" on the Steam Deck). Other controllers + # keep the lighter no-parse load: the daemon runs a menu action by id, so + # the OSD menu itself never needs the parsed action. + parser = TalkingActionParser() if getattr(self.args, "controller_type", None) == "deck" else None if self.args.from_profile: try: self._menuid = self.args.items[0] - self.items = MenuData.from_profile(self.args.from_profile, self._menuid) + self.items = MenuData.from_profile(self.args.from_profile, self._menuid, parser) except OSError: print("%s: error: profile file not found" % (sys.argv[0]), file=sys.stderr) return False @@ -180,7 +259,7 @@ def parse_menu(self): elif self.args.from_file: try: self._menuid = self.args.from_file - self.items = MenuData.from_file(self.args.from_file) + self.items = MenuData.from_file(self.args.from_file, parser) except: print("%s: error: failed to load menu file" % (sys.argv[0]), file=sys.stderr) return False @@ -193,6 +272,24 @@ def parse_menu(self): return False return True + def _drop_inapplicable_items(self, items): + """Removes generated menu items that don't apply to the connected + controller - currently "Turn Controller OFF" for the Steam Deck's + built-in controls, which can't be powered off. The live controller + isn't known when items are built (that happens before we connect to + the daemon), so the daemon passes its type via --controller-type. + """ + if getattr(self.args, "controller_type", None) != "deck": + return items + def is_turnoff(action): + # turnoff() directly, or wrapped e.g. as osd(turnoff()) - unwrap .action + while action is not None: + if getattr(action, "SA", "") == "turnoff": + return True + action = getattr(action, "action", None) + return False + return [i for i in items if not is_turnoff(getattr(i, "action", None))] + def parse_arguments(self, argv): if not OSDWindow.parse_arguments(self, argv): return False @@ -205,7 +302,7 @@ def parse_arguments(self, argv): self._size = self.args.size # Create buttons that are displayed on screen - items = self.items.generate(self) + items = self._drop_inapplicable_items(self.items.generate(self)) self.items = [] for item in items: item.widget = self.generate_widget(item) @@ -293,6 +390,7 @@ def select(self, index): self.controller.feedback(*self.feedback) self._selected = self.items[index] self._selected.widget.set_name(self._selected.widget.get_name() + "-selected") + self._ensure_visible(self._selected.widget) GLib.timeout_add(2, self._check_on_screen_position) return True return False @@ -342,9 +440,45 @@ def run(self): def show(self, *a): if not self.select(0): self.next_item(1) - OSDWindow.show(self, *a) + self._fit_scroll() + # Reveal only once inputs are locked, so a button press can never reach + # the normal mapping before the lock lands. That matters for more than a + # stray keystroke: if the press is handled by the old action (e.g. a key + # goes DOWN) and the release is then captured by the menu, the key never + # goes UP and gets stuck. So we must never show the menu while unlocked: + # if the lock does not land within the timeout, give up and CLOSE the + # menu rather than revealing it. + self._show_pending = True + if self._reveal_timer is None: + self._reveal_timer = GLib.timeout_add(2000, self._lock_timed_out) + self._reveal_if_locked() + + def _on_inputs_locked(self, *a: object) -> None: + """Called from the lock-success callback, once inputs are diverted to + this menu and it is safe to reveal it.""" + self._inputs_locked = True + self._reveal_if_locked() + + def _reveal_if_locked(self) -> None: + """Reveal the menu, but only once it is both requested and locked.""" + if not (self._show_pending and self._inputs_locked): + return + self._show_pending = False + if self._reveal_timer is not None: + GLib.source_remove(self._reveal_timer) + self._reveal_timer = None + OSDWindow.show(self) GLib.timeout_add(1, self._check_on_screen_position, True) + def _lock_timed_out(self, *a: object) -> None: + """The lock never landed; close the menu instead of revealing it while + unlocked (which could leak input or strand a key).""" + self._reveal_timer = None + if self._show_pending: + self._show_pending = False + self.quit(3) + return False + def on_daemon_connected(self, *a): if not self.config: self.config = Config() @@ -399,7 +533,7 @@ def use_controller(self, controller): def lock_inputs(self): def success(*a): - log.error("Sucessfully locked input") + self._on_inputs_locked() locks = [self._control_with, self._confirm_with, self._cancel_with] if self._control_with == "STICK": @@ -409,6 +543,19 @@ def success(*a): self.controller.lock(success, self.on_failed_to_lock, *locks) def quit(self, code=-2): + # A menu must unlock exactly once. quit() can be re-entered — most + # importantly by a *previous* menu's leftover timeout firing after this + # object should be gone — and unlock_all() is per-CLIENT: it clears every + # lock the OSD daemon holds, not just this menu's. Re-running it would + # strip the locks of whatever menu is open *now*, leaving it visible but + # dead (it stops responding and leaks input to the normal mapping). So + # make quit idempotent: a menu that already quit never unlocks again. + if self._quit_done: + return + self._quit_done = True + if self._reveal_timer is not None: + GLib.source_remove(self._reveal_timer) + self._reveal_timer = None if not self._is_submenu: if self.get_controller(): self.get_controller().unlock_all() diff --git a/scc/osd/quick_menu.py b/scc/osd/quick_menu.py index 515aa79f5..757a18239 100644 --- a/scc/osd/quick_menu.py +++ b/scc/osd/quick_menu.py @@ -108,7 +108,6 @@ def _check_on_screen_position(self, quick=False): def lock_inputs(self): def success(*a): - log.error("Sucessfully locked input") config = self.controller.load_gui_config(os.path.join(get_share_path(), "images")) if config and config["gui"] and config["gui"]["buttons"]: buttons = config["gui"]["buttons"] @@ -117,10 +116,16 @@ def success(*a): icon = self._icons[i] name = buttons[self.BUTTON_INDEXES[i]] filename, trash = find_icon("buttons/%s" % name) - icon.set_filename(filename) - icon.queue_draw() + # Some controllers (e.g. the v2) name their face buttons + # sc2_A/B/X/Y, which have no icon under buttons/. Only + # override when a real icon is found, otherwise keep the + # generic A/B/X/Y/LB/RB icon already set in generate_widget. + if filename: + icon.set_filename(filename) + icon.queue_draw() except IndexError: pass + self._on_inputs_locked() locks = [x for x in self.BUTTONS] + [self._cancel_with] self.controller.lock(success, self.on_failed_to_lock, *locks) @@ -139,7 +144,7 @@ def parse_arguments(self, argv): self._timeout = self.args.timeout # Create buttons that are displayed on screen - items = self.items.generate(self) + items = self._drop_inapplicable_items(self.items.generate(self)) self.items = [] self._button_index = 0 for item in items: @@ -241,6 +246,13 @@ def cancel_timer(self): GLib.source_remove(self._timer) self._timer = None + def quit(self, code: int = -2) -> None: + # Cancel the auto-timeout before quitting; otherwise it lingers and fires + # after this menu is gone, calling quit() again -> unlock_all(), which is + # per-client and would clear the *next* menu's locks. See Menu.quit. + self.cancel_timer() + Menu.quit(self, code) + def on_event(self, daemon, what, data): if self._submenu: return self._submenu.on_event(daemon, what, data) diff --git a/scc/osd/radial_menu.py b/scc/osd/radial_menu.py index 39d9dfba5..6c11a723f 100644 --- a/scc/osd/radial_menu.py +++ b/scc/osd/radial_menu.py @@ -51,6 +51,9 @@ def __init__(self) -> None: self.set_app_paintable(True) self.connect("draw", self._on_draw_clip_circle) + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: + return parent # radial menu draws items on an SVG; no scroll viewport + def create_parent(self) -> SVGWidget: background = os.path.join(get_share_path(), "images", "radial-menu.svg") self.b = SVGWidget(background) diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 6c03ff4c4..bb194665a 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -33,7 +33,7 @@ from scc.poller import Poller from scc.profile import Profile from scc.scheduler import Scheduler -from scc.tools import clamp, find_binary, find_menu, find_profile, nameof, set_logging_level, shjoin, shsplit +from scc.tools import clamp, find_binary, find_menu, find_profile, get_profile_name, nameof, set_logging_level, shjoin, shsplit from scc.uinput import CannotCreateUInputException if TYPE_CHECKING: @@ -280,6 +280,31 @@ def _set_profile(self, mapper: Mapper, filename: str) -> None: else: self.send_profile_info(None, self._send_to_all, mapper=mapper) + def _remember_controller_profile(self, client: "Client", filename: str) -> None: + """Persists a controller's profile so it is restored on (re)connect. + + Only explicit, user-initiated selections are remembered: the autoswitch + daemon's contextual per-window switches and transient live-edit (.mod) + profiles are skipped. Stored by name under + config["controllers"][]["profile"]; with "Use Serial Numbers" on + that id is the physical device, otherwise it is the connection slot. + """ + if client is self.autoswitch_daemon: + # Autoswitcher changes are contextual, not the controller's choice + return + if filename.endswith(".mod"): + # Transient profile produced while live-editing in the GUI + return + c = client.mapper.get_controller() if client.mapper else None + if c is None: + return + name = get_profile_name(filename) + config = Config() + cc = config.get_controller_config(c.get_id()) + if cc.get("profile") != name: + cc["profile"] = name + config.save() + def _send_to_all(self, message_str: bytes) -> None: """Sends message to all connect clients. @@ -311,6 +336,25 @@ def on_sa_led(self, mapper: Mapper, action: LedAction) -> None: def on_sa_shell(self, mapper: Mapper, action: ShellCommandAction) -> Popen[bytes]: """Called when 'shell' action is used""" + cmd = shsplit(action.command) + # scc's own helpers (scc-osd-launcher, scc-osd-show-bindings, sc-controller + # ...) are Python entry points whose shebang and PATH can't be relied on in + # every environment - notably inside the AppImage, where a bare shell spawn + # silently fails (the same reason the daemon launches its other helpers via + # find_python() + find_binary()). Launch these the same way, bypassing the + # shebang; arbitrary user commands still go through the shell unchanged. + if cmd and (cmd[0].startswith("scc-") or cmd[0] == "sc-controller"): + args = cmd[1:] + # scc-osd-show-bindings renders, and locks input on, one specific + # controller. Without --controller it falls back to the first + # connected controller (OSDWindow.choose_controller), so with several + # controllers it shows the wrong one's bindings AND its cancel button + # is locked on that other controller, so the window can't be + # dismissed. Target the controller that actually invoked the action. + c = mapper.get_controller() + if c and cmd[0] == "scc-osd-show-bindings" and "--controller" not in args: + args = ["--controller", c.get_id(), *args] + return subprocess.Popen([find_python(), find_binary(cmd[0]), *args]) return subprocess.Popen(action.command, shell=True) def on_sa_gestures(self, mapper: Mapper, action: GesturesAction, x, y, what) -> None: @@ -394,8 +438,9 @@ def on_sa_keyboard(self, mapper: Mapper, action: KeyboardAction) -> None: def on_sa_menu(self, mapper: Mapper, action: MenuAction, *pars) -> None: """Called when 'menu' action is used""" p = [action.MENU_TYPE] - if mapper.get_controller(): - p += ["--controller", mapper.get_controller().get_id()] + c = mapper.get_controller() + if c: + p += ["--controller", c.get_id(), "--controller-type", c.get_type()] if "." in action.menu_id: path = find_menu(action.menu_id) if not path: @@ -568,9 +613,27 @@ def add_controller(self, c: Controller) -> None: else: # New controller, but no mapper created mapper = self.init_mapper() - self.load_default_profile(mapper) mapper.set_controller(c) c.set_mapper(mapper) + + # Load this controller's remembered profile if it has a valid one, + # otherwise fall back to the global default. Done explicitly (rather + # than relying on the mapper's existing profile) so a reused/pooled + # mapper never carries over the profile of a previously-bound + # controller. With a single controller and no remembered profile this + # is exactly the old behavior (the global default is loaded). + remembered = Config().get_controller_config(c.get_id()).get("profile") + path = find_profile(remembered) if remembered else None + if path: + try: + mapper.profile.load(path).compress() + log.debug("Loaded remembered profile '%s' for %s", remembered, c.get_id()) + except Exception as e: + log.warning("Failed to load remembered profile '%s' for %s: %s", remembered, c.get_id(), e) + self.load_default_profile(mapper) + else: + self.load_default_profile(mapper) + if mapper == self.default_mapper: log.debug("Assigned default_mapper to %s", c) if mapper.profile.gyro: @@ -779,6 +842,7 @@ def _handle_message(self, client: Client, message: bytes) -> None: try: filename = message[8:].decode("utf-8").strip("\t ") self._set_profile(client.mapper, filename) + self._remember_controller_profile(client, filename) log.info("Loaded profile '%s'", filename) client.wfile.write(b"OK.\n") except Exception as e: @@ -1279,6 +1343,16 @@ class LockedAction(ReportingAction): def __init__(self, what, client: Client, original_action) -> None: ReportingAction.__init__(self, what, client) self.original_action = original_action + # If the button being locked is currently held, the original action's + # press already ran (a key it bound may be DOWN). Its release will now be + # captured by this lock instead of the original action, leaving the key + # stuck down (it has locked people's keyboards). Release the original + # action here so the matching key-UP is sent. + if what in SCButtons.__members__.values() and self.mapper and (self.mapper.buttons & what): + try: + original_action.button_release(self.mapper) + except Exception as e: + log.warning("Failed to release held action while locking %s: %s", what, e) original_action.cancel(self.mapper) self._store_lock() log.debug("%s locked by %s", nameof(self.what), self.client) diff --git a/setup.py b/setup.py index 36c69a5f2..eb0e020d6 100755 --- a/setup.py +++ b/setup.py @@ -30,6 +30,13 @@ ] + [ # menu icons subfolders ("share/scc/images/menu-icons/" + x.split("/")[-1], [x + "/LICENSES"] + glob.glob(x + "/*.png")) for x in glob.glob("images/menu-icons/*") +] + [ # per-controller icon override dirs: images//*.svg (e.g. sc2, deck) + ("share/scc/images/" + d.split("/")[-1], glob.glob(d + "/*.svg")) + for d in (p.rstrip("/") for p in glob.glob("images/*/")) + if d.split("/")[-1] not in ( + "button-images", "controller-icons", "controller-images", + "24x24", "256x256", "menu-icons") + and glob.glob(d + "/*.svg") ] extensions = [ diff --git a/tests/test_gyro.py b/tests/test_gyro.py new file mode 100644 index 000000000..8a2c5b3dd --- /dev/null +++ b/tests/test_gyro.py @@ -0,0 +1,193 @@ +"""Runtime behaviour of the gyro actions and of the analog modeshift gate. + +Both are driven through a fake mapper: the real one needs a daemon, a +controller and uinput devices. +""" +import math + +from scc.actions import GyroAbsAction, GyroAction, RangeOP +from scc.constants import STICK_PAD_MAX, TRIGGER_MAX, ControllerFlags, SCButtons +from scc.parser import ActionParser +from scc.uinput import Axes + +# driver-side euler encoding: 2**15 / PI fixed point, see ControllerFlags.EUREL_GYROS +EUREL = 32768.0 / math.pi + + +class FakeGamepad: + def __init__(self): + self.events = {} + + def axisEvent(self, axis, value): + self.events.setdefault(axis, []).append(value) + + +class FakeController: + flags = ControllerFlags.EUREL_GYROS + + +class FakeState: + rtrig = 0 + + +class FakeMapper: + def __init__(self): + self.gamepad = FakeGamepad() + self.syn_list = set() + self.mouse_moves = [] + self.state = FakeState() + self._controller = FakeController() + + def get_controller(self): + return self._controller + + def mouse_move(self, dx, dy): + self.mouse_moves.append((dx, dy)) + + def send_feedback(self, *a): + pass + + +def sweep(action, mapper, to_degrees, steps=20): + """Rotates all three gyro axes from neutral to 'to_degrees'.""" + prev = None + for n in range(steps + 1): + a = math.radians(to_degrees * n / steps) + rates = (0, 0, 0) if prev is None else tuple([(a - prev) * 3000.0] * 3) + prev = a + q = int(a * EUREL) + action.gyro(mapper, rates[0], rates[1], rates[2], q, q, q, 0) + + +def peak(mapper, axis): + vals = mapper.gamepad.events[axis] + return max(vals, key=abs) + + +class TestGyroAxisRange: + """A gyro bound to a trigger must use the trigger's own 0..255 range. + + Feeding it a stick-range value and clamping (what it used to do) threw + away the negative half and saturated within a fraction of a degree, so + the trigger behaved like a button. + """ + + def test_absolute_trigger_is_proportional(self): + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 20) + # 20 of the 90 deg that deflect a stick fully -> ~22% of trigger travel + assert 0.15 * TRIGGER_MAX < peak(m, Axes.ABS_Z) < 0.30 * TRIGGER_MAX + + def test_absolute_trigger_reaches_full_pull(self): + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 90) + assert peak(m, Axes.ABS_Z) == TRIGGER_MAX + + def test_absolute_trigger_rests_released(self): + """A trigger has to sit at 0 when the controller is not rotated.""" + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 0) + assert set(m.gamepad.events[Axes.ABS_Z]) == {0} + + def test_relative_trigger_is_not_digital(self): + m = FakeMapper() + sweep(GyroAction(Axes.ABS_Z), m, -20) + assert 0 < peak(m, Axes.ABS_Z) < TRIGGER_MAX + + def test_stick_range_is_unchanged(self): + """The rescale must apply to triggers only.""" + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_X), m, 90) + assert peak(m, Axes.ABS_X) == STICK_PAD_MAX + + +class TestGyroSensitivity: + """Per-axis sensitivity used to be read from index 2 for every axis and + then applied a second time per-axis, squaring it. + """ + + def test_applied_once(self): + plain, scaled = FakeMapper(), FakeMapper() + a = GyroAbsAction(Axes.ABS_X) + sweep(a, plain, 10) + b = GyroAbsAction(Axes.ABS_X) + b.set_speed(2.0, 2.0, 2.0) + sweep(b, scaled, 10) + assert peak(scaled, Axes.ABS_X) == 2 * peak(plain, Axes.ABS_X) + + def test_is_per_axis(self): + """Sensitivity of gyro axis 0 must not be taken from axis 2.""" + m = FakeMapper() + a = GyroAbsAction(Axes.ABS_X, Axes.ABS_Y, Axes.ABS_RX) + a.set_speed(1.0, 1.0, 3.0) + sweep(a, m, 10) + assert peak(m, Axes.ABS_RX) == 3 * peak(m, Axes.ABS_X) + + def test_moves_the_full_deflection_point(self): + """Sensitivity has to change the rotation needed to peg the axis, not + just scale a value that already saturated at 90 deg. + """ + m = FakeMapper() + a = GyroAbsAction(Axes.ABS_X) + a.set_speed(3.0, 3.0, 3.0) + sweep(a, m, 35) # 3x35 deg is past the 90 deg full-deflection point + assert peak(m, Axes.ABS_X) == STICK_PAD_MAX + + +class TestRangeOPHysteresis: + """`mode(RT >= 0.7, ...)` must not flip while the trigger is parked near + the threshold: every flip runs ModeModifier's switch path, which recenters + gyro references and releases held buttons. + """ + + def test_holds_through_jitter(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int(0.9 * TRIGGER_MAX) + assert op(m) + # dip just under the raw threshold, still inside the hysteresis band + m.state.rtrig = int((0.7 - RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert op(m) + + def test_still_releases(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int(0.9 * TRIGGER_MAX) + assert op(m) + m.state.rtrig = int((0.7 - 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert not op(m) + + def test_needs_the_full_threshold_to_engage(self): + """Approaching from below, the band tightens rather than loosens.""" + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int((0.7 + RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert not op(m) + m.state.rtrig = int((0.7 + 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert op(m) + + def test_less_than_direction(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, "<", 0.3) + m.state.rtrig = 0 + assert op(m) + m.state.rtrig = int((0.3 + RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert op(m) + m.state.rtrig = int((0.3 + 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert not op(m) + + def test_gated_absolute_gyro_survives_jitter(self): + """End to end: the reported symptom was an absolute gyro producing + nothing at all when gated behind a trigger held at 70%. + """ + action = ActionParser().restart("mode(RT >= 0.7, gyroabs(Axes.ABS_X), None)").parse().compress() + m = FakeMapper() + prev = None + for n in range(21): + m.state.rtrig = TRIGGER_MAX if n % 3 else int(0.69 * TRIGGER_MAX) + a = math.radians(20.0 * n / 20) + rate = 0 if prev is None else (a - prev) * 3000.0 + prev = a + q = int(a * EUREL) + action.gyro(m, rate, rate, rate, q, q, q, 0) + assert peak(m, Axes.ABS_X) > 0.15 * STICK_PAD_MAX diff --git a/tests/test_parser/test_modifiers.py b/tests/test_parser/test_modifiers.py index 303ba8029..133902910 100644 --- a/tests/test_parser/test_modifiers.py +++ b/tests/test_parser/test_modifiers.py @@ -37,6 +37,13 @@ def test_released(self): a = _parse_compressed("pressed(axis(KEY_A))") assert isinstance(a, PressedModifier) + def test_inverted(self): + """Tests if InvertedButtonModifier is parsed + """ + a = _parse_compressed("inverted(button(KEY_A))") + assert isinstance(a, InvertedButtonModifier) + assert isinstance(a.action, ButtonAction) + def test_touched(self): """Tests if TouchedModifier is parsed""" a = _parse_compressed("touched(button(KEY_A))") diff --git a/tests/test_profile/test_modifiers.py b/tests/test_profile/test_modifiers.py index 81fa99383..f2195074b 100644 --- a/tests/test_profile/test_modifiers.py +++ b/tests/test_profile/test_modifiers.py @@ -53,6 +53,13 @@ def test_released(self): assert isinstance(a, ReleasedModifier) assert _is_axis_with_value(a.action) + def test_inverted(self): + """Tests if InvertedButtonModifier is parsed correctly from json. + """ + a = parser.from_json_data({"action": "inverted(axis(ABS_X))"}) + assert isinstance(a, InvertedButtonModifier) + assert _is_axis_with_value(a.action) + def test_touched(self): """Tests if TouchedModifier is parsed correctly from json.""" a = parser.from_json_data({"action": "touched(button(KEY_A))"}) diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py new file mode 100644 index 000000000..3ebffe9d5 --- /dev/null +++ b/tools/gen_binding_display.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Generate the per-controller binding-display templates from controller art. + +The OSD "Display Current Bindings" window (scc/osd/binding_display.py) draws each +control's binding into boxes laid out around a controller picture, using a +per-controller template SVG. This tool builds those templates -- one per entry +in CONTROLLERS -- straight from the existing controller drawings +(images/controller-images/.svg), so there is no hand-drawn per-controller +art to maintain: change a controller image and rerun this. + +For each controller it emits images/binding-display/.svg, containing all the +elements Generator needs: + - a 1280x720 canvas with `background` (sizes the layout + dark backdrop), + `label_template` (label font/metrics) and `root` (boxes are drawn into it); + - the controller drawing, scaled + centred into the canvas and recoloured into + the "Matrix" binding-display palette (green outlines + two greys) so it reads + as a subdued filled silhouette behind the bright binding boxes/labels; + - the six `markers_` groups (system/lshoulder/rshoulder/lthumb/rthumb/ + face -- the box set in binding_display.py LAYOUTS[]), each a ring at the + matching AREA_* anchor centre of the source drawing, mapped into canvas + coords. Each box draws a connector line to up to two of them. + +The AREA_* anchors live in an untransformed layer of the source drawing in its +own coords; the same drawing->canvas transform places both the drawing and the +markers, so they always register regardless of the source viewBox (sc2.svg has a +non-zero origin). If you change ART_MAX_*_FRAC, both move together. + +Run from repo root: python3 tools/gen_binding_display.py +""" +import copy +import math +import os +import re +import sys +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _svgo # noqa: E402 + +SVG = "http://www.w3.org/2000/svg" +ET.register_namespace("", SVG) + +CANVAS_W, CANVAS_H = 1280, 720 +OUT_DIR = "images/binding-display" + +# The controller drawing's footprint in the canvas (leaves the margins free for +# the binding boxes). Both the drawing and its AREA-anchor markers are placed +# through this, so they stay registered. +ART_MAX_W_FRAC = 0.38 +ART_MAX_H_FRAC = 0.70 + +# "Matrix" binding-display palette: the GUI controller art is full-colour, but +# here it is flattened to green outlines over two greys on a dark backdrop, so it +# recedes behind the bright binding boxes and labels while staying readable. +GREEN = "#047100" # every outline/stroke -> this green +GRAY_LIGHT = "#3d3d3d" # lighter fills (luminance >= GRAY_SPLIT) +GRAY_DARK = "#262626" # darker fills, and the default for un-filled shapes +GRAY_SPLIT = 80 # fill luminance split between GRAY_LIGHT and GRAY_DARK +MARKER_GREEN = "#06a400" # marker rings + connector lines (matches Generator) + +# Standard gamepads (ds4, ds5, x360) share one anchor set: left stick + d-pad +# (LPAD) on the left, right stick (RPAD) on the right, the ABXY face cluster, and +# the top bumpers/triggers. Their drawings use the same AREA_* names. +_GAMEPAD_MARKERS = { + "system": ["BACK", "START"], "lshoulder": ["LB_1", "LT_1"], + "rshoulder": ["RB_4", "RT_1"], "lthumb": ["STICK_1", "LPAD_1"], + "rthumb": ["RPAD_1"], "face": ["Y", "A"], +} + +# Per-controller source drawing + the AREA_* anchors each binding box points at. +# The box names match scc/osd/binding_display.py LAYOUTS[]. A box draws at +# most two connector lines; anchor names differ per controller image. +CONTROLLERS = { + "sc2": { + "src": "images/controller-images/sc2.svg", + "markers": { + "system": ["BACK", "START"], "lshoulder": ["LB", "LGRIPTOUCH"], + "rshoulder": ["RB", "RGRIPTOUCH"], "lthumb": ["STICK", "LPAD"], + "rthumb": ["RSTICK", "RPAD"], "face": ["Y", "A"], + }, + }, + "deck": { + "src": "images/controller-images/deck.svg", + # Deck AREA naming differs (segmented pads/bumpers, no grip-touch). + "markers": { + "system": ["BACK", "START"], "lshoulder": ["LB_2", "LT_2"], + "rshoulder": ["RB_2", "RT_2"], "lthumb": ["STICK", "LPAD_1"], + "rthumb": ["RSTICK", "RPAD_1"], "face": ["Y", "A"], + }, + }, + "ds4": {"src": "images/controller-images/ds4.svg", "markers": _GAMEPAD_MARKERS}, + "ds5": {"src": "images/controller-images/ds5.svg", "markers": _GAMEPAD_MARKERS}, + "x360": {"src": "images/controller-images/x360.svg", "markers": _GAMEPAD_MARKERS}, +} + +_FILL = re.compile(r"fill:\s*#([0-9a-fA-F]{3,6})") +_STROKE = re.compile(r"stroke:\s*#([0-9a-fA-F]{3,6})") + + +def q(tag: str) -> str: + return "{%s}%s" % (SVG, tag) + + +def parse_viewbox(svg: ET.Element) -> tuple[float, float, float, float]: + """(x, y, w, h) of the source viewBox -- x/y may be non-zero (e.g. sc2.svg).""" + vb = svg.get("viewBox") + if vb: + p = [float(x) for x in vb.replace(",", " ").split()] + return p[0], p[1], p[2], p[3] + return 0.0, 0.0, float(svg.get("width")), float(svg.get("height")) + + +# --- affine transforms (a, b, c, d, e, f) mapping (x,y) -> (ax+cy+e, bx+dy+f) --- +IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) +_TRANSFORM = re.compile(r"(matrix|translate|scale|rotate)\s*\(([^)]*)\)") + + +def _compose(a: tuple, b: tuple) -> tuple: + """Return a after b (apply b first, then a) -- SVG's nested-transform order.""" + a1, b1, c1, d1, e1, f1 = a + a2, b2, c2, d2, e2, f2 = b + return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1) + + +def _parse_transform(s: str | None) -> tuple: + """Parse an SVG transform attribute into one composed affine matrix. Handles + space- OR comma-separated args and a list of transforms (leftmost outermost).""" + m = IDENTITY + if not s: + return m + for name, argstr in _TRANSFORM.findall(s): + v = [float(x) for x in re.split(r"[\s,]+", argstr.strip()) if x] + if name == "translate": + t = (1.0, 0.0, 0.0, 1.0, v[0], v[1] if len(v) > 1 else 0.0) + elif name == "scale": + t = (v[0], 0.0, 0.0, v[1] if len(v) > 1 else v[0], 0.0, 0.0) + elif name == "matrix": + t = tuple(v[:6]) + elif name == "rotate": + rad = math.radians(v[0]) + cos, sin = math.cos(rad), math.sin(rad) + t = (cos, sin, -sin, cos, 0.0, 0.0) + if len(v) >= 3: # rotate about (cx, cy) + t = _compose(_compose((1.0, 0.0, 0.0, 1.0, v[1], v[2]), t), + (1.0, 0.0, 0.0, 1.0, -v[1], -v[2])) + else: + t = IDENTITY + m = _compose(m, t) + return m + + +def read_area_centers(root: ET.Element) -> dict[str, tuple[float, float]]: + """Centre of each AREA_ rect in the drawing's user (viewBox) space. + + The rects may sit inside groups with their own transforms (the Deck nests + them under translated groups in a separate layer), so the full ancestor + transform chain is accumulated -- reading raw x/y put the Deck's shoulder + anchors hundreds of units outside the viewBox. sc2's anchors are in an + identity layer, so its result is unchanged.""" + centers = {} + + def walk(el: ET.Element, acc: tuple) -> None: + acc = _compose(acc, _parse_transform(el.get("transform"))) + for child in el: + rid = child.get("id") or "" + if child.tag == q("rect") and rid.startswith("AREA_"): + try: + x, y, w, h = (float(child.get(k)) for k in ("x", "y", "width", "height")) + except (TypeError, ValueError): + continue + a, b, c, d, e, f = acc + cx, cy = x + w / 2.0, y + h / 2.0 + centers[rid[5:]] = (a * cx + c * cy + e, b * cx + d * cy + f) + else: + walk(child, acc) + + walk(root, IDENTITY) + return centers + + +def _luminance(hexcolor: str) -> float: + h = hexcolor.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return 0.299 * r + 0.587 * g + 0.114 * b + + +def _gray(hexcolor: str) -> str: + """Map a source fill to one of the two binding-display greys by luminance, so + light body panels stay lighter than dark detailing.""" + return GRAY_LIGHT if _luminance(hexcolor) >= GRAY_SPLIT else GRAY_DARK + + +def recolor(el: ET.Element) -> None: + """Recolour a drawing subtree into the Matrix palette in place: every stroke + becomes GREEN, every explicit fill becomes one of the two greys. Shapes with + no fill inherit the drawing group's GRAY_DARK default (so they read instead + of falling back to SVG black); stroke-only outlines keep their fill:none.""" + style = el.get("style") + if style: + style = _FILL.sub(lambda m: "fill:" + _gray(m.group(1)), style) + style = _STROKE.sub(lambda _m: "stroke:" + GREEN, style) + el.set("style", style) + if (el.get("fill") or "").startswith("#"): + el.set("fill", _gray(el.get("fill"))) + if (el.get("stroke") or "").startswith("#"): + el.set("stroke", GREEN) + for child in el: + recolor(child) + + +def strip_areas(el: ET.Element) -> None: + """Remove AREA_* hotspot rects recursively -- they are invisible in the GUI + but recolour() would give them a grey fill and cover the drawing.""" + for child in list(el): + if (child.get("id") or "").startswith("AREA_"): + el.remove(child) + else: + strip_areas(child) + + +def build(key: str, spec: dict) -> None: + src_path = spec["src"] + if not os.path.exists(src_path): + raise SystemExit("run from repo root: %s not found" % src_path) + src = ET.parse(src_path).getroot() + vx, vy, cw, ch = parse_viewbox(src) + centers = read_area_centers(src) + + # Scale + centre the drawing in the canvas. + s = min(CANVAS_W * ART_MAX_W_FRAC / cw, CANVAS_H * ART_MAX_H_FRAC / ch) + ox = (CANVAS_W - cw * s) / 2.0 + oy = (CANVAS_H - ch * s) / 2.0 + + def to_canvas(pt: tuple[float, float]) -> tuple[float, float]: + return ox + s * (pt[0] - vx), oy + s * (pt[1] - vy) + + out = ET.Element(q("svg"), { + "width": str(CANVAS_W), "height": str(CANVAS_H), + "viewBox": "0 0 %d %d" % (CANVAS_W, CANVAS_H), "version": "1.1"}) + ET.SubElement(out, q("defs"), {"id": "defs1"}) + + # background: drives the Generator's layout (it reads width/height) and gives + # the OSD a dark backdrop so the labels read. + ET.SubElement(out, q("rect"), { + "id": "background", "x": "0", "y": "0", + "width": str(CANVAS_W), "height": str(CANVAS_H), + "style": "fill:#000000;fill-opacity:0.85"}) + + # The controller drawing, scaled into the canvas so it registers with the + # markers, then recoloured. The group's GRAY_DARK default catches shapes with + # no explicit fill (which would otherwise render SVG-black on the dark bg). + g = ET.SubElement(out, q("g"), { + "fill": GRAY_DARK, + "transform": "translate(%g,%g) scale(%g) translate(%g,%g)" % (ox, oy, s, -vx, -vy)}) + for child in list(src): + if child.get("id") == "layerAreas": + continue + g.append(copy.deepcopy(child)) + strip_areas(g) + recolor(g) + + # foreground: label_template + root (boxes drawn here) + the marker groups. + root = ET.SubElement(out, q("g"), {"id": "root", "style": "display:inline"}) + lt = ET.SubElement(root, q("text"), { + "id": "label_template", + "style": "font-size:22px;font-family:'Ubuntu Mono';fill:#ffffff", + "width": "11", "height": "15", "x": "-100", "y": "-100"}) + lt.text = "X" + + missing = [] + for name, anchors in spec["markers"].items(): + mg = ET.SubElement(root, q("g"), {"id": "markers_%s" % name}) + for a in anchors: + if a not in centers: + missing.append(a) + continue + cx, cy = to_canvas(centers[a]) + ET.SubElement(mg, q("circle"), { + "cx": "%g" % cx, "cy": "%g" % cy, "r": "5", + "style": "fill:none;stroke:%s;stroke-width:1" % MARKER_GREEN}) + + out_path = os.path.join(OUT_DIR, "%s.svg" % key) + ET.ElementTree(out).write(out_path, encoding="unicode", xml_declaration=True) + _svgo.optimize(out_path) + print("wrote %s (from %s, scale %.3f at %.1f,%.1f)" % (out_path, src_path, s, ox, oy)) + if missing: + print(" WARNING: AREA anchors not found in %s: %s" % (src_path, ", ".join(missing))) + + +def main() -> None: + """Build every controller's binding-display template into OUT_DIR.""" + os.makedirs(OUT_DIR, exist_ok=True) + for key, spec in CONTROLLERS.items(): + build(key, spec) + + +if __name__ == "__main__": + main()