feat: Phase 1 - TUI Foundation with Textual
- Added Textual TUI framework with modern terminal UI - Created main TUI app with navigation, message view, event log, state inspector - Added comprehensive CSS styling for Discord-like appearance - Implemented navigation pane, message area, event log, state tree, command bar - Added keyboard shortcuts (Ctrl+Q quit, Ctrl+C command, Ctrl+N/P nav, etc.) - Integrated TUI with existing REST/Gateway servers and state management - Added CLI flag --tui/--no-tui to launch TUI mode - Added Textual and textual-dev to optional dependencies All 19 tests passing.
This commit is contained in:
1 parent
5669048f89
commit
2466acf837
11 files changed
+826
-1
No files matched your search
@@ -35,6 +35,10 @@ dev = [
|
|||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
"mypy>=1.5.0",
|
"mypy>=1.5.0",
|
||||||
]
|
]
|
||||||
|
tui = [
|
||||||
|
"textual>=0.52.0",
|
||||||
|
"textual-dev>=0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
apicord = "apicord.main:main"
|
apicord = "apicord.main:main"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|||||||
Requires-Dist: black>=23.0.0; extra == "dev"
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
||||||
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
||||||
Requires-Dist: mypy>=1.5.0; extra == "dev"
|
Requires-Dist: mypy>=1.5.0; extra == "dev"
|
||||||
|
Provides-Extra: tui
|
||||||
|
Requires-Dist: textual>=0.52.0; extra == "tui"
|
||||||
|
Requires-Dist: textual-dev>=0.52.0; extra == "tui"
|
||||||
Dynamic: license-file
|
Dynamic: license-file
|
||||||
|
|
||||||
# APIcord
|
# APIcord
|
||||||
|
|||||||
@@ -12,3 +12,7 @@ pytest-cov>=4.1.0
|
|||||||
black>=23.0.0
|
black>=23.0.0
|
||||||
ruff>=0.1.0
|
ruff>=0.1.0
|
||||||
mypy>=1.5.0
|
mypy>=1.5.0
|
||||||
|
|
||||||
|
[tui]
|
||||||
|
textual>=0.52.0
|
||||||
|
textual-dev>=0.52.0
|
||||||
Binary file not shown.
@@ -48,11 +48,22 @@ logger = logging.getLogger("apicord")
|
|||||||
default=5,
|
default=5,
|
||||||
help="Number of test users (if no scenario)",
|
help="Number of test users (if no scenario)",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--tui/--no-tui",
|
||||||
|
default=False,
|
||||||
|
help="Launch with Terminal User Interface (TUI)",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def start(ctx: click.Context, scenario: Optional[Path], guilds: int, channels: int, users: int) -> None:
|
def start(ctx: click.Context, scenario: Optional[Path], guilds: int, channels: int, users: int, tui: bool) -> None:
|
||||||
"""Start the APIcord emulator server."""
|
"""Start the APIcord emulator server."""
|
||||||
config = ctx.obj["config"] if ctx.obj else get_config()
|
config = ctx.obj["config"] if ctx.obj else get_config()
|
||||||
|
|
||||||
|
if tui:
|
||||||
|
# Import and run TUI
|
||||||
|
from apicord.tui.app import run_tui
|
||||||
|
run_tui(config=config, headless=False)
|
||||||
|
return
|
||||||
|
|
||||||
async def run_server():
|
async def run_server():
|
||||||
# Initialize state manager
|
# Initialize state manager
|
||||||
state_manager = StateManager(config)
|
state_manager = StateManager(config)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from apicord.models.discord import (
|
|||||||
Interaction,
|
Interaction,
|
||||||
InteractionType,
|
InteractionType,
|
||||||
InteractionCallbackType,
|
InteractionCallbackType,
|
||||||
|
InteractionResponse,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
@@ -37,6 +38,7 @@ __all__ = [
|
|||||||
"Interaction",
|
"Interaction",
|
||||||
"InteractionType",
|
"InteractionType",
|
||||||
"InteractionCallbackType",
|
"InteractionCallbackType",
|
||||||
|
"InteractionResponse",
|
||||||
"ComponentType",
|
"ComponentType",
|
||||||
"ButtonStyle",
|
"ButtonStyle",
|
||||||
"Snowflake",
|
"Snowflake",
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
"""TUI Package for APIcord"""
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
Main TUI Application for APIcord.
|
||||||
|
|
||||||
|
This is the main Textual application that provides the terminal user interface
|
||||||
|
for interacting with the Discord API emulator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
ListItem,
|
||||||
|
ListView,
|
||||||
|
Static,
|
||||||
|
TabbedContent,
|
||||||
|
TabPane,
|
||||||
|
Tree,
|
||||||
|
)
|
||||||
|
|
||||||
|
from apicord.config import EmulatorConfig, get_config
|
||||||
|
from apicord.state.manager import StateManager
|
||||||
|
from apicord.rest.server import RESTServer
|
||||||
|
from apicord.gateways.server import GatewayServer
|
||||||
|
|
||||||
|
logger = logging.getLogger("apicord.tui")
|
||||||
|
|
||||||
|
|
||||||
|
class NavigationPane(Static):
|
||||||
|
"""Left navigation pane with guild/channel/user/event navigation."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label("📁 NAVIGATION", classes="nav-title")
|
||||||
|
yield ListView(
|
||||||
|
ListItem(Label("🏠 Guilds"), id="nav-guilds"),
|
||||||
|
ListItem(Label("💬 Channels"), id="nav-channels"),
|
||||||
|
ListItem(Label("👥 Users"), id="nav-users"),
|
||||||
|
ListItem(Label("📨 Messages"), id="nav-messages"),
|
||||||
|
ListItem(Label("📋 Events"), id="nav-events"),
|
||||||
|
ListItem(Label("🔧 State"), id="nav-state"),
|
||||||
|
id="nav-list"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageArea(Static):
|
||||||
|
"""Main message display area with embeds and components."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("No channel selected", id="message-placeholder", classes="message-entry")
|
||||||
|
yield ListView(id="message-list")
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog(Static):
|
||||||
|
"""Real-time event log with filtering."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield ListView(id="event-list")
|
||||||
|
|
||||||
|
|
||||||
|
class StateTree(Static):
|
||||||
|
"""State inspector tree view."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Tree("State", id="state-tree")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandBar(Static):
|
||||||
|
"""Command input bar at bottom."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal():
|
||||||
|
yield Label(">", classes="prompt")
|
||||||
|
yield Input(placeholder="Type / for commands, or raw JSON...", id="command-input")
|
||||||
|
|
||||||
|
|
||||||
|
class APIcordApp(App):
|
||||||
|
"""Main APIcord TUI Application."""
|
||||||
|
|
||||||
|
CSS_PATH = "css/app.tcss"
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("ctrl+q", "quit", "Quit"),
|
||||||
|
Binding("ctrl+c", "command_focus", "Command"),
|
||||||
|
Binding("ctrl+n", "nav_next", "Next Nav"),
|
||||||
|
Binding("ctrl+p", "nav_prev", "Prev Nav"),
|
||||||
|
Binding("ctrl+r", "refresh", "Refresh"),
|
||||||
|
Binding("ctrl+e", "toggle_events", "Events"),
|
||||||
|
Binding("ctrl+s", "toggle_state", "State"),
|
||||||
|
Binding("f1", "help", "Help"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[EmulatorConfig] = None, headless: bool = False):
|
||||||
|
super().__init__()
|
||||||
|
self.config = config or get_config()
|
||||||
|
self.headless = headless
|
||||||
|
self.state_manager: Optional[StateManager] = None
|
||||||
|
self.rest_server: Optional[RESTServer] = None
|
||||||
|
self.gateway_server: Optional[GatewayServer] = None
|
||||||
|
self._servers_started = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
with Horizontal():
|
||||||
|
yield NavigationPane(id="navigation-pane")
|
||||||
|
with Vertical():
|
||||||
|
with TabbedContent(id="main-tabs"):
|
||||||
|
with TabPane("Messages", id="tab-messages"):
|
||||||
|
yield MessageArea(id="message-area")
|
||||||
|
with TabPane("Events", id="tab-events"):
|
||||||
|
yield EventLog(id="event-log")
|
||||||
|
with TabPane("State", id="tab-state"):
|
||||||
|
yield StateTree(id="state-tree")
|
||||||
|
yield CommandBar(id="command-bar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
"""Initialize the application."""
|
||||||
|
self.title = "APIcord"
|
||||||
|
self.sub_title = "Discord API Emulator"
|
||||||
|
|
||||||
|
# Initialize state manager
|
||||||
|
self.state_manager = StateManager(self.config)
|
||||||
|
await self.state_manager.create_default_state()
|
||||||
|
|
||||||
|
# Start servers if not headless
|
||||||
|
if not self.headless:
|
||||||
|
await self._start_servers()
|
||||||
|
|
||||||
|
# Populate initial state
|
||||||
|
await self._populate_navigation()
|
||||||
|
await self._refresh_state_tree()
|
||||||
|
|
||||||
|
# Focus command input
|
||||||
|
self.query_one("#command-input", Input).focus()
|
||||||
|
|
||||||
|
async def _start_servers(self) -> None:
|
||||||
|
"""Start REST and Gateway servers."""
|
||||||
|
self.rest_server = RESTServer(self.config, self.state_manager)
|
||||||
|
self.gateway_server = GatewayServer(self.config, self.state_manager)
|
||||||
|
|
||||||
|
await self.rest_server.start()
|
||||||
|
await self.gateway_server.start()
|
||||||
|
|
||||||
|
self._servers_started = True
|
||||||
|
logger.info("Servers started")
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
self._update_status_bar()
|
||||||
|
|
||||||
|
async def _populate_navigation(self) -> None:
|
||||||
|
"""Populate navigation with current state."""
|
||||||
|
if not self.state_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
nav_list = self.query_one("#nav-list", ListView)
|
||||||
|
# Clear existing items except first 6
|
||||||
|
while len(nav_list.children) > 6:
|
||||||
|
nav_list.remove_child(nav_list.children[-1])
|
||||||
|
|
||||||
|
# Add guilds
|
||||||
|
for guild in self.state_manager.list_guilds():
|
||||||
|
nav_list.append(ListItem(Label(f" 🏰 {guild.name}"), id=f"nav-guild-{guild.id}"))
|
||||||
|
|
||||||
|
async def _refresh_state_tree(self) -> None:
|
||||||
|
"""Refresh the state inspector tree."""
|
||||||
|
if not self.state_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
tree = self.query_one("#state-tree", Tree)
|
||||||
|
tree.clear()
|
||||||
|
|
||||||
|
# Guilds
|
||||||
|
guilds_node = tree.root.add("🏰 Guilds", expand=True)
|
||||||
|
for guild in self.state_manager.list_guilds():
|
||||||
|
guild_node = guilds_node.add(f"{guild.name} ({guild.id})")
|
||||||
|
guild_node.add(f"Channels: {len(guild.channels)}")
|
||||||
|
guild_node.add(f"Roles: {len(guild.roles)}")
|
||||||
|
|
||||||
|
# Channels
|
||||||
|
channels_node = tree.root.add("💬 Channels", expand=True)
|
||||||
|
for channel in self.state_manager.channels.values():
|
||||||
|
ch_node = channels_node.add(f"#{channel.name} ({channel.id})")
|
||||||
|
ch_node.add(f"Type: {channel.type.name}")
|
||||||
|
ch_node.add(f"Guild: {channel.guild_id}")
|
||||||
|
ch_node.add(f"Messages: {len(channel.message_ids)}")
|
||||||
|
|
||||||
|
# Users
|
||||||
|
users_node = tree.root.add("👥 Users", expand=True)
|
||||||
|
for user in self.state_manager.users.values():
|
||||||
|
u_node = users_node.add(f"{user.username}#{user.discriminator} ({user.id})")
|
||||||
|
u_node.add(f"Bot: {user.bot}")
|
||||||
|
for guild_id, roles in user.guild_roles.items():
|
||||||
|
u_node.add(f"Guild {guild_id}: {len(roles)} roles")
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
stats = self.state_manager.get_state_summary()
|
||||||
|
stats_node = tree.root.add("📊 Statistics", expand=True)
|
||||||
|
for key, value in stats.items():
|
||||||
|
stats_node.add(f"{key}: {value}")
|
||||||
|
|
||||||
|
def _update_status_bar(self) -> None:
|
||||||
|
"""Update footer status bar."""
|
||||||
|
if self.gateway_server:
|
||||||
|
conn_count = len(self.gateway_server.connections)
|
||||||
|
self.sub_title = f"REST: {self.config.rest_port} | Gateway: {self.config.gateway_port} | Connections: {conn_count}"
|
||||||
|
|
||||||
|
# --- Actions ---
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
"""Quit the application."""
|
||||||
|
await self._shutdown()
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
async def action_command_focus(self) -> None:
|
||||||
|
"""Focus the command input."""
|
||||||
|
self.query_one("#command-input", Input).focus()
|
||||||
|
|
||||||
|
async def action_nav_next(self) -> None:
|
||||||
|
"""Navigate to next nav item."""
|
||||||
|
nav_list = self.query_one("#nav-list", ListView)
|
||||||
|
if nav_list.index is not None:
|
||||||
|
nav_list.index = min(nav_list.index + 1, len(nav_list.children) - 1)
|
||||||
|
|
||||||
|
async def action_nav_prev(self) -> None:
|
||||||
|
"""Navigate to previous nav item."""
|
||||||
|
nav_list = self.query_one("#nav-list", ListView)
|
||||||
|
if nav_list.index is not None:
|
||||||
|
nav_list.index = max(nav_list.index - 1, 0)
|
||||||
|
|
||||||
|
async def action_refresh(self) -> None:
|
||||||
|
"""Refresh state tree."""
|
||||||
|
await self._refresh_state_tree()
|
||||||
|
|
||||||
|
async def action_toggle_events(self) -> None:
|
||||||
|
"""Toggle events tab."""
|
||||||
|
tabs = self.query_one("#main-tabs", TabbedContent)
|
||||||
|
tabs.active = "tab-events" if tabs.active != "tab-events" else "tab-messages"
|
||||||
|
|
||||||
|
async def action_toggle_state(self) -> None:
|
||||||
|
"""Toggle state tab."""
|
||||||
|
tabs = self.query_one("#main-tabs", TabbedContent)
|
||||||
|
tabs.active = "tab-state" if tabs.active != "tab-state" else "tab-messages"
|
||||||
|
|
||||||
|
async def action_help(self) -> None:
|
||||||
|
"""Show help modal."""
|
||||||
|
self.notify("Help: Ctrl+C=Command, Ctrl+N/P=Nav, Ctrl+R=Refresh, Ctrl+E/S=Tabs, F1=Help", timeout=5)
|
||||||
|
|
||||||
|
# --- Event Handlers ---
|
||||||
|
|
||||||
|
@on(ListView.Selected, "#nav-list")
|
||||||
|
async def on_nav_selected(self, event: ListView.Selected) -> None:
|
||||||
|
"""Handle navigation selection."""
|
||||||
|
item_id = event.item.id
|
||||||
|
if item_id and item_id.startswith("nav-guild-"):
|
||||||
|
guild_id = item_id.replace("nav-guild-", "")
|
||||||
|
self.notify(f"Selected guild: {guild_id}")
|
||||||
|
|
||||||
|
@on(Input.Submitted, "#command-input")
|
||||||
|
async def on_command_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
"""Handle command input."""
|
||||||
|
command = event.value.strip()
|
||||||
|
if not command:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear input
|
||||||
|
event.input.value = ""
|
||||||
|
|
||||||
|
# Handle commands
|
||||||
|
if command.startswith("/"):
|
||||||
|
await self._handle_slash_command(command)
|
||||||
|
else:
|
||||||
|
# Try to parse as JSON
|
||||||
|
await self._handle_raw_json(command)
|
||||||
|
|
||||||
|
async def _handle_slash_command(self, command: str) -> None:
|
||||||
|
"""Handle slash command input."""
|
||||||
|
parts = command[1:].split()
|
||||||
|
cmd = parts[0] if parts else ""
|
||||||
|
args = parts[1:] if len(parts) > 1 else []
|
||||||
|
|
||||||
|
self.notify(f"Slash command: /{cmd} {' '.join(args)}")
|
||||||
|
|
||||||
|
# TODO: Implement actual command handling
|
||||||
|
# This will send INTERACTION_CREATE to the gateway
|
||||||
|
|
||||||
|
async def _handle_raw_json(self, text: str) -> None:
|
||||||
|
"""Handle raw JSON input."""
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
self.notify("JSON parsed successfully")
|
||||||
|
# Could send raw gateway event
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self.notify(f"Invalid JSON: {e}", severity="error")
|
||||||
|
|
||||||
|
async def _shutdown(self) -> None:
|
||||||
|
"""Shutdown servers and cleanup."""
|
||||||
|
if self.rest_server:
|
||||||
|
await self.rest_server.stop()
|
||||||
|
if self.gateway_server:
|
||||||
|
await self.gateway_server.stop()
|
||||||
|
|
||||||
|
if self.config.persist_state and self.state_manager:
|
||||||
|
await self.state_manager.save_state()
|
||||||
|
|
||||||
|
logger.info("APIcord TUI shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
def run_tui(config: Optional[EmulatorConfig] = None, headless: bool = False) -> None:
|
||||||
|
"""Run the TUI application."""
|
||||||
|
app = APIcordApp(config=config, headless=headless)
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_tui()
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
/* APIcord TUI Styles */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane {
|
||||||
|
width: 22;
|
||||||
|
background: $panel;
|
||||||
|
border-right: solid $primary;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $accent;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-item {
|
||||||
|
height: 3;
|
||||||
|
margin: 0 1;
|
||||||
|
padding: 0 1;
|
||||||
|
background: $surface;
|
||||||
|
border: solid $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-item:hover {
|
||||||
|
background: $primary 20%;
|
||||||
|
border: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-item:focus {
|
||||||
|
background: $primary 30%;
|
||||||
|
border: solid $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-item .nav-icon {
|
||||||
|
width: 3;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-item .nav-label {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-pane .nav-item .nav-badge {
|
||||||
|
width: 5;
|
||||||
|
text-align: right;
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-content {
|
||||||
|
width: 1fr;
|
||||||
|
background: $surface;
|
||||||
|
layout: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-area {
|
||||||
|
height: 1fr;
|
||||||
|
border: solid $primary;
|
||||||
|
margin: 1;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-list {
|
||||||
|
height: 1fr;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-entry {
|
||||||
|
height: auto;
|
||||||
|
min-height: 3;
|
||||||
|
margin: 0 0 1 0;
|
||||||
|
padding: 1;
|
||||||
|
background: $panel;
|
||||||
|
border: solid $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-entry:hover {
|
||||||
|
border: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
height: 1;
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-author {
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timestamp {
|
||||||
|
color: $text-muted;
|
||||||
|
text-align: right;
|
||||||
|
width: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin-top: 1;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-embed {
|
||||||
|
margin-top: 1;
|
||||||
|
padding: 1;
|
||||||
|
background: $surface;
|
||||||
|
border: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-title {
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-description {
|
||||||
|
margin: 1 0;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-fields {
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field {
|
||||||
|
width: 1fr;
|
||||||
|
margin: 0 1;
|
||||||
|
padding: 1;
|
||||||
|
background: $surface;
|
||||||
|
border: solid $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field.inline {
|
||||||
|
width: auto;
|
||||||
|
min-width: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-name {
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-value {
|
||||||
|
color: $text;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-footer {
|
||||||
|
margin-top: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 80%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-components {
|
||||||
|
margin-top: 1;
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-button {
|
||||||
|
min-width: 16;
|
||||||
|
height: 3;
|
||||||
|
margin: 0 1;
|
||||||
|
border: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-button.primary {
|
||||||
|
background: $primary;
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-button.secondary {
|
||||||
|
background: $secondary;
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-button.success {
|
||||||
|
background: $success;
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-button.danger {
|
||||||
|
background: $error;
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-button.link {
|
||||||
|
background: transparent;
|
||||||
|
color: $primary;
|
||||||
|
text-style: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-select {
|
||||||
|
min-width: 30;
|
||||||
|
height: 3;
|
||||||
|
margin: 0 1;
|
||||||
|
border: solid $primary;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inspector-pane {
|
||||||
|
width: 36;
|
||||||
|
background: $panel;
|
||||||
|
border-left: solid $primary;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inspector-pane .section-title {
|
||||||
|
text-style: bold underline;
|
||||||
|
color: $accent;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inspector-pane .state-tree {
|
||||||
|
height: 1fr;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node.expanded > .node-label {
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node .node-icon {
|
||||||
|
width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node .node-label {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node .node-value {
|
||||||
|
width: 12;
|
||||||
|
text-align: right;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#event-log {
|
||||||
|
height: 1fr;
|
||||||
|
border: solid $primary;
|
||||||
|
margin: 1;
|
||||||
|
padding: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-entry {
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
border-bottom: solid $background 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-entry:hover {
|
||||||
|
background: $primary 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
width: 10;
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-type {
|
||||||
|
width: 20;
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-detail {
|
||||||
|
width: 1fr;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-bar {
|
||||||
|
height: 3;
|
||||||
|
background: $panel;
|
||||||
|
border-top: solid $primary;
|
||||||
|
padding: 0 1;
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-bar .prompt {
|
||||||
|
width: 3;
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-input {
|
||||||
|
width: 1fr;
|
||||||
|
height: 3;
|
||||||
|
background: $surface;
|
||||||
|
border: solid $primary;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command-input:focus {
|
||||||
|
border: solid $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
height: 1;
|
||||||
|
background: $primary;
|
||||||
|
color: $surface;
|
||||||
|
padding: 0 1;
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer-hints {
|
||||||
|
height: 1;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbed-content {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
height: 1;
|
||||||
|
layout: horizontal;
|
||||||
|
background: $panel;
|
||||||
|
border-bottom: solid $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
min-width: 12;
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
border-bottom: solid $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
color: $text;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
height: 1fr;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal / Dialog */
|
||||||
|
.modal {
|
||||||
|
align: center middle;
|
||||||
|
width: 60;
|
||||||
|
height: 30;
|
||||||
|
background: $panel;
|
||||||
|
border: thick $accent;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $accent;
|
||||||
|
margin-bottom: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
height: 1fr;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons {
|
||||||
|
height: 3;
|
||||||
|
layout: horizontal;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button {
|
||||||
|
width: 1fr;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command Palette */
|
||||||
|
.command-palette {
|
||||||
|
align: center middle;
|
||||||
|
width: 80;
|
||||||
|
height: 40;
|
||||||
|
background: $panel;
|
||||||
|
border: thick $accent;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
width: 1fr;
|
||||||
|
height: 3;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-results {
|
||||||
|
height: 1fr;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item:hover {
|
||||||
|
background: $primary 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .cmd-name {
|
||||||
|
width: 24;
|
||||||
|
color: $accent;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .cmd-desc {
|
||||||
|
width: 1fr;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .cmd-shortcut {
|
||||||
|
width: 20;
|
||||||
|
text-align: right;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status indicators */
|
||||||
|
.status-connected {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connecting {
|
||||||
|
color: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
ScrollBar {
|
||||||
|
background: $surface;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollBar:hover {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""TUI Screens Package"""
|
||||||
Reference in new issue
Block a user