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",
|
||||
"mypy>=1.5.0",
|
||||
]
|
||||
tui = [
|
||||
"textual>=0.52.0",
|
||||
"textual-dev>=0.52.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
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: ruff>=0.1.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
|
||||
|
||||
# APIcord
|
||||
|
||||
@@ -12,3 +12,7 @@ pytest-cov>=4.1.0
|
||||
black>=23.0.0
|
||||
ruff>=0.1.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,
|
||||
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
|
||||
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."""
|
||||
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():
|
||||
# Initialize state manager
|
||||
state_manager = StateManager(config)
|
||||
|
||||
@@ -12,6 +12,7 @@ from apicord.models.discord import (
|
||||
Interaction,
|
||||
InteractionType,
|
||||
InteractionCallbackType,
|
||||
InteractionResponse,
|
||||
ComponentType,
|
||||
ButtonStyle,
|
||||
Snowflake,
|
||||
@@ -37,6 +38,7 @@ __all__ = [
|
||||
"Interaction",
|
||||
"InteractionType",
|
||||
"InteractionCallbackType",
|
||||
"InteractionResponse",
|
||||
"ComponentType",
|
||||
"ButtonStyle",
|
||||
"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