fix(console): arrow keys and layout in modern renderer

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>
This commit is contained in:
2026-02-15 02:29:32 +00:00
parent 8c7caf3969
commit a3aa2a7608

View File

@@ -4,6 +4,9 @@ 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
@@ -33,15 +36,17 @@ class TextualRenderer(BaseRenderer):
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."""
fd = sys.stdin.fileno()
self._fd = sys.stdin.fileno()
try:
self._old_term_settings = termios.tcgetattr(fd)
self._old_term_settings = termios.tcgetattr(self._fd)
except (termios.error, ValueError, OSError):
self._old_term_settings = None
@@ -51,7 +56,7 @@ class TextualRenderer(BaseRenderer):
# Set raw mode once for the entire session
try:
tty.setraw(fd)
tty.setraw(self._fd)
except termios.error:
pass
@@ -63,8 +68,7 @@ class TextualRenderer(BaseRenderer):
# Restore original terminal attributes
if self._old_term_settings is not None:
try:
fd = sys.stdin.fileno()
termios.tcsetattr(fd, termios.TCSADRAIN,
termios.tcsetattr(self._fd, termios.TCSADRAIN,
self._old_term_settings)
except (termios.error, ValueError, OSError):
pass
@@ -87,10 +91,20 @@ class TextualRenderer(BaseRenderer):
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."""
# Render all buffered content to a string via Rich
sio = StringIO()
console = Console(
file=sio,
@@ -100,9 +114,6 @@ class TextualRenderer(BaseRenderer):
color_system="truecolor",
)
for item in self._buffer:
if isinstance(item, str):
console.print(item, end="", highlight=False)
else:
console.print(item, end="", highlight=False)
rendered = sio.getvalue()
@@ -111,82 +122,100 @@ class TextualRenderer(BaseRenderer):
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 (terminal is already in raw mode)."""
fd = sys.stdin.fileno()
ch = sys.stdin.read(1)
"""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 == "\x1b":
if _has_data(fd):
ch2 = sys.stdin.read(1)
if ch2 == "[":
return self._parse_csi(fd)
elif ch2 == "O":
return self._parse_ss3(fd)
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 _has_data(fd):
sys.stdin.read(1)
while self._has_data():
self._read_byte()
return Key.ESCAPE
return Key.ESCAPE
if ch == "\r" or ch == "\n":
if ch == 0x0d or ch == 0x0a: # CR or LF
return Key.ENTER
if ch == "\t":
if ch == 0x09: # TAB
return Key.TAB
if ch == "\x7f" or ch == "\x08":
if ch == 0x7f or ch == 0x08: # DEL or BS
return Key.BACKSPACE
if ch == "\x03":
if ch == 0x03: # Ctrl-C
return Key.ESCAPE
return ord(ch)
return ch
def _parse_csi(self, fd):
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 = sys.stdin.read(1)
if ch3 == "A": return Key.UP
if ch3 == "B": return Key.DOWN
if ch3 == "C": return Key.RIGHT
if ch3 == "D": return Key.LEFT
if ch3 == "H": return Key.HOME
if ch3 == "F": return Key.END
if ch3 == "5":
sys.stdin.read(1) # ~
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 == "6":
sys.stdin.read(1) # ~
if ch3 == 0x36: # 6
self._read_one() # ~
return Key.PGDN
if ch3 == "1":
ch4 = sys.stdin.read(1)
if ch4 == "~": return Key.HOME
if ch4 == "5": sys.stdin.read(1); return Key.F5
if ch4 == "7": sys.stdin.read(1); return Key.F6
if ch4 == "8": sys.stdin.read(1); return Key.F7
if ch4 == "9": sys.stdin.read(1); return Key.F8
if _has_data(fd): sys.stdin.read(1)
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 == "2":
ch4 = sys.stdin.read(1)
if ch4 == "0": sys.stdin.read(1); return Key.F9
if ch4 == "1": sys.stdin.read(1); return Key.F10
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 == "3":
if _has_data(fd): sys.stdin.read(1)
if ch3 == 0x33: # 3 (Delete)
if self._has_data(): self._read_one()
return Key.BACKSPACE
if ch3 == "4":
if _has_data(fd): sys.stdin.read(1)
if ch3 == 0x34: # 4
if self._has_data(): self._read_one()
return Key.END
while _has_data(fd):
sys.stdin.read(1)
# Drain remaining
while self._has_data():
self._read_one()
return Key.ESCAPE
def _parse_ss3(self, fd):
def _parse_ss3(self):
"""Parse SS3 escape sequence (ESC O ...)."""
ch3 = sys.stdin.read(1)
if ch3 == "P": return Key.F1
if ch3 == "Q": return Key.F2
if ch3 == "R": return Key.F3
if ch3 == "S": return Key.F4
if ch3 == "H": return Key.HOME
if ch3 == "F": return Key.END
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 ───────────────────────────────────────────
@@ -203,18 +232,17 @@ class TextualRenderer(BaseRenderer):
if header.cell_len < w:
header.append(" " * (w - header.cell_len))
header.stylize("on rgb(20,40,80)")
self._buffer.append(header)
self._buffer.append(Text("" * w, style="blue"))
self._add_line(header)
self._add_line(Text("" * w, style="blue"))
def draw_footer(self, key_labels):
h, w = self.get_size()
# Pad remaining space before footer
used = len(self._buffer)
remaining = h - used - 2 # 2 lines for separator + footer
# Pad remaining lines to push footer to bottom
remaining = h - self._line_count - 2 # 2 = separator + footer
for _ in range(max(remaining, 0)):
self._buffer.append(Text(""))
self._add_line(Text(""))
self._buffer.append(Text("" * w, style="blue"))
self._add_line(Text("" * w, style="blue"))
footer = Text()
for i, (key, desc) in enumerate(key_labels):
if i > 0:
@@ -226,15 +254,15 @@ class TextualRenderer(BaseRenderer):
" " * (w - footer.cell_len),
style="on rgb(20,40,80)",
)
self._buffer.append(footer)
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._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(""))
self._add_line(Text(f" {title}", style="bold white"))
self._add_line(Text(""))
visible_lines -= 2
if visible_lines < 1:
@@ -252,7 +280,7 @@ class TextualRenderer(BaseRenderer):
continue
if num == "\u2500" or num == "---":
self._buffer.append(
self._add_line(
Text(" " + "" * (w - 4), style="dim blue")
)
drawn += 1
@@ -261,11 +289,11 @@ class TextualRenderer(BaseRenderer):
marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index:
entry = pad_right(f"{marker}{num}. {label}", w - 4)
self._buffer.append(
self._add_line(
Text(f" {entry}", style="bold white on rgb(30,60,120)")
)
else:
self._buffer.append(
self._add_line(
Text(f" {marker}{num}. {label}", style="white")
)
drawn += 1
@@ -292,6 +320,7 @@ class TextualRenderer(BaseRenderer):
if visible < 1:
visible = 1
actual_rows = 0
for i, row_data in enumerate(rows):
if i >= visible:
break
@@ -299,14 +328,16 @@ class TextualRenderer(BaseRenderer):
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
self._buffer.append(table)
# 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._buffer.append(Text(
self._add_line(Text(
f" Pagina {page}/{total} ({total_rows} registros)",
style="dim cyan",
))
@@ -315,9 +346,9 @@ class TextualRenderer(BaseRenderer):
h, w = self.get_size()
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._buffer.append(Text(""))
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
@@ -330,15 +361,15 @@ class TextualRenderer(BaseRenderer):
line = Text()
line.append(f" {label}{dots}: ", style="cyan")
line.append(str(value), style="bold white")
self._buffer.append(line)
self._add_line(line)
def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size()
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._buffer.append(Text(""))
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
@@ -365,25 +396,25 @@ class TextualRenderer(BaseRenderer):
if hint:
line.append(f" {hint}", style="dim cyan")
self._buffer.append(line)
self._buffer.append(Text("")) # spacing
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._buffer.append(Text(f" {title}", style="bold white"))
self._add_line(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
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._buffer.append(filter_line)
self._add_line(filter_line)
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._add_line(Text(" " + "" * (w - 4), style="blue"))
visible = h - 10
if visible < 1:
@@ -403,17 +434,17 @@ class TextualRenderer(BaseRenderer):
marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index:
entry = pad_right(f"{marker}{num}. {label}", w - 4)
self._buffer.append(
self._add_line(
Text(f" {entry}",
style="bold white on rgb(30,60,120)")
)
else:
self._buffer.append(
self._add_line(
Text(f" {marker}{num}. {label}", style="white")
)
drawn += 1
self._buffer.append(Text(
self._add_line(Text(
f" {len(items)} elementos", style="dim cyan"
))
@@ -421,9 +452,9 @@ class TextualRenderer(BaseRenderer):
h, w = self.get_size()
if title:
self._buffer.append(Text(f" {title}", style="bold white"))
self._buffer.append(Text(" " + "" * (w - 4), style="blue"))
self._buffer.append(Text(""))
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:
@@ -444,12 +475,13 @@ class TextualRenderer(BaseRenderer):
no_wrap=True)
if not columns[0].get("rows"):
self._buffer.append(table)
self._add_lines(table, 2)
return
n_rows = len(columns[0]["rows"])
max_rows = h - 8
for i in range(min(n_rows, max_rows)):
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 = []
@@ -459,10 +491,18 @@ class TextualRenderer(BaseRenderer):
vals.append(str(val))
table.add_row(lbl, *vals)
self._buffer.append(table)
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",
@@ -473,14 +513,9 @@ class TextualRenderer(BaseRenderer):
"field_label": "cyan", "field_value": "bold white",
"field_active": "bold white on rgb(0,100,140)",
}
# For positioned text, write directly (used by dialogs)
rich_style = style_map.get(style, "white")
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
sio = StringIO()
c = Console(file=sio, width=self._width, highlight=False,
force_terminal=True, color_system="truecolor")
c.print(Text(text, style=rich_style), end="")
sys.stdout.write(sio.getvalue())
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=''):
@@ -494,29 +529,25 @@ class TextualRenderer(BaseRenderer):
if height < 2 or width < 2:
return
style = "blue"
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, style)
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,
style)
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, style)
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."""
sio = StringIO()
c = Console(file=sio, width=self._width, highlight=False,
force_terminal=True, color_system="truecolor")
c.print(Text(text, style=style), end="")
sys.stdout.write(f"\033[{row + 1};{col + 1}H{sio.getvalue()}")
rendered = self._render_styled(text, style)
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
# ── Dialogs ──────────────────────────────────────────────────────
@@ -568,7 +599,6 @@ class TextualRenderer(BaseRenderer):
left = max((w - box_w) // 2, 0)
buf = []
# Show cursor during input
sys.stdout.write("\033[?25h")
sys.stdout.flush()
@@ -602,11 +632,3 @@ class TextualRenderer(BaseRenderer):
elif 32 <= key <= 126:
if len(buf) < max_len:
buf.append(chr(key))
# ── Module-level helpers ─────────────────────────────────────────────
def _has_data(fd, timeout=0.02):
"""Return True if there is data waiting on file descriptor *fd*."""
r, _, _ = select.select([fd], [], [], timeout)
return bool(r)