TUI: bracketed paste plus slash-command autocomplete
Paste: enable CSI ? 2004 mode, parse ESC[200~...ESC[201~ into one TuiPaste event routed to the focused panel, literal body, over-cap pastes dropped whole. TextBox inserts with CR/CRLF normalized, Terminal queues bytes to the pty, unmarked CRLF coalesces to one Enter, idle flush no longer shreds slow pastes. Complete: new TuiAutocomplete popup owned by TuiTextBox via complete=, typing filters, arrows move, Enter accepts, Esc dismisses. tui_complete_command feeds COMMANDS with HELP_TEXT descriptions. Demo prompt dialog wired as the showcase.
This commit is contained in:
@@ -9330,6 +9330,18 @@ TUI_KITTY_ENABLE = "\x1b[>10u"
|
||||
|
||||
TUI_KITTY_DISABLE = "\x1b[<u"
|
||||
|
||||
TUI_PASTE_ENABLE = "\x1b[?2004h"
|
||||
|
||||
TUI_PASTE_DISABLE = "\x1b[?2004l"
|
||||
|
||||
TUI_PASTE_OPEN = b"\x1b[200~"
|
||||
|
||||
TUI_PASTE_CLOSE = b"\x1b[201~"
|
||||
|
||||
TUI_PASTE_CAP = 1 << 20
|
||||
|
||||
TUI_COMPLETE_MAXH = 8
|
||||
|
||||
|
||||
class TuiCompositor:
|
||||
"""Paints panels to runs, resolves occlusion per row through cuts and
|
||||
@@ -9638,6 +9650,30 @@ def tui_parse_kitty(body, rest):
|
||||
return (TuiKey(key=key, char=char), rest)
|
||||
|
||||
|
||||
def tui_paste_head(buf):
|
||||
"""True when buf starts with, or could still become, the bracketed
|
||||
paste opener. Used to keep slow pastes buffered instead of forcing
|
||||
a lone-ESC resolution through them."""
|
||||
head = bytes(buf[:len(TUI_PASTE_OPEN)])
|
||||
return head == TUI_PASTE_OPEN[:len(head)]
|
||||
|
||||
|
||||
def tui_parse_paste(buf, rest):
|
||||
"""Parse a bracketed paste body. Bytes between the markers arrive
|
||||
literally: no escape parsing inside. An over-cap paste is dropped
|
||||
whole, never truncated into the session."""
|
||||
rest = bytes(rest)
|
||||
close = rest.find(TUI_PASTE_CLOSE)
|
||||
if close < 0:
|
||||
if len(buf) > TUI_PASTE_CAP:
|
||||
return (None, b"")
|
||||
return ("need_more", buf)
|
||||
if close > TUI_PASTE_CAP:
|
||||
return (None, rest[close + len(TUI_PASTE_CLOSE):])
|
||||
text = rest[:close].decode("utf-8", "replace")
|
||||
return (TuiPaste(text=text), rest[close + len(TUI_PASTE_CLOSE):])
|
||||
|
||||
|
||||
def tui_parse_one(buf, final):
|
||||
"""Parse one event from head of buf. Returns (event|"need_more"|None, rest).
|
||||
None means empty buffer; "need_more" means wait for bytes (or flush)."""
|
||||
@@ -9645,6 +9681,10 @@ def tui_parse_one(buf, final):
|
||||
return (None, buf)
|
||||
if buf[0] != 0x1B:
|
||||
if buf[0] in (0x0D, 0x0A):
|
||||
if buf[0] == 0x0D and len(buf) > 1 and buf[1] == 0x0A:
|
||||
return (TuiKey(key="enter", char="\n"), buf[2:])
|
||||
if buf[0] == 0x0D and len(buf) == 1 and not final:
|
||||
return ("need_more", buf)
|
||||
return (TuiKey(key="enter", char="\n"), buf[1:])
|
||||
if buf[0] == 0x09:
|
||||
return (TuiKey(key="tab", char="\t"), buf[1:])
|
||||
@@ -9699,6 +9739,8 @@ def tui_parse_one(buf, final):
|
||||
except UnicodeDecodeError:
|
||||
return (None, buf[end + 1:])
|
||||
rest = buf[end + 1:]
|
||||
if final_char == "~" and body == "200":
|
||||
return tui_parse_paste(buf, rest)
|
||||
if final_char == "u":
|
||||
return tui_parse_kitty(body, rest)
|
||||
if final_char == "~" and ";" not in body:
|
||||
@@ -9740,7 +9782,7 @@ class TuiInput:
|
||||
|
||||
def flush(self):
|
||||
events = self._drain(True)
|
||||
while self.buf and self.buf[:1] == b"\x1b":
|
||||
while self.buf and self.buf[:1] == b"\x1b" and not tui_paste_head(self.buf):
|
||||
before = len(self.buf)
|
||||
events.extend(self._drain(True))
|
||||
if len(self.buf) == before:
|
||||
@@ -10509,12 +10551,68 @@ class TuiOptions(TuiPanel):
|
||||
self.move(1)
|
||||
|
||||
|
||||
COMPLETE_WORD_RE = re.compile(r"(?:^|\s)/([A-Za-z0-9_-]*)$")
|
||||
|
||||
|
||||
class TuiAutocomplete(TuiOptions):
|
||||
"""Non-focusable completion popup owned by a TuiTextBox. The box keeps
|
||||
focus and drives the list: typing filters, arrows move, Enter accepts
|
||||
via the Select event, Esc dismisses. Tab is never used: it always
|
||||
moves focus by framework rule."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs.setdefault("focusable", False)
|
||||
kwargs.setdefault("border", True)
|
||||
kwargs.setdefault("layer", "dialog")
|
||||
kwargs.setdefault("z", 5)
|
||||
super().__init__((), **kwargs)
|
||||
|
||||
def set_items(self, items):
|
||||
self.options = [TuiOptions.norm_option(item) for item in items]
|
||||
self.selected = 0
|
||||
self.scroll_y = 0
|
||||
if self.app is not None:
|
||||
self.app.dirty_paint()
|
||||
|
||||
|
||||
TUI_COMMAND_HELP = {}
|
||||
|
||||
|
||||
def tui_command_help():
|
||||
"""Slash-command descriptions parsed once from HELP_TEXT."""
|
||||
if not TUI_COMMAND_HELP:
|
||||
for line in HELP_TEXT.splitlines():
|
||||
match = re.match(r"\s*/(\S+)\s+(.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
rest = match.group(2)
|
||||
desc = re.split(r"\s{2,}", rest.strip())[-1].strip()
|
||||
TUI_COMMAND_HELP.setdefault(match.group(1), desc)
|
||||
return TUI_COMMAND_HELP
|
||||
|
||||
|
||||
def tui_complete_command(prefix):
|
||||
"""Completion source for slash commands: [(label, name)] filtered by
|
||||
prefix. Labels carry the HELP_TEXT description when one exists."""
|
||||
help_map = tui_command_help()
|
||||
found = []
|
||||
for name in COMMANDS:
|
||||
if not name.startswith(prefix or ""):
|
||||
continue
|
||||
desc = help_map.get(name, "")
|
||||
label = "/%s - %s" % (name, desc) if desc else "/" + name
|
||||
found.append((label, name))
|
||||
return found
|
||||
|
||||
|
||||
class TuiTextBox(TuiPanel):
|
||||
"""Multiline text editor. Arrows/home/end/pgup/pgdn move, Enter splits
|
||||
lines, Backspace/Delete join them, clicks position the cursor. Escape
|
||||
is left alone so dialogs can close on it."""
|
||||
is left alone so dialogs can close on it, unless a completion popup
|
||||
is open: then Esc dismisses the popup first. complete= wires a
|
||||
prefix-to-items callable driving a TuiAutocomplete popup."""
|
||||
|
||||
def __init__(self, text="", **kwargs):
|
||||
def __init__(self, text="", complete=None, **kwargs):
|
||||
kwargs.setdefault("focusable", True)
|
||||
kwargs.setdefault("border", True)
|
||||
super().__init__(**kwargs)
|
||||
@@ -10522,6 +10620,8 @@ class TuiTextBox(TuiPanel):
|
||||
self.cx = len(self.lines[-1])
|
||||
self.cy = len(self.lines) - 1
|
||||
self.top = 0
|
||||
self.complete_fn = complete
|
||||
self.complete_popup = None
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
@@ -10588,12 +10688,141 @@ class TuiTextBox(TuiPanel):
|
||||
self._clamp()
|
||||
self._touch()
|
||||
|
||||
def _complete_refresh(self):
|
||||
popup = self.complete_popup
|
||||
if not callable(self.complete_fn) or self.app is None:
|
||||
if popup is not None:
|
||||
self._complete_hide()
|
||||
return
|
||||
match = COMPLETE_WORD_RE.search(self.lines[self.cy][:self.cx])
|
||||
try:
|
||||
items = self.complete_fn(match.group(1)) if match else []
|
||||
except Exception:
|
||||
items = []
|
||||
if not items:
|
||||
if popup is not None:
|
||||
self._complete_hide()
|
||||
return
|
||||
if popup is None:
|
||||
popup = TuiAutocomplete()
|
||||
popup.on_select = self._complete_picked
|
||||
self.complete_popup = popup
|
||||
self.root().add(popup)
|
||||
popup.set_items(items)
|
||||
self._complete_place(popup)
|
||||
self.app.dirty_layout()
|
||||
|
||||
def _complete_place(self, popup):
|
||||
root = self.root()
|
||||
edge = 1 if root.border else 0
|
||||
ox = root.ax + edge + root.padding[3]
|
||||
oy = root.ay + edge + root.padding[0]
|
||||
room_w = max(0, root._content_w())
|
||||
room_h = max(0, root._content_h())
|
||||
chrome_w = popup.chrome_w()
|
||||
chrome_h = popup.chrome_h()
|
||||
width = 4
|
||||
for label, _value in popup.options:
|
||||
width = max(width, len(label) + 4)
|
||||
width = max(4, min(width, max(4, room_w - chrome_w)))
|
||||
height = max(1, min(len(popup.options), TUI_COMPLETE_MAXH))
|
||||
view_h = self._content_h()
|
||||
if self.cy < self.top:
|
||||
self.top = self.cy
|
||||
elif self.cy >= self.top + view_h:
|
||||
self.top = self.cy - view_h + 1
|
||||
box_ox, box_oy = self._origin()
|
||||
left = max(0, self.cx - self._content_w() + 1)
|
||||
cur_x = box_ox + (self.cx - left)
|
||||
cur_y = box_oy + (self.cy - self.top)
|
||||
popup.x = min(max(0, cur_x - ox), max(0, room_w - width - chrome_w))
|
||||
below = cur_y - oy + 1
|
||||
if below + height + chrome_h <= room_h:
|
||||
popup.y = below
|
||||
else:
|
||||
popup.y = max(0, cur_y - oy - height - chrome_h)
|
||||
popup.w = width
|
||||
popup.h = height
|
||||
|
||||
def _complete_move(self, key):
|
||||
popup = self.complete_popup
|
||||
if popup is None:
|
||||
return
|
||||
if key == "up":
|
||||
popup.move(-1)
|
||||
elif key == "down":
|
||||
popup.move(1)
|
||||
elif key == "home":
|
||||
popup.move(-len(popup.options))
|
||||
elif key == "end":
|
||||
popup.move(len(popup.options))
|
||||
elif key == "pageup":
|
||||
popup.move(-max(1, popup._content_h()))
|
||||
elif key == "pagedown":
|
||||
popup.move(max(1, popup._content_h()))
|
||||
|
||||
def _complete_hide(self):
|
||||
popup = self.complete_popup
|
||||
self.complete_popup = None
|
||||
if popup is not None and popup.parent is not None:
|
||||
popup.parent.remove(popup)
|
||||
|
||||
def _complete_picked(self, event):
|
||||
if event.panel is not self.complete_popup:
|
||||
return
|
||||
event.stop()
|
||||
self._complete_accept(event.value)
|
||||
|
||||
def _complete_accept(self, value):
|
||||
line = self.lines[self.cy]
|
||||
start = self.cx
|
||||
while start > 0 and (line[start - 1].isalnum() or line[start - 1] in "_-"):
|
||||
start -= 1
|
||||
if start <= 0 or line[start - 1] != "/":
|
||||
self._complete_hide()
|
||||
return
|
||||
word_start = start - 1
|
||||
end = self.cx
|
||||
while end < len(line) and (line[end].isalnum() or line[end] in "_-"):
|
||||
end += 1
|
||||
replacement = "/" + str(value)
|
||||
self.lines[self.cy] = line[:word_start] + replacement + line[end:]
|
||||
self.cx = word_start + len(replacement)
|
||||
if self.cx >= len(self.lines[self.cy]):
|
||||
self.lines[self.cy] += " "
|
||||
self.cx += 1
|
||||
self._clamp()
|
||||
self._touch()
|
||||
self._complete_hide()
|
||||
|
||||
def on_paste(self, event):
|
||||
if not self.enabled:
|
||||
return
|
||||
event.stop()
|
||||
text = (event.text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
if text:
|
||||
self.insert(text)
|
||||
self._complete_refresh()
|
||||
|
||||
def on_key(self, event):
|
||||
if not self.enabled:
|
||||
return
|
||||
key = event.key or ""
|
||||
if key == "escape" and self.complete_popup is not None:
|
||||
event.stop()
|
||||
self._complete_hide()
|
||||
return
|
||||
if key in ("escape", "tab", "backtab"):
|
||||
return
|
||||
if self.complete_popup is not None:
|
||||
if key in ("up", "down", "home", "end", "pageup", "pagedown"):
|
||||
event.stop()
|
||||
self._complete_move(key)
|
||||
return
|
||||
if key == "enter":
|
||||
event.stop()
|
||||
self.complete_popup.choose()
|
||||
return
|
||||
if key == "left":
|
||||
event.stop()
|
||||
self.cx -= 1
|
||||
@@ -10619,6 +10848,7 @@ class TuiTextBox(TuiPanel):
|
||||
elif key == "enter":
|
||||
event.stop()
|
||||
self.insert("\n")
|
||||
self._complete_refresh()
|
||||
return
|
||||
elif key == "backspace":
|
||||
event.stop()
|
||||
@@ -10645,10 +10875,12 @@ class TuiTextBox(TuiPanel):
|
||||
if char and "+" not in key and not key.startswith(("ctrl+", "alt+")):
|
||||
event.stop()
|
||||
self.insert(char)
|
||||
self._complete_refresh()
|
||||
return
|
||||
return
|
||||
self._clamp()
|
||||
self._touch()
|
||||
self._complete_refresh()
|
||||
|
||||
def on_click(self, event):
|
||||
if not self.enabled or not event.pressed:
|
||||
@@ -10661,6 +10893,13 @@ class TuiTextBox(TuiPanel):
|
||||
self.cx = left + (event.x - ox)
|
||||
self._clamp()
|
||||
self._touch()
|
||||
self._complete_refresh()
|
||||
|
||||
def on_blur(self, event):
|
||||
self._complete_hide()
|
||||
|
||||
def on_unmount(self, event):
|
||||
self._complete_hide()
|
||||
|
||||
|
||||
class TuiDialog(TuiPanel):
|
||||
@@ -10729,13 +10968,13 @@ class TuiPromptDialog(TuiDialog):
|
||||
"""Pretty prompt box: label, multiline editor, OK/Cancel. OK runs
|
||||
on_execute(value) then closes; Cancel and Esc just close."""
|
||||
|
||||
def __init__(self, label="Enter prompt:", box_h=3, on_execute=None, **kwargs):
|
||||
def __init__(self, label="Enter prompt:", box_h=3, on_execute=None, complete=None, **kwargs):
|
||||
kwargs.setdefault("title", "Prompt")
|
||||
kwargs.setdefault("w", 56)
|
||||
super().__init__(**kwargs)
|
||||
self.on_execute = on_execute
|
||||
self.add(TuiText(label, style=TuiStyle(dim=True)))
|
||||
self.box = TuiTextBox(h=box_h, style=TuiStyle(bg="black", fg="white"))
|
||||
self.box = TuiTextBox(h=box_h, style=TuiStyle(bg="black", fg="white"), complete=complete)
|
||||
self.add(self.box)
|
||||
self.buttons(("OK", lambda _event: self._run()), ("Cancel", lambda _event: self.close()))
|
||||
|
||||
@@ -11006,6 +11245,12 @@ class TuiTerminal(TuiPanel):
|
||||
def send_text(self, text):
|
||||
self._pending += text.encode("utf-8", "replace")
|
||||
|
||||
def on_paste(self, event):
|
||||
if not self.interactive or self.exit_status is not None:
|
||||
return
|
||||
event.stop()
|
||||
self._pending += (event.text or "").encode("utf-8", "replace")
|
||||
|
||||
@staticmethod
|
||||
def key_bytes(event):
|
||||
key = event.key or ""
|
||||
@@ -11293,6 +11538,12 @@ class TuiApp:
|
||||
if isinstance(event, TuiScroll):
|
||||
self._dispatch_scroll(event)
|
||||
return
|
||||
if isinstance(event, TuiPaste):
|
||||
if self.focused is not None:
|
||||
self.focused.emit(event)
|
||||
else:
|
||||
self.root.emit(event)
|
||||
return
|
||||
self.root.emit(event)
|
||||
|
||||
def _modal_blocks(self, panel):
|
||||
@@ -11398,12 +11649,12 @@ class TuiApp:
|
||||
except (ValueError, OSError):
|
||||
self._old_winch = None
|
||||
out = sys.stdout.fileno()
|
||||
os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + "\x1b[?25l").encode("utf-8"))
|
||||
os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l").encode("utf-8"))
|
||||
|
||||
def _teardown(self):
|
||||
try:
|
||||
out = sys.stdout.fileno()
|
||||
os.write(out, (TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8"))
|
||||
os.write(out, (TUI_PASTE_DISABLE + TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
if self._old_winch is not None:
|
||||
@@ -11566,7 +11817,8 @@ def tui_build_demo(app, live=True):
|
||||
first = text.split("\n")[0][:40] or "(empty)"
|
||||
toast("executing: %s" % first)
|
||||
log.append("prompt: %s" % text.split("\n")[0][:60])
|
||||
root.add(TuiPromptDialog(on_execute=execute, id="prompt"))
|
||||
root.add(TuiPromptDialog(on_execute=execute, id="prompt", complete=tui_complete_command,
|
||||
label="Enter prompt (/ for commands):"))
|
||||
return True
|
||||
|
||||
def on_double_tap(event):
|
||||
|
||||
+211
@@ -3929,6 +3929,54 @@ class TuiInputTests(unittest.TestCase):
|
||||
_parser, events = self.feed(b"\x1b[1;2A")
|
||||
self.assertEqual(events[0].key, "shift+up")
|
||||
|
||||
def test_bracketed_paste(self):
|
||||
_parser, events = self.feed(b"\x1b[200~hello\nworld\x1b[201~")
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertIsInstance(events[0], tai.TuiPaste)
|
||||
self.assertEqual(events[0].text, "hello\nworld")
|
||||
|
||||
def test_split_paste(self):
|
||||
parser, events = self.feed(b"\x1b[200~ab")
|
||||
self.assertEqual(events, [])
|
||||
events = parser.feed(b"cd\x1b[201~")
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0].text, "abcd")
|
||||
|
||||
def test_paste_keeps_escapes_literal(self):
|
||||
_parser, events = self.feed(b"\x1b[200~\x1b[A\tX\x1b[201~")
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0].text, "\x1b[A\tX")
|
||||
|
||||
def test_paste_cap_drops_whole(self):
|
||||
big = b"x" * (tai.TUI_PASTE_CAP + 1)
|
||||
parser, events = self.feed(b"\x1b[200~" + big)
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual(parser.buf, b"")
|
||||
events = parser.feed(b"\x1b[201~")
|
||||
self.assertEqual(events, [])
|
||||
|
||||
def test_flush_keeps_partial_paste(self):
|
||||
parser, events = self.feed(b"\x1b[200~ab")
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual(parser.flush(), [])
|
||||
events = parser.feed(b"c\x1b[201~")
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0].text, "abc")
|
||||
|
||||
def test_crlf_single_enter(self):
|
||||
_parser, events = self.feed(b"a\r\nb")
|
||||
self.assertEqual([event.key for event in events], ["a", "enter", "b"])
|
||||
|
||||
def test_lone_cr_waits_for_next_byte(self):
|
||||
parser, events = self.feed(b"\r")
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual([event.key for event in parser.feed(b"x")], ["enter", "x"])
|
||||
|
||||
def test_lone_cr_resolves_on_flush(self):
|
||||
parser, events = self.feed(b"\r")
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual([event.key for event in parser.flush()], ["enter"])
|
||||
|
||||
|
||||
class TuiKittyTests(unittest.TestCase):
|
||||
def feed(self, *chunks):
|
||||
@@ -4094,6 +4142,15 @@ class TuiWidgetTests(unittest.TestCase):
|
||||
dialog.emit(tai.TuiKey(key="escape", char="\x1b"))
|
||||
self.assertIsNone(dialog.parent)
|
||||
|
||||
def test_terminal_paste_queues_bytes(self):
|
||||
term = tai.TuiTerminal()
|
||||
term.on_paste(tai.TuiPaste(text="a\nb"))
|
||||
self.assertEqual(bytes(term._pending), b"a\nb")
|
||||
term.exit_status = 0
|
||||
term._pending = bytearray()
|
||||
term.on_paste(tai.TuiPaste(text="a\nb"))
|
||||
self.assertEqual(bytes(term._pending), b"")
|
||||
|
||||
@staticmethod
|
||||
def _btn():
|
||||
return tai.TuiButton("ok", w=8)
|
||||
@@ -4298,6 +4355,149 @@ class TuiTextBoxTests(unittest.TestCase):
|
||||
self.key(app, "space", " ")
|
||||
self.assertEqual(box.value, " ")
|
||||
|
||||
def test_paste_multiline(self):
|
||||
app, box = self.make_box()
|
||||
app.dispatch(tai.TuiPaste(text="a\r\nb\rc"))
|
||||
self.assertEqual(box.value, "a\nb\nc")
|
||||
self.assertEqual((box.cx, box.cy), (1, 2))
|
||||
|
||||
|
||||
class TuiAutocompleteTests(unittest.TestCase):
|
||||
def make_box(self, complete=tai.tui_complete_command, **kwargs):
|
||||
app = tai.TuiApp()
|
||||
app.rows = 12
|
||||
app.cols = 50
|
||||
root = tai.TuiPanel(flow=None)
|
||||
kwargs.setdefault("x", 1)
|
||||
kwargs.setdefault("y", 1)
|
||||
kwargs.setdefault("w", 30)
|
||||
kwargs.setdefault("h", 3)
|
||||
box = tai.TuiTextBox(complete=complete, **kwargs)
|
||||
root.add(box)
|
||||
app.mount(root)
|
||||
return app, box
|
||||
|
||||
def key(self, app, key, char=""):
|
||||
app.dispatch(tai.TuiKey(key=key, char=char))
|
||||
|
||||
def type(self, app, text):
|
||||
for char in text:
|
||||
self.key(app, "space" if char == " " else char, char)
|
||||
|
||||
def test_opens_and_filters(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
popup = box.complete_popup
|
||||
self.assertIsNotNone(popup)
|
||||
self.assertEqual([value for _label, value in popup.options], ["bots", "bot"])
|
||||
self.assertIs(popup.parent, box.root())
|
||||
self.assertIs(app.focused, box)
|
||||
|
||||
def test_accept_replaces_word(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
self.key(app, "down", "")
|
||||
self.key(app, "enter", "\n")
|
||||
self.assertEqual(box.value, "/bot ")
|
||||
self.assertEqual(box.cx, 5)
|
||||
self.assertIsNone(box.complete_popup)
|
||||
|
||||
def test_accept_mid_word_replaces_through_end(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bot x")
|
||||
for _i in range(5):
|
||||
self.key(app, "left", "")
|
||||
self.key(app, "enter", "\n")
|
||||
self.assertEqual(box.value, "/help x")
|
||||
self.assertEqual(box.cx, 5)
|
||||
self.assertIsNone(box.complete_popup)
|
||||
|
||||
def test_escape_dismisses_then_bubbles(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
event = tai.TuiKey(key="escape", char="\x1b")
|
||||
box.emit(event)
|
||||
self.assertTrue(event.stopped)
|
||||
self.assertIsNone(box.complete_popup)
|
||||
event = tai.TuiKey(key="escape", char="\x1b")
|
||||
box.emit(event)
|
||||
self.assertFalse(event.stopped)
|
||||
|
||||
def test_no_match_hides(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/zzz")
|
||||
self.assertIsNone(box.complete_popup)
|
||||
|
||||
def test_space_hides(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bot")
|
||||
self.assertIsNotNone(box.complete_popup)
|
||||
self.type(app, " ")
|
||||
self.assertIsNone(box.complete_popup)
|
||||
|
||||
def test_up_down_navigate(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
popup = box.complete_popup
|
||||
self.assertEqual(popup.selected, 0)
|
||||
self.key(app, "down", "")
|
||||
self.assertEqual(popup.selected, 1)
|
||||
self.key(app, "down", "")
|
||||
self.assertEqual(popup.selected, 1)
|
||||
self.key(app, "up", "")
|
||||
self.assertEqual(popup.selected, 0)
|
||||
self.assertEqual(box.value, "/bo")
|
||||
|
||||
def test_popup_placed_under_cursor(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
popup = box.complete_popup
|
||||
self.assertEqual((popup.x, popup.y), (4, 3))
|
||||
|
||||
def test_popup_flips_above_when_no_room(self):
|
||||
app, box = self.make_box(y=8)
|
||||
self.type(app, "/bo")
|
||||
popup = box.complete_popup
|
||||
self.assertEqual(popup.y, 5)
|
||||
|
||||
def test_click_accepts(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
app.layout()
|
||||
popup = box.complete_popup
|
||||
app._dispatch_click(tai.TuiClick(x=popup.ax + 1, y=popup.ay + 1, button=0, modifiers=[], pressed=True))
|
||||
self.assertEqual(box.value, "/bots ")
|
||||
|
||||
def test_blur_hides(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
self.assertIsNotNone(box.complete_popup)
|
||||
box.emit(tai.TuiBlur(panel=box))
|
||||
self.assertIsNone(box.complete_popup)
|
||||
|
||||
def test_unmount_removes_popup(self):
|
||||
app, box = self.make_box()
|
||||
self.type(app, "/bo")
|
||||
popup = box.complete_popup
|
||||
box.parent.remove(box)
|
||||
self.assertIsNone(popup.parent)
|
||||
|
||||
def test_completer_failure_hides(self):
|
||||
def broken(_prefix):
|
||||
raise ValueError("nope")
|
||||
|
||||
app, box = self.make_box(complete=broken)
|
||||
self.type(app, "/bo")
|
||||
self.assertIsNone(box.complete_popup)
|
||||
self.assertEqual(box.value, "/bo")
|
||||
|
||||
def test_tui_complete_command_labels(self):
|
||||
items = tai.tui_complete_command("bo")
|
||||
self.assertEqual([value for _label, value in items], ["bots", "bot"])
|
||||
self.assertIn("list bots", items[0][0])
|
||||
plain = [label for label, value in tai.tui_complete_command("exi") if value == "exit"]
|
||||
self.assertEqual(plain, ["/exit"])
|
||||
|
||||
|
||||
class TuiPromptDialogTests(unittest.TestCase):
|
||||
def make_demo(self):
|
||||
@@ -4348,6 +4548,17 @@ class TuiPromptDialogTests(unittest.TestCase):
|
||||
self.assertIs(app.focused, actions)
|
||||
self.assertIsNone(root.find("prompt"))
|
||||
|
||||
def test_complete_passthrough(self):
|
||||
dialog = tai.TuiPromptDialog(complete=tai.tui_complete_command)
|
||||
self.assertIs(dialog.box.complete_fn, tai.tui_complete_command)
|
||||
|
||||
def test_demo_prompt_has_command_completion(self):
|
||||
app, root, _refs = self.make_demo()
|
||||
app.dispatch(tai.TuiKey(key="shift", char=""))
|
||||
app.dispatch(tai.TuiKey(key="shift", char=""))
|
||||
dialog = root.find("prompt")
|
||||
self.assertIs(dialog.box.complete_fn, tai.tui_complete_command)
|
||||
|
||||
|
||||
class TuiCommandTests(unittest.TestCase):
|
||||
def test_demo_in_commands_and_help(self):
|
||||
|
||||
@@ -89,7 +89,7 @@ and padding add outside the given w/h (CSS `content-box`).
|
||||
## Events
|
||||
|
||||
`TuiEvent` subclasses: Key, Click, Scroll, Focus, Blur, Resize, Tick,
|
||||
Mount, Unmount, Select, Press, Close. Dispatch calls `on_<name>` on
|
||||
Mount, Unmount, Select, Press, Close, Paste. Dispatch calls `on_<name>` on
|
||||
the target, then walks base classes implicitly (plain attribute
|
||||
lookup), then bubbles to the parent while `bubble` is true.
|
||||
`event.stop()` halts everything. Custom events: subclass `TuiEvent`,
|
||||
@@ -147,6 +147,14 @@ an injectable clock; set `app.on_double_tap` to a callback returning
|
||||
True when it acted (falsy falls through to normal handling, so fast
|
||||
double-Tab still cycles).
|
||||
|
||||
Paste: setup enables bracketed paste (`CSI ? 2004 h`, best-effort
|
||||
like the kitty flags) and teardown disables it. A marked paste arrives
|
||||
as one `TuiPaste(text)` event routed to the focused panel; the body
|
||||
stays literal (no escape parsing inside) and a paste over 1MB
|
||||
(`TUI_PASTE_CAP`) is dropped whole, never truncated. Unmarked CRLF
|
||||
still coalesces to a single Enter, so plain pastes keep correct line
|
||||
breaks on terminals without bracketed mode.
|
||||
|
||||
## Animation and timers
|
||||
|
||||
```python
|
||||
@@ -174,14 +182,24 @@ callbacks; exceptions in callbacks never break the loop.
|
||||
Press event); `on_close(event)` fires on close.
|
||||
- `TuiQuestion(prompt, options, on_done, multi)` - prompt plus
|
||||
options; single-select answers on first pick, multi-select via OK.
|
||||
- `TuiTextBox(text)` - multiline editor; arrows/home/end/pgup/pgdn
|
||||
move (no line wrap), Enter splits, Backspace/Delete join,
|
||||
- `TuiTextBox(text, complete)` - multiline editor; arrows/home/end/
|
||||
pgup/pgdn move (no line wrap), Enter splits, Backspace/Delete join,
|
||||
click positions the cursor, Escape bubbles for dialogs;
|
||||
`.value` joins lines.
|
||||
- `TuiPromptDialog(label, box_h, on_execute)` - label plus a
|
||||
`.value` joins lines. Bracketed pastes insert literally with
|
||||
CR/CRLF normalized to LF. `complete(prefix)` wires a
|
||||
`TuiAutocomplete` popup for `/words` before the cursor.
|
||||
- `TuiAutocomplete` - non-focusable `TuiOptions` popup owned by a
|
||||
textbox; typing filters, arrows/home/end/pgup/pgdn move, Enter
|
||||
accepts, Esc dismisses (a second Esc still bubbles); flips above
|
||||
the cursor when short on room, max 8 rows. The box keeps focus
|
||||
throughout, and Tab is never used for completion (it always moves
|
||||
focus). Clicks pick outside modals; inside a modal the modal click
|
||||
guard swallows them and the keyboard path still works.
|
||||
- `TuiPromptDialog(label, box_h, on_execute, complete)` - label plus a
|
||||
`box_h`-row editor with OK/Cancel; OK runs
|
||||
`on_execute(value)` then closes. The demo opens one on
|
||||
double-Shift and feeds accepted text to the shell.
|
||||
double-Shift with slash-command completion and feeds accepted
|
||||
text to the shell.
|
||||
- `TuiTerminal(cmd, interactive)` - real pty child streamed through
|
||||
the VT screen; click to focus and type; read-only mode for logs.
|
||||
- `TuiProgress(value)` - fraction bar; `set()` clamps 0..1.
|
||||
@@ -201,10 +219,10 @@ the widget shows a plain error line instead of crashing.
|
||||
|
||||
- Wide (CJK/emoji) characters count as one column; columns around
|
||||
them misalign. Structured to fix with a width table later.
|
||||
- No stylesheet language (constructor kwargs instead), no bracketed
|
||||
paste, only the key-event subset of the kitty protocol (no
|
||||
release tracking, no super/hyper/meta, no alternate-key shortcut
|
||||
matching), no true opacity blending.
|
||||
- No stylesheet language (constructor kwargs instead), only the
|
||||
key-event subset of the kitty protocol (no release tracking, no
|
||||
super/hyper/meta, no alternate-key shortcut matching), no true
|
||||
opacity blending.
|
||||
- No per-widget async tasks; blocking work belongs in threads that
|
||||
post back via timers.
|
||||
- The VT parser drops provably-malformed tails past 64KB and caps
|
||||
|
||||
Reference in New Issue
Block a user