"""TerminalEmulator -- VT100 terminal emulator widget with character cell grid,
cursor, scrollback buffer, and ANSI escape sequence handling.
Supports enough VT100/xterm to run interactive applications like bash, nano, vim.
"""
import logging
from dataclasses import dataclass
from enum import Enum, auto
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .ansi_parser import ANSI_COLORS
from .core import Control, ThemeColour
log = logging.getLogger(__name__)
__all__ = ["TerminalEmulator"]
_DEFAULT_FG: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
_DEFAULT_BG: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
_SB_W = 12.0
class _PS(Enum):
NORMAL = auto()
ESC = auto()
CSI = auto()
OSC = auto()
CHARSET = auto()
@dataclass(slots=True)
class _Cell:
char: str = " "
fg: tuple[float, float, float, float] = _DEFAULT_FG
bg: tuple[float, float, float, float] = _DEFAULT_BG
bold: bool = False
underline: bool = False
# DEC Special Graphics character mapping (ESC ( 0)
# Maps ASCII 0x60-0x7e to box-drawing / special characters
_DEC_GRAPHICS = {
"`": "\u25c6",
"a": "\u2592",
"b": "\u2409",
"c": "\u240c",
"d": "\u240d",
"e": "\u240a",
"f": "\u00b0",
"g": "\u00b1",
"h": "\u2424",
"i": "\u240b",
"j": "\u2518",
"k": "\u2510",
"l": "\u250c",
"m": "\u2514",
"n": "\u253c",
"o": "\u23ba",
"p": "\u23bb",
"q": "\u2500",
"r": "\u23bc",
"s": "\u23bd",
"t": "\u251c",
"u": "\u2524",
"v": "\u2534",
"w": "\u252c",
"x": "\u2502",
"y": "\u2264",
"z": "\u2265",
"{": "\u03c0",
"|": "\u2260",
"}": "\u00a3",
"~": "\u00b7",
}
def _colour_256(n: int) -> tuple[float, float, float, float]:
if n < 16:
return ANSI_COLORS[n]
if n < 232:
n -= 16
return ((n // 36) * 51 / 255, ((n % 36) // 6) * 51 / 255, (n % 6) * 51 / 255, 1.0)
v = (n - 232) * 10 + 8
return (v / 255, v / 255, v / 255, 1.0)
def _blank_row(cols: int, fg=_DEFAULT_FG, bg=_DEFAULT_BG) -> list[_Cell]:
return [_Cell(fg=fg, bg=bg) for _ in range(cols)]
[docs]
class TerminalEmulator(Control):
"""VT100 terminal emulator widget.
Supports ANSI/VT100 escape sequences for cursor movement, text styling,
scroll regions, alternate screen buffer, and more. Enough for bash + nano.
Example:
term = TerminalEmulator()
proc = ShellNode("bash", use_pty=True)
term.attach(proc)
"""
font_size = Property(14.0, range=(8, 72), hint="Font size")
cols = Property(80, range=(1, 500), hint="Terminal columns")
rows = Property(24, range=(1, 200), hint="Terminal rows")
cursor_blink = Property(True, hint="Enable cursor blinking")
max_scrollback = Property(1000, range=(0, 100000), hint="Scrollback buffer lines")
# Theme-aware colours
border_colour = ThemeColour("border")
def __init__(self, cols: int = 80, rows: int = 24, **kwargs):
super().__init__(**kwargs)
self.font_size = 14.0
self.cols, self.rows = cols, rows
self.cursor_blink, self.max_scrollback = True, 1000
self.bg_colour, self.fg_colour = _DEFAULT_BG, _DEFAULT_FG
self._grid: list[list[_Cell]] = [_blank_row(cols) for _ in range(rows)]
self._scrollback: list[list[_Cell]] = []
self._scroll_offset: int = 0
self._cursor_row = self._cursor_col = 0
self._saved_cursor: tuple[int, int] | None = None
self._cursor_visible: bool = True
self._blink_timer: float = 0.0
# SGR state
self._cur_fg, self._cur_bg = _DEFAULT_FG, _DEFAULT_BG
self._cur_bold = self._cur_reverse = self._cur_underline = False
# Parser state
self._state = _PS.NORMAL
self._csi_buf: str = ""
self._osc_buf: str = ""
self._charset_target: str = "" # '(' or ')' — which G-set is being designated
# Character set state (DEC line drawing)
self._g0_charset: str = "B" # "B" = ASCII, "0" = DEC Special Graphics
self._insert_mode: bool = False
# Output processing: LF implies CR (standard Unix terminal default)
self.onlcr: bool = True
# Scroll region (top and bottom inclusive, 0-indexed)
self._scroll_top: int = 0
self._scroll_bottom: int = rows - 1
# Alternate screen buffer
self._alt_grid: list[list[_Cell]] | None = None
self._alt_cursor: tuple[int, int] | None = None
self._alt_scrollback: list[list[_Cell]] | None = None
# Auto-wrap mode
self._auto_wrap: bool = True
self._wrap_pending: bool = False # deferred wrap at right margin
# Modifier tracking for Ctrl+key
self._ctrl_held = self._shift_held = self._alt_held = False
# Selection state
self._sel_start: tuple[int, int] | None = None # (row, col) in absolute coords
self._sel_end: tuple[int, int] | None = None
self._selecting: bool = False
# Signals / integration
self.input_data = Signal()
self._attached_process = None
self._stdout_conn = self._stderr_conn = self._input_conn = None
self._update_size()
[docs]
def resize(self, cols: int, rows: int):
"""Resize the terminal grid, preserving visible content where possible."""
_old_c, _old_r = int(self.cols), int(self.rows)
self.cols, self.rows = cols, rows
# Resize each existing row
new_grid: list[list[_Cell]] = []
for ri in range(rows):
if ri < len(self._grid):
row = self._grid[ri]
if len(row) < cols:
row.extend(_Cell() for _ in range(cols - len(row)))
elif len(row) > cols:
del row[cols:]
new_grid.append(row)
else:
new_grid.append(_blank_row(cols))
self._grid = new_grid
self._cursor_row = min(self._cursor_row, rows - 1)
self._cursor_col = min(self._cursor_col, cols - 1)
self._scroll_top = 0
self._scroll_bottom = rows - 1
self._update_size()
# Notify attached PTY process of new size
if self._attached_process and hasattr(self._attached_process, "resize"):
self._attached_process.resize(cols, rows)
def _update_size(self):
"""Recalculate widget size from cols/rows/font_size."""
c, r, fs = int(self.cols), int(self.rows), float(self.font_size)
self.size = Vec2(c * fs * 0.6 + _SB_W, r * fs * 1.4)
# -- Grid helpers ----------------------------------------------------------
def _cell_size(self) -> tuple[float, float]:
cw = float(self.font_size) * 0.6
ch = float(self.font_size) * 1.4
return (cw, ch)
def _effective_fg(self) -> tuple[float, float, float, float]:
return self._cur_bg if self._cur_reverse else self._cur_fg
def _effective_bg(self) -> tuple[float, float, float, float]:
return self._cur_fg if self._cur_reverse else self._cur_bg
def _scroll_region_up(self, n: int = 1):
"""Scroll the scroll region up by n lines."""
top, bot = self._scroll_top, self._scroll_bottom
for _ in range(n):
row = self._grid.pop(top)
if top == 0 and bot == int(self.rows) - 1:
self._scrollback.append(row)
self._grid.insert(bot, _blank_row(int(self.cols), self._effective_fg(), self._effective_bg()))
excess = len(self._scrollback) - int(self.max_scrollback)
if excess > 0:
del self._scrollback[:excess]
def _scroll_region_down(self, n: int = 1):
"""Scroll the scroll region down by n lines."""
top, bot = self._scroll_top, self._scroll_bottom
for _ in range(n):
self._grid.pop(bot)
self._grid.insert(top, _blank_row(int(self.cols), self._effective_fg(), self._effective_bg()))
# -- Write / parse ---------------------------------------------------------
[docs]
def write(self, data: str):
"""Feed data into the terminal, processing VT100 escape sequences."""
self._scroll_offset = 0
for ch in data:
if self._state == _PS.NORMAL:
self._normal(ch)
elif self._state == _PS.ESC:
self._esc(ch)
elif self._state == _PS.CSI:
if 0x40 <= ord(ch) <= 0x7E:
self._csi(self._csi_buf, ch)
self._state = _PS.NORMAL
else:
self._csi_buf += ch
elif self._state == _PS.CHARSET:
# Consume the character after ESC ( or ESC )
if self._charset_target == "(":
self._g0_charset = ch # "B" = ASCII, "0" = DEC graphics
# G1 charset (ESC )) — track but don't use (G0 is active by default)
self._state = _PS.NORMAL
elif self._state == _PS.OSC:
if ch in ("\x07", "\x1b"): # BEL or ESC terminates OSC
self._state = _PS.NORMAL
else:
self._osc_buf += ch
def _esc(self, ch: str):
if ch == "[":
self._state, self._csi_buf = _PS.CSI, ""
elif ch == "]":
self._state, self._osc_buf = _PS.OSC, ""
elif ch in ("(", ")"): # Charset designation: ESC ( X or ESC ) X
self._charset_target = ch
self._state = _PS.CHARSET
elif ch == "7": # DECSC - save cursor
self._saved_cursor = (self._cursor_row, self._cursor_col)
self._state = _PS.NORMAL
elif ch == "8": # DECRC - restore cursor
if self._saved_cursor:
self._cursor_row, self._cursor_col = self._saved_cursor
self._state = _PS.NORMAL
elif ch == "M": # RI - reverse index (scroll down if at top of scroll region)
if self._cursor_row == self._scroll_top:
self._scroll_region_down()
elif self._cursor_row > 0:
self._cursor_row -= 1
self._state = _PS.NORMAL
elif ch in ("=", ">"): # DECKPAM / DECKPNM (keypad modes) — consume silently
self._state = _PS.NORMAL
else:
self._state = _PS.NORMAL
def _normal(self, ch: str):
c, r = int(self.cols), int(self.rows)
if ch == "\x1b":
self._state = _PS.ESC
elif ch == "\n":
if self.onlcr:
self._cursor_col = 0
if self._cursor_row == self._scroll_bottom:
self._scroll_region_up()
elif self._cursor_row < r - 1:
self._cursor_row += 1
self._wrap_pending = False
elif ch == "\r":
self._cursor_col = 0
self._wrap_pending = False
elif ch == "\t":
self._cursor_col = min((self._cursor_col // 8 + 1) * 8, c - 1)
self._wrap_pending = False
elif ch == "\b":
if self._cursor_col > 0:
self._cursor_col -= 1
self._wrap_pending = False
elif ch == "\x07":
pass
elif ch >= " ":
# Deferred wrap: if previous char landed on last column, wrap now
if self._wrap_pending:
self._wrap_pending = False
self._cursor_col = 0
if self._cursor_row == self._scroll_bottom:
self._scroll_region_up()
elif self._cursor_row < r - 1:
self._cursor_row += 1
# DEC Special Graphics charset translation
if self._g0_charset == "0" and ch in _DEC_GRAPHICS:
ch = _DEC_GRAPHICS[ch]
cell = self._grid[self._cursor_row][self._cursor_col]
cell.char = ch
cell.fg, cell.bg = self._effective_fg(), self._effective_bg()
cell.bold, cell.underline = self._cur_bold, self._cur_underline
if self._cursor_col < c - 1:
self._cursor_col += 1
else:
self._wrap_pending = self._auto_wrap
def _params(self, buf: str) -> list[int]:
if not buf:
return [0]
return [int(p) if p else 0 for p in buf.split(";")]
def _csi(self, buf: str, final: str):
c, r = int(self.cols), int(self.rows)
# DEC private modes: CSI ? ... h/l
if buf.startswith("?"):
self._dec_private(buf[1:], final)
return
p = self._params(buf)
n = max(p[0], 1)
if final == "A": # CUU - cursor up
self._cursor_row = max(self._scroll_top, self._cursor_row - n)
elif final == "B": # CUD - cursor down
self._cursor_row = min(self._scroll_bottom, self._cursor_row + n)
elif final == "C": # CUF - cursor forward
self._cursor_col = min(c - 1, self._cursor_col + n)
elif final == "D": # CUB - cursor back
self._cursor_col = max(0, self._cursor_col - n)
elif final == "G": # CHA - cursor horizontal absolute
self._cursor_col = min(max(n - 1, 0), c - 1)
elif final in ("H", "f"): # CUP - cursor position
self._cursor_row = min(max(p[0], 1) - 1, r - 1)
self._cursor_col = min(max(p[1] if len(p) > 1 else 1, 1) - 1, c - 1)
self._wrap_pending = False
elif final == "J": # ED - erase in display
m = p[0]
if m == 0:
self._clear(self._cursor_row, self._cursor_col, r - 1, c - 1)
elif m == 1:
self._clear(0, 0, self._cursor_row, self._cursor_col)
elif m == 2:
self._clear(0, 0, r - 1, c - 1)
elif final == "K": # EL - erase in line
m, row = p[0], self._cursor_row
if m == 0:
self._clear(row, self._cursor_col, row, c - 1)
elif m == 1:
self._clear(row, 0, row, self._cursor_col)
elif m == 2:
self._clear(row, 0, row, c - 1)
elif final == "L": # IL - insert lines
for _ in range(n):
if self._cursor_row <= self._scroll_bottom:
self._grid.pop(self._scroll_bottom)
self._grid.insert(self._cursor_row, _blank_row(c, self._effective_fg(), self._effective_bg()))
elif final == "M": # DL - delete lines
for _ in range(n):
if self._cursor_row <= self._scroll_bottom:
self._grid.pop(self._cursor_row)
self._grid.insert(self._scroll_bottom, _blank_row(c, self._effective_fg(), self._effective_bg()))
elif final == "P": # DCH - delete characters
row = self._grid[self._cursor_row]
for _ in range(min(n, c - self._cursor_col)):
if self._cursor_col < len(row):
row.pop(self._cursor_col)
row.append(_Cell(fg=self._effective_fg(), bg=self._effective_bg()))
elif final == "@": # ICH - insert characters
row = self._grid[self._cursor_row]
for _ in range(min(n, c - self._cursor_col)):
row.insert(self._cursor_col, _Cell(fg=self._effective_fg(), bg=self._effective_bg()))
if len(row) > c:
row.pop()
elif final == "X": # ECH - erase characters
self._clear(self._cursor_row, self._cursor_col, self._cursor_row, min(self._cursor_col + n - 1, c - 1))
elif final == "S": # SU - scroll up
self._scroll_region_up(n)
elif final == "T": # SD - scroll down
self._scroll_region_down(n)
elif final == "r": # DECSTBM - set scroll region
top = max(p[0], 1) - 1
bot = (p[1] if len(p) > 1 and p[1] > 0 else r) - 1
self._scroll_top = max(0, min(top, r - 1))
self._scroll_bottom = max(self._scroll_top, min(bot, r - 1))
self._cursor_row = 0
self._cursor_col = 0
self._wrap_pending = False
elif final == "s": # SCP - save cursor position
self._saved_cursor = (self._cursor_row, self._cursor_col)
elif final == "u": # RCP - restore cursor position
if self._saved_cursor:
self._cursor_row, self._cursor_col = self._saved_cursor
elif final == "d": # VPA - line position absolute
self._cursor_row = min(max(n - 1, 0), r - 1)
elif final == "m": # SGR
self._sgr(p)
elif final == "h": # SM - set mode (non-private)
if p[0] == 4:
self._insert_mode = True
elif final == "l": # RM - reset mode (non-private)
if p[0] == 4:
self._insert_mode = False
elif final == "t": # Window manipulation — silently ignore
pass
elif final == "n": # DSR - device status report
if p[0] == 6: # cursor position report
self.input_data(f"\x1b[{self._cursor_row + 1};{self._cursor_col + 1}R")
def _dec_private(self, buf: str, final: str):
"""Handle DEC private mode sequences: CSI ? Pn h/l"""
for code in self._params(buf):
if final == "h": # set
if code == 1:
pass # application cursor keys
elif code == 7:
self._auto_wrap = True
elif code == 12:
pass # cursor blink on — no-op
elif code == 25:
self._cursor_visible = True
elif code == 1049:
self._enter_alt_screen()
elif code == 2004:
pass # bracketed paste mode on — no-op
elif final == "l": # reset
if code == 7:
self._auto_wrap = False
elif code == 12:
pass # cursor blink off — no-op
elif code == 25:
self._cursor_visible = False
elif code == 1049:
self._leave_alt_screen()
elif code == 2004:
pass # bracketed paste mode off — no-op
def _enter_alt_screen(self):
"""Switch to alternate screen buffer, saving main screen state."""
if self._alt_grid is not None:
return # already in alt screen
self._alt_grid = self._grid
self._alt_cursor = (self._cursor_row, self._cursor_col)
self._alt_scrollback = self._scrollback
r, c = int(self.rows), int(self.cols)
self._grid = [_blank_row(c) for _ in range(r)]
self._scrollback = []
self._cursor_row = self._cursor_col = 0
self._scroll_top = 0
self._scroll_bottom = r - 1
def _leave_alt_screen(self):
"""Restore main screen buffer."""
if self._alt_grid is None:
return
self._grid = self._alt_grid
self._scrollback = self._alt_scrollback or []
if self._alt_cursor:
self._cursor_row, self._cursor_col = self._alt_cursor
self._alt_grid = self._alt_cursor = self._alt_scrollback = None
self._scroll_top = 0
self._scroll_bottom = int(self.rows) - 1
def _clear(self, r1: int, c1: int, r2: int, c2: int):
c = int(self.cols)
r = int(self.rows)
fg, bg = self._effective_fg(), self._effective_bg()
for row in range(max(r1, 0), min(r2 + 1, r)):
s = c1 if row == r1 else 0
e = c2 if row == r2 else c - 1
for col in range(s, min(e + 1, c)):
cell = self._grid[row][col]
cell.char, cell.fg, cell.bg, cell.bold, cell.underline = " ", fg, bg, False, False
def _sgr(self, codes: list[int]):
i = 0
while i < len(codes):
c = codes[i]
if c == 0:
self._cur_fg, self._cur_bg = _DEFAULT_FG, _DEFAULT_BG
self._cur_bold = self._cur_reverse = self._cur_underline = False
elif c == 1:
self._cur_bold = True
elif c == 4:
self._cur_underline = True
elif c == 7:
self._cur_reverse = True
elif c == 22:
self._cur_bold = False
elif c == 24:
self._cur_underline = False
elif c == 27:
self._cur_reverse = False
elif 30 <= c <= 37:
self._cur_fg = ANSI_COLORS[c - 30]
elif c == 39:
self._cur_fg = _DEFAULT_FG
elif 40 <= c <= 47:
self._cur_bg = ANSI_COLORS[c - 40]
elif c == 49:
self._cur_bg = _DEFAULT_BG
elif 90 <= c <= 97:
self._cur_fg = ANSI_COLORS[c - 90 + 8]
elif 100 <= c <= 107:
self._cur_bg = ANSI_COLORS[c - 100 + 8]
elif c in (38, 48):
if i + 1 < len(codes):
mode = codes[i + 1]
if mode == 5 and i + 2 < len(codes):
colour = _colour_256(codes[i + 2])
if c == 38:
self._cur_fg = colour
else:
self._cur_bg = colour
i += 3
continue
if mode == 2 and i + 4 < len(codes):
colour = (codes[i + 2] / 255, codes[i + 3] / 255, codes[i + 4] / 255, 1.0)
if c == 38:
self._cur_fg = colour
else:
self._cur_bg = colour
i += 5
continue
i += 1
# -- ShellNode integration -------------------------------------------------
[docs]
def attach(self, shell_node):
"""Connect to a ShellNode's stdout/stderr and wire input."""
self.detach()
self._attached_process = shell_node
self._stdout_conn = shell_node.stdout_data.connect(self.write)
self._stderr_conn = shell_node.stderr_data.connect(self.write)
self._input_conn = self.input_data.connect(shell_node.write)
[docs]
def detach(self):
"""Disconnect from the currently attached ShellNode."""
if self._attached_process is None:
return
p = self._attached_process
if self._stdout_conn:
p.stdout_data.disconnect(self._stdout_conn)
if self._stderr_conn:
p.stderr_data.disconnect(self._stderr_conn)
if self._input_conn:
self.input_data.disconnect(self._input_conn)
self._attached_process = None
self._stdout_conn = self._stderr_conn = self._input_conn = None
# -- Input handling --------------------------------------------------------
_VT100_KEYS = {
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"home": "\x1b[H",
"end": "\x1b[F",
"delete": "\x1b[3~",
"pageup": "\x1b[5~",
"pagedown": "\x1b[6~",
"insert": "\x1b[2~",
"f1": "\x1bOP",
"f2": "\x1bOQ",
"f3": "\x1bOR",
"f4": "\x1bOS",
"f5": "\x1b[15~",
"f6": "\x1b[17~",
"f7": "\x1b[18~",
"f8": "\x1b[19~",
"f9": "\x1b[20~",
"f10": "\x1b[21~",
"f11": "\x1b[23~",
"f12": "\x1b[24~",
"enter": "\r",
"backspace": "\x7f",
"tab": "\t",
"escape": "\x1b",
}
def _on_gui_input(self, event):
# Track modifier state (these events always arrive)
if event.key == "ctrl":
self._ctrl_held = event.pressed
return
if event.key == "shift":
self._shift_held = event.pressed
return
if event.key == "alt":
self._alt_held = event.pressed
return
# Mouse selection: start on left-click press
if event.button == 1 and event.pressed and self.is_point_inside(event.position):
self.set_focus()
px = event.position.x if hasattr(event.position, "x") else event.position[0]
py = event.position.y if hasattr(event.position, "y") else event.position[1]
self._sel_start = self._screen_pos_to_cell(px, py)
self._sel_end = self._sel_start
self._selecting = True
self.grab_mouse()
return
# Mouse selection: update during drag (motion events have button=0 while grabbed)
if self._selecting and event.button == 0 and event.position:
px = event.position.x if hasattr(event.position, "x") else event.position[0]
py = event.position.y if hasattr(event.position, "y") else event.position[1]
self._sel_end = self._screen_pos_to_cell(px, py)
return
# Mouse selection: finish on left-click release
if event.button == 1 and not event.pressed and self._selecting:
self._selecting = False
self.release_mouse()
# If start == end, it was a click with no drag — clear selection
if self._sel_start == self._sel_end:
self.clear_selection()
return
if event.key == "scroll_up":
self._scroll_offset = min(self._scroll_offset + 3, len(self._scrollback))
return
if event.key == "scroll_down":
self._scroll_offset = max(self._scroll_offset - 3, 0)
return
if not self.focused:
return
if event.key and event.pressed:
# Ctrl+C: copy selection if present, otherwise send SIGINT
if self._ctrl_held and event.key == "c":
if self._sel_start is not None and self._sel_end is not None and self._sel_start != self._sel_end:
self.copy_selection()
self.clear_selection()
return
# No selection — send Ctrl+C (SIGINT) to process
self.input_data("\x03")
return
# Ctrl+letter → control code (other than 'c' handled above)
if self._ctrl_held and len(event.key) == 1 and event.key.isalpha():
self.input_data(chr(ord(event.key.lower()) - ord("a") + 1))
return
# VT100 special keys
vt = self._VT100_KEYS.get(event.key)
if vt:
self.input_data(vt)
return
# Character input (typed text — only fires for printable chars)
if event.char and len(event.char) == 1 and not self._ctrl_held:
self.input_data(event.char)
# -- Content extraction ----------------------------------------------------
[docs]
def get_row_text(self, row: int) -> str:
r = int(self.rows)
if 0 <= row < r:
return "".join(c.char for c in self._grid[row])
return ""
[docs]
def get_cell(self, row: int, col: int) -> _Cell | None:
r, c = int(self.rows), int(self.cols)
if 0 <= row < r and 0 <= col < c:
return self._grid[row][col]
return None
# -- Selection -------------------------------------------------------------
def _screen_pos_to_cell(self, px: float, py: float) -> tuple[int, int]:
"""Convert screen pixel coords to absolute (row, col) in the scrollback + grid buffer."""
x, y, w, h = self.get_global_rect()
cw, ch = self._cell_size()
c, r = int(self.cols), int(self.rows)
col = max(0, min(int((px - x) / cw), c - 1))
vis_row = int((py - y) / ch)
sb_len = len(self._scrollback)
abs_row = sb_len - self._scroll_offset + vis_row
abs_row = max(0, min(abs_row, sb_len + r - 1))
return (abs_row, col)
def _ordered_selection(self) -> tuple[tuple[int, int], tuple[int, int]] | None:
"""Return (start, end) in correct order, or None if no selection."""
if self._sel_start is None or self._sel_end is None:
return None
a, b = self._sel_start, self._sel_end
return (a, b) if (a[0], a[1]) <= (b[0], b[1]) else (b, a)
def _get_row_cells(self, abs_row: int) -> list[_Cell] | None:
"""Get the cell list for an absolute row index (scrollback + grid)."""
sb_len = len(self._scrollback)
if abs_row < 0:
return None
if abs_row < sb_len:
return self._scrollback[abs_row]
grid_row = abs_row - sb_len
if 0 <= grid_row < int(self.rows):
return self._grid[grid_row]
return None
[docs]
@property
def selected_text(self) -> str:
"""Text currently selected, or empty string."""
sel = self._ordered_selection()
if sel is None:
return ""
(r1, c1), (r2, c2) = sel
c = int(self.cols)
lines: list[str] = []
for row_idx in range(r1, r2 + 1):
cells = self._get_row_cells(row_idx)
if cells is None:
lines.append("")
continue
sc = c1 if row_idx == r1 else 0
ec = c2 if row_idx == r2 else c - 1
lines.append("".join(cell.char for cell in cells[sc : ec + 1]).rstrip())
return "\n".join(lines)
[docs]
def clear_selection(self):
"""Clear the current selection."""
self._sel_start = self._sel_end = None
self._selecting = False
[docs]
def copy_selection(self):
"""Copy the current selection to the system clipboard."""
from .clipboard import copy as _cb_copy
text = self.selected_text
if text:
_cb_copy(text)
# -- Process / Draw --------------------------------------------------------
[docs]
def process(self, dt: float):
self._blink_timer += dt
if self._blink_timer > 1.0:
self._blink_timer = 0.0
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
cw, ch = self._cell_size()
scale = float(self.font_size) / 14.0
c, r = int(self.cols), int(self.rows)
renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
# Visible rows (scrollback + grid)
sb_len = len(self._scrollback)
view_top = sb_len + r - self._scroll_offset - r
sel = self._ordered_selection()
for vi in range(r):
abs_row = view_top + vi
if abs_row < 0:
continue
row = self._scrollback[abs_row] if abs_row < sb_len else self._grid[abs_row - sb_len]
ry = y + vi * ch
for ci, cell in enumerate(row[:c]):
cx = x + ci * cw
if cell.bg != self.bg_colour:
renderer.draw_rect((cx, ry), (cw, ch), colour=cell.bg, filled=True)
if cell.char != " ":
renderer.draw_text(
cell.char, (cx, ry + (ch - float(self.font_size)) / 2), colour=cell.fg, scale=scale
)
# Selection highlight
if sel is not None:
(sr1, sc1), (sr2, sc2) = sel
for vi in range(r):
abs_row = view_top + vi
if abs_row < sr1 or abs_row > sr2:
continue
col_start = sc1 if abs_row == sr1 else 0
col_end = sc2 if abs_row == sr2 else c - 1
sel_colour = self.get_theme().selection
for ci in range(col_start, col_end + 1):
renderer.draw_rect((x + ci * cw, y + vi * ch), (cw, ch), colour=sel_colour, filled=True)
# Cursor
if self._scroll_offset == 0 and self.focused and self._cursor_visible:
show = not self.cursor_blink or self._blink_timer < 0.5
if show and 0 <= self._cursor_row < r and 0 <= self._cursor_col < c:
renderer.draw_rect(
(x + self._cursor_col * cw, y + self._cursor_row * ch),
(cw, ch),
colour=(1.0, 1.0, 1.0, 0.5),
filled=True,
)
# Scrollbar
if self._scrollback:
theme = self.get_theme()
total = sb_len + r
sb_x = x + w - _SB_W
renderer.draw_rect((sb_x, y), (_SB_W, h), colour=theme.scrollbar_track, filled=True)
thumb_h = max(20.0, h * r / total)
ratio = 1.0 - (self._scroll_offset / max(sb_len, 1))
renderer.draw_rect(
(sb_x, y + ratio * (h - thumb_h)), (_SB_W, thumb_h), colour=theme.scrollbar_fg, filled=True
)
border_colour = self.get_theme().accent if self.focused else self.border_colour
renderer.draw_rect((x, y), (w, h), colour=border_colour)