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


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)