"""Theme picker modal screen for Navi Code TUI."""
from __future__ import annotations
from textual import events
from textual.app import ComposeResult
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Input, Label, ListItem, ListView, Static
from clients.terminal.tui.themes import ThemeRegistry, set_active_theme
class ThemePickerScreen(ModalScreen[str | None]):
"""A palette-like screen for switching themes with live preview.
Dismisses with the selected theme name or None if cancelled.
"""
NAME = "ThemePickerScreen"
DEFAULT_CSS = """
ThemePickerScreen {
align: center middle;
}
ThemePickerScreen > Container {
width: 60;
height: auto;
max-height: 24;
border: thick $tui-primary;
background: $tui-surface;
padding: 0 0 1 0;
}
ThemePickerScreen .title {
text-style: bold;
color: $tui-primary;
background: $tui-panel;
padding: 1;
height: auto;
text-align: center;
}
ThemePickerScreen Input {
height: auto;
border: none;
border-bottom: solid $tui-border;
background: $tui-background;
color: $tui-text;
padding: 0 1;
margin: 0;
}
ThemePickerScreen ListView {
height: auto;
max-height: 16;
border: none;
background: $tui-surface;
padding: 0;
margin: 0;
}
ThemePickerScreen ListItem {
color: $tui-text;
background: transparent;
height: auto;
padding: 0 1;
}
ThemePickerScreen ListItem.--highlight {
background: $tui-selection;
color: $tui-background;
}
ThemePickerScreen .empty {
color: $tui-text-dim;
text-align: center;
padding: 1;
}
"""
BINDINGS = [
("escape", "dismiss_cancel", "Cancel"),
]
def __init__(self, current_theme: str) -> None:
super().__init__()
self._current_theme = current_theme
self._original_theme = current_theme
self._themes = ThemeRegistry.all()
self._filtered = list(self._themes)
self._list_items: list[ListItem] = []
# Debounced live preview: apply_theme re-renders every chat bubble, so
# applying it on every highlight while scrolling the theme list would
# stutter. Coalesce rapid highlight moves into one apply per ~100 ms.
self._preview_timer = None
self._pending_preview: str | None = None
def compose(self) -> ComposeResult:
with Container():
yield Static("Pick a theme (live preview)", classes="title")
yield Input(placeholder="Type to filter themes...", id="theme-input")
yield ListView(id="theme-list")
def on_mount(self) -> None:
self._render_list()
list_view = self.query_one("#theme-list", ListView)
list_view.focus()
def _render_list(self) -> None:
list_view = self.query_one("#theme-list", ListView)
list_view.clear()
self._list_items = []
if not self._filtered:
list_view.append(ListItem(Static("No matching themes", classes="empty")))
return
selected_index = 0
for index, name in enumerate(self._filtered):
label = Label(name)
if name == self._current_theme:
label.update(f"[b]* {name}[/b]")
selected_index = index
self._list_items.append(ListItem(label))
list_view.append(self._list_items[-1])
list_view.index = selected_index
def _preview_theme(self, name: str) -> None:
self._current_theme = name
app = self.app
app.theme = name
set_active_theme(name)
app.apply_theme()
self._render_list()
def _schedule_preview(self, name: str) -> None:
"""Remember the latest highlighted theme and apply it once per ~100 ms.
Re-scheduling the timer is deliberately avoided: a rapid burst of
highlights coalesces — the single pending fire picks up the freshest
``_pending_preview``. """
self._pending_preview = name
if self._preview_timer is None:
self._preview_timer = self.set_timer(0.1, self._apply_pending_preview)
def _apply_pending_preview(self) -> None:
self._preview_timer = None
name = self._pending_preview
self._pending_preview = None
if name is not None:
self._preview_theme(name)
def _cancel_pending_preview(self) -> None:
if self._preview_timer is not None:
self._preview_timer.stop()
self._preview_timer = None
self._pending_preview = None
def _restore_original(self) -> None:
# Cancel any pending preview first, otherwise it would fire after the
# restore and re-apply the highlighted (not original) theme.
self._cancel_pending_preview()
app = self.app
app.theme = self._original_theme
set_active_theme(self._original_theme)
app.apply_theme()
def _filter(self, query: str) -> None:
query = query.strip().lower()
if not query:
self._filtered = list(self._themes)
else:
self._filtered = [name for name in self._themes if query in name.lower()]
self._render_list()
def _select_highlighted(self) -> None:
# The caller (ThemesCommand callback) applies the chosen theme itself;
# cancel a pending preview so it cannot fire after dismiss and override
# the selection (or restore) with whatever was highlighted mid-scroll.
self._cancel_pending_preview()
list_view = self.query_one("#theme-list", ListView)
highlighted = list_view.index
if highlighted is not None and 0 <= highlighted < len(self._filtered):
self.dismiss(self._filtered[highlighted])
elif self._filtered:
self.dismiss(self._filtered[0])
def on_input_changed(self, event: Input.Changed) -> None:
self._filter(event.value)
def on_input_submitted(self, event: Input.Submitted) -> None:
self._select_highlighted()
def on_list_view_selected(self, event: ListView.Selected) -> None:
index = self._list_items.index(event.item) if event.item in self._list_items else None
if index is not None:
self.dismiss(self._filtered[index])
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
index = self._list_items.index(event.item) if event.item in self._list_items else None
if index is not None:
self._schedule_preview(self._filtered[index])
def on_key(self, event: events.Key) -> None:
list_view = self.query_one("#theme-list", ListView)
if event.key == "down":
list_view.action_cursor_down()
event.stop()
event.prevent_default()
elif event.key == "up":
list_view.action_cursor_up()
event.stop()
event.prevent_default()
elif event.key in ("enter", "return"):
self._select_highlighted()
event.stop()
event.prevent_default()
elif event.key == "escape":
self._restore_original()
self.dismiss(None)
event.stop()
event.prevent_default()
def action_dismiss_cancel(self) -> None:
self._restore_original()
self.dismiss(None)