Source code for simvx.core.ui.terminal

"""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)