mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
chore: restore tui screen from history
This commit is contained in:
391
astrbot/tui/screen.py
Normal file
391
astrbot/tui/screen.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user