"""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
from ..input.enums import MouseButton
from ..math.types import Vec2
from ..signals import Signal
from ..text.measure import LOGICAL_EM, measure_text_width
from .ansi_parser import ANSI_COLORS
from .core import Control, ThemeColour
from .enums import FocusMode
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
#: Every printable ASCII character, which is what the cell pitch is taken from.
#: A terminal shows whatever a subprocess writes, so the pitch has to be the
#: widest of them: pick a narrower one and every wider character spills into the
#: column to its right, where the next character is already being drawn.
_PITCH_CHARACTERS = "".join(chr(code) for code in range(0x21, 0x7F))
def _cell_pitch(font_size: float) -> float:
"""Width of one character cell at *font_size*, in pixels.
Walked on every read rather than memoised here. Each character's width comes
out of the measurement cache, which the metrics module drops before it tells
anything the font has changed; a cache of its own would be dropped by a
listener instead, and a listener that re-fits a widget can run before the one
that clears it and size the widget to the font it is no longer drawn in.
"""
scale = font_size / LOGICAL_EM
return max(measure_text_width(char, scale) for char in _PITCH_CHARACTERS)
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)
"""
# A live terminal is genuinely per-frame: the cursor blinks (``cursor_blink``
# defaults on) and PTY output streams into ``_grid`` continuously, neither of
# which routes through a Property write. Under the retained 2D renderer that
# would freeze (stale cursor + stale output), so the honest declaration is a
# per-frame node. Zero-cost when the terminal is hidden (visibility-pruned
# before collection); only a visible terminal re-runs its small-grid ``on_draw``.
dynamic = True
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")
# Clicking it focuses it, but every key (Tab included) belongs to the shell,
# so it is a focus source and never a Tab destination. Declared rather than
# assigned in ``__init__``, so a caller's ``focus_mode=`` survives.
focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus")
# Theme-aware colours
border_colour = ThemeColour("border")
def __init__(self, cols: int = 80, rows: int = 24, **kwargs):
super().__init__(**kwargs)
# The keyword form reaches the Property through the gate; the positional
# parameters only fill in what nobody asked for. Everything below sizes
# itself from the Properties, so both routes build the same grid.
if "cols" not in kwargs:
self.cols = cols
if "rows" not in kwargs:
self.rows = rows
cols, rows = self.cols, self.rows
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."""
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)
[docs]
def get_minimum_size(self) -> Vec2:
"""Size of the whole character grid plus the scrollbar strip.
A terminal cannot be shown smaller than its grid without losing columns,
so its minimum is its full size.
"""
cw, ch = self.cell_size
return Vec2(int(self.cols) * cw + _SB_W, int(self.rows) * ch)
[docs]
def content_signature(self) -> object:
return (int(self.cols), int(self.rows), float(self.font_size))
[docs]
def content_uses(self, chars: str) -> bool:
# The grid is sized from the widest character the face draws, so any of
# them can move it, whatever the terminal is currently showing.
return any(char in _PITCH_CHARACTERS for char in chars)
def _update_size(self):
"""Recalculate widget size from cols/rows/font_size."""
self.autosize_to_content()
# -- Grid helpers ----------------------------------------------------------
[docs]
@property
def cell_size(self) -> tuple[float, float]:
"""Width and height of one character cell, in pixels.
The width is the widest advance the drawn font gives any printable ASCII
character, so the grid the cursor and the click hit-testing use is the
grid the glyphs actually land on and no character can reach the column
beside it. Taking it from a digit instead would be right only for a face
that draws every character on one body, and the face a terminal is drawn
in is not always one: the engine's own ``"ui"`` face is proportional, and
a project can point the renderer at anything.
Public because anything that sizes a terminal has to work in the same
cells it draws in: a panel deciding how many columns fit its width must
divide by this, not by a second guess at the font's advance, or its
columns and the terminal's stop being the same columns.
"""
return (_cell_pitch(float(self.font_size)), float(self.font_size) * 1.4)
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",
"shift+tab": "\x1b[Z",
"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 == MouseButton.LEFT and event.pressed and self.is_point_inside(event.position):
self.set_focus()
px = event.position.x
py = event.position.y
self._sel_start = self._screen_pos_to_cell(px, py)
self._sel_end = self._sel_start
self._selecting = True
self.grab_mouse()
event.handled = True
return
# Mouse selection: update during drag (motion events have button=0 while grabbed)
if self._selecting and event.button is None and event.position:
px = event.position.x
py = event.position.y
self._sel_end = self._screen_pos_to_cell(px, py)
return
# Mouse selection: finish on left-click release
if event.button == MouseButton.LEFT 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()
event.handled = True
return
if event.key == "scroll_up":
self._scroll_offset = min(self._scroll_offset + 3, len(self._scrollback))
event.handled = True
return
if event.key == "scroll_down":
self._scroll_offset = max(self._scroll_offset - 3, 0)
event.handled = True
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. Marking the event handled keeps the router's
# fallbacks off keys the shell has already been sent: Tab indents
# inside the terminal instead of moving focus, and Escape reaches
# the program instead of closing the panel around it.
vt = self._VT100_KEYS.get(event.key)
if vt:
self.input_data(vt)
event.handled = True
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 on_update(self, dt: float):
self._blink_timer += dt
if self._blink_timer > 1.0:
self._blink_timer = 0.0
[docs]
def on_draw(self, renderer):
x, y, w, h = self.get_global_rect()
cw, ch = self.cell_size
scale = float(self.font_size) / 16.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 != " ":
# Each character is drawn centred inside its own cell rather
# than anchored at the cell's left edge, and shrunk if it is
# wider than one -- which a character borrowed from a CJK or
# an icon face is. Anchoring it left would let a wide glyph
# reach across into the column to its right, where the next
# character is already drawn, and the two would overlap.
# ``cell_width`` says the same thing to the layout about the
# one case it decides itself: a character no font can draw
# has its box fitted to the cell instead of being given the
# room proportional text would give it.
renderer.draw_text(
cell.char,
rect=(cx, ry, cw, ch),
colour=cell.fg,
scale=scale,
alignment="centre",
vertical_alignment="centre",
fit_to_width=True,
cell_width=cw,
)
# 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)