Files
benilla-octo/crates/benilla-world/src/modkeys.rs
T
SamandClaude Fable 5 4585a12259 the same-continent teleport fall is found and fixed
- a teleport between cities on one continent could release you before the destination had streamed in and drop you under the map — the residency publish now waits for the snap and names the tile its facts are about, so the loading screen provably holds until the ground is real
- nameplates stop jittering: plates of units you cannot see no longer shove the visible ones around, and the snap rides the correct pixel grid
- unit-name text takes its first fix — a string fits the width its own measure reported, and a re-bake reaches the screen
- books and plaques render through a real SimpleHTML widget inside a real ScrollFrame — strict parse, per-element fonts, block layout, and a scroll range measured from the whole subtree

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:43:01 +07:00

263 lines
13 KiB
Rust

//! **The modifier keys** — the dev-overlay chord every instrument is bound on, and the
//! stuck-modifier reconciliation that keeps the keyboard honest underneath it.
//!
//! The chord ([`dev_chord`]) lived in `debug_panel` because the panel was its first user; six
//! modules then reached into a *debug overlay* to ask whether Ctrl+Shift was down — the player
//! controller's free-fly toggle and land-here, the perf HUD, the sound mute, the inspector.
//! Decision 1160 moves it here, where the leaf module that already owns modifier state is
//! (92 lines, no dependants of its own). Nothing about "which two modifiers are the dev plane" is
//! a debug-panel opinion.
//!
//! ## Stuck-modifier reconciliation against the OS's live flag state (decision 0606)
//!
//! macOS system shortcuts that grab the keyboard *without* de-focusing the window — the ⇧⌘5
//! screenshot overlay is the canonical one — swallow the modifiers' release events: the overlay is
//! a non-activating panel, so the app never receives `Focused(false)` (Bevy's stuck-key reset,
//! `KeyboardFocusLost` → `release_all`, never fires), and winit only synthesizes modifier key
//! events from `flagsChanged`, which the grabbed keyboard never delivers (verified in
//! winit 0.30.13 `platform_impl/macos/view.rs::update_modifiers`). `ButtonInput<KeyCode>` then
//! reports Shift/Cmd held indefinitely, and every bare-key binding — the action bar's number row
//! behind `ui_script::input`'s bare-binding gate — goes dead until the user happens to tap the
//! stuck modifier again.
//!
//! The fix: poll the OS's live modifier state (`+[NSEvent modifierFlags]` — hardware-derived and
//! documented as independent of the event stream) once per frame, right after Bevy's own input
//! collection, and **release** any modifier Bevy believes is down but the OS says is up.
//! Release-only by design: the hardware state leads the event stream, so a legitimately
//! just-pressed modifier is never wrongly released — whereas synthesizing *presses* from the poll
//! could race in-flight release events and manufacture the same bug in reverse. The poll can't
//! tell left from right within a family, so an up family releases both variants (only ever a
//! correction — a genuinely held key keeps its family bit set and is never touched).
use bevy::prelude::*;
/// How the dev plane is written on screen. Every surface that names a chord reads this instead of
/// spelling one out, so the panel footer, the inspector badge and the mute checkbox can't drift from
/// what [`dev_chord`] actually listens for.
pub const DEV_CHORD: &str = "Ctrl+Shift";
/// Did the **dev-overlay chord** — [`DEV_CHORD`]+*key* — just fire? (decisions 0585, 0867, 0870,
/// 1043 — which moved the last two dev keys onto it, so the whole fleet is here now.)
///
/// The dev instruments used to sit on bare letters, which is a namespace we don't own: every letter is
/// a *game* binding in the reference client, so `P` both opened the spellbook and toggled the perf HUD
/// (0585). They moved to `Ctrl`+`Cmd`, and then off it: on Windows that is `Ctrl`+`Win`, a plane the
/// shell owns and keeps extending — `Win+Ctrl+M` is Magnifier settings, which had our mute (0867).
///
/// **`Ctrl`+`Shift`, one plane on every OS** (0870, director's call). The alternative was keeping
/// `Ctrl`+`Cmd` on macOS for its one real advantage — Cmd is outside the reference's binding namespace
/// (1.12 builds binding names from `ALT-`/`CTRL-`/`SHIFT-` only), so nothing in game could *ever* claim
/// it. That buys protection against a binding no default declares and no player has yet written, and
/// it costs a per-OS split in the docs, the hints and the reader's head. One plane wins. It also drops
/// our dependence on winit's `sendEvent:` swizzle, without which AppKit's swallowed `keyUp` under Cmd
/// would latch a chord after one use (0585's macOS risk, now simply not run).
///
/// `Ctrl`+`Shift` is the emptiest plane the reference *can* name: of its 152 defaults, exactly two
/// carry two modifiers — `CTRL-SHIFT-TAB` and `CTRL-SHIFT-PAGEDOWN` — and no letter at all.
/// `Ctrl`+`Alt` was never available: that is AltGr, which European layouts type real characters with
/// (decision 0702).
///
/// **Exactly those two modifiers and no others**, both sides of each. The block is what makes this
/// plane safe to leave ungated below: AltGr+Shift+*key* is `Ctrl`+`Alt`+`Shift`+*key*, and a German
/// layout typing one of those into chat must not fire an overlay.
///
/// **"No letter at all" was half the story, and the missing half cost us a plane's worth of safety**
/// (decision 1142). The reference does not match bindings by equality alone: an exact miss re-probes
/// **once** with the leftmost modifier dropped, so `CTRL-SHIFT-`*key* falls through to
/// `SHIFT-`*key* — never to the bare letter, which is why this plane survived the correction at all,
/// but far enough that `Ctrl`+`Shift`+`P` would open the pet paper doll (`SHIFT-P`,
/// `TOGGLECHARACTER3`) under the perf HUD. So the other half of the rule now lives where the law
/// itself does, in `bindings::BindingDispatch::resolve`: this plane spends the keyboard's
/// **fallback probe**, and only that — an exact `CTRL-SHIFT-` binding, the reference's two included,
/// still dispatches normally.
///
/// Deliberately **not** gated on [`crate::ui_script::UiKeyboardCapture`] the way the bare-key toggles
/// were: a chord can't be mistaken for typed text, so the dev overlays stay reachable with the chat bar
/// open.
pub fn dev_chord(keys: &ButtonInput<KeyCode>, key: KeyCode) -> bool {
dev_plane(keys) && keys.just_pressed(key)
}
/// The modifier half of [`dev_chord`]. See there for why the plane is what it is.
fn dev_plane(keys: &ButtonInput<KeyCode>) -> bool {
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
let blocked = keys.any_pressed([
KeyCode::AltLeft,
KeyCode::AltRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
]);
ctrl && shift && !blocked
}
/// Keys a **synthetic** input source is holding this frame — the probe harness's `WOW_PROBE_KEY`
/// taps are the only writer today.
///
/// The reconciliation above polls the *hardware* flag state, which by construction reads "up" for
/// a key no hand is on. Without this list a synthesized `Shift`/`Ctrl` is released the frame after
/// it is pressed — and logged as a stuck-key correction, so the instrument reads as working while
/// **no probe can reach a chord binding on macOS at all** (`SHIFT-V`'s friendly nameplates, the
/// `Ctrl`+`Shift`+`F` free-fly the `"Shift"` tap name was added for). The engine owns the type
/// because the reconciler does; whoever synthesizes input writes it, on every platform.
#[derive(Resource, Default)]
pub struct SyntheticHold(pub Vec<KeyCode>);
/// Is `code` a modifier Bevy holds down that nothing is legitimately holding — i.e. stuck, and the
/// reconciler's to release? Pure, so the rule is testable off-platform (the reconciler itself
/// needs AppKit and the main thread) — which is also why it is compiled for the test build of
/// every platform, and only there off macOS.
#[cfg(any(target_os = "macos", test))]
fn is_stuck(code: KeyCode, pressed: &ButtonInput<KeyCode>, hold: &SyntheticHold) -> bool {
pressed.pressed(code) && !hold.0.contains(&code)
}
pub struct ModKeysPlugin;
impl Plugin for ModKeysPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SyntheticHold>();
#[cfg(target_os = "macos")]
app.add_systems(
PreUpdate,
mac::reconcile_modifiers.after(bevy::input::InputSystems),
);
}
}
#[cfg(target_os = "macos")]
mod mac {
use bevy::ecs::system::NonSendMarker;
use bevy::input::keyboard::Key;
use bevy::prelude::*;
use objc2_app_kit::{NSEvent, NSEventModifierFlags};
/// Release any modifier key Bevy holds pressed that the OS's live flag state says is up.
/// `NonSendMarker` pins the system to the main thread, which AppKit requires.
pub(super) fn reconcile_modifiers(
_main_thread: NonSendMarker,
mut codes: ResMut<ButtonInput<KeyCode>>,
mut logical: ResMut<ButtonInput<Key>>,
hold: Res<super::SyntheticHold>,
) {
let flags = unsafe { NSEvent::modifierFlags_class() };
let families = [
(
NSEventModifierFlags::NSEventModifierFlagShift,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
Key::Shift,
),
(
NSEventModifierFlags::NSEventModifierFlagControl,
[KeyCode::ControlLeft, KeyCode::ControlRight],
Key::Control,
),
(
NSEventModifierFlags::NSEventModifierFlagOption,
[KeyCode::AltLeft, KeyCode::AltRight],
Key::Alt,
),
(
NSEventModifierFlags::NSEventModifierFlagCommand,
[KeyCode::SuperLeft, KeyCode::SuperRight],
Key::Super,
),
];
for (flag, keys, key) in families {
if flags.contains(flag) {
continue;
}
// Guarded so an in-sync frame (the overwhelmingly common case) never marks the
// resources changed — and a key a synthetic source is holding
// ([`super::SyntheticHold`]) is never "stuck": the hardware poll cannot see it.
let mut synthetic = false;
for code in keys {
if super::is_stuck(code, &codes, &hold) {
warn!("modkeys: releasing stuck {code:?} (OS reports it up)");
codes.release(code);
}
synthetic |= hold.0.contains(&code);
}
if !synthetic && logical.pressed(key.clone()) {
logical.release(key);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::input::ButtonInput;
use bevy::prelude::KeyCode;
fn keys(held: &[KeyCode]) -> ButtonInput<KeyCode> {
let mut k = ButtonInput::default();
for &c in held {
k.press(c);
}
k
}
/// The plane fires on its two modifiers, either side of each.
#[test]
fn ctrl_shift_is_the_plane() {
for pair in [
[KeyCode::ControlLeft, KeyCode::ShiftRight],
[KeyCode::ControlRight, KeyCode::ShiftLeft],
] {
assert!(dev_plane(&keys(&pair)), "{pair:?} fires");
}
}
/// One modifier is not the chord, and a third one names something else. The case that matters:
/// AltGr *is* `Ctrl`+`Alt`, so AltGr+Shift+key is a character a European layout types, never a
/// dev chord — the overlays are ungated while the chat bar is open. `Ctrl`+`Cmd` is likewise
/// nothing of ours now (0870): on Windows it is the shell's, `Win+Ctrl+M` being Magnifier
/// settings.
#[test]
fn a_lone_or_extra_modifier_is_not_the_chord() {
for held in [
vec![KeyCode::ControlLeft],
vec![KeyCode::ShiftLeft],
vec![KeyCode::ControlLeft, KeyCode::SuperLeft],
vec![KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::AltLeft],
vec![KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::SuperLeft],
] {
assert!(!dev_plane(&keys(&held)), "{held:?} is not the chord");
}
}
/// A modifier a **synthetic** source is holding is never "stuck". The macOS reconciler polls
/// the hardware flags, which read up for a key no hand is on — so without the exemption a
/// synthesized chord dies the frame after it is pressed, and every `WOW_PROBE_KEY` chord
/// (`SHIFT-V`'s friendly nameplates, `Ctrl`+`Shift`+`F`'s free-fly) is unreachable on the
/// platform we develop on, while the log calls it a stuck-key fix.
#[test]
fn a_synthesized_modifier_is_not_stuck() {
let held = keys(&[KeyCode::ShiftLeft]);
assert!(is_stuck(
KeyCode::ShiftLeft,
&held,
&SyntheticHold::default()
));
let hold = SyntheticHold(vec![KeyCode::ShiftLeft]);
assert!(!is_stuck(KeyCode::ShiftLeft, &held, &hold));
assert!(
!is_stuck(KeyCode::ControlLeft, &held, &hold),
"an unheld key is nothing to release either way"
);
}
/// The label a hint prints is the plane actually listened for — one const, one predicate, so a
/// player is never told to press a key we stopped reading.
#[test]
fn the_label_matches_the_plane() {
assert_eq!(DEV_CHORD, "Ctrl+Shift");
assert!(dev_plane(&keys(&[
KeyCode::ControlLeft,
KeyCode::ShiftLeft
])));
}
}