Compare commits

..
Author SHA1 Message Date
enricobuehler c9a76287d8 fix(console-ui): the layer under a card takes the card's corner plus its own outset
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 42s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 5m34s
ci / rust (pull_request) Successful in 5m45s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m9s
The focus halo grows the card's rect by 4 design units on every side but drew
it with the card's own corner radius. A shape grown by `d` keeps its corners
parallel to the original's only if its radius grows by `d` too — otherwise the
two arcs stop sharing a centre. So the halo came out squarer than the card it
sits under: clean along the edges, visibly misaligned at the four corners,
where it read as a badly drawn outline rather than as light spilling out.

Same rule applied to `panel_highlight`, which pulls in half a unit and kept the
full radius. `drop_shadow` only offsets, so its geometry was already right, and
the collections plate uses `RRect::with_outset`, which adjusts the radii itself.

Every card in the console goes through these two helpers — the home tiles, the
library grid, the coverflow, the collections deck.
2026-08-20 21:44:55 +02:00
enricobuehler cbd3d02817 fix(clients/android): the console's Android rows were nested where serde flattens them
Turning "Controller-optimized UI" off in the console did nothing: the console
stayed up, because the setting never left the console.

`trust::Settings::extra` is `#[serde(flatten)]`, so the `android.*` keys are
TOP-LEVEL keys of the settings document, beside `width` and `codec`.
`ConsoleJson` wrote and read them nested under an `"extra"` object instead.
Serde put that whole object into the map under the literal key `"extra"`, so
no console row ever found `android.gamepad_ui_enabled` — the row showed its
own default, and the value the console saved came back to Kotlin as the one
Kotlin had just sent. `applySettings` then saw no change, raised no callback,
and `App` never recomputed `gamepadUiActive`.

Every Android-only row rode the same broken path: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.

A store written by the nesting build carries the dead wrapper; it is dropped
on the next write rather than echoed for the life of the install.

The new test pins the shape from both sides. A round-trip alone could not have
caught this — both halves agreed on the same wrong nesting, which is exactly
how it survived review.
2026-08-20 21:44:46 +02:00
5 changed files with 133 additions and 125 deletions
@@ -317,16 +317,20 @@ internal object ConsoleJson {
j.put("invert_scroll", s.invertScroll)
j.put("pad_haptics", s.padHaptics)
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
val extra = j.optJSONObject("extra") ?: JSONObject()
extra.put("android.low_latency", s.lowLatencyMode)
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
extra.put("android.gyro_on_phone", s.gyroOnPhone)
extra.put("android.sc2_capture", s.sc2Capture)
extra.put("android.ds_capture", s.dsCapture)
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
extra.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
j.put("extra", extra)
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
// whole object into the map under the literal key "extra", where no console row could
// read it and every value the console wrote came straight back as the one we had sent.
j.put("android.low_latency", s.lowLatencyMode)
j.put("android.rumble_on_phone", s.rumbleOnPhone)
j.put("android.gyro_on_phone", s.gyroOnPhone)
j.put("android.sc2_capture", s.sc2Capture)
j.put("android.ds_capture", s.dsCapture)
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
// A store written by the nesting build carries the stale wrapper; drop it rather than
// round-trip a copy of these keys that nothing reads for the life of the install.
j.remove("extra")
return j
}
@@ -336,7 +340,8 @@ internal object ConsoleJson {
*/
fun applySettings(s: Settings, j: JSONObject): Settings {
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
val extra = j.optJSONObject("extra") ?: JSONObject()
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
return s.copy(
width = j.optInt("width", s.width),
height = j.optInt("height", s.height),
@@ -373,14 +378,14 @@ internal object ConsoleJson {
"off" -> false
else -> s.padSpeaker
},
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = extra.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
)
}
}
@@ -0,0 +1,73 @@
package io.unom.punktfunk
import io.unom.punktfunk.console.ConsoleJson
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
*
* They were written and read nested under an `"extra"` object instead. Serde put that whole
* object into the map under the literal key `"extra"`, so no console row ever found
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
* with nothing happening: the console stayed up, because the setting never moved.
*/
class ConsoleSettingsExtraTest {
@Test
fun androidKeysAreWrittenFlat() {
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.low_latency"))
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
}
/** A store written by the nesting build must not keep echoing its dead wrapper. */
@Test
fun aStaleNestedWrapperIsDropped() {
val base = JSONObject().put(
"extra",
JSONObject().put("android.gamepad_ui_enabled", true),
)
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
}
@Test
fun theConsolesOwnSaveIsReadBack() {
val saved = JSONObject()
.put("android.gamepad_ui_enabled", false)
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
.put("android.ds_capture", false)
val next = ConsoleJson.applySettings(Settings(), saved)
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
assertFalse(next.dsCapture)
}
/** Both halves against each other — the shape only holds if they agree. */
@Test
fun theRoundTripKeepsEveryAndroidRow() {
val want = Settings(
gamepadUiEnabled = false,
gamepadUiMode = GAMEPAD_UI_ALWAYS,
lowLatencyMode = false,
rumbleOnPhone = true,
gyroOnPhone = true,
sc2Capture = false,
dsCapture = false,
)
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
assertEquals(want.sc2Capture, got.sc2Capture)
assertEquals(want.dsCapture, got.dsCapture)
}
}
@@ -366,26 +366,15 @@ object Gamepad {
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
// 1. The correction is applied ONLY when the delivered keycode is what `Generic.kl` would
// have said ([genericKeyCode]). A different keycode means a device-specific layout IS in
// force and already knows this pad better than we do, so we leave it alone.
// 2. Which report order to read is decided from what the DEVICE declares, never a model
// table: a pad numbering straight through claims BUTTON_C and BUTTON_Z ([PadButtons]),
// keycodes no real controller has a button for.
//
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
// Moonlight carries the same two tables (`ControllerHandler`'s `isNonStandardDualShock4` /
// `isNonStandardXboxBtController`), which is why both pads work there on the same box.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
@@ -537,42 +526,22 @@ object Gamepad {
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
* Which report order [dev]'s buttons follow, asked of the device rather than a model table.
*
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
* declaring six or more — a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
* there is one — where a false positive costs nothing.
* A pad numbering its HID buttons straight through reaches BUTTON_C and BUTTON_Z, keycodes
* that exist only as `Generic.kl` positions — no controller has a physical C or Z button, and
* a pad with a kernel driver behind it emits the modern Linux gamepad codes, which skip both.
* Declaring the pair is therefore the signature of a pad Android is guessing at.
*/
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
fun padButtons(dev: InputDevice): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
val straightThrough = has[0] && has[1]
return when {
straightThrough && dev.vendorId == VID_SONY -> PadButtons.GENERIC_SONY
straightThrough -> PadButtons.GENERIC_XBOX
dev.vendorId == VID_SONY -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
}
/**
@@ -597,11 +566,11 @@ object Gamepad {
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
val buttons = padButtons(dev)
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
@@ -200,55 +200,4 @@ class PadButtonsTest {
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves — the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
+15 -3
View File
@@ -396,9 +396,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
),
None,
));
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
let r = ((corner - 0.5) * k).max(0.0);
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
}
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
/// rect AND the corner radius take it — see the draw there.
const HALO_OUTSET: f32 = 4.0;
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
@@ -439,8 +446,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
// farthest-first the focused card's corona landed on top of its neighbours — which is
// what made every card look like it was glowing.
let spread = rect.with_outset((4.0 * k, 4.0 * k));
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
// original's only if its radius grows by `d` too (the two arcs then share a centre).
// Reusing the card's own radius left the halo squarer than the card it sits under, so
// it read as a misaligned outline at the four corners and a clean glow along the edges.
let r = (corner + HALO_OUTSET) * k;
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
}
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {