Skip to content

[BUG] Input/Textarea action synchronization bug #6605

Description

@Ben-Collett

The Bug

The Input and TextArea widgets use _on_key to handle processing printable character key events; however, for keys like backspace or left arrow, they are handled by BINDINGS. This can create a problem in the order in which they are processed. The bindings are processed separately from printable characters, so if the user types multiple characters very quickly with a backspace in the middle, the backspace could end up occurring anywhere in the input. This is nearly impossible to occur with a normal user typing (unless they can type several hundred WPM or perhaps if their system is under load, slowing the program down). However, it can occur frequently if a user is using a device like a CharaChorder to type faster than normal (which is how I originally found the bug). If a user binds macros to type any text for them, it could have the same problems if it incorporates a backspace or a left arrow.
While I highlighted individual keys like backspace, the problem is true for all keybindings like paste.

demo

inj.py is a quick script I vibe coded for Unix-based systems to allow running a program with it and intercepting stdin and allows overwriting the data that gets sent to the program it is running.

in this script:
if I press f it becomes "ba\x7fckspace" or [b, a, <backspace>, c, k, s, p, a, c, e] to demonstrate the synchronization problem using a backspace
if I press j it will press \x15\x0b or [ctrl+k, ctrl+u] to clear the input
if I press k it will press left\x1b[Dic\x1b[Car or [l,e,f,t,<left arrow>,i,c,<right arrow>,a,r]

in the demo video I ran python inj.py example.py; python inj.py nvim, so I could demonstrate
what the output of f and k are in textual vs what it is in neovim.

textual_demo.mp4

Here's example.py's code:

from textual.app import App, ComposeResult
from textual.widgets import Input, TextArea


class InputTextAreaApp(App):
    def compose(self) -> ComposeResult:
        yield Input(placeholder="Type something...")
        yield TextArea()


if __name__ == "__main__":
    app = InputTextAreaApp()
    app.run()

here's the code for inj.py

import os
import sys
import subprocess
import pty
import tty
import termios
import select


def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <command> [args...]")
        sys.exit(1)

    cmd = sys.argv[1:]
    if cmd[0].endswith(".py"):
        cmd = [sys.executable] + cmd

    _run_unix(cmd)


def _run_unix(cmd):
    master_fd, slave_fd = pty.openpty()

    proc = subprocess.Popen(
        cmd,
        stdin=slave_fd,
        stdout=slave_fd,
        stderr=slave_fd,
        close_fds=True,
    )

    os.close(slave_fd)

    stdin_fd = sys.stdin.fileno()

    old_term = termios.tcgetattr(stdin_fd)
    tty.setraw(stdin_fd)

    try:
        while proc.poll() is None:
            readable, _, _ = select.select(
                [stdin_fd, master_fd],
                [],
                [],
                0.01,
            )

            if master_fd in readable:
                try:
                    data = os.read(master_fd, 4096)
                except OSError:
                    break
                if not data:
                    break

                os.write(sys.stdout.fileno(), data)

            if stdin_fd in readable:
                ch = os.read(stdin_fd, 1)

                if ch == b"f":
                    os.write(master_fd, b"ba\x7fckspace")
                    continue

                if ch == b"j":
                    os.write(master_fd, b"\x15\x0b")
                    continue

                if ch == b"k":
                    os.write(master_fd, b"lt\x1b[Def\x1b[Car")
                    continue

                os.write(master_fd, ch)

    finally:
        termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_term)
        try:
            proc.terminate()
        except Exception:
            pass


if __name__ == "__main__":
    main()

possible fix

force the bindings to run synchronously in the _on_key. Add something like this: you would need to convert the bindings into a map of
their string value and run something like

...
BINDINGS = {}
SYNC_BINDINGS = {
    "left": "cursor_left",
    "shift+left": "cursor_left(True)",
    ...
}

...
async def _on_key(self, event):
  self._restart_blink()
  if event.is_printable:
    ...
  elif event.key in self.SYNC_BINDINGS:
      await self.run_action(self.SYNC_BINDINGS[event.key])

This would break if a user overridden bindings since normally everything in SYNC_BINDINGS would be run.
This could be solved by checking if the bindings are empty, but that could lead to the user reintroducing the synchronization issue.

It may be possible to use some sort of metaclass to change BINDINGS to SYNC_BINDINGS. This would force everything to run in _on_key and theoretically shouldn't break if a user overrode BINDINGS unless they depended on the binding running separately
to be nonblocking for typing or if they were referencing BINDINGS directly in a function.

There could be another simple approach that I missed I'm not super familiar with textual's internals. I could open a PR with this approach if it is desired.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions