fix(console): rewrite modern renderer with buffered output
Major issues fixed: - Rich printed directly to stdout causing visible flicker on every redraw - get_key() toggled raw mode per keypress causing glitches and slowness - No alternate screen buffer — output contaminated terminal scrollback Rewrite approach: - Use alternate screen buffer (ESC[?1049h) for clean enter/exit - Persistent raw mode for entire session instead of per-keypress toggle - Buffer all Rich renderables during render cycle, flush once in refresh() - Render to StringIO then write entire frame in single sys.stdout.write() - Reduced ESC sequence timeout from 50ms to 20ms for snappier response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,22 +2,19 @@
|
|||||||
Rich-based modern renderer for the AUTOPARTES console application.
|
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. Keyboard input is handled via
|
dark-themed TUI with blue/cyan accents. Uses alternate screen buffer
|
||||||
raw terminal mode using :mod:`tty` / :mod:`termios` since Rich is a
|
and buffered output for flicker-free rendering.
|
||||||
display-only library.
|
|
||||||
|
|
||||||
NOTE: Despite the module name (historical), this uses **Rich** only --
|
|
||||||
not the Textual framework.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tty
|
import tty
|
||||||
import termios
|
import termios
|
||||||
import select
|
import select
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from rich import box as rich_box
|
from rich import box as rich_box
|
||||||
|
|
||||||
@@ -25,33 +22,43 @@ from console.core.keybindings import Key
|
|||||||
from console.renderers.base import BaseRenderer
|
from console.renderers.base import BaseRenderer
|
||||||
from console.utils.formatting import pad_right, truncate
|
from console.utils.formatting import pad_right, truncate
|
||||||
|
|
||||||
|
# Reduce ESC delay for this renderer too
|
||||||
|
os.environ.setdefault('ESCDELAY', '25')
|
||||||
|
|
||||||
|
|
||||||
class TextualRenderer(BaseRenderer):
|
class TextualRenderer(BaseRenderer):
|
||||||
"""Rich-based modern renderer with blue/cyan colour scheme."""
|
"""Rich-based modern renderer with blue/cyan colour scheme."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._console = None
|
|
||||||
self._old_term_settings = None
|
self._old_term_settings = None
|
||||||
|
self._width = 80
|
||||||
|
self._height = 24
|
||||||
|
self._buffer = [] # list of Rich renderables per render cycle
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────
|
# ── Lifecycle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def init_screen(self):
|
def init_screen(self):
|
||||||
"""Create a Rich Console and put the terminal into raw mode."""
|
"""Enter alternate screen, hide cursor, set persistent raw mode."""
|
||||||
self._console = Console(highlight=False, force_terminal=True)
|
fd = sys.stdin.fileno()
|
||||||
# Save terminal state *before* entering raw mode
|
|
||||||
try:
|
try:
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
self._old_term_settings = termios.tcgetattr(fd)
|
self._old_term_settings = termios.tcgetattr(fd)
|
||||||
except (termios.error, ValueError, OSError):
|
except (termios.error, ValueError, OSError):
|
||||||
self._old_term_settings = None
|
self._old_term_settings = None
|
||||||
# Hide cursor
|
|
||||||
sys.stdout.write("\033[?25l")
|
# Enter alternate screen buffer + hide cursor
|
||||||
|
sys.stdout.write("\033[?1049h\033[?25l")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# Set raw mode once for the entire session
|
||||||
|
try:
|
||||||
|
tty.setraw(fd)
|
||||||
|
except termios.error:
|
||||||
|
pass
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Restore the terminal to its original state."""
|
"""Restore terminal: exit alternate screen, show cursor, reset mode."""
|
||||||
# Show cursor
|
# Show cursor + exit alternate screen
|
||||||
sys.stdout.write("\033[?25h")
|
sys.stdout.write("\033[?25h\033[?1049l")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
# Restore original terminal attributes
|
# Restore original terminal attributes
|
||||||
if self._old_term_settings is not None:
|
if self._old_term_settings is not None:
|
||||||
@@ -62,143 +69,125 @@ class TextualRenderer(BaseRenderer):
|
|||||||
except (termios.error, ValueError, OSError):
|
except (termios.error, ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
self._old_term_settings = None
|
self._old_term_settings = None
|
||||||
self._console = None
|
|
||||||
|
|
||||||
# ── Screen queries ───────────────────────────────────────────────
|
# ── Screen queries ───────────────────────────────────────────────
|
||||||
|
|
||||||
def get_size(self) -> tuple:
|
def get_size(self) -> tuple:
|
||||||
"""Return ``(height, width)`` of the terminal."""
|
"""Return ``(height, width)``."""
|
||||||
size = self._console.size
|
return (self._height, self._width)
|
||||||
return (size.height, size.width)
|
|
||||||
|
|
||||||
# ── Primitive operations ─────────────────────────────────────────
|
# ── Primitive operations ─────────────────────────────────────────
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear the screen."""
|
"""Start a new render cycle — update size and reset buffer."""
|
||||||
self._console.clear()
|
try:
|
||||||
|
size = os.get_terminal_size()
|
||||||
|
self._width = size.columns
|
||||||
|
self._height = size.lines
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._buffer = []
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
"""No-op -- Rich prints immediately to stdout."""
|
"""Flush the entire buffered output to screen in one write."""
|
||||||
pass
|
# Render all buffered content to a string via Rich
|
||||||
|
sio = StringIO()
|
||||||
|
console = Console(
|
||||||
|
file=sio,
|
||||||
|
width=self._width,
|
||||||
|
highlight=False,
|
||||||
|
force_terminal=True,
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Move cursor home, clear screen, write everything at once
|
||||||
|
sys.stdout.write(f"\033[H\033[2J{rendered}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
def get_key(self) -> int:
|
def get_key(self) -> int:
|
||||||
"""Read a single key from stdin using raw terminal mode.
|
"""Read a key (terminal is already in raw mode)."""
|
||||||
|
|
||||||
Escape sequences (arrows, F-keys, etc.) are decoded and mapped
|
|
||||||
to the same integer constants used by :class:`Key` (which mirror
|
|
||||||
curses key codes).
|
|
||||||
"""
|
|
||||||
fd = sys.stdin.fileno()
|
fd = sys.stdin.fileno()
|
||||||
old = termios.tcgetattr(fd)
|
ch = sys.stdin.read(1)
|
||||||
try:
|
|
||||||
tty.setraw(fd)
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
|
|
||||||
if ch == "\x1b":
|
if ch == "\x1b":
|
||||||
# Check if more bytes are available (escape sequence)
|
if _has_data(fd):
|
||||||
if _has_data(fd):
|
ch2 = sys.stdin.read(1)
|
||||||
ch2 = sys.stdin.read(1)
|
if ch2 == "[":
|
||||||
if ch2 == "[":
|
return self._parse_csi(fd)
|
||||||
ch3 = sys.stdin.read(1)
|
elif ch2 == "O":
|
||||||
# Arrow keys
|
return self._parse_ss3(fd)
|
||||||
if ch3 == "A":
|
# Drain unknown escape
|
||||||
return Key.UP
|
while _has_data(fd):
|
||||||
if ch3 == "B":
|
sys.stdin.read(1)
|
||||||
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
|
|
||||||
# Page Up / Page Down / Home / End / Insert / Delete
|
|
||||||
if ch3 == "5":
|
|
||||||
sys.stdin.read(1) # consume '~'
|
|
||||||
return Key.PGUP
|
|
||||||
if ch3 == "6":
|
|
||||||
sys.stdin.read(1) # consume '~'
|
|
||||||
return Key.PGDN
|
|
||||||
if ch3 == "1":
|
|
||||||
# Could be: Home (1~), F5-F8 (15~,17~,18~,19~)
|
|
||||||
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
|
|
||||||
# Consume trailing ~ if present
|
|
||||||
if _has_data(fd):
|
|
||||||
sys.stdin.read(1)
|
|
||||||
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
|
|
||||||
# 2~ = Insert -- map to escape for now
|
|
||||||
return Key.ESCAPE
|
|
||||||
if ch3 == "3":
|
|
||||||
# Delete key: 3~
|
|
||||||
if _has_data(fd):
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.BACKSPACE
|
|
||||||
if ch3 == "4":
|
|
||||||
if _has_data(fd):
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.END
|
|
||||||
# Drain any remaining escape sequence bytes
|
|
||||||
while _has_data(fd):
|
|
||||||
sys.stdin.read(1)
|
|
||||||
return Key.ESCAPE
|
|
||||||
elif ch2 == "O":
|
|
||||||
# SS3 sequences (F1-F4, sometimes Home/End)
|
|
||||||
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
|
|
||||||
return Key.ESCAPE
|
|
||||||
# Unknown escape -- drain and return ESC
|
|
||||||
while _has_data(fd):
|
|
||||||
sys.stdin.read(1)
|
|
||||||
return Key.ESCAPE
|
|
||||||
# Bare ESC (no further bytes)
|
|
||||||
return Key.ESCAPE
|
return Key.ESCAPE
|
||||||
|
return Key.ESCAPE
|
||||||
|
|
||||||
if ch == "\r" or ch == "\n":
|
if ch == "\r" or ch == "\n":
|
||||||
return Key.ENTER
|
return Key.ENTER
|
||||||
if ch == "\t":
|
if ch == "\t":
|
||||||
return Key.TAB
|
return Key.TAB
|
||||||
if ch == "\x7f" or ch == "\x08":
|
if ch == "\x7f" or ch == "\x08":
|
||||||
return Key.BACKSPACE
|
return Key.BACKSPACE
|
||||||
if ch == "\x03":
|
if ch == "\x03":
|
||||||
# Ctrl-C -- treat as escape so the app can exit gracefully
|
return Key.ESCAPE
|
||||||
return Key.ESCAPE
|
return ord(ch)
|
||||||
return ord(ch)
|
|
||||||
finally:
|
def _parse_csi(self, fd):
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
"""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) # ~
|
||||||
|
return Key.PGUP
|
||||||
|
if ch3 == "6":
|
||||||
|
sys.stdin.read(1) # ~
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
return Key.ESCAPE
|
||||||
|
if ch3 == "3":
|
||||||
|
if _has_data(fd): sys.stdin.read(1)
|
||||||
|
return Key.BACKSPACE
|
||||||
|
if ch3 == "4":
|
||||||
|
if _has_data(fd): sys.stdin.read(1)
|
||||||
|
return Key.END
|
||||||
|
while _has_data(fd):
|
||||||
|
sys.stdin.read(1)
|
||||||
|
return Key.ESCAPE
|
||||||
|
|
||||||
|
def _parse_ss3(self, fd):
|
||||||
|
"""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
|
||||||
|
return Key.ESCAPE
|
||||||
|
|
||||||
# ── High-level widgets ───────────────────────────────────────────
|
# ── High-level widgets ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -211,50 +200,46 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if padding > 0:
|
if padding > 0:
|
||||||
header.append(" " * padding)
|
header.append(" " * padding)
|
||||||
header.append(subtitle, style="dim white")
|
header.append(subtitle, style="dim white")
|
||||||
# Pad to full width
|
|
||||||
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._console.print(header, end="")
|
self._buffer.append(header)
|
||||||
# Separator line
|
self._buffer.append(Text("─" * w, style="blue"))
|
||||||
sep = Text("─" * w, style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
|
|
||||||
def draw_footer(self, key_labels):
|
def draw_footer(self, key_labels):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
# Separator
|
# Pad remaining space before footer
|
||||||
sep = Text("─" * w, style="blue")
|
used = len(self._buffer)
|
||||||
self._console.print(sep, end="")
|
remaining = h - used - 2 # 2 lines for separator + footer
|
||||||
# Key labels
|
for _ in range(max(remaining, 0)):
|
||||||
|
self._buffer.append(Text(""))
|
||||||
|
|
||||||
|
self._buffer.append(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:
|
||||||
footer.append(" ", style="dim white on rgb(20,40,80)")
|
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" {key} ", style="bold white on rgb(40,80,120)")
|
||||||
footer.append(f" {desc}", style="white on rgb(20,40,80)")
|
footer.append(f" {desc}", style="white on rgb(20,40,80)")
|
||||||
# Pad to full width
|
|
||||||
if footer.cell_len < w:
|
if footer.cell_len < w:
|
||||||
footer.append(
|
footer.append(
|
||||||
" " * (w - footer.cell_len),
|
" " * (w - footer.cell_len),
|
||||||
style="on rgb(20,40,80)",
|
style="on rgb(20,40,80)",
|
||||||
)
|
)
|
||||||
self._console.print(footer, end="")
|
self._buffer.append(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 # header(2) + footer(2) + margins
|
visible_lines = h - 6
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
title_text = Text()
|
self._buffer.append(Text(f" {title}", style="bold white"))
|
||||||
title_text.append(f" {title}", style="bold white")
|
self._buffer.append(Text(""))
|
||||||
self._console.print(title_text, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
visible_lines -= 2
|
visible_lines -= 2
|
||||||
|
|
||||||
if visible_lines < 1:
|
if visible_lines < 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Scrolling offset
|
|
||||||
offset = 0
|
offset = 0
|
||||||
if selected_index >= visible_lines:
|
if selected_index >= visible_lines:
|
||||||
offset = selected_index - visible_lines + 1
|
offset = selected_index - visible_lines + 1
|
||||||
@@ -266,24 +251,23 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if idx < offset:
|
if idx < offset:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Separator
|
|
||||||
if num == "\u2500" or num == "---":
|
if num == "\u2500" or num == "---":
|
||||||
sep = Text(" " + "─" * (w - 4), style="dim blue")
|
self._buffer.append(
|
||||||
self._console.print(sep, end="")
|
Text(" " + "─" * (w - 4), style="dim blue")
|
||||||
|
)
|
||||||
drawn += 1
|
drawn += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
line = Text()
|
|
||||||
marker = "\u25b8 " if idx == selected_index else " "
|
marker = "\u25b8 " if idx == selected_index else " "
|
||||||
|
|
||||||
if idx == selected_index:
|
if idx == selected_index:
|
||||||
entry = f"{marker}{num}. {label}"
|
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
||||||
entry = pad_right(entry, w - 4)
|
self._buffer.append(
|
||||||
line.append(f" {entry}", style="bold white on rgb(30,60,120)")
|
Text(f" {entry}", style="bold white on rgb(30,60,120)")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
line.append(f" {marker}{num}. {label}", style="white")
|
self._buffer.append(
|
||||||
|
Text(f" {marker}{num}. {label}", style="white")
|
||||||
self._console.print(line, end="")
|
)
|
||||||
drawn += 1
|
drawn += 1
|
||||||
|
|
||||||
def draw_table(self, headers, rows, widths, page_info=None,
|
def draw_table(self, headers, rows, widths, page_info=None,
|
||||||
@@ -300,13 +284,11 @@ class TextualRenderer(BaseRenderer):
|
|||||||
row_styles=["white", "dim white"],
|
row_styles=["white", "dim white"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Row number column
|
|
||||||
table.add_column("#", style="dim cyan", width=4, justify="right")
|
table.add_column("#", style="dim cyan", width=4, justify="right")
|
||||||
|
|
||||||
for hdr, wd in zip(headers, widths):
|
for hdr, wd in zip(headers, widths):
|
||||||
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
|
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
|
||||||
|
|
||||||
visible = h - 8 # header, table header, separator, footer, page info
|
visible = h - 8
|
||||||
if visible < 1:
|
if visible < 1:
|
||||||
visible = 1
|
visible = 1
|
||||||
|
|
||||||
@@ -318,36 +300,28 @@ class TextualRenderer(BaseRenderer):
|
|||||||
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)
|
||||||
|
|
||||||
self._console.print(table, end="")
|
self._buffer.append(table)
|
||||||
|
|
||||||
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))
|
||||||
info = Text()
|
self._buffer.append(Text(
|
||||||
info.append(
|
|
||||||
f" Pagina {page}/{total} ({total_rows} registros)",
|
f" Pagina {page}/{total} ({total_rows} registros)",
|
||||||
style="dim cyan",
|
style="dim cyan",
|
||||||
)
|
))
|
||||||
self._console.print(info, end="")
|
|
||||||
|
|
||||||
def draw_detail(self, fields, title=''):
|
def draw_detail(self, fields, title=''):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
title_text = Text()
|
self._buffer.append(Text(f" {title}", style="bold white"))
|
||||||
title_text.append(f" {title}", style="bold white")
|
self._buffer.append(Text(" " + "─" * (w - 4), style="blue"))
|
||||||
self._console.print(title_text, end="")
|
self._buffer.append(Text(""))
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
|
|
||||||
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
|
||||||
|
lines_available = h - 6 - (3 if title else 0)
|
||||||
lines_available = h - 6
|
|
||||||
if title:
|
|
||||||
lines_available -= 3
|
|
||||||
|
|
||||||
for i, (label, value) in enumerate(fields):
|
for i, (label, value) in enumerate(fields):
|
||||||
if i >= lines_available:
|
if i >= lines_available:
|
||||||
@@ -356,18 +330,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._console.print(line, end="")
|
self._buffer.append(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:
|
||||||
title_text = Text()
|
self._buffer.append(Text(f" {title}", style="bold white"))
|
||||||
title_text.append(f" {title}", style="bold white")
|
self._buffer.append(Text(" " + "─" * (w - 4), style="blue"))
|
||||||
self._console.print(title_text, end="")
|
self._buffer.append(Text(""))
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
|
|
||||||
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
|
||||||
@@ -394,35 +365,27 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if hint:
|
if hint:
|
||||||
line.append(f" {hint}", style="dim cyan")
|
line.append(f" {hint}", style="dim cyan")
|
||||||
|
|
||||||
self._console.print(line, end="")
|
self._buffer.append(line)
|
||||||
# Blank line between fields for spacing
|
self._buffer.append(Text("")) # spacing
|
||||||
self._console.print("", end="")
|
|
||||||
|
|
||||||
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:
|
||||||
title_text = Text()
|
self._buffer.append(Text(f" {title}", style="bold white"))
|
||||||
title_text.append(f" {title}", style="bold white")
|
|
||||||
self._console.print(title_text, end="")
|
|
||||||
|
|
||||||
# Separator
|
self._buffer.append(Text(" " + "─" * (w - 4), style="blue"))
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
|
|
||||||
# Filter input
|
|
||||||
filter_line = Text()
|
filter_line = Text()
|
||||||
filter_line.append(" Filtro: ", style="cyan")
|
filter_line.append(" Filtro: ", style="cyan")
|
||||||
filter_line.append(filter_text, style="bold white on rgb(0,100,140)")
|
filter_line.append(filter_text + "_",
|
||||||
filter_line.append("_", style="bold white on rgb(0,100,140)")
|
style="bold white on rgb(0,100,140)")
|
||||||
self._console.print(filter_line, end="")
|
self._buffer.append(filter_line)
|
||||||
|
|
||||||
# Separator
|
self._buffer.append(Text(" " + "─" * (w - 4), style="blue"))
|
||||||
self._console.print(sep, end="")
|
|
||||||
|
|
||||||
# Scrollable list
|
visible = h - 10
|
||||||
visible = h - 10 # header, title, filter, separators, footer, count
|
|
||||||
if visible < 1:
|
if visible < 1:
|
||||||
visible = 1
|
visible = 1
|
||||||
|
|
||||||
@@ -438,39 +401,34 @@ class TextualRenderer(BaseRenderer):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
marker = "\u25b8 " if idx == selected_index else " "
|
marker = "\u25b8 " if idx == selected_index else " "
|
||||||
line = Text()
|
|
||||||
if idx == selected_index:
|
if idx == selected_index:
|
||||||
entry = f"{marker}{num}. {label}"
|
entry = pad_right(f"{marker}{num}. {label}", w - 4)
|
||||||
entry = pad_right(entry, w - 4)
|
self._buffer.append(
|
||||||
line.append(f" {entry}",
|
Text(f" {entry}",
|
||||||
style="bold white on rgb(30,60,120)")
|
style="bold white on rgb(30,60,120)")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
line.append(f" {marker}{num}. {label}", style="white")
|
self._buffer.append(
|
||||||
|
Text(f" {marker}{num}. {label}", style="white")
|
||||||
self._console.print(line, end="")
|
)
|
||||||
drawn += 1
|
drawn += 1
|
||||||
|
|
||||||
# Count at bottom
|
self._buffer.append(Text(
|
||||||
count_line = Text()
|
f" {len(items)} elementos", style="dim cyan"
|
||||||
count_line.append(f" {len(items)} elementos", style="dim cyan")
|
))
|
||||||
self._console.print(count_line, end="")
|
|
||||||
|
|
||||||
def draw_comparison(self, columns, title=''):
|
def draw_comparison(self, columns, title=''):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
title_text = Text()
|
self._buffer.append(Text(f" {title}", style="bold white"))
|
||||||
title_text.append(f" {title}", style="bold white")
|
self._buffer.append(Text(" " + "─" * (w - 4), style="blue"))
|
||||||
self._console.print(title_text, end="")
|
self._buffer.append(Text(""))
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
|
|
||||||
n_cols = len(columns)
|
n_cols = len(columns)
|
||||||
if n_cols == 0:
|
if n_cols == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build a Rich Table for the comparison
|
|
||||||
table = Table(
|
table = Table(
|
||||||
box=rich_box.SIMPLE_HEAD,
|
box=rich_box.SIMPLE_HEAD,
|
||||||
show_edge=False,
|
show_edge=False,
|
||||||
@@ -480,19 +438,13 @@ class TextualRenderer(BaseRenderer):
|
|||||||
header_style="bold cyan",
|
header_style="bold cyan",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Label column
|
|
||||||
table.add_column("", style="cyan", no_wrap=True)
|
table.add_column("", style="cyan", no_wrap=True)
|
||||||
|
|
||||||
for col in columns:
|
for col in columns:
|
||||||
table.add_column(
|
table.add_column(col.get("header", ""), style="white",
|
||||||
col.get("header", ""),
|
no_wrap=True)
|
||||||
style="white",
|
|
||||||
no_wrap=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Data rows -- use the first column's labels as the canonical set
|
|
||||||
if not columns[0].get("rows"):
|
if not columns[0].get("rows"):
|
||||||
self._console.print(table, end="")
|
self._buffer.append(table)
|
||||||
return
|
return
|
||||||
|
|
||||||
n_rows = len(columns[0]["rows"])
|
n_rows = len(columns[0]["rows"])
|
||||||
@@ -507,47 +459,34 @@ class TextualRenderer(BaseRenderer):
|
|||||||
vals.append(str(val))
|
vals.append(str(val))
|
||||||
table.add_row(lbl, *vals)
|
table.add_row(lbl, *vals)
|
||||||
|
|
||||||
self._console.print(table, end="")
|
self._buffer.append(table)
|
||||||
|
|
||||||
# ── Low-level drawing ────────────────────────────────────────────
|
# ── Low-level drawing ────────────────────────────────────────────
|
||||||
|
|
||||||
def draw_text(self, row, col, text, style='normal'):
|
def draw_text(self, row, col, text, style='normal'):
|
||||||
"""Draw text using Rich styling.
|
|
||||||
|
|
||||||
Since Rich does not support absolute cursor positioning the way
|
|
||||||
curses does, we approximate by printing the text preceded by
|
|
||||||
ANSI escape codes that move the cursor to the requested row/col.
|
|
||||||
"""
|
|
||||||
style_map = {
|
style_map = {
|
||||||
"normal": "white",
|
"normal": "white", "header": "bold cyan",
|
||||||
"header": "bold cyan",
|
"footer": "white on rgb(20,40,80)",
|
||||||
"footer": "white on rgb(20,40,80)",
|
"highlight": "bold white on rgb(30,60,120)",
|
||||||
"highlight": "bold white on rgb(30,60,120)",
|
"border": "blue", "title": "bold white",
|
||||||
"border": "blue",
|
"error": "bold red", "info": "dim cyan",
|
||||||
"title": "bold white",
|
"field_label": "cyan", "field_value": "bold white",
|
||||||
"error": "bold red",
|
|
||||||
"info": "dim cyan",
|
|
||||||
"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")
|
||||||
styled = Text(text, style=rich_style)
|
|
||||||
# Use ANSI escape to position cursor
|
|
||||||
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
|
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())
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
def draw_box(self, top, left, height, width, title=''):
|
def draw_box(self, top, left, height, width, title=''):
|
||||||
"""Draw a box using Rich's Panel.
|
|
||||||
|
|
||||||
Since Rich Panel does not support absolute positioning, we build
|
|
||||||
the box manually with Unicode line-drawing characters and ANSI
|
|
||||||
cursor movement for precise placement.
|
|
||||||
"""
|
|
||||||
BOX_H = "\u2500"
|
BOX_H = "\u2500"
|
||||||
BOX_V = "\u2502"
|
BOX_V = "\u2502"
|
||||||
BOX_TL = "\u256d" # rounded corners for modern look
|
BOX_TL = "\u256d"
|
||||||
BOX_TR = "\u256e"
|
BOX_TR = "\u256e"
|
||||||
BOX_BL = "\u2570"
|
BOX_BL = "\u2570"
|
||||||
BOX_BR = "\u256f"
|
BOX_BR = "\u256f"
|
||||||
@@ -555,31 +494,29 @@ class TextualRenderer(BaseRenderer):
|
|||||||
if height < 2 or width < 2:
|
if height < 2 or width < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Top border
|
style = "blue"
|
||||||
if title:
|
if title:
|
||||||
t = truncate(title, width - 4)
|
t = truncate(title, width - 4)
|
||||||
top_line = (BOX_TL + BOX_H + t
|
top_line = BOX_TL + BOX_H + t + BOX_H * (width - 3 - len(t)) + BOX_TR
|
||||||
+ 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
|
||||||
|
|
||||||
sys.stdout.write(f"\033[{top + 1};{left + 1}H")
|
self._write_at(top, left, top_line, style)
|
||||||
styled = Text(top_line, style="blue")
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# Side borders
|
|
||||||
for r in range(1, height - 1):
|
for r in range(1, height - 1):
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + 1}H")
|
self._write_at(top + r, left, BOX_V + " " * (width - 2) + BOX_V,
|
||||||
styled = Text(BOX_V, style="blue")
|
style)
|
||||||
self._console.print(styled, end="")
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + width}H")
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# Bottom border
|
|
||||||
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
|
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
|
||||||
sys.stdout.write(f"\033[{top + height};{left + 1}H")
|
self._write_at(top + height - 1, left, bottom_line, style)
|
||||||
styled = Text(bottom_line, style="blue")
|
|
||||||
self._console.print(styled, end="")
|
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()}")
|
||||||
|
|
||||||
# ── Dialogs ──────────────────────────────────────────────────────
|
# ── Dialogs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -591,48 +528,27 @@ class TextualRenderer(BaseRenderer):
|
|||||||
top = max((h - box_h) // 2, 0)
|
top = max((h - box_h) // 2, 0)
|
||||||
left = max((w - box_w) // 2, 0)
|
left = max((w - box_w) // 2, 0)
|
||||||
|
|
||||||
# Determine style
|
|
||||||
if msg_type == "error":
|
if msg_type == "error":
|
||||||
border_style = "bold red"
|
|
||||||
text_style = "bold red"
|
text_style = "bold red"
|
||||||
title_label = " Error "
|
|
||||||
elif msg_type == "confirm":
|
elif msg_type == "confirm":
|
||||||
border_style = "bold yellow"
|
text_style = "bold yellow"
|
||||||
text_style = "white"
|
|
||||||
title_label = " Confirmar "
|
|
||||||
else:
|
else:
|
||||||
border_style = "bold cyan"
|
text_style = "bold cyan"
|
||||||
text_style = "white"
|
|
||||||
title_label = " Info "
|
|
||||||
|
|
||||||
# Draw box
|
self.draw_box(top, left, box_h, box_w)
|
||||||
self.draw_box(top, left, box_h, box_w, title_label)
|
|
||||||
|
|
||||||
# Fill interior and draw message lines
|
|
||||||
interior_style = text_style
|
|
||||||
for r in range(1, box_h - 1):
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
|
|
||||||
fill = Text(" " * (box_w - 2), style=interior_style)
|
|
||||||
self._console.print(fill, end="")
|
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
x = left + max((box_w - len(line)) // 2, 2)
|
x = left + max((box_w - len(line)) // 2, 2)
|
||||||
sys.stdout.write(f"\033[{top + 2 + i};{x + 1}H")
|
self._write_at(top + 1 + i, x, line, text_style)
|
||||||
styled = Text(line, style=text_style)
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# Prompt line
|
|
||||||
if msg_type == "confirm":
|
if msg_type == "confirm":
|
||||||
prompt = "[S]i / [N]o"
|
prompt = "[S]i / [N]o"
|
||||||
else:
|
else:
|
||||||
prompt = "Presione cualquier tecla..."
|
prompt = "Presione cualquier tecla..."
|
||||||
px = left + max((box_w - len(prompt)) // 2, 2)
|
px = left + max((box_w - len(prompt)) // 2, 2)
|
||||||
sys.stdout.write(f"\033[{top + box_h};{px + 1}H")
|
self._write_at(top + box_h - 2, px, prompt, "bold cyan")
|
||||||
prompt_styled = Text(prompt, style="bold cyan")
|
|
||||||
self._console.print(prompt_styled, end="")
|
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
# Wait for key
|
|
||||||
if msg_type == "confirm":
|
if msg_type == "confirm":
|
||||||
while True:
|
while True:
|
||||||
key = self.get_key()
|
key = self.get_key()
|
||||||
@@ -652,39 +568,23 @@ class TextualRenderer(BaseRenderer):
|
|||||||
left = max((w - box_w) // 2, 0)
|
left = max((w - box_w) // 2, 0)
|
||||||
|
|
||||||
buf = []
|
buf = []
|
||||||
|
|
||||||
# Show cursor during input
|
# Show cursor during input
|
||||||
sys.stdout.write("\033[?25h")
|
sys.stdout.write("\033[?25h")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
self.draw_box(top, left, box_h, box_w, " Entrada ")
|
self.draw_box(top, left, box_h, box_w)
|
||||||
|
|
||||||
# Fill interior
|
self._write_at(top + 1, left + 2, prompt, "cyan")
|
||||||
for r in range(1, box_h - 1):
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
|
|
||||||
fill = Text(" " * (box_w - 2))
|
|
||||||
self._console.print(fill, end="")
|
|
||||||
|
|
||||||
# Prompt label
|
|
||||||
sys.stdout.write(f"\033[{top + 2};{left + 3}H")
|
|
||||||
label = Text(prompt, style="cyan")
|
|
||||||
self._console.print(label, end="")
|
|
||||||
|
|
||||||
# Input field
|
|
||||||
val = "".join(buf)
|
val = "".join(buf)
|
||||||
display = pad_right(val, max_len)
|
display = pad_right(val, max_len)
|
||||||
sys.stdout.write(f"\033[{top + 3};{left + 3}H")
|
self._write_at(top + 2, left + 2, f"[{display}]",
|
||||||
field = Text(f"[{display}]",
|
"bold white on rgb(0,100,140)")
|
||||||
style="bold white on rgb(0,100,140)")
|
|
||||||
self._console.print(field, end="")
|
|
||||||
|
|
||||||
# Hint
|
|
||||||
hint = "ENTER=Aceptar ESC=Cancelar"
|
hint = "ENTER=Aceptar ESC=Cancelar"
|
||||||
hx = left + max((box_w - len(hint)) // 2, 2)
|
hx = left + max((box_w - len(hint)) // 2, 2)
|
||||||
sys.stdout.write(f"\033[{top + 4};{hx + 1}H")
|
self._write_at(top + 3, hx, hint, "dim cyan")
|
||||||
hint_styled = Text(hint, style="dim cyan")
|
|
||||||
self._console.print(hint_styled, end="")
|
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
key = self.get_key()
|
key = self.get_key()
|
||||||
@@ -706,7 +606,7 @@ class TextualRenderer(BaseRenderer):
|
|||||||
|
|
||||||
# ── Module-level helpers ─────────────────────────────────────────────
|
# ── Module-level helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _has_data(fd, timeout=0.05):
|
def _has_data(fd, timeout=0.02):
|
||||||
"""Return True if there is data waiting on file descriptor *fd*."""
|
"""Return True if there is data waiting on file descriptor *fd*."""
|
||||||
r, _, _ = select.select([fd], [], [], timeout)
|
r, _, _ = select.select([fd], [], [], timeout)
|
||||||
return bool(r)
|
return bool(r)
|
||||||
|
|||||||
Reference in New Issue
Block a user