From 2466acf837abfde19021462d8f40d831951530bc Mon Sep 17 00:00:00 2001 From: Eaven Kimura Date: Sun, 1 Nov 2026 03:09:40 +0000 Subject: [PATCH] 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. --- pyproject.toml | 4 + src/apicord.egg-info/PKG-INFO | 3 + src/apicord.egg-info/requires.txt | 4 + .../cli/__pycache__/commands.cpython-314.pyc | Bin 20225 -> 20559 bytes src/apicord/cli/commands.py | 13 +- src/apicord/models/__init__.py | 2 + .../__pycache__/__init__.cpython-314.pyc | Bin 762 -> 797 bytes src/apicord/tui/__init__.py | 1 + src/apicord/tui/app.py | 325 ++++++++++++ src/apicord/tui/css/app.tcss | 474 ++++++++++++++++++ src/apicord/tui/screens/__init__.py | 1 + 11 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/apicord/tui/__init__.py create mode 100644 src/apicord/tui/app.py create mode 100644 src/apicord/tui/css/app.tcss create mode 100644 src/apicord/tui/screens/__init__.py 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 f79433f58612a169f0c8f769a1e748c522605833..05f3945582296c8a7b7ad0402e52d8f22a634460 100644 GIT binary patch delta 3224 zcma)8YitzP6`s2@yE_YhfH8iq?e&A*9oxH$@f(aEb$D19hYrLR3h`>#9pg3Yo#oDK zfT)SxG)f>Of$Fwul!}NSEd(k2h&EDRQIblP8mXaeq*fu*v}t~nrczS5as-NsqMmbS z!KRIzjeAQ}= zTC4hdMfO*z&Rth2sP(K!^0R;>ulo5|scS}TPy=k5G>VOC6MIvvVgXX>UavMg-1RN$ zmPab52h^Zi#xA%U+#%2vUZu;|Tcaw#YpoR08?0Kj4d`}G`_%~0QBK#XF`&0{x?b%7 zdK>$eI3#weU0}1ew7Zw!Q3OHlQF{xP`ar55EKL=pxMyOIx}9B+n#CRJ0PbABvU4Z< zP-=7!!mgo$e?As+&xpft67+loR=xo%b>1eGGz-dT{)YP;i9c2niCDQ*EE35WNbG)0 z8P{@|#BAVX%9;%v(&>CEqoo56nK})OWh|Y}XbC+KKJ?I-qPS==I8BQH5_p2bDTETB zMffehz&`G2W*5Cj%8&450Z&l9svPrz2R=sRXsHNBZ4cG09L-E99@Z~6wA<2A!$>Q{ z_RJV`Ub9S8++@dMv&Ot0OBhL=#zqc|B@CL3u{pVQp$^vj;b-mzSQKsx9i;wW2+!rt zKYsS{B_v;!UoXE_!pUEW{PnKMUw?Rb+Ij0K{{DT2^1aKTOGtPS3y&M#a4u>2Qy?^= zxinFeG-4C;1{L^)ivhYD`q_n!lb8BI4G;4D)qV?p$0*9Hw%;E zq&(@GB$HC7TlGMJ$XV|ZnO)uOml}mhFT3R1>;^H#%YN=_uZM_uX)Y7TJVEdSpvjhJ zbuF3JO>-iD)AyE40_<{G3-Pnxl(mx4{H?On;)3F_MdRokl~BG^%cd}JQBV@qvRT>) zTpGb85p*j)x&av*5y}wYECMY@*o?r%N015tm_C5u3_QU=V3_)mAG-^cC~!cK1>w%3 zu(X%3*FrU${_b7(H@WQtx%9%LuekpxM?a8D*gq@kNqxS!ax3YpfISn6L~$>zLcn;@ zY5;}MT6j6d;F!X^Q-r5ju4)u?{H&^-kU;)s^<^&^W6w8s^#xG`6GOuY3PLLaCX==y zV2Y_10mDF}2r>3W<3ZBN_BTD;+XD>Sty5}HTb|KU=^WKfrG)N4!2yKr2s;sm5C##3 z*+)&GFcu__loQc0_%yLS0{5Opc&ML6o41fN`9sa0i3_8+2b1OavkR$j0@#v%DrG6& zRT<*yQ7Ju&0??k2{TEZwx|46x_n$9v`9keF>kbMQ*&O6XGP%)#?h zXQ$ZPp>9&oZiVy>D~IDYD%kOGUE|l~;vIH1+zGk(bNHG>uCsFyd*}$*qtgITwKedv zMQ}tBc|IvRV;9HciL_>#$yCCM$4#6bO&Aw=(>nI!Xdh_$OSBdI^2G*=P@gTOQ=ny< z9gK|zO_W1LG!4MF58vHBi);hnI(sA5;;F~aS#~q_EIG_hY#nbqjttypd#uyhROXoD zk$6nEC{~Z{J*m+Q@d zLQkM-lCmY!(zA4df5EiHC4Potpn^eYE9V(5bk`ILAiN_8`A<83;_CYza(D&u=DF&k zDdb>%uhlZQ*`K;Fo)`O~tgE{{lmmI#drzB*jTa0$DR>?=BHU)rbk7BUfS)e{tYzR! zHq^VBwfAf;7|Hf*=|&^;C4l?1zkpnXFX6W=1N;M~_{Wm`1^pp>u>D;VVfugB6bT#d zRSE`Q>VmA5^OoRyG%?NMdI%s$6#yaP=_o!`@O-%&51 zW(0Y~GK=)T-iOy0brLB4*YsN}$Q7)5dlZ^&?{*UgAA4iRfqg6E!v{KTPcD;AWsdVw z=7SLWj*ob5F#saGU7*rKFgElYEzk%5*sw4^O1bMskf#uFpe z5AZ`=MX>+^j%3?yS{jTx5715nIS2FTr?LjMXdd`25PkDH5HSCm@LtM)GBQg-JpS}Y zz+dYa53-Y^81u`cH4=G({c=yY2iun}vO9Z{3x|>X4#HCiPXfR!z(Mj=*O2WyJhFd$ z7v3#=Y^*x>S={n0!gC1c0F7>cWh%;u2AJx*S zWL!&FDI;T2hWdD;qu*u`z{;Qr6zV|cdmxbX7qGrN-!t}8(i6d**8ps9+BkL$ha$h3 zyjjhfO{b1JxAhs6!eVd=Sz#^vPAy;rY;VGtpVu-;sH80V!Y_8aC<~K6o-p9=giH(O9T%$|e|O>; z|C9b3Qu&RdjW>KjYiXU}^1e?ty+<~E?t*`b%c9^aUtSL@%cY+Cpx6zcu)1;S V0RnJvPn0O-2M7eELPQ#V07Csyks!BQsUQ`VbM9^& zC#_6Jns3fM_q^}D=gx;Ov4>w|m+=B`Io=WeQP{lUdzt>vHdiI_p~Z4P$QW^ z7*XqCzv?f(B`_w>D0OB%9AR6O2D1^qR%)S%RcPzY%?0lICUeW-nj<4-vv~_#)HZ7^ zXs30Rosw|SY(rkCMoMmQ>&$kfI|N;C8c25vy20#1daIxt&230`!x?2v*>3ir&$^1< zFr&aF$?P+C6g}-ksebfypeQ9GQ~JySxTv-$VKZ`*=Ac;~R^fBjpbeqsa52UbIH1ia zBbW_EzYAwZai+o7Y_b-~7|Z$fQ>-RN-ir}PrARCI<{yFR=mhlpNBpL5yN=R+c+B5D ztaIDV^3*;<^R!qp5xd{>O{7zNn{;nnf~MjUc&ckVF1Jwd7<|%k2djq;!`^T`0b!>a zerq&r`JsBBhfj?iSnrC?H&u2C&Ubs`2Pld|2!`yb$I|Jfr=T}O5sSv}qwvb2(OA-Q zoOmMUMxzdHT&bCoC(SzeWP1cnRrGXXU_(8lrDV`klL@qP2p;X32&GAm4Dne6p+0_1 zJx1&~giCO>r&X_~VGL?|pJsQ#E4@=)GsGY@Pj^pb5~%DY8g#1H`UV`J1DmWajE_!)6+}2NC_^%Ld zF#j}h38;)hbsLK6f_-^cVaCKxq8-vkZn|9JZ7Of5sooErllW50u)Nq@L zb@nvu61eSOMV<^#?mB_l$fK8UBWX`hXWdNJEyVZVeVFX56=VlAjtyd8I5_r!jIp)v z9$&oP%}P*N95!)wL`&mQ;#&2cSkaMa8TC#sNanQkv+(%j{Lz==REzx(H`T@;_cgp9RvnS|FTyD^u`lR=d| z0ROnXU#A1iAB1f`iZ9+p?c+-5sz9iH<3;`{8`czN1ft! z=vU`@t7$532EWk3%JH)}Kb)K0bB+y&?)L_cJzp|CJ4=ZmH1=dDy6w^N2<5%;%On!(;5jTL{N#18>oMxxP)z9YJWII-g3 zh#e}{O~l5 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 89f095e72fac18fe2003bbb15fb28be9d2060f76..ea29da11883886a12bd45d7cc6ea719d21dfbbdd 100644 GIT binary patch delta 155 zcmeyxI+u-Cn~#@^0SJWGKFgXlkynzjY@&KPpNB+=ggk>DLlC1sV~OO%89I#O6OT3s z3w!33q!uM6mt^MW1*H}j1eFf%eT f-el1H&LY9c^qm0*@ew5P$wY#isfoQv7N`vXlO!gH delta 123 zcmbQs_KTHQn~#@^0SK(FJk3g*$ScWMG*LaBOP)cGA&AkRu|#6x934ini6 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