Files
AstrBot/astrbot/tui/screen.py
2026-03-22 19:53:56 +08:00

392 lines
12 KiB
Python

"""Curses screen management for AstrBot TUI - Modern Design."""
from __future__ import annotations
import curses
from collections.abc import Callable
from enum import Enum
class ColorPair(Enum):
# Basic colors
WHITE = 1
CYAN = 2
GREEN = 3
YELLOW = 4
RED = 5
MAGENTA = 6
DIM = 7
BOLD = 8
# Extended colors for new design
HEADER_BG = 9
HEADER_FG = 10
USER_MSG = 11
BOT_MSG = 12
SYSTEM_MSG = 13
INPUT_BG = 14
STATUS_BG = 15
BORDER = 16
_COLOR_MAP = {
ColorPair.WHITE: curses.COLOR_WHITE,
ColorPair.CYAN: curses.COLOR_CYAN,
ColorPair.GREEN: curses.COLOR_GREEN,
ColorPair.YELLOW: curses.COLOR_YELLOW,
ColorPair.RED: curses.COLOR_RED,
ColorPair.MAGENTA: curses.COLOR_MAGENTA,
ColorPair.DIM: curses.COLOR_WHITE,
ColorPair.HEADER_BG: curses.COLOR_BLUE,
ColorPair.HEADER_FG: curses.COLOR_WHITE,
ColorPair.USER_MSG: curses.COLOR_GREEN,
ColorPair.BOT_MSG: curses.COLOR_CYAN,
ColorPair.SYSTEM_MSG: curses.COLOR_YELLOW,
ColorPair.INPUT_BG: curses.COLOR_BLACK,
ColorPair.STATUS_BG: curses.COLOR_BLUE,
ColorPair.BORDER: curses.COLOR_CYAN,
}
# Box drawing characters
BOX_VERT = ""
BOX_HORIZ = ""
BOX_TL = ""
BOX_TR = ""
BOX_BL = ""
BOX_BR = ""
BOX_LT = ""
BOX_RT = ""
BOX_BT = ""
BOX_TT = ""
BOX_CROSS = ""
# Header and footer characters
HEADER_PREFIX = ""
HEADER_CONTENT = ""
HEADER_SUFFIX = ""
FOOTER_PREFIX = ""
FOOTER_CONTENT = ""
FOOTER_SUFFIX = ""
class Screen:
"""Main curses screen manager with modern design."""
def __init__(self, stdscr: curses.window):
self.stdscr = stdscr
self.height, self.width = stdscr.getmaxyx()
self._header_height = 1
self._footer_height = 1
self._chat_height = self.height - self._header_height - self._footer_height - 3
self._input_height = 3
self._header_win: curses.window | None = None
self._chat_win: curses.window | None = None
self._footer_win: curses.window | None = None
self._input_win: curses.window | None = None
self._running = False
self._color_pairs: dict[int, int] = {}
def setup_colors(self) -> None:
curses.start_color()
curses.use_default_colors()
curses.curs_set(1)
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
# Initialize all color pairs
for i, (name, fg) in enumerate(_COLOR_MAP.items(), start=1):
if name in (ColorPair.HEADER_BG, ColorPair.INPUT_BG, ColorPair.STATUS_BG):
curses.init_pair(i, fg, curses.COLOR_BLACK)
elif name == ColorPair.DIM:
curses.init_pair(i, fg, curses.COLOR_BLACK)
self._color_pairs[name.value] = curses.color_pair(i) | curses.A_DIM
elif name == ColorPair.BOLD:
curses.init_pair(i, curses.COLOR_WHITE, curses.COLOR_BLACK)
self._color_pairs[name.value] = curses.color_pair(i) | curses.A_BOLD
else:
curses.init_pair(i, fg, curses.COLOR_BLACK)
self._color_pairs[name.value] = curses.color_pair(i)
def get_color(self, pair: ColorPair) -> int:
"""Get curses attribute for a color pair."""
return self._color_pairs.get(pair.value, curses.color_pair(pair.value))
def layout_windows(self) -> None:
"""Create the window layout with modern design."""
self.height, self.width = self.stdscr.getmaxyx()
self._chat_height = max(
1,
self.height
- self._header_height
- self._footer_height
- self._input_height
- 2,
)
# Header window (top bar with branding)
self._header_win = curses.newwin(self._header_height, self.width, 0, 0)
self._header_win.nodelay(True)
# Chat window (middle area)
self._chat_win = curses.newwin(
self._chat_height, self.width, self._header_height + 1, 0
)
# Disable auto-scroll so text stays visible for copy/mouse selection
self._chat_win.scrollok(False)
self._chat_win.idlok(True)
self._chat_win.leaveok(False)
# Enable mouse wheel support (KEY_MOUSE reports scroll events)
self._chat_win.keypad(True)
# Footer window (separator above status)
self._footer_win = curses.newwin(
self._footer_height,
self.width,
self._header_height + 1 + self._chat_height,
0,
)
self._footer_win.nodelay(True)
# Input window (bottom)
self._input_win = curses.newwin(
self._input_height, self.width, self.height - self._input_height, 0
)
self._input_win.keypad(True)
self._input_win.timeout(100)
self._running = True
@property
def chat_win(self):
return self._chat_win
@property
def input_win(self):
return self._input_win
@property
def footer_win(self):
return self._footer_win
@property
def header_win(self):
return self._header_win
def draw_border_line(self) -> None:
"""Draw the border/divider line in the footer window."""
if not self._footer_win:
return
self._footer_win.clear()
if self.width >= 4:
line = BOX_TL + BOX_HORIZ * (self.width - 2) + BOX_TR
try:
self._footer_win.addstr(0, 0, line, self.get_color(ColorPair.BORDER))
except curses.error:
pass
self._footer_win.refresh()
def draw_header(self) -> None:
"""Draw the header bar with AstrBot branding."""
if not self._header_win:
return
self._header_win.clear()
# Create header content
title = " AstrBot TUI "
try:
# Draw background with color
self._header_win.bkgdset(curses.color_pair(ColorPair.HEADER_FG.value))
self._header_win.erase()
# Draw title with bold
title_attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
self._header_win.addstr(0, 0, title, title_attr)
# Draw decorative border continuation
if self.width > len(title) + 2:
border_attr = self.get_color(ColorPair.HEADER_FG)
remaining = self.width - len(title)
self._header_win.addstr(
0, len(title), BOX_HORIZ * remaining, border_attr
)
except curses.error:
pass
self._header_win.refresh()
def draw_chat_log(self, lines: list[tuple[str, str]]) -> None:
"""Draw the chat log window with styled message bubbles.
Args:
lines: List of tuples (sender_type, text) where sender_type is
'user', 'bot', or 'system'
"""
if not self._chat_win:
return
self._chat_win.clear()
y = 0
max_y = self._chat_height - 1
# Only show last N lines that fit
visible_lines = lines[-max_y:] if len(lines) > max_y else lines
for sender, text in visible_lines:
if y >= self._chat_height:
break
# Select color based on sender
if sender == "user":
indicator = ">"
color = self.get_color(ColorPair.USER_MSG)
elif sender == "bot":
indicator = ""
color = self.get_color(ColorPair.BOT_MSG)
else: # system
indicator = ""
color = self.get_color(ColorPair.SYSTEM_MSG)
# Truncate text to fit width
max_text_width = self.width - 4 # Account for indicator and spacing
if max_text_width < 1:
continue
# Word wrap text
words = text.split()
current_line = ""
lines_buffer = []
for word in words:
test_line = current_line + (" " if current_line else "") + word
if len(test_line) <= max_text_width:
current_line = test_line
else:
if current_line:
lines_buffer.append(current_line)
current_line = word
if current_line:
lines_buffer.append(current_line)
# Draw each line of the message
for i, line_text in enumerate(lines_buffer):
if y >= self._chat_height:
break
try:
if i == 0:
self._chat_win.addstr(
y, 0, f"{indicator} ", color | curses.A_BOLD
)
self._chat_win.addstr(y, 2, line_text, color)
else:
# Continuation lines - indent
self._chat_win.addstr(y, 0, " ", color)
self._chat_win.addstr(y, 2, line_text, color)
except curses.error:
pass
y += 1
self._chat_win.clrtobot()
self._chat_win.refresh()
def draw_input(self, text: str, cursor_x: int) -> None:
"""Draw the input area with modern styling."""
if not self._input_win:
return
self._input_win.clear()
# Draw input box with border
prompt = "> "
prompt_len = 2
max_input_width = self.width - 2
try:
# Draw prompt
self._input_win.addstr(
0, 0, prompt, curses.color_pair(ColorPair.GREEN.value) | curses.A_BOLD
)
# Draw text content
display_text = text[: max_input_width - prompt_len]
self._input_win.addstr(
0, prompt_len, display_text, curses.color_pair(ColorPair.WHITE.value)
)
# Draw cursor
cursor_pos = min(cursor_x + prompt_len, self.width - 1)
self._input_win.chgat(0, cursor_pos, 1, curses.A_REVERSE)
except curses.error:
pass
self._input_win.clrtobot()
self._input_win.refresh()
def draw_status(self, status: str) -> None:
"""Draw the status bar at the bottom."""
if not self._footer_win:
return
self._footer_win.clear()
# Status bar at the very bottom
status_y = self.height - 1
# Truncate status text
status_text = status[: self.width - 2]
try:
# Use the main stdscr for bottom status
attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
self.stdscr.addstr(status_y, 0, " " + status_text, attr)
# Fill remainder of line
remaining = self.width - len(status_text) - 1
if remaining > 0:
self.stdscr.addstr(
status_y, len(status_text) + 1, " " * remaining, attr
)
self.stdscr.chgat(status_y, 0, self.width, attr)
except curses.error:
pass
self.stdscr.refresh()
def draw_all(
self, lines: list[tuple[str, str]], input_text: str, cursor_x: int, status: str
) -> None:
"""Redraw all windows."""
self.draw_header()
self.draw_border_line()
self.draw_chat_log(lines)
self.draw_input(input_text, cursor_x)
self.draw_status(status)
def resize(self) -> bool:
"""Handle resize event. Returns True if resized."""
self.height, self.width = self.stdscr.getmaxyx()
if self._chat_win:
self._chat_win.clear()
self.layout_windows()
return True
def clear_status(self) -> None:
"""Clear the status line at bottom."""
try:
attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
self.stdscr.addstr(self.height - 1, 0, " " * self.width, attr)
self.stdscr.refresh()
except curses.error:
pass
def run_curses(main_loop: Callable[[curses.window], None]):
"""Entry point for curses wrapper."""
def _main(stdscr: curses.window):
main_loop(stdscr)
curses.wrapper(_main)