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:
@@ -4,6 +4,9 @@ Rich-based modern renderer for the AUTOPARTES console application.
|
|||||||
Implements :class:`BaseRenderer` using the ``rich`` library for a modern
|
Implements :class:`BaseRenderer` using the ``rich`` library for a modern
|
||||||
dark-themed TUI with blue/cyan accents. Uses alternate screen buffer
|
dark-themed TUI with blue/cyan accents. Uses alternate screen buffer
|
||||||
and buffered output for flicker-free rendering.
|
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 os
|
||||||
@@ -33,15 +36,17 @@ class TextualRenderer(BaseRenderer):
|
|||||||
self._old_term_settings = None
|
self._old_term_settings = None
|
||||||
self._width = 80
|
self._width = 80
|
||||||
self._height = 24
|
self._height = 24
|
||||||
self._buffer = [] # list of Rich renderables per render cycle
|
self._fd = -1
|
||||||
|
self._buffer = [] # list of Rich renderables per render cycle
|
||||||
|
self._line_count = 0 # track actual lines consumed by buffer
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────
|
# ── Lifecycle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def init_screen(self):
|
def init_screen(self):
|
||||||
"""Enter alternate screen, hide cursor, set persistent raw mode."""
|
"""Enter alternate screen, hide cursor, set persistent raw mode."""
|
||||||
fd = sys.stdin.fileno()
|
self._fd = sys.stdin.fileno()
|
||||||
try:
|
try:
|
||||||
self._old_term_settings = termios.tcgetattr(fd)
|
self._old_term_settings = termios.tcgetattr(self._fd)
|
||||||
except (termios.error, ValueError, OSError):
|
except (termios.error, ValueError, OSError):
|
||||||
self._old_term_settings = None
|
self._old_term_settings = None
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ class TextualRenderer(BaseRenderer):
|
|||||||
|
|
||||||
# Set raw mode once for the entire session
|
# Set raw mode once for the entire session
|
||||||
try:
|
try:
|
||||||
tty.setraw(fd)
|
tty.setraw(self._fd)
|
||||||
except termios.error:
|
except termios.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -63,8 +68,7 @@ class TextualRenderer(BaseRenderer):
|
|||||||
# Restore original terminal attributes
|
# Restore original terminal attributes
|
||||||
if self._old_term_settings is not None:
|
if self._old_term_settings is not None:
|
||||||
try:
|
try:
|
||||||
fd = sys.stdin.fileno()
|
termios.tcsetattr(self._fd, termios.TCSADRAIN,
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN,
|
|
||||||
self._old_term_settings)
|
self._old_term_settings)
|
||||||
except (termios.error, ValueError, OSError):
|
except (termios.error, ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
@@ -87,10 +91,20 @@ class TextualRenderer(BaseRenderer):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
self._buffer = []
|
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):
|
def refresh(self):
|
||||||
"""Flush the entire buffered output to screen in one write."""
|
"""Flush the entire buffered output to screen in one write."""
|
||||||
# Render all buffered content to a string via Rich
|
|
||||||
sio = StringIO()
|
sio = StringIO()
|
||||||
console = Console(
|
console = Console(
|
||||||
file=sio,
|
file=sio,
|
||||||
@@ -100,10 +114,7 @@ class TextualRenderer(BaseRenderer):
|
|||||||
color_system="truecolor",
|
color_system="truecolor",
|
||||||
)
|
)
|
||||||
for item in self._buffer:
|
for item in self._buffer:
|
||||||
if isinstance(item, str):
|
console.print(item, end="", highlight=False)
|
||||||
console.print(item, end="", highlight=False)
|
|
||||||
else:
|
|
||||||
console.print(item, end="", highlight=False)
|
|
||||||
|
|
||||||
rendered = sio.getvalue()
|
rendered = sio.getvalue()
|
||||||
|
|
||||||
@@ -111,82 +122,100 @@ class TextualRenderer(BaseRenderer):
|
|||||||
sys.stdout.write(f"\033[H\033[2J{rendered}")
|
sys.stdout.write(f"\033[H\033[2J{rendered}")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
def get_key(self) -> int:
|
# ── Key input (uses os.read to bypass Python stdin buffer) ──────
|
||||||
"""Read a key (terminal is already in raw mode)."""
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
|
|
||||||
if ch == "\x1b":
|
def _read_byte(self) -> bytes:
|
||||||
if _has_data(fd):
|
"""Read exactly one byte from the raw fd."""
|
||||||
ch2 = sys.stdin.read(1)
|
return os.read(self._fd, 1)
|
||||||
if ch2 == "[":
|
|
||||||
return self._parse_csi(fd)
|
def _has_data(self, timeout=0.02) -> bool:
|
||||||
elif ch2 == "O":
|
"""Return True if there is data waiting on the fd."""
|
||||||
return self._parse_ss3(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
|
# Drain unknown escape
|
||||||
while _has_data(fd):
|
while self._has_data():
|
||||||
sys.stdin.read(1)
|
self._read_byte()
|
||||||
return Key.ESCAPE
|
return Key.ESCAPE
|
||||||
return Key.ESCAPE
|
return Key.ESCAPE
|
||||||
|
|
||||||
if ch == "\r" or ch == "\n":
|
if ch == 0x0d or ch == 0x0a: # CR or LF
|
||||||
return Key.ENTER
|
return Key.ENTER
|
||||||
if ch == "\t":
|
if ch == 0x09: # TAB
|
||||||
return Key.TAB
|
return Key.TAB
|
||||||
if ch == "\x7f" or ch == "\x08":
|
if ch == 0x7f or ch == 0x08: # DEL or BS
|
||||||
return Key.BACKSPACE
|
return Key.BACKSPACE
|
||||||
if ch == "\x03":
|
if ch == 0x03: # Ctrl-C
|
||||||
return Key.ESCAPE
|
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 [ ...)."""
|
"""Parse CSI escape sequence (ESC [ ...)."""
|
||||||
ch3 = sys.stdin.read(1)
|
ch3 = self._read_one()
|
||||||
if ch3 == "A": return Key.UP
|
if ch3 == 0x41: return Key.UP # A
|
||||||
if ch3 == "B": return Key.DOWN
|
if ch3 == 0x42: return Key.DOWN # B
|
||||||
if ch3 == "C": return Key.RIGHT
|
if ch3 == 0x43: return Key.RIGHT # C
|
||||||
if ch3 == "D": return Key.LEFT
|
if ch3 == 0x44: return Key.LEFT # D
|
||||||
if ch3 == "H": return Key.HOME
|
if ch3 == 0x48: return Key.HOME # H
|
||||||
if ch3 == "F": return Key.END
|
if ch3 == 0x46: return Key.END # F
|
||||||
if ch3 == "5":
|
if ch3 == 0x35: # 5
|
||||||
sys.stdin.read(1) # ~
|
self._read_one() # ~
|
||||||
return Key.PGUP
|
return Key.PGUP
|
||||||
if ch3 == "6":
|
if ch3 == 0x36: # 6
|
||||||
sys.stdin.read(1) # ~
|
self._read_one() # ~
|
||||||
return Key.PGDN
|
return Key.PGDN
|
||||||
if ch3 == "1":
|
if ch3 == 0x31: # 1
|
||||||
ch4 = sys.stdin.read(1)
|
ch4 = self._read_one()
|
||||||
if ch4 == "~": return Key.HOME
|
if ch4 == 0x7e: return Key.HOME # 1~
|
||||||
if ch4 == "5": sys.stdin.read(1); return Key.F5
|
if ch4 == 0x35: self._read_one(); return Key.F5 # 15~
|
||||||
if ch4 == "7": sys.stdin.read(1); return Key.F6
|
if ch4 == 0x37: self._read_one(); return Key.F6 # 17~
|
||||||
if ch4 == "8": sys.stdin.read(1); return Key.F7
|
if ch4 == 0x38: self._read_one(); return Key.F7 # 18~
|
||||||
if ch4 == "9": sys.stdin.read(1); return Key.F8
|
if ch4 == 0x39: self._read_one(); return Key.F8 # 19~
|
||||||
if _has_data(fd): sys.stdin.read(1)
|
if self._has_data(): self._read_one()
|
||||||
return Key.HOME
|
return Key.HOME
|
||||||
if ch3 == "2":
|
if ch3 == 0x32: # 2
|
||||||
ch4 = sys.stdin.read(1)
|
ch4 = self._read_one()
|
||||||
if ch4 == "0": sys.stdin.read(1); return Key.F9
|
if ch4 == 0x30: self._read_one(); return Key.F9 # 20~
|
||||||
if ch4 == "1": sys.stdin.read(1); return Key.F10
|
if ch4 == 0x31: self._read_one(); return Key.F10 # 21~
|
||||||
return Key.ESCAPE
|
return Key.ESCAPE
|
||||||
if ch3 == "3":
|
if ch3 == 0x33: # 3 (Delete)
|
||||||
if _has_data(fd): sys.stdin.read(1)
|
if self._has_data(): self._read_one()
|
||||||
return Key.BACKSPACE
|
return Key.BACKSPACE
|
||||||
if ch3 == "4":
|
if ch3 == 0x34: # 4
|
||||||
if _has_data(fd): sys.stdin.read(1)
|
if self._has_data(): self._read_one()
|
||||||
return Key.END
|
return Key.END
|
||||||
while _has_data(fd):
|
# Drain remaining
|
||||||
sys.stdin.read(1)
|
while self._has_data():
|
||||||
|
self._read_one()
|
||||||
return Key.ESCAPE
|
return Key.ESCAPE
|
||||||
|
|
||||||
def _parse_ss3(self, fd):
|
def _parse_ss3(self):
|
||||||
"""Parse SS3 escape sequence (ESC O ...)."""
|
"""Parse SS3 escape sequence (ESC O ...)."""
|
||||||
ch3 = sys.stdin.read(1)
|
ch3 = self._read_one()
|
||||||
if ch3 == "P": return Key.F1
|
if ch3 == 0x50: return Key.F1 # P
|
||||||
if ch3 == "Q": return Key.F2
|
if ch3 == 0x51: return Key.F2 # Q
|
||||||
if ch3 == "R": return Key.F3
|
if ch3 == 0x52: return Key.F3 # R
|
||||||
if ch3 == "S": return Key.F4
|
if ch3 == 0x53: return Key.F4 # S
|
||||||
if ch3 == "H": return Key.HOME
|
if ch3 == 0x48: return Key.HOME # H
|
||||||
if ch3 == "F": return Key.END
|
if ch3 == 0x46: return Key.END # F
|
||||||
return Key.ESCAPE
|
return Key.ESCAPE
|
||||||
|
|
||||||
# ── High-level widgets ───────────────────────────────────────────
|
# ── High-level widgets ───────────────────────────────────────────
|
||||||
@@ -203,18 +232,17 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if header.cell_len < w:
|
if header.cell_len < w:
|
||||||
header.append(" " * (w - header.cell_len))
|
header.append(" " * (w - header.cell_len))
|
||||||
header.stylize("on rgb(20,40,80)")
|
header.stylize("on rgb(20,40,80)")
|
||||||
self._buffer.append(header)
|
self._add_line(header)
|
||||||
self._buffer.append(Text("─" * w, style="blue"))
|
self._add_line(Text("─" * w, style="blue"))
|
||||||
|
|
||||||
def draw_footer(self, key_labels):
|
def draw_footer(self, key_labels):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
# Pad remaining space before footer
|
# Pad remaining lines to push footer to bottom
|
||||||
used = len(self._buffer)
|
remaining = h - self._line_count - 2 # 2 = separator + footer
|
||||||
remaining = h - used - 2 # 2 lines for separator + footer
|
|
||||||
for _ in range(max(remaining, 0)):
|
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()
|
footer = Text()
|
||||||
for i, (key, desc) in enumerate(key_labels):
|
for i, (key, desc) in enumerate(key_labels):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
@@ -226,15 +254,15 @@ class TextualRenderer(BaseRenderer):
|
|||||||
" " * (w - footer.cell_len),
|
" " * (w - footer.cell_len),
|
||||||
style="on rgb(20,40,80)",
|
style="on rgb(20,40,80)",
|
||||||
)
|
)
|
||||||
self._buffer.append(footer)
|
self._add_line(footer)
|
||||||
|
|
||||||
def draw_menu(self, items, selected_index=0, title=''):
|
def draw_menu(self, items, selected_index=0, title=''):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
visible_lines = h - 6
|
visible_lines = h - 6
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
self._buffer.append(Text(f" {title}", style="bold white"))
|
self._add_line(Text(f" {title}", style="bold white"))
|
||||||
self._buffer.append(Text(""))
|
self._add_line(Text(""))
|
||||||
visible_lines -= 2
|
visible_lines -= 2
|
||||||
|
|
||||||
if visible_lines < 1:
|
if visible_lines < 1:
|
||||||
@@ -252,7 +280,7 @@ class TextualRenderer(BaseRenderer):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if num == "\u2500" or num == "---":
|
if num == "\u2500" or num == "---":
|
||||||
self._buffer.append(
|
self._add_line(
|
||||||
Text(" " + "─" * (w - 4), style="dim blue")
|
Text(" " + "─" * (w - 4), style="dim blue")
|
||||||
)
|
)
|
||||||
drawn += 1
|
drawn += 1
|
||||||
@@ -261,11 +289,11 @@ class TextualRenderer(BaseRenderer):
|
|||||||
marker = "\u25b8 " if idx == selected_index else " "
|
marker = "\u25b8 " if idx == selected_index else " "
|
||||||
if idx == selected_index:
|
if idx == selected_index:
|
||||||
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
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)")
|
Text(f" {entry}", style="bold white on rgb(30,60,120)")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._buffer.append(
|
self._add_line(
|
||||||
Text(f" {marker}{num}. {label}", style="white")
|
Text(f" {marker}{num}. {label}", style="white")
|
||||||
)
|
)
|
||||||
drawn += 1
|
drawn += 1
|
||||||
@@ -292,6 +320,7 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if visible < 1:
|
if visible < 1:
|
||||||
visible = 1
|
visible = 1
|
||||||
|
|
||||||
|
actual_rows = 0
|
||||||
for i, row_data in enumerate(rows):
|
for i, row_data in enumerate(rows):
|
||||||
if i >= visible:
|
if i >= visible:
|
||||||
break
|
break
|
||||||
@@ -299,14 +328,16 @@ class TextualRenderer(BaseRenderer):
|
|||||||
row_style = ("bold white on rgb(30,60,120)"
|
row_style = ("bold white on rgb(30,60,120)"
|
||||||
if i == selected_row else None)
|
if i == selected_row else None)
|
||||||
table.add_row(str(i + 1), *cells, style=row_style)
|
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:
|
if page_info:
|
||||||
page = page_info.get("page", 1)
|
page = page_info.get("page", 1)
|
||||||
total = page_info.get("total_pages", 1)
|
total = page_info.get("total_pages", 1)
|
||||||
total_rows = page_info.get("total_rows", len(rows))
|
total_rows = page_info.get("total_rows", len(rows))
|
||||||
self._buffer.append(Text(
|
self._add_line(Text(
|
||||||
f" Pagina {page}/{total} ({total_rows} registros)",
|
f" Pagina {page}/{total} ({total_rows} registros)",
|
||||||
style="dim cyan",
|
style="dim cyan",
|
||||||
))
|
))
|
||||||
@@ -315,9 +346,9 @@ class TextualRenderer(BaseRenderer):
|
|||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
|
|
||||||
if title:
|
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"))
|
||||||
self._buffer.append(Text(""))
|
self._add_line(Text(""))
|
||||||
|
|
||||||
max_label = max((len(lbl) for lbl, _ in fields), default=10)
|
max_label = max((len(lbl) for lbl, _ in fields), default=10)
|
||||||
dot_total = max_label + 4
|
dot_total = max_label + 4
|
||||||
@@ -330,15 +361,15 @@ class TextualRenderer(BaseRenderer):
|
|||||||
line = Text()
|
line = Text()
|
||||||
line.append(f" {label}{dots}: ", style="cyan")
|
line.append(f" {label}{dots}: ", style="cyan")
|
||||||
line.append(str(value), style="bold white")
|
line.append(str(value), style="bold white")
|
||||||
self._buffer.append(line)
|
self._add_line(line)
|
||||||
|
|
||||||
def draw_form(self, fields, focused_index=0, title=''):
|
def draw_form(self, fields, focused_index=0, title=''):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
|
|
||||||
if title:
|
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"))
|
||||||
self._buffer.append(Text(""))
|
self._add_line(Text(""))
|
||||||
|
|
||||||
max_label = max((len(f.get("label", "")) for f in fields), default=10)
|
max_label = max((len(f.get("label", "")) for f in fields), default=10)
|
||||||
dot_total = max_label + 4
|
dot_total = max_label + 4
|
||||||
@@ -365,25 +396,25 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if hint:
|
if hint:
|
||||||
line.append(f" {hint}", style="dim cyan")
|
line.append(f" {hint}", style="dim cyan")
|
||||||
|
|
||||||
self._buffer.append(line)
|
self._add_line(line)
|
||||||
self._buffer.append(Text("")) # spacing
|
self._add_line(Text("")) # spacing
|
||||||
|
|
||||||
def draw_filter_list(self, items, filter_text, selected_index,
|
def draw_filter_list(self, items, filter_text, selected_index,
|
||||||
title=''):
|
title=''):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
|
|
||||||
if title:
|
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 = Text()
|
||||||
filter_line.append(" Filtro: ", style="cyan")
|
filter_line.append(" Filtro: ", style="cyan")
|
||||||
filter_line.append(filter_text + "_",
|
filter_line.append(filter_text + "_",
|
||||||
style="bold white on rgb(0,100,140)")
|
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
|
visible = h - 10
|
||||||
if visible < 1:
|
if visible < 1:
|
||||||
@@ -403,17 +434,17 @@ class TextualRenderer(BaseRenderer):
|
|||||||
marker = "\u25b8 " if idx == selected_index else " "
|
marker = "\u25b8 " if idx == selected_index else " "
|
||||||
if idx == selected_index:
|
if idx == selected_index:
|
||||||
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
||||||
self._buffer.append(
|
self._add_line(
|
||||||
Text(f" {entry}",
|
Text(f" {entry}",
|
||||||
style="bold white on rgb(30,60,120)")
|
style="bold white on rgb(30,60,120)")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._buffer.append(
|
self._add_line(
|
||||||
Text(f" {marker}{num}. {label}", style="white")
|
Text(f" {marker}{num}. {label}", style="white")
|
||||||
)
|
)
|
||||||
drawn += 1
|
drawn += 1
|
||||||
|
|
||||||
self._buffer.append(Text(
|
self._add_line(Text(
|
||||||
f" {len(items)} elementos", style="dim cyan"
|
f" {len(items)} elementos", style="dim cyan"
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -421,9 +452,9 @@ class TextualRenderer(BaseRenderer):
|
|||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
|
|
||||||
if title:
|
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"))
|
||||||
self._buffer.append(Text(""))
|
self._add_line(Text(""))
|
||||||
|
|
||||||
n_cols = len(columns)
|
n_cols = len(columns)
|
||||||
if n_cols == 0:
|
if n_cols == 0:
|
||||||
@@ -444,12 +475,13 @@ class TextualRenderer(BaseRenderer):
|
|||||||
no_wrap=True)
|
no_wrap=True)
|
||||||
|
|
||||||
if not columns[0].get("rows"):
|
if not columns[0].get("rows"):
|
||||||
self._buffer.append(table)
|
self._add_lines(table, 2)
|
||||||
return
|
return
|
||||||
|
|
||||||
n_rows = len(columns[0]["rows"])
|
n_rows = len(columns[0]["rows"])
|
||||||
max_rows = h - 8
|
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]
|
lbl = (columns[0]["rows"][i][0]
|
||||||
if i < len(columns[0]["rows"]) else "")
|
if i < len(columns[0]["rows"]) else "")
|
||||||
vals = []
|
vals = []
|
||||||
@@ -459,10 +491,18 @@ class TextualRenderer(BaseRenderer):
|
|||||||
vals.append(str(val))
|
vals.append(str(val))
|
||||||
table.add_row(lbl, *vals)
|
table.add_row(lbl, *vals)
|
||||||
|
|
||||||
self._buffer.append(table)
|
self._add_lines(table, actual + 2)
|
||||||
|
|
||||||
# ── Low-level drawing ────────────────────────────────────────────
|
# ── 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'):
|
def draw_text(self, row, col, text, style='normal'):
|
||||||
style_map = {
|
style_map = {
|
||||||
"normal": "white", "header": "bold cyan",
|
"normal": "white", "header": "bold cyan",
|
||||||
@@ -473,14 +513,9 @@ class TextualRenderer(BaseRenderer):
|
|||||||
"field_label": "cyan", "field_value": "bold white",
|
"field_label": "cyan", "field_value": "bold white",
|
||||||
"field_active": "bold white on rgb(0,100,140)",
|
"field_active": "bold white on rgb(0,100,140)",
|
||||||
}
|
}
|
||||||
# For positioned text, write directly (used by dialogs)
|
|
||||||
rich_style = style_map.get(style, "white")
|
rich_style = style_map.get(style, "white")
|
||||||
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
|
rendered = self._render_styled(text, rich_style)
|
||||||
sio = StringIO()
|
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
|
||||||
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())
|
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
def draw_box(self, top, left, height, width, title=''):
|
def draw_box(self, top, left, height, width, title=''):
|
||||||
@@ -494,29 +529,25 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if height < 2 or width < 2:
|
if height < 2 or width < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
style = "blue"
|
|
||||||
if title:
|
if title:
|
||||||
t = truncate(title, width - 4)
|
t = truncate(title, width - 4)
|
||||||
top_line = BOX_TL + BOX_H + t + BOX_H * (width - 3 - len(t)) + BOX_TR
|
top_line = BOX_TL + BOX_H + t + BOX_H * (width - 3 - len(t)) + BOX_TR
|
||||||
else:
|
else:
|
||||||
top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR
|
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):
|
for r in range(1, height - 1):
|
||||||
self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V,
|
self._write_at(top + r, left,
|
||||||
style)
|
BOX_V + " " * (width - 2) + BOX_V, "blue")
|
||||||
|
|
||||||
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
|
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"):
|
def _write_at(self, row, col, text, style="white"):
|
||||||
"""Write styled text at absolute screen position."""
|
"""Write styled text at absolute screen position."""
|
||||||
sio = StringIO()
|
rendered = self._render_styled(text, style)
|
||||||
c = Console(file=sio, width=self._width, highlight=False,
|
sys.stdout.write(f"\033[{row + 1};{col + 1}H{rendered}")
|
||||||
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()}")
|
|
||||||
|
|
||||||
# ── Dialogs ──────────────────────────────────────────────────────
|
# ── Dialogs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -568,7 +599,6 @@ class TextualRenderer(BaseRenderer):
|
|||||||
left = max((w - box_w) // 2, 0)
|
left = max((w - box_w) // 2, 0)
|
||||||
|
|
||||||
buf = []
|
buf = []
|
||||||
# Show cursor during input
|
|
||||||
sys.stdout.write("\033[?25h")
|
sys.stdout.write("\033[?25h")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
@@ -602,11 +632,3 @@ class TextualRenderer(BaseRenderer):
|
|||||||
elif 32 <= key <= 126:
|
elif 32 <= key <= 126:
|
||||||
if len(buf) < max_len:
|
if len(buf) < max_len:
|
||||||
buf.append(chr(key))
|
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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user