Two bugs fixed: 1. Arrow keys detected as ESC: sys.stdin.read(1) uses Python's internal buffer, so after reading ESC byte, the remaining escape sequence bytes ([A for up-arrow) were in Python's buffer but not visible to select.select() on the OS fd. Switched to os.read(fd, 1) which reads directly from the file descriptor, bypassing Python's buffer. 2. Footer positioned wrong: draw_footer() counted buffer items to calculate padding, but a Rich Table renders as multiple lines. Added _line_count tracker with _add_line() and _add_lines(n) so footer padding is calculated from actual rendered line count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
635 lines
22 KiB
Python
635 lines
22 KiB
Python
"""
|
|
Rich-based modern renderer for the AUTOPARTES console application.
|
|
|
|
Implements :class:`BaseRenderer` using the ``rich`` library for a modern
|
|
dark-themed TUI with blue/cyan accents. Uses alternate screen buffer
|
|
and buffered output for flicker-free rendering.
|
|
|
|
Key input uses os.read() on the raw fd to bypass Python's stdin buffer,
|
|
which ensures escape sequences (arrow keys, F-keys) are read correctly.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tty
|
|
import termios
|
|
import select
|
|
from io import StringIO
|
|
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
from rich import box as rich_box
|
|
|
|
from console.core.keybindings import Key
|
|
from console.renderers.base import BaseRenderer
|
|
from console.utils.formatting import pad_right, truncate
|
|
|
|
# Reduce ESC delay for this renderer too
|
|
os.environ.setdefault('ESCDELAY', '25')
|
|
|
|
|
|
class TextualRenderer(BaseRenderer):
|
|
"""Rich-based modern renderer with blue/cyan colour scheme."""
|
|
|
|
def __init__(self):
|
|
self._old_term_settings = None
|
|
self._width = 80
|
|
self._height = 24
|
|
self._fd = -1
|
|
self._buffer = [] # list of Rich renderables per render cycle
|
|
self._line_count = 0 # track actual lines consumed by buffer
|
|
|
|
# ── Lifecycle ────────────────────────────────────────────────────
|
|
|
|
def init_screen(self):
|
|
"""Enter alternate screen, hide cursor, set persistent raw mode."""
|
|
self._fd = sys.stdin.fileno()
|
|
try:
|
|
self._old_term_settings = termios.tcgetattr(self._fd)
|
|
except (termios.error, ValueError, OSError):
|
|
self._old_term_settings = None
|
|
|
|
# Enter alternate screen buffer + hide cursor
|
|
sys.stdout.write("\033[?1049h\033[?25l")
|
|
sys.stdout.flush()
|
|
|
|
# Set raw mode once for the entire session
|
|
try:
|
|
tty.setraw(self._fd)
|
|
except termios.error:
|
|
pass
|
|
|
|
def cleanup(self):
|
|
"""Restore terminal: exit alternate screen, show cursor, reset mode."""
|
|
# Show cursor + exit alternate screen
|
|
sys.stdout.write("\033[?25h\033[?1049l")
|
|
sys.stdout.flush()
|
|
# Restore original terminal attributes
|
|
if self._old_term_settings is not None:
|
|
try:
|
|
termios.tcsetattr(self._fd, termios.TCSADRAIN,
|
|
self._old_term_settings)
|
|
except (termios.error, ValueError, OSError):
|
|
pass
|
|
self._old_term_settings = None
|
|
|
|
# ── Screen queries ───────────────────────────────────────────────
|
|
|
|
def get_size(self) -> tuple:
|
|
"""Return ``(height, width)``."""
|
|
return (self._height, self._width)
|
|
|
|
# ── Primitive operations ─────────────────────────────────────────
|
|
|
|
def clear(self):
|
|
"""Start a new render cycle — update size and reset buffer."""
|
|
try:
|
|
size = os.get_terminal_size()
|
|
self._width = size.columns
|
|
self._height = size.lines
|
|
except OSError:
|
|
pass
|
|
self._buffer = []
|
|
self._line_count = 0
|
|
|
|
def _add_line(self, renderable):
|
|
"""Add a single-line renderable to the buffer."""
|
|
self._buffer.append(renderable)
|
|
self._line_count += 1
|
|
|
|
def _add_lines(self, renderable, line_count):
|
|
"""Add a multi-line renderable (like a Table) to the buffer."""
|
|
self._buffer.append(renderable)
|
|
self._line_count += line_count
|
|
|
|
def refresh(self):
|
|
"""Flush the entire buffered output to screen in one write."""
|
|
sio = StringIO()
|
|
console = Console(
|
|
file=sio,
|
|
width=self._width,
|
|
highlight=False,
|
|
force_terminal=True,
|
|
color_system="truecolor",
|
|
)
|
|
for item in self._buffer:
|
|
console.print(item, end="", highlight=False)
|
|
|
|
rendered = sio.getvalue()
|
|
|
|
# Move cursor home, clear screen, write everything at once
|
|
sys.stdout.write(f"\033[H\033[2J{rendered}")
|
|
sys.stdout.flush()
|
|
|
|
# ── Key input (uses os.read to bypass Python stdin buffer) ──────
|
|
|
|
def _read_byte(self) -> bytes:
|
|
"""Read exactly one byte from the raw fd."""
|
|
return os.read(self._fd, 1)
|
|
|
|
def _has_data(self, timeout=0.02) -> bool:
|
|
"""Return True if there is data waiting on the fd."""
|
|
r, _, _ = select.select([self._fd], [], [], timeout)
|
|
return bool(r)
|
|
|
|
def get_key(self) -> int:
|
|
"""Read a key using os.read() to bypass Python's stdin buffer."""
|
|
b = self._read_byte()
|
|
ch = b[0] if b else 0
|
|
|
|
if ch == 0x1b: # ESC
|
|
if self._has_data():
|
|
b2 = self._read_byte()
|
|
ch2 = b2[0] if b2 else 0
|
|
if ch2 == 0x5b: # [
|
|
return self._parse_csi()
|
|
elif ch2 == 0x4f: # O
|
|
return self._parse_ss3()
|
|
# Drain unknown escape
|
|
while self._has_data():
|
|
self._read_byte()
|
|
return Key.ESCAPE
|
|
return Key.ESCAPE
|
|
|
|
if ch == 0x0d or ch == 0x0a: # CR or LF
|
|
return Key.ENTER
|
|
if ch == 0x09: # TAB
|
|
return Key.TAB
|
|
if ch == 0x7f or ch == 0x08: # DEL or BS
|
|
return Key.BACKSPACE
|
|
if ch == 0x03: # Ctrl-C
|
|
return Key.ESCAPE
|
|
return ch
|
|
|
|
def _read_one(self) -> int:
|
|
"""Read one byte and return as int."""
|
|
b = self._read_byte()
|
|
return b[0] if b else 0
|
|
|
|
def _parse_csi(self):
|
|
"""Parse CSI escape sequence (ESC [ ...)."""
|
|
ch3 = self._read_one()
|
|
if ch3 == 0x41: return Key.UP # A
|
|
if ch3 == 0x42: return Key.DOWN # B
|
|
if ch3 == 0x43: return Key.RIGHT # C
|
|
if ch3 == 0x44: return Key.LEFT # D
|
|
if ch3 == 0x48: return Key.HOME # H
|
|
if ch3 == 0x46: return Key.END # F
|
|
if ch3 == 0x35: # 5
|
|
self._read_one() # ~
|
|
return Key.PGUP
|
|
if ch3 == 0x36: # 6
|
|
self._read_one() # ~
|
|
return Key.PGDN
|
|
if ch3 == 0x31: # 1
|
|
ch4 = self._read_one()
|
|
if ch4 == 0x7e: return Key.HOME # 1~
|
|
if ch4 == 0x35: self._read_one(); return Key.F5 # 15~
|
|
if ch4 == 0x37: self._read_one(); return Key.F6 # 17~
|
|
if ch4 == 0x38: self._read_one(); return Key.F7 # 18~
|
|
if ch4 == 0x39: self._read_one(); return Key.F8 # 19~
|
|
if self._has_data(): self._read_one()
|
|
return Key.HOME
|
|
if ch3 == 0x32: # 2
|
|
ch4 = self._read_one()
|
|
if ch4 == 0x30: self._read_one(); return Key.F9 # 20~
|
|
if ch4 == 0x31: self._read_one(); return Key.F10 # 21~
|
|
return Key.ESCAPE
|
|
if ch3 == 0x33: # 3 (Delete)
|
|
if self._has_data(): self._read_one()
|
|
return Key.BACKSPACE
|
|
if ch3 == 0x34: # 4
|
|
if self._has_data(): self._read_one()
|
|
return Key.END
|
|
# Drain remaining
|
|
while self._has_data():
|
|
self._read_one()
|
|
return Key.ESCAPE
|
|
|
|
def _parse_ss3(self):
|
|
"""Parse SS3 escape sequence (ESC O ...)."""
|
|
ch3 = self._read_one()
|
|
if ch3 == 0x50: return Key.F1 # P
|
|
if ch3 == 0x51: return Key.F2 # Q
|
|
if ch3 == 0x52: return Key.F3 # R
|
|
if ch3 == 0x53: return Key.F4 # S
|
|
if ch3 == 0x48: return Key.HOME # H
|
|
if ch3 == 0x46: return Key.END # F
|
|
return Key.ESCAPE
|
|
|
|
# ── High-level widgets ───────────────────────────────────────────
|
|
|
|
def draw_header(self, title, subtitle=''):
|
|
h, w = self.get_size()
|
|
header = Text()
|
|
header.append(title, style="bold cyan")
|
|
if subtitle:
|
|
padding = w - len(title) - len(subtitle)
|
|
if padding > 0:
|
|
header.append(" " * padding)
|
|
header.append(subtitle, style="dim white")
|
|
if header.cell_len < w:
|
|
header.append(" " * (w - header.cell_len))
|
|
header.stylize("on rgb(20,40,80)")
|
|
self._add_line(header)
|
|
self._add_line(Text("─" * w, style="blue"))
|
|
|
|
def draw_footer(self, key_labels):
|
|
h, w = self.get_size()
|
|
# Pad remaining lines to push footer to bottom
|
|
remaining = h - self._line_count - 2 # 2 = separator + footer
|
|
for _ in range(max(remaining, 0)):
|
|
self._add_line(Text(""))
|
|
|
|
self._add_line(Text("─" * w, style="blue"))
|
|
footer = Text()
|
|
for i, (key, desc) in enumerate(key_labels):
|
|
if i > 0:
|
|
footer.append(" ", style="dim white on rgb(20,40,80)")
|
|
footer.append(f" {key} ", style="bold white on rgb(40,80,120)")
|
|
footer.append(f" {desc}", style="white on rgb(20,40,80)")
|
|
if footer.cell_len < w:
|
|
footer.append(
|
|
" " * (w - footer.cell_len),
|
|
style="on rgb(20,40,80)",
|
|
)
|
|
self._add_line(footer)
|
|
|
|
def draw_menu(self, items, selected_index=0, title=''):
|
|
h, w = self.get_size()
|
|
visible_lines = h - 6
|
|
|
|
if title:
|
|
self._add_line(Text(f" {title}", style="bold white"))
|
|
self._add_line(Text(""))
|
|
visible_lines -= 2
|
|
|
|
if visible_lines < 1:
|
|
return
|
|
|
|
offset = 0
|
|
if selected_index >= visible_lines:
|
|
offset = selected_index - visible_lines + 1
|
|
|
|
drawn = 0
|
|
for idx, (num, label) in enumerate(items):
|
|
if drawn >= visible_lines:
|
|
break
|
|
if idx < offset:
|
|
continue
|
|
|
|
if num == "\u2500" or num == "---":
|
|
self._add_line(
|
|
Text(" " + "─" * (w - 4), style="dim blue")
|
|
)
|
|
drawn += 1
|
|
continue
|
|
|
|
marker = "\u25b8 " if idx == selected_index else " "
|
|
if idx == selected_index:
|
|
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
|
self._add_line(
|
|
Text(f" {entry}", style="bold white on rgb(30,60,120)")
|
|
)
|
|
else:
|
|
self._add_line(
|
|
Text(f" {marker}{num}. {label}", style="white")
|
|
)
|
|
drawn += 1
|
|
|
|
def draw_table(self, headers, rows, widths, page_info=None,
|
|
selected_row=-1):
|
|
h, w = self.get_size()
|
|
|
|
table = Table(
|
|
box=rich_box.SIMPLE_HEAD,
|
|
show_edge=False,
|
|
pad_edge=False,
|
|
expand=True,
|
|
style="white",
|
|
header_style="bold cyan",
|
|
row_styles=["white", "dim white"],
|
|
)
|
|
|
|
table.add_column("#", style="dim cyan", width=4, justify="right")
|
|
for hdr, wd in zip(headers, widths):
|
|
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
|
|
|
|
visible = h - 8
|
|
if visible < 1:
|
|
visible = 1
|
|
|
|
actual_rows = 0
|
|
for i, row_data in enumerate(rows):
|
|
if i >= visible:
|
|
break
|
|
cells = [str(v) for v in row_data]
|
|
row_style = ("bold white on rgb(30,60,120)"
|
|
if i == selected_row else None)
|
|
table.add_row(str(i + 1), *cells, style=row_style)
|
|
actual_rows += 1
|
|
|
|
# Table renders: 1 header + 1 separator + actual_rows
|
|
self._add_lines(table, actual_rows + 2)
|
|
|
|
if page_info:
|
|
page = page_info.get("page", 1)
|
|
total = page_info.get("total_pages", 1)
|
|
total_rows = page_info.get("total_rows", len(rows))
|
|
self._add_line(Text(
|
|
f" Pagina {page}/{total} ({total_rows} registros)",
|
|
style="dim cyan",
|
|
))
|
|
|
|
def draw_detail(self, fields, title=''):
|
|
h, w = self.get_size()
|
|
|
|
if title:
|
|
self._add_line(Text(f" {title}", style="bold white"))
|
|
self._add_line(Text(" " + "─" * (w - 4), style="blue"))
|
|
self._add_line(Text(""))
|
|
|
|
max_label = max((len(lbl) for lbl, _ in fields), default=10)
|
|
dot_total = max_label + 4
|
|
lines_available = h - 6 - (3 if title else 0)
|
|
|
|
for i, (label, value) in enumerate(fields):
|
|
if i >= lines_available:
|
|
break
|
|
dots = "." * (dot_total - len(label))
|
|
line = Text()
|
|
line.append(f" {label}{dots}: ", style="cyan")
|
|
line.append(str(value), style="bold white")
|
|
self._add_line(line)
|
|
|
|
def draw_form(self, fields, focused_index=0, title=''):
|
|
h, w = self.get_size()
|
|
|
|
if title:
|
|
self._add_line(Text(f" {title}", style="bold white"))
|
|
self._add_line(Text(" " + "─" * (w - 4), style="blue"))
|
|
self._add_line(Text(""))
|
|
|
|
max_label = max((len(f.get("label", "")) for f in fields), default=10)
|
|
dot_total = max_label + 4
|
|
|
|
for i, field in enumerate(fields):
|
|
label = field.get("label", "")
|
|
value = field.get("value", "")
|
|
fw = field.get("width", 20)
|
|
hint = field.get("hint", "")
|
|
|
|
dots = "." * (dot_total - len(label))
|
|
num_str = f"{i + 1}. "
|
|
|
|
line = Text()
|
|
line.append(f" {num_str}{label}{dots}: ", style="cyan")
|
|
|
|
display_val = pad_right(str(value), fw)
|
|
if i == focused_index:
|
|
line.append(f"[{display_val}]",
|
|
style="bold white on rgb(0,100,140)")
|
|
else:
|
|
line.append(f"[{display_val}]", style="white")
|
|
|
|
if hint:
|
|
line.append(f" {hint}", style="dim cyan")
|
|
|
|
self._add_line(line)
|
|
self._add_line(Text("")) # spacing
|
|
|
|
def draw_filter_list(self, items, filter_text, selected_index,
|
|
title=''):
|
|
h, w = self.get_size()
|
|
|
|
if title:
|
|
self._add_line(Text(f" {title}", style="bold white"))
|
|
|
|
self._add_line(Text(" " + "─" * (w - 4), style="blue"))
|
|
|
|
filter_line = Text()
|
|
filter_line.append(" Filtro: ", style="cyan")
|
|
filter_line.append(filter_text + "_",
|
|
style="bold white on rgb(0,100,140)")
|
|
self._add_line(filter_line)
|
|
|
|
self._add_line(Text(" " + "─" * (w - 4), style="blue"))
|
|
|
|
visible = h - 10
|
|
if visible < 1:
|
|
visible = 1
|
|
|
|
offset = 0
|
|
if selected_index >= visible:
|
|
offset = selected_index - visible + 1
|
|
|
|
drawn = 0
|
|
for idx, (num, label) in enumerate(items):
|
|
if drawn >= visible:
|
|
break
|
|
if idx < offset:
|
|
continue
|
|
|
|
marker = "\u25b8 " if idx == selected_index else " "
|
|
if idx == selected_index:
|
|
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
|
self._add_line(
|
|
Text(f" {entry}",
|
|
style="bold white on rgb(30,60,120)")
|
|
)
|
|
else:
|
|
self._add_line(
|
|
Text(f" {marker}{num}. {label}", style="white")
|
|
)
|
|
drawn += 1
|
|
|
|
self._add_line(Text(
|
|
f" {len(items)} elementos", style="dim cyan"
|
|
))
|
|
|
|
def draw_comparison(self, columns, title=''):
|
|
h, w = self.get_size()
|
|
|
|
if title:
|
|
self._add_line(Text(f" {title}", style="bold white"))
|
|
self._add_line(Text(" " + "─" * (w - 4), style="blue"))
|
|
self._add_line(Text(""))
|
|
|
|
n_cols = len(columns)
|
|
if n_cols == 0:
|
|
return
|
|
|
|
table = Table(
|
|
box=rich_box.SIMPLE_HEAD,
|
|
show_edge=False,
|
|
pad_edge=True,
|
|
expand=True,
|
|
style="white",
|
|
header_style="bold cyan",
|
|
)
|
|
|
|
table.add_column("", style="cyan", no_wrap=True)
|
|
for col in columns:
|
|
table.add_column(col.get("header", ""), style="white",
|
|
no_wrap=True)
|
|
|
|
if not columns[0].get("rows"):
|
|
self._add_lines(table, 2)
|
|
return
|
|
|
|
n_rows = len(columns[0]["rows"])
|
|
max_rows = h - 8
|
|
actual = min(n_rows, max_rows)
|
|
for i in range(actual):
|
|
lbl = (columns[0]["rows"][i][0]
|
|
if i < len(columns[0]["rows"]) else "")
|
|
vals = []
|
|
for col in columns:
|
|
rows_data = col.get("rows", [])
|
|
val = rows_data[i][1] if i < len(rows_data) else ""
|
|
vals.append(str(val))
|
|
table.add_row(lbl, *vals)
|
|
|
|
self._add_lines(table, actual + 2)
|
|
|
|
# ── Low-level drawing ────────────────────────────────────────────
|
|
|
|
def _render_styled(self, text, style="white"):
|
|
"""Render styled text to a string via Rich."""
|
|
sio = StringIO()
|
|
c = Console(file=sio, width=self._width, highlight=False,
|
|
force_terminal=True, color_system="truecolor")
|
|
c.print(Text(text, style=style), end="")
|
|
return sio.getvalue()
|
|
|
|
def draw_text(self, row, col, text, style='normal'):
|
|
style_map = {
|
|
"normal": "white", "header": "bold cyan",
|
|
"footer": "white on rgb(20,40,80)",
|
|
"highlight": "bold white on rgb(30,60,120)",
|
|
"border": "blue", "title": "bold white",
|
|
"error": "bold red", "info": "dim cyan",
|
|
"field_label": "cyan", "field_value": "bold white",
|
|
"field_active": "bold white on rgb(0,100,140)",
|
|
}
|
|
rich_style = style_map.get(style, "white")
|
|
rendered = self._render_styled(text, rich_style)
|
|
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
|
|
sys.stdout.flush()
|
|
|
|
def draw_box(self, top, left, height, width, title=''):
|
|
BOX_H = "\u2500"
|
|
BOX_V = "\u2502"
|
|
BOX_TL = "\u256d"
|
|
BOX_TR = "\u256e"
|
|
BOX_BL = "\u2570"
|
|
BOX_BR = "\u256f"
|
|
|
|
if height < 2 or width < 2:
|
|
return
|
|
|
|
if title:
|
|
t = truncate(title, width - 4)
|
|
top_line = BOX_TL + BOX_H + t + BOX_H * (width - 3 - len(t)) + BOX_TR
|
|
else:
|
|
top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR
|
|
|
|
self._write_at(top, left, top_line, "blue")
|
|
|
|
for r in range(1, height - 1):
|
|
self._write_at(top + r, left,
|
|
BOX_V + " " * (width - 2) + BOX_V, "blue")
|
|
|
|
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
|
|
self._write_at(top + height - 1, left, bottom_line, "blue")
|
|
|
|
def _write_at(self, row, col, text, style="white"):
|
|
"""Write styled text at absolute screen position."""
|
|
rendered = self._render_styled(text, style)
|
|
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
|
|
|
|
# ── Dialogs ──────────────────────────────────────────────────────
|
|
|
|
def show_message(self, text, msg_type='info') -> bool:
|
|
h, w = self.get_size()
|
|
lines = text.split("\n")
|
|
box_w = max(max((len(l) for l in lines), default=20) + 6, 30)
|
|
box_h = len(lines) + 4
|
|
top = max((h - box_h) // 2, 0)
|
|
left = max((w - box_w) // 2, 0)
|
|
|
|
if msg_type == "error":
|
|
text_style = "bold red"
|
|
elif msg_type == "confirm":
|
|
text_style = "bold yellow"
|
|
else:
|
|
text_style = "bold cyan"
|
|
|
|
self.draw_box(top, left, box_h, box_w)
|
|
|
|
for i, line in enumerate(lines):
|
|
x = left + max((box_w - len(line)) // 2, 2)
|
|
self._write_at(top + 1 + i, x, line, text_style)
|
|
|
|
if msg_type == "confirm":
|
|
prompt = "[S]i / [N]o"
|
|
else:
|
|
prompt = "Presione cualquier tecla..."
|
|
px = left + max((box_w - len(prompt)) // 2, 2)
|
|
self._write_at(top + box_h - 2, px, prompt, "bold cyan")
|
|
sys.stdout.flush()
|
|
|
|
if msg_type == "confirm":
|
|
while True:
|
|
key = self.get_key()
|
|
if key in (ord("s"), ord("S")):
|
|
return True
|
|
if key in (ord("n"), ord("N"), Key.ESCAPE):
|
|
return False
|
|
else:
|
|
self.get_key()
|
|
return True
|
|
|
|
def show_input(self, prompt, max_len=40):
|
|
h, w = self.get_size()
|
|
box_w = max(len(prompt) + max_len + 8, 30)
|
|
box_h = 5
|
|
top = max((h - box_h) // 2, 0)
|
|
left = max((w - box_w) // 2, 0)
|
|
|
|
buf = []
|
|
sys.stdout.write("\033[?25h")
|
|
sys.stdout.flush()
|
|
|
|
while True:
|
|
self.draw_box(top, left, box_h, box_w)
|
|
|
|
self._write_at(top + 1, left + 2, prompt, "cyan")
|
|
|
|
val = "".join(buf)
|
|
display = pad_right(val, max_len)
|
|
self._write_at(top + 2, left + 2, f"[{display}]",
|
|
"bold white on rgb(0,100,140)")
|
|
|
|
hint = "ENTER=Aceptar ESC=Cancelar"
|
|
hx = left + max((box_w - len(hint)) // 2, 2)
|
|
self._write_at(top + 3, hx, hint, "dim cyan")
|
|
sys.stdout.flush()
|
|
|
|
key = self.get_key()
|
|
if key == Key.ESCAPE:
|
|
sys.stdout.write("\033[?25l")
|
|
sys.stdout.flush()
|
|
return None
|
|
elif key == Key.ENTER:
|
|
sys.stdout.write("\033[?25l")
|
|
sys.stdout.flush()
|
|
return "".join(buf)
|
|
elif key == Key.BACKSPACE:
|
|
if buf:
|
|
buf.pop()
|
|
elif 32 <= key <= 126:
|
|
if len(buf) < max_len:
|
|
buf.append(chr(key))
|