Files
benilla-octo/crates/benilla-app/src/textinput/mod.rs
T
gromandClaude Opus 5 c8c4e7f855 Four defects out of his log, and the 65 warnings that are not ours
His 19-minute session put 8 errors on screen from two causes, and 73 WARN lines
of which 8 are ours. The whole triage is written down in the play-test memory,
because most of the log is the addons' own and nothing should re-chase it: five
files their `.toc`s name and do not ship, a QuickHeal template that is never
defined, an ATSW global string that only exists in its deDE branch, inspect
frames from a load-on-demand addon with no `## Dependencies` line, `<Binding>`
elements the frame loader is *supposed* to log, and a world-map lag line that
reported its own recovery 5.2 s later. Four of the eight are fixed here.

`GameTooltip:SetSpellByID` was nil, and it is not a keypress: the traceback is
pfUI's `ButtonEnter` into `SuperCleveRoidMacros/Core.lua:4217`, so it raised on
every action-bar hover of a macro that resolves to a spell — 7 times, each
leaving the button with no tooltip at all. `compat/classic_api/tooltip.rs` had
declined the whole `Set*ByID` family pending "an argument-shape check against
the stock verb each aliases". Done, and the check is why this is four lines:
there is no aliasing to do, because `tooltip_spell::set_spell_by_id` is the body
all four of 1.12's spell verbs already funnel through, and it takes a bare id —
the book slot or list row is what *they* have to convert first. `show_rank` is
the action's rather than the spellbook's, because the book draws "Rank N"
itself as the button's `subSpellName` and an id-named caller has no such label.
The caller's own comment says it reaches this only for a spell in neither
spellbook, so the cold-view path is the common one here; that needs no special
case, since the app's feed builds a view for any id in the catalog and an action
button re-asks its tooltip every `TOOLTIP_UPDATE_TIME` while hovered.

`resolve_spell_fx` despawned an entity the world no longer had. `FxInstance`'s
root is a *stored handle*, and `attach_spell_fx` is the system that notices one
lost with the unit's whole visual and re-arms or drops the instance — so between
that loss and that pass, the same-slot replace, the spell-id reap and the
self-termination clock all hold handles to entities that are gone. `try_despawn`
at those three, as `lifecycle.rs` already does; `tend_world_plants` keeps its
plain `despawn` because its entity comes from a query, not from a record.

The first paste of every Wayland session could never have worked.
`smithay-clipboard` sets `latest_seat` only on a `wl_keyboard` Key/Modifiers
event or a pointer press (`state.rs:522`, `:386`) — `Enter` sets `has_focus` and
nothing else — and refuses to load a selection without it. `HostClipboard`
opened its backend on first *use*, so the backend that read the clipboard was
created by the very Ctrl+V asking for it and its keyboard object was younger
than that chord's own key events. It is now opened as soon as the window can
give the `wl_display`. Costs +2 on the `Update` ambiguity ceiling, both pairs
against the schedule's two exclusive `&mut World` systems, which pair with
everything they have no declared order against.

And the one `changed_windows` ERROR a login prints had no remedy behind it: no
client can warp a free pointer on Wayland, so the loading screen's cursor
hand-back now asks only where the answer can be yes — off Wayland, or with the
pointer locked, which is winit's own rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 20:26:05 -06:00

238 lines
8.7 KiB
Rust

//! The host half of text input: [`keymap`] maps chords per OS, [`clipboard`] holds the OS
//! pasteboard, and [`feed_key`] routes a key into the engine's [`EditBoxState`], which owns the
//! editing itself. The glue-screen fields call [`feed_key`]; the FrameXML EditBoxes have their own
//! dispatcher, which fires Lua handlers, over the same keymap and clipboard.
use std::ffi::c_void;
use bevy::input::keyboard::KeyboardInput;
use bevy::input::ButtonState;
use bevy::prelude::*;
use benilla_ui::widget::EditBoxState;
pub(crate) mod clipboard;
pub(crate) mod keymap;
pub(crate) use clipboard::{wayland_display, HostClipboard};
pub(crate) use keymap::{chord, Chord, Mods};
/// Adds the process-wide OS pasteboard; [`feed_key`] is called from each screen's own input pass.
pub(crate) struct TextInputPlugin;
impl Plugin for TextInputPlugin {
fn build(&self, app: &mut App) {
// Held for the whole run: on X11 dropping the handle clears the clipboard. `NonSend`: no
// backend is `Sync`, and NSPasteboard is main-thread-only.
app.init_non_send_resource::<HostClipboard>();
app.add_systems(Update, open_early.run_if(clipboard_unopened));
}
}
/// Only until the backend is resolved, which is once per run: a `run_if` rather than a system
/// that returns on line one, since a registered system still moves the frame either way.
fn clipboard_unopened(clipboard: Option<NonSend<HostClipboard>>) -> bool {
clipboard.is_some_and(|c| !c.is_open())
}
/// Resolve the pasteboard backend as soon as the window can give the Wayland display pointer,
/// rather than on the first paste. [`HostClipboard::ensure`] has the reason: a Wayland backend
/// opened by the Ctrl+V that wants to read is younger than that chord's own key events, and
/// `smithay-clipboard` reads a selection only for a seat it has seen a key or click on.
fn open_early(
mut clipboard: NonSendMut<HostClipboard>,
window: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
) {
// No handle yet is not a failure: the gate holds and the next frame tries again. Calling
// `ensure` here would spend the one attempt on a display pointer that does not exist, and
// land every Wayland session on arboard, which here speaks only X11.
let Ok(handle) = window.single() else {
return;
};
clipboard.ensure(wayland_display(Some(handle)));
}
/// The modifier snapshot for this frame, read once and handed to every [`feed_key`] call.
pub(crate) fn mods_now(keys: &ButtonInput<KeyCode>) -> Mods {
Mods {
shift: keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight),
ctrl: keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight),
alt: keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight),
sup: keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight),
}
}
/// Which characters a field accepts, typed or pasted; the box's own `numeric` flag covers digits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CharFilter {
/// Everything printable (the login boxes).
Any,
/// ASCII letters only (the character-name box).
Letters,
}
impl CharFilter {
fn allows(self, c: char) -> bool {
match self {
CharFilter::Any => true,
CharFilter::Letters => c.is_ascii_alphabetic(),
}
}
/// Keep only the characters this filter allows.
fn keep(self, text: &str) -> String {
match self {
CharFilter::Any => text.chars().filter(|c| !c.is_control()).collect(),
CharFilter::Letters => text.chars().filter(|&c| self.allows(c)).collect(),
}
}
}
/// What [`feed_key`] did with a key press.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FieldKey {
/// The field handled it; the screen must not also act on this key.
Consumed,
/// Not a text-editing key; the screen decides, as the FrameXML box hands ENTER, ESCAPE and TAB
/// to its scripts.
Passthrough,
}
/// Feed one key press to `field`. `wl_display` comes from [`wayland_display`], `None` off Wayland.
pub(crate) fn feed_key(
field: &mut EditBoxState,
ev: &KeyboardInput,
mods: Mods,
clipboard: &mut HostClipboard,
wl_display: Option<*mut c_void>,
filter: CharFilter,
) -> FieldKey {
if ev.state != ButtonState::Pressed {
return FieldKey::Passthrough;
}
// The box-event keys are always the screen's, as in `script::editbox::key_input`.
if matches!(
ev.key_code,
KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Escape | KeyCode::Tab
) {
return FieldKey::Passthrough;
}
if let Some(chord) = chord(ev.key_code, mods, cfg!(target_os = "macos")) {
match chord {
Chord::Edit(action) => {
field.apply(action);
}
Chord::Copy => {
if let Some(text) = field.selected_text() {
clipboard.write(wl_display, &text);
}
}
Chord::Cut => {
if let Some(text) = field.cut_selection() {
clipboard.write(wl_display, &text);
}
}
Chord::Paste => {
if let Some(text) = clipboard.read(wl_display) {
field.paste(&filter.keep(&text));
}
}
}
return FieldKey::Consumed;
}
// A command-modified char never types, but Ctrl+Alt (AltGr) does; the FrameXML feed and the
// chord table use the same guard.
if !(mods.sup || (mods.ctrl && !mods.alt)) {
if let Some(text) = &ev.text {
// C0 control characters are dropped, as in the box's own `char_input`.
let printable = filter.keep(text);
if !printable.is_empty() {
field.insert(&printable);
return FieldKey::Consumed;
}
}
}
FieldKey::Passthrough
}
/// A fresh single-line field with `max_letters` (0 = unlimited) and optional password masking.
pub(crate) fn field(max_letters: usize, password: bool) -> EditBoxState {
EditBoxState {
max_letters,
password,
..Default::default()
}
}
/// Advance the caret blink and report whether it is drawn: the box's own blink (`E+0x370`,
/// `E+0x374`, default 0.5 s). An unfocused field is hidden and does not accumulate.
pub(crate) fn tick_caret(field: &mut EditBoxState, focused: bool, dt: f32) -> bool {
if !focused {
return false;
}
if field.blink_period > 0.0 {
field.blink_accum += dt;
if field.blink_accum > field.blink_period {
field.caret_shown = !field.caret_shown;
field.blink_accum = 0.0;
}
}
field.caret_shown
}
#[cfg(test)]
mod tests {
use super::*;
/// The eager open has exactly one attempt (`HostClipboard::opened`), so spending it on a
/// frame with no window handle would strand every Wayland session on arboard, which here
/// speaks only X11 — the paste failure this pass exists to fix, made permanent. The gate has
/// to still be open after a frame that had no window, and no backend may have been resolved.
#[test]
fn the_early_open_does_not_spend_its_one_attempt_before_there_is_a_window() {
let mut app = App::new();
app.add_plugins(TextInputPlugin);
app.update();
let clipboard = app
.world()
.get_non_send_resource::<HostClipboard>()
.expect("the plugin installs the resource");
assert!(
!clipboard.is_open(),
"no window handle yet, so nothing may have been opened; `clipboard_unopened` is \
that same bit, so the gate is still open for the frame that does have one"
);
}
#[test]
fn letters_filter_applies_to_pasted_text() {
assert_eq!(CharFilter::Letters.keep("Bob123"), "Bob");
assert_eq!(CharFilter::Letters.keep("a b\tc\n"), "abc");
assert_eq!(CharFilter::Letters.keep("123"), "");
}
#[test]
fn any_filter_keeps_printables_and_drops_controls() {
assert_eq!(CharFilter::Any.keep("pass word!"), "pass word!");
assert_eq!(CharFilter::Any.keep("one\ntwo\r"), "onetwo");
}
#[test]
fn a_filtered_paste_still_obeys_max_letters() {
let mut f = field(4, false);
f.paste(&CharFilter::Letters.keep("ab12cdef"));
assert_eq!(f.text, "abcd");
}
/// Copying from a password field yields the mask, not the secret.
#[test]
fn a_password_field_masks_display_and_copies() {
let mut f = field(0, true);
f.insert("hunter2");
assert_eq!(f.text, "hunter2");
assert_eq!(f.display(), "*******");
f.highlight_text(0, -1);
assert_eq!(f.selected_text().as_deref(), Some("*******"));
}
}