diff --git a/pyproject.toml b/pyproject.toml index d9fc1de..88846a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/apicord.egg-info/PKG-INFO b/src/apicord.egg-info/PKG-INFO index b14cfcf..d50f9a4 100644 --- a/src/apicord.egg-info/PKG-INFO +++ b/src/apicord.egg-info/PKG-INFO @@ -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 diff --git a/src/apicord.egg-info/requires.txt b/src/apicord.egg-info/requires.txt index 536fee4..6eb1956 100644 --- a/src/apicord.egg-info/requires.txt +++ b/src/apicord.egg-info/requires.txt @@ -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 diff --git a/src/apicord/cli/__pycache__/commands.cpython-314.pyc b/src/apicord/cli/__pycache__/commands.cpython-314.pyc index f79433f..05f3945 100644 Binary files a/src/apicord/cli/__pycache__/commands.cpython-314.pyc and b/src/apicord/cli/__pycache__/commands.cpython-314.pyc differ diff --git a/src/apicord/cli/commands.py b/src/apicord/cli/commands.py index 10fca9f..6e5438f 100644 --- a/src/apicord/cli/commands.py +++ b/src/apicord/cli/commands.py @@ -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) diff --git a/src/apicord/models/__init__.py b/src/apicord/models/__init__.py index 3ddb4ef..11cc15c 100644 --- a/src/apicord/models/__init__.py +++ b/src/apicord/models/__init__.py @@ -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", diff --git a/src/apicord/models/__pycache__/__init__.cpython-314.pyc b/src/apicord/models/__pycache__/__init__.cpython-314.pyc index 89f095e..ea29da1 100644 Binary files a/src/apicord/models/__pycache__/__init__.cpython-314.pyc and b/src/apicord/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/apicord/tui/__init__.py b/src/apicord/tui/__init__.py new file mode 100644 index 0000000..4243799 --- /dev/null +++ b/src/apicord/tui/__init__.py @@ -0,0 +1 @@ +"""TUI Package for APIcord""" \ No newline at end of file diff --git a/src/apicord/tui/app.py b/src/apicord/tui/app.py new file mode 100644 index 0000000..e561f29 --- /dev/null +++ b/src/apicord/tui/app.py @@ -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() \ No newline at end of file diff --git a/src/apicord/tui/css/app.tcss b/src/apicord/tui/css/app.tcss new file mode 100644 index 0000000..1e95a0a --- /dev/null +++ b/src/apicord/tui/css/app.tcss @@ -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; +} \ No newline at end of file diff --git a/src/apicord/tui/screens/__init__.py b/src/apicord/tui/screens/__init__.py new file mode 100644 index 0000000..613f136 --- /dev/null +++ b/src/apicord/tui/screens/__init__.py @@ -0,0 +1 @@ +"""TUI Screens Package""" \ No newline at end of file