273 lines
7.9 KiB
Python
273 lines
7.9 KiB
Python
"""
|
|
RoadPad Terminal - Integrated terminal emulator.
|
|
|
|
Features:
|
|
- PTY-based terminal
|
|
- Split integration
|
|
- Scrollback
|
|
- Copy/paste
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import select
|
|
import fcntl
|
|
import termios
|
|
import struct
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Callable
|
|
import threading
|
|
|
|
|
|
@dataclass
|
|
class TerminalBuffer:
|
|
"""Terminal screen buffer."""
|
|
lines: List[str] = field(default_factory=list)
|
|
scrollback: List[str] = field(default_factory=list)
|
|
max_scrollback: int = 10000
|
|
cursor_row: int = 0
|
|
cursor_col: int = 0
|
|
width: int = 80
|
|
height: int = 24
|
|
|
|
def write(self, text: str) -> None:
|
|
"""Write text to buffer."""
|
|
for char in text:
|
|
if char == '\n':
|
|
self._newline()
|
|
elif char == '\r':
|
|
self.cursor_col = 0
|
|
elif char == '\b':
|
|
if self.cursor_col > 0:
|
|
self.cursor_col -= 1
|
|
elif char == '\t':
|
|
self.cursor_col = (self.cursor_col + 8) & ~7
|
|
elif ord(char) >= 32 or char == '\x1b':
|
|
self._put_char(char)
|
|
|
|
def _put_char(self, char: str) -> None:
|
|
"""Put character at cursor."""
|
|
while len(self.lines) <= self.cursor_row:
|
|
self.lines.append("")
|
|
|
|
line = self.lines[self.cursor_row]
|
|
while len(line) <= self.cursor_col:
|
|
line += " "
|
|
|
|
self.lines[self.cursor_row] = line[:self.cursor_col] + char + line[self.cursor_col + 1:]
|
|
self.cursor_col += 1
|
|
|
|
if self.cursor_col >= self.width:
|
|
self._newline()
|
|
|
|
def _newline(self) -> None:
|
|
"""Move to new line."""
|
|
self.cursor_row += 1
|
|
self.cursor_col = 0
|
|
|
|
if self.cursor_row >= self.height:
|
|
# Scroll
|
|
if self.lines:
|
|
self.scrollback.append(self.lines.pop(0))
|
|
while len(self.scrollback) > self.max_scrollback:
|
|
self.scrollback.pop(0)
|
|
self.cursor_row = self.height - 1
|
|
|
|
def clear(self) -> None:
|
|
"""Clear screen."""
|
|
self.lines = []
|
|
self.cursor_row = 0
|
|
self.cursor_col = 0
|
|
|
|
def get_visible(self) -> List[str]:
|
|
"""Get visible lines."""
|
|
result = []
|
|
for i in range(self.height):
|
|
if i < len(self.lines):
|
|
line = self.lines[i][:self.width]
|
|
result.append(line + " " * (self.width - len(line)))
|
|
else:
|
|
result.append(" " * self.width)
|
|
return result
|
|
|
|
|
|
class Terminal:
|
|
"""PTY-based terminal."""
|
|
|
|
def __init__(self, shell: str = "/bin/bash"):
|
|
self.shell = shell
|
|
self.master_fd: int = -1
|
|
self.slave_fd: int = -1
|
|
self.pid: int = -1
|
|
self.running: bool = False
|
|
|
|
self.buffer = TerminalBuffer()
|
|
self.on_output: Callable[[str], None] | None = None
|
|
|
|
self._reader_thread: threading.Thread | None = None
|
|
|
|
def start(self, width: int = 80, height: int = 24) -> bool:
|
|
"""Start terminal."""
|
|
self.buffer.width = width
|
|
self.buffer.height = height
|
|
|
|
try:
|
|
# Create PTY
|
|
self.master_fd, self.slave_fd = os.openpty()
|
|
|
|
# Set window size
|
|
winsize = struct.pack('HHHH', height, width, 0, 0)
|
|
fcntl.ioctl(self.slave_fd, termios.TIOCSWINSZ, winsize)
|
|
|
|
# Fork
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
# Child
|
|
os.setsid()
|
|
os.dup2(self.slave_fd, 0)
|
|
os.dup2(self.slave_fd, 1)
|
|
os.dup2(self.slave_fd, 2)
|
|
os.close(self.master_fd)
|
|
os.close(self.slave_fd)
|
|
os.execvp(self.shell, [self.shell])
|
|
else:
|
|
# Parent
|
|
self.pid = pid
|
|
os.close(self.slave_fd)
|
|
self.running = True
|
|
|
|
# Start reader
|
|
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
self._reader_thread.start()
|
|
|
|
return True
|
|
except Exception as e:
|
|
return False
|
|
|
|
return False
|
|
|
|
def stop(self) -> None:
|
|
"""Stop terminal."""
|
|
self.running = False
|
|
if self.pid > 0:
|
|
try:
|
|
os.kill(self.pid, 9)
|
|
os.waitpid(self.pid, 0)
|
|
except:
|
|
pass
|
|
if self.master_fd >= 0:
|
|
os.close(self.master_fd)
|
|
self.master_fd = -1
|
|
|
|
def write(self, data: str) -> None:
|
|
"""Write to terminal."""
|
|
if self.master_fd >= 0:
|
|
os.write(self.master_fd, data.encode())
|
|
|
|
def send_key(self, key: int, char: str = "") -> None:
|
|
"""Send key to terminal."""
|
|
if char:
|
|
self.write(char)
|
|
elif key == 10 or key == 13:
|
|
self.write("\r")
|
|
elif key == 127:
|
|
self.write("\x7f")
|
|
elif key == 27:
|
|
self.write("\x1b")
|
|
|
|
def resize(self, width: int, height: int) -> None:
|
|
"""Resize terminal."""
|
|
self.buffer.width = width
|
|
self.buffer.height = height
|
|
|
|
if self.master_fd >= 0:
|
|
winsize = struct.pack('HHHH', height, width, 0, 0)
|
|
try:
|
|
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
|
|
except:
|
|
pass
|
|
|
|
def _read_loop(self) -> None:
|
|
"""Read output from terminal."""
|
|
while self.running and self.master_fd >= 0:
|
|
try:
|
|
r, _, _ = select.select([self.master_fd], [], [], 0.1)
|
|
if r:
|
|
data = os.read(self.master_fd, 4096)
|
|
if data:
|
|
text = data.decode('utf-8', errors='replace')
|
|
self.buffer.write(text)
|
|
if self.on_output:
|
|
self.on_output(text)
|
|
else:
|
|
self.running = False
|
|
break
|
|
except:
|
|
break
|
|
|
|
def get_lines(self) -> List[str]:
|
|
"""Get visible lines."""
|
|
return self.buffer.get_visible()
|
|
|
|
|
|
class TerminalManager:
|
|
"""Manages multiple terminals."""
|
|
|
|
def __init__(self):
|
|
self.terminals: List[Terminal] = []
|
|
self.active_index: int = -1
|
|
|
|
def create(self, shell: str = "/bin/bash", width: int = 80, height: int = 24) -> Terminal:
|
|
"""Create new terminal."""
|
|
term = Terminal(shell)
|
|
if term.start(width, height):
|
|
self.terminals.append(term)
|
|
self.active_index = len(self.terminals) - 1
|
|
return term
|
|
return None
|
|
|
|
def close(self, index: int | None = None) -> None:
|
|
"""Close terminal."""
|
|
idx = index if index is not None else self.active_index
|
|
if 0 <= idx < len(self.terminals):
|
|
self.terminals[idx].stop()
|
|
self.terminals.pop(idx)
|
|
if self.active_index >= len(self.terminals):
|
|
self.active_index = len(self.terminals) - 1
|
|
|
|
def close_all(self) -> None:
|
|
"""Close all terminals."""
|
|
for term in self.terminals:
|
|
term.stop()
|
|
self.terminals.clear()
|
|
self.active_index = -1
|
|
|
|
@property
|
|
def active(self) -> Terminal | None:
|
|
"""Get active terminal."""
|
|
if 0 <= self.active_index < len(self.terminals):
|
|
return self.terminals[self.active_index]
|
|
return None
|
|
|
|
def next_terminal(self) -> None:
|
|
"""Switch to next terminal."""
|
|
if self.terminals:
|
|
self.active_index = (self.active_index + 1) % len(self.terminals)
|
|
|
|
def prev_terminal(self) -> None:
|
|
"""Switch to previous terminal."""
|
|
if self.terminals:
|
|
self.active_index = (self.active_index - 1) % len(self.terminals)
|
|
|
|
|
|
# Global manager
|
|
_manager: TerminalManager | None = None
|
|
|
|
def get_terminal_manager() -> TerminalManager:
|
|
"""Get global terminal manager."""
|
|
global _manager
|
|
if _manager is None:
|
|
_manager = TerminalManager()
|
|
return _manager
|