"""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.
"""
from __future__ import annotations
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 = ProcessNode("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
# -- ProcessNode integration -----------------------------------------------
[docs]
def attach(self, process_node):
"""Connect to a ProcessNode's stdout/stderr and wire input."""
self.detach()
self._attached_process = process_node
self._stdout_conn = process_node.stdout_data.connect(self.write)
self._stderr_conn = process_node.stderr_data.connect(self.write)
self._input_conn = self.input_data.connect(process_node.write)
[docs]
def detach(self):
"""Disconnect from the currently attached ProcessNode."""
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]
def get_selected_text(self) -> str:
"""Return the 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.get_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_filled_rect(x, y, w, h, self.bg_colour)
# 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_filled_rect(cx, ry, cw, ch, cell.bg)
if cell.char != " ":
renderer.draw_text_coloured(cell.char, cx, ry + (ch - float(self.font_size)) / 2, scale, cell.fg)
# 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_filled_rect(x + ci * cw, y + vi * ch, cw, ch, sel_colour)
# 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_filled_rect(
x + self._cursor_col * cw,
y + self._cursor_row * ch,
cw,
ch,
(1.0, 1.0, 1.0, 0.5),
)
# Scrollbar
if self._scrollback:
theme = self.get_theme()
total = sb_len + r
sb_x = x + w - _SB_W
renderer.draw_filled_rect(sb_x, y, _SB_W, h, theme.scrollbar_track)
thumb_h = max(20.0, h * r / total)
ratio = 1.0 - (self._scroll_offset / max(sb_len, 1))
renderer.draw_filled_rect(sb_x, y + ratio * (h - thumb_h), _SB_W, thumb_h, theme.scrollbar_fg)
border_colour = self.get_theme().accent if self.focused else self.border_colour
renderer.draw_rect_coloured(x, y, w, h, border_colour)