import copy from rich.markup import escape from rich.text import Text from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.widgets import DataTable, Static import site_themes from fields import FieldSpec from tui.content_pane import ContentPane from tui.dialogs import ConfirmDialog from tui.record_editor import RecordEditor SWATCH_KEYS = ["accent", "red", "yellow", "orange", "green", "cyan", "blue", "magenta"] THEME_FIELDS = [ FieldSpec("id", "Id", required=True), FieldSpec("name", "Name", required=True), FieldSpec("mode", "Mode", "select", options=site_themes.THEME_MODES), FieldSpec("wallpaper", "Wallpaper", "image"), FieldSpec("borderStart", "Active border start", "color"), FieldSpec("borderEnd", "Active border end", "color"), *[FieldSpec(f"colors.{key}", key.replace("_", " ").capitalize(), "color", required=True) for key in site_themes.COLOR_KEYS], ] def palette_swatches(theme: dict) -> Text: colors = theme.get("colors", {}) return Text.assemble(*[("██", colors[key]) for key in SWATCH_KEYS if key in colors]) def to_form_record(theme: dict) -> dict: record = copy.deepcopy(theme) border = record.pop("activeBorder", None) or [] if len(border) == 2: record["borderStart"], record["borderEnd"] = border return record def from_form_record(record: dict) -> dict: theme = copy.deepcopy(record) start, end = theme.pop("borderStart", None), theme.pop("borderEnd", None) if start and end: theme["activeBorder"] = [start, end] elif start or end: raise ValueError("Set both active border colors, or leave both empty") ordered_keys = ["id", "name", "mode", "wallpaper", "activeBorder", "colors"] return {key: theme[key] for key in ordered_keys if key in theme} def theme_details(theme: dict, is_default: bool) -> str: colors = theme.get("colors", {}) lines = [f"[b]{escape(theme.get('name', theme.get('id', '')))}[/b]{' [dim](default)[/dim]' if is_default else ''}", ""] lines.append(f"[dim]{'Id':<20}[/dim]{escape(theme.get('id', ''))}") lines.append(f"[dim]{'Mode':<20}[/dim]{escape(theme.get('mode', ''))}") if theme.get("wallpaper"): lines.append(f"[dim]{'Wallpaper':<20}[/dim]{escape(theme['wallpaper'])}") if theme.get("activeBorder"): start, end = theme["activeBorder"] lines.append(f"[dim]{'Active border':<20}[/dim][{start}]██[/] {start} [{end}]██[/] {end}") lines.append("") for key in site_themes.COLOR_KEYS: value = colors.get(key, "") swatch = f"[{value}]██████[/]" if value else " " lines.append(f"{swatch} [dim]{key.replace('_', ' '):<19}[/dim]{escape(value)}") return "\n".join(lines) class ThemesPane(ContentPane): BINDINGS = [ Binding("a", "add", "Duplicate"), Binding("e", "edit", "Edit"), Binding("d", "delete", "Delete"), Binding("s", "set_default", "Set default"), Binding("p", "preview", "Preview"), Binding("r", "reload", "Reload"), ] content_file = site_themes.THEMES_FILE def compose(self) -> ComposeResult: with Horizontal(classes="split-layout"): yield DataTable(cursor_type="row", zebra_stripes=True) with VerticalScroll(classes="detail-scroll"): yield Static("", id="theme-detail") def on_mount(self): table = self.table() table.add_column("", key="default") table.add_column("Id", key="id") table.add_column("Name", key="name") table.add_column("Mode", key="mode") table.add_column("Palette", key="palette") self.reload() def reload(self, focus_key: str | None = None): default_id = site_themes.default_theme_id() table = self.table() table.clear() for theme in site_themes.load_themes(): marker = "*" if theme.get("id") == default_id else "" table.add_row(marker, theme.get("id", ""), theme.get("name", ""), theme.get("mode", ""), palette_swatches(theme), key=theme.get("id")) self.restore_cursor(focus_key) self.show_details(self.selected_key()) def selected_theme(self) -> dict | None: return site_themes.find_theme(site_themes.load_themes(), self.selected_key() or "") def show_details(self, theme_id: str | None): theme = site_themes.find_theme(site_themes.load_themes(), theme_id or "") detail = self.query_one("#theme-detail", Static) detail.update(theme_details(theme, theme_id == site_themes.default_theme_id()) if theme else "") @on(DataTable.RowHighlighted) def highlight_changed(self, event: DataTable.RowHighlighted): self.show_details(event.row_key.value if event.row_key else None) @on(DataTable.RowSelected) def row_selected(self): self.action_edit() def open_editor(self, original_id: str | None, theme: dict, title: str): def saved(record: dict | None): if record is None: return try: updated = from_form_record(record) site_themes.save_theme(original_id, updated) except (OSError, ValueError) as error: self.notify(str(error), title="Not saved", severity="error") return self.app.reload_site_themes(updated["id"]) self.reload(updated["id"]) self.notify(f"Saved {updated['name']}") validate = lambda record: site_themes.check_theme(from_form_record(record)) editor = RecordEditor(title, THEME_FIELDS, to_form_record(theme), validate=validate, image_folder="wallpapers") self.app.push_screen(editor, saved) def action_edit(self): theme = self.selected_theme() if theme: self.open_editor(theme["id"], theme, f"Edit theme {theme.get('name', theme['id'])}") def action_add(self): template = self.selected_theme() or {"mode": "dark", "colors": {}} theme = copy.deepcopy(template) theme["id"] = f"{template.get('id', 'theme')}-copy" theme["name"] = f"{template.get('name', 'New theme')} Copy" self.open_editor(None, theme, "New theme") def action_delete(self): theme = self.selected_theme() if not theme: return def confirmed(should_delete: bool | None): if should_delete: self.run_now(lambda: site_themes.remove_theme(theme["id"]), f"Removed {theme.get('name', theme['id'])}") self.app.push_screen(ConfirmDialog(f"Remove the {theme.get('name', theme['id'])} theme?"), confirmed) def action_set_default(self): theme = self.selected_theme() if theme: self.run_now(lambda: site_themes.set_default_theme(theme["id"]), f"{theme.get('name', theme['id'])} is now the default theme", theme["id"]) def action_preview(self): theme = self.selected_theme() if theme: self.app.reload_site_themes(theme["id"]) self.notify(f"Previewing {theme.get('name', theme['id'])}")