diff --git a/after-render/screenshots/all/catppuccin-latte.png b/after-render/screenshots/all/catppuccin-latte.png new file mode 100644 index 00000000..ffb231c2 Binary files /dev/null and b/after-render/screenshots/all/catppuccin-latte.png differ diff --git a/after-render/screenshots/all/catppuccin-mocha.png b/after-render/screenshots/all/catppuccin-mocha.png new file mode 100644 index 00000000..67742320 Binary files /dev/null and b/after-render/screenshots/all/catppuccin-mocha.png differ diff --git a/after-render/screenshots/all/dracula.png b/after-render/screenshots/all/dracula.png new file mode 100644 index 00000000..4ae973f4 Binary files /dev/null and b/after-render/screenshots/all/dracula.png differ diff --git a/after-render/screenshots/all/everforest.png b/after-render/screenshots/all/everforest.png new file mode 100644 index 00000000..44f227ba Binary files /dev/null and b/after-render/screenshots/all/everforest.png differ diff --git a/after-render/screenshots/all/flexoki.png b/after-render/screenshots/all/flexoki.png new file mode 100644 index 00000000..4451e329 Binary files /dev/null and b/after-render/screenshots/all/flexoki.png differ diff --git a/after-render/screenshots/all/gruvbox.png b/after-render/screenshots/all/gruvbox.png new file mode 100644 index 00000000..1e3ead7d Binary files /dev/null and b/after-render/screenshots/all/gruvbox.png differ diff --git a/after-render/screenshots/all/hackerman.png b/after-render/screenshots/all/hackerman.png new file mode 100644 index 00000000..6e083daa Binary files /dev/null and b/after-render/screenshots/all/hackerman.png differ diff --git a/after-render/screenshots/all/kanagawa.png b/after-render/screenshots/all/kanagawa.png new file mode 100644 index 00000000..9269508a Binary files /dev/null and b/after-render/screenshots/all/kanagawa.png differ diff --git a/after-render/screenshots/all/matte-black.png b/after-render/screenshots/all/matte-black.png new file mode 100644 index 00000000..10ba2c1d Binary files /dev/null and b/after-render/screenshots/all/matte-black.png differ diff --git a/after-render/screenshots/all/monokai.png b/after-render/screenshots/all/monokai.png new file mode 100644 index 00000000..5d4bbd07 Binary files /dev/null and b/after-render/screenshots/all/monokai.png differ diff --git a/after-render/screenshots/all/nord.png b/after-render/screenshots/all/nord.png new file mode 100644 index 00000000..ec9a840d Binary files /dev/null and b/after-render/screenshots/all/nord.png differ diff --git a/after-render/screenshots/all/osaka-jade.png b/after-render/screenshots/all/osaka-jade.png new file mode 100644 index 00000000..769cb8d8 Binary files /dev/null and b/after-render/screenshots/all/osaka-jade.png differ diff --git a/after-render/screenshots/all/ristretto.png b/after-render/screenshots/all/ristretto.png new file mode 100644 index 00000000..e630c163 Binary files /dev/null and b/after-render/screenshots/all/ristretto.png differ diff --git a/after-render/screenshots/all/rose-pine-dawn.png b/after-render/screenshots/all/rose-pine-dawn.png new file mode 100644 index 00000000..592defb9 Binary files /dev/null and b/after-render/screenshots/all/rose-pine-dawn.png differ diff --git a/after-render/screenshots/all/rose-pine-moon.png b/after-render/screenshots/all/rose-pine-moon.png new file mode 100644 index 00000000..457e742b Binary files /dev/null and b/after-render/screenshots/all/rose-pine-moon.png differ diff --git a/after-render/screenshots/all/rose-pine.png b/after-render/screenshots/all/rose-pine.png new file mode 100644 index 00000000..f87b4857 Binary files /dev/null and b/after-render/screenshots/all/rose-pine.png differ diff --git a/after-render/screenshots/all/solarized-dark.png b/after-render/screenshots/all/solarized-dark.png new file mode 100644 index 00000000..3dcafe81 Binary files /dev/null and b/after-render/screenshots/all/solarized-dark.png differ diff --git a/after-render/screenshots/all/solarized-light.png b/after-render/screenshots/all/solarized-light.png new file mode 100644 index 00000000..ab57a822 Binary files /dev/null and b/after-render/screenshots/all/solarized-light.png differ diff --git a/after-render/screenshots/all/sqlit-light.png b/after-render/screenshots/all/sqlit-light.png new file mode 100644 index 00000000..1183f909 Binary files /dev/null and b/after-render/screenshots/all/sqlit-light.png differ diff --git a/after-render/screenshots/all/sqlit.png b/after-render/screenshots/all/sqlit.png new file mode 100644 index 00000000..4ac84944 Binary files /dev/null and b/after-render/screenshots/all/sqlit.png differ diff --git a/after-render/screenshots/all/textual-ansi.png b/after-render/screenshots/all/textual-ansi.png new file mode 100644 index 00000000..9da8ae5c Binary files /dev/null and b/after-render/screenshots/all/textual-ansi.png differ diff --git a/after-render/screenshots/all/textual-dark.png b/after-render/screenshots/all/textual-dark.png new file mode 100644 index 00000000..33f63eef Binary files /dev/null and b/after-render/screenshots/all/textual-dark.png differ diff --git a/after-render/screenshots/all/textual-light.png b/after-render/screenshots/all/textual-light.png new file mode 100644 index 00000000..e0d27ab7 Binary files /dev/null and b/after-render/screenshots/all/textual-light.png differ diff --git a/after-render/screenshots/all/tokyo-night.png b/after-render/screenshots/all/tokyo-night.png new file mode 100644 index 00000000..3b0a32a2 Binary files /dev/null and b/after-render/screenshots/all/tokyo-night.png differ diff --git a/settings.template.json b/settings.template.json index 69f819d3..38bb383e 100644 --- a/settings.template.json +++ b/settings.template.json @@ -1,6 +1,7 @@ { "_note": "Copy to .sqlit/settings.json (gitignored) and run: sqlit --settings .sqlit/settings.json", "theme": "tokyo-night", + "custom_themes": [], "expanded_nodes": [], "allow_plaintext_credentials": false, "mock": { diff --git a/sqlit/app.py b/sqlit/app.py index ef504a99..5d7de511 100644 --- a/sqlit/app.py +++ b/sqlit/app.py @@ -16,7 +16,6 @@ from textual.containers import Container, Horizontal, Vertical from textual.lazy import Lazy from textual.screen import ModalScreen -from textual.theme import Theme from textual.timer import Timer from textual.widgets import Static, TextArea, Tree from textual.worker import Worker @@ -24,18 +23,12 @@ from .config import ( ConnectionConfig, load_connections, - load_settings, - save_settings, ) from .db import DatabaseAdapter from .mock_settings import apply_mock_environment, build_mock_profile_from_settings from .mocks import MockProfile -from .omarchy import ( - DEFAULT_THEME, - get_current_theme_name, - get_matching_textual_theme, - is_omarchy_installed, -) +from .theme_manager import ThemeManager +from .omarchy import DEFAULT_THEME from .state_machine import ( UIStateMachine, get_leader_bindings, @@ -75,31 +68,6 @@ class SSMSTUI( TITLE = "sqlit" - _SQLIT_THEMES = [ - Theme( - name="sqlit", - primary="#97CB93", - secondary="#6D8DC4", - accent="#6D8DC4", - warning="#f59e0b", - error="#BE728C", - success="#4ADE80", - foreground="#a9b1d6", - background="#1A1B26", - surface="#24283B", - panel="#414868", - dark=True, - variables={ - "border": "#7a7f99", - "border-blurred": "#7a7f99", - "footer-background": "#24283B", - "footer-key-foreground": "#7FA1DE", - "button-color-foreground": "#1A1B26", - "input-selection-background": "#2a3144 40%", - }, - ), - ] - CSS = """ Screen { background: $surface; @@ -374,6 +342,7 @@ def __init__( self._query_worker: Worker[Any] | None = None self._query_executing: bool = False self._cancellable_query: Any | None = None + self._theme_manager = ThemeManager(self) self._spinner_index: int = 0 self._spinner_timer: Timer | None = None # Schema indexing state @@ -387,8 +356,6 @@ def __init__( self._session_factory: Any | None = None self._last_query_table: dict | None = None # Omarchy theme sync state - self._omarchy_theme_watcher: Timer | None = None - self._omarchy_last_theme_name: str | None = None if mock_profile: self._session_factory = self._create_mock_session_factory(mock_profile) @@ -564,15 +531,12 @@ def on_mount(self) -> None: self._startup_stamp("on_mount_start") self._restart_argv = self._compute_restart_argv() - for theme in self._SQLIT_THEMES: - self.register_theme(theme) + self._theme_manager.register_builtin_themes() + self._theme_manager.register_textarea_themes() - settings = load_settings() + settings = self._theme_manager.initialize() self._startup_stamp("settings_loaded") - # Initialize Omarchy theme sync - self._init_omarchy_theme(settings) - self._expanded_paths = set(settings.get("expanded_nodes", [])) self._startup_stamp("settings_applied") @@ -755,31 +719,19 @@ def _maybe_restore_connection_screen(self) -> None: def watch_theme(self, old_theme: str, new_theme: str) -> None: """Save theme whenever it changes.""" - settings = load_settings() - settings["theme"] = new_theme - save_settings(settings) + self._theme_manager.on_theme_changed(new_theme) - def _init_omarchy_theme(self, settings: dict) -> None: - """Initialize theme on startup, with Omarchy matching if installed. + def get_custom_theme_names(self) -> set[str]: + return self._theme_manager.get_custom_theme_names() - Strategy: - 1. If Omarchy is installed, try to match the Omarchy theme to a Textual theme - 2. If a match is found, use it and start watching for changes - 3. If no match or Omarchy not installed, use saved theme or default - """ - saved_theme = settings.get("theme") + def add_custom_theme(self, theme_name: str) -> str: + return self._theme_manager.add_custom_theme(theme_name) - # Check if Omarchy is installed - if not is_omarchy_installed(): - # No Omarchy, use saved theme or default - self._apply_theme_safe(saved_theme or DEFAULT_THEME) - return + def open_custom_theme_in_editor(self, theme_name: str) -> None: + self._theme_manager.open_custom_theme_in_editor(theme_name) - # Omarchy is installed - match theme and start watcher - matched_theme = get_matching_textual_theme(self.available_themes) - self._omarchy_last_theme_name = get_current_theme_name() - self._apply_theme_safe(matched_theme) - self._start_omarchy_watcher() + def get_custom_theme_path(self, theme_name: str) -> Path: + return self._theme_manager.get_custom_theme_path(theme_name) def _apply_theme_safe(self, theme_name: str) -> None: """Apply a theme with fallback to default on error.""" @@ -790,33 +742,3 @@ def _apply_theme_safe(self, theme_name: str) -> None: self.theme = DEFAULT_THEME except Exception: self.theme = "sqlit" - - def _start_omarchy_watcher(self) -> None: - """Start watching for Omarchy theme changes.""" - if self._omarchy_theme_watcher is not None: - return # Already watching - - # Check for theme changes every 2 seconds - self._omarchy_theme_watcher = self.set_interval(2.0, self._check_omarchy_theme_change) - - def _stop_omarchy_watcher(self) -> None: - """Stop watching for Omarchy theme changes.""" - if self._omarchy_theme_watcher is not None: - self._omarchy_theme_watcher.stop() - self._omarchy_theme_watcher = None - - def _check_omarchy_theme_change(self) -> None: - """Check if the Omarchy theme has changed and apply if so.""" - current_name = get_current_theme_name() - if current_name is None: - return - - # Check if theme name changed - if current_name != self._omarchy_last_theme_name: - self._omarchy_last_theme_name = current_name - self._apply_omarchy_theme() - - def _apply_omarchy_theme(self) -> None: - """Match and apply the current Omarchy theme.""" - matched_theme = get_matching_textual_theme(self.available_themes) - self._apply_theme_safe(matched_theme) diff --git a/sqlit/omarchy.py b/sqlit/omarchy.py index ad358cf3..a175ec00 100644 --- a/sqlit/omarchy.py +++ b/sqlit/omarchy.py @@ -27,6 +27,13 @@ "catppuccin": "catppuccin-mocha", # Flexoki light variant "flexoki-light": "flexoki", + # Handle space/underscore variations in omarchy theme names + "matte black": "matte-black", + "matte_black": "matte-black", + "osaka jade": "osaka-jade", + "osaka_jade": "osaka-jade", + # Ethereal doesn't have an exact match, use rose-pine as closest + "ethereal": "rose-pine", } diff --git a/sqlit/theme_manager.py b/sqlit/theme_manager.py new file mode 100644 index 00000000..eb90bcf7 --- /dev/null +++ b/sqlit/theme_manager.py @@ -0,0 +1,770 @@ +"""Theme management utilities for sqlit.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys +from contextlib import AbstractContextManager +from pathlib import Path +from typing import Any, Protocol + +from rich.style import Style +from textual.theme import Theme +from textual.timer import Timer +from textual.widgets.text_area import TextAreaTheme + +from .config import load_settings, save_settings +from .omarchy import ( + DEFAULT_THEME, + get_current_theme_name, + get_matching_textual_theme, + is_omarchy_installed, +) + +CUSTOM_THEME_SETTINGS_KEY = "custom_themes" +CUSTOM_THEME_DIR = Path.home() / ".slit" / "themes" +CUSTOM_THEME_FIELDS = { + "name", + "primary", + "secondary", + "warning", + "error", + "success", + "accent", + "foreground", + "background", + "surface", + "panel", + "boost", + "dark", + "luminosity_spread", + "text_alpha", + "variables", +} + +LIGHT_THEME_NAMES = { + "sqlit-light", + "textual-light", + "solarized-light", + "catppuccin-latte", + "rose-pine-dawn", +} + +SQLIT_THEMES = [ + Theme( + name="sqlit", + primary="#97CB93", + secondary="#6D8DC4", + accent="#6D8DC4", + warning="#f59e0b", + error="#BE728C", + success="#4ADE80", + foreground="#a9b1d6", + background="#1A1B26", + surface="#24283B", + panel="#414868", + dark=True, + variables={ + "border": "#414868", + "footer-background": "#24283B", + "footer-key-foreground": "#7FA1DE", + "button-color-foreground": "#1A1B26", + "input-selection-background": "#2a3144 40%", + }, + ), + Theme( + name="sqlit-light", + primary="#2E7D32", + secondary="#1565C0", + accent="#1565C0", + warning="#F57C00", + error="#C62828", + success="#2E7D32", + foreground="#37474F", + background="#FAFAFA", + surface="#FFFFFF", + panel="#ECEFF1", + dark=False, + variables={ + "border": "#B0BEC5", + "footer-background": "#ECEFF1", + "footer-key-foreground": "#1565C0", + "button-color-foreground": "#FFFFFF", + "input-selection-background": "#1565C0 25%", + }, + ), + Theme( + name="hackerman", + primary="#00FF21", + secondary="#149414", + accent="#00FF21", + warning="#FFFF33", + error="#FF0000", + success="#00FF21", + foreground="#00FF21", + background="#0D0D0D", + surface="#171B1F", + panel="#1E2428", + dark=True, + variables={ + "border": "#0e6b0e", + "footer-background": "#0D0D0D", + "footer-key-foreground": "#00FF21", + "button-color-foreground": "#0D0D0D", + "input-selection-background": "#149414 40%", + }, + ), + Theme( + name="everforest", + primary="#A7C080", + secondary="#83C092", + accent="#7FBBB3", + warning="#DBBC7F", + error="#E67E80", + success="#A7C080", + foreground="#D3C6AA", + background="#232A2E", + surface="#2D353B", + panel="#3D484D", + dark=True, + variables={ + "border": "#3D484D", + "footer-background": "#2D353B", + "footer-key-foreground": "#A7C080", + "button-color-foreground": "#232A2E", + "input-selection-background": "#3D484D 40%", + }, + ), + Theme( + name="kanagawa", + primary="#7E9CD8", + secondary="#7FB4CA", + accent="#D27E99", + warning="#FFA066", + error="#E46876", + success="#98BB6C", + foreground="#DCD7BA", + background="#1F1F28", + surface="#16161D", + panel="#223249", + dark=True, + variables={ + "border": "#2D4F67", + "footer-background": "#16161D", + "footer-key-foreground": "#7E9CD8", + "button-color-foreground": "#1F1F28", + "input-selection-background": "#2D4F67 40%", + }, + ), + Theme( + name="matte-black", + primary="#FFFFFF", + secondary="#888888", + accent="#FFFFFF", + warning="#FFA500", + error="#FF4444", + success="#44FF44", + foreground="#CCCCCC", + background="#000000", + surface="#0A0A0A", + panel="#1A1A1A", + dark=True, + variables={ + "border": "#333333", + "footer-background": "#0A0A0A", + "footer-key-foreground": "#FFFFFF", + "button-color-foreground": "#000000", + "input-selection-background": "#333333 40%", + }, + ), + Theme( + name="ristretto", + primary="#F5D1C8", + secondary="#C9A9A6", + accent="#F5D1C8", + warning="#EBD9B4", + error="#E67E7E", + success="#A8C4A0", + foreground="#D5C4A1", + background="#1C1410", + surface="#2D2420", + panel="#3D322C", + dark=True, + variables={ + "border": "#4D3C36", + "footer-background": "#2D2420", + "footer-key-foreground": "#F5D1C8", + "button-color-foreground": "#1C1410", + "input-selection-background": "#4D3C36 40%", + }, + ), + Theme( + name="osaka-jade", + primary="#7DCFB6", + secondary="#5BA492", + accent="#7DCFB6", + warning="#E0C380", + error="#D87A7A", + success="#7DCFB6", + foreground="#B9D7D0", + background="#0D1512", + surface="#1A2420", + panel="#2D4038", + dark=True, + variables={ + "border": "#2D4038", + "footer-background": "#1A2420", + "footer-key-foreground": "#7DCFB6", + "button-color-foreground": "#0D1512", + "input-selection-background": "#253530 40%", + }, + ), + # Terminal Default - uses ANSI colors from terminal + Theme( + name="textual-ansi", + primary="#FFFFFF", + secondary="ansi_blue", + accent="ansi_blue", + warning="ansi_yellow", + error="ansi_red", + success="ansi_green", + foreground="ansi_default", + background="ansi_default", + surface="ansi_default", + panel="ansi_default", + dark=True, + variables={ + "border": "#555555", + }, + ), + # Built-in theme overrides (only adding border variable for contrast) + Theme( + name="dracula", + primary="#BD93F9", + secondary="#FF79C6", + accent="#BD93F9", + warning="#F1FA8C", + error="#FF5555", + success="#50FA7B", + foreground="#F8F8F2", + background="#282A36", + surface="#2B2E3B", + panel="#313442", + dark=True, + variables={ + "border": "#44475A", + "button-color-foreground": "#282A36", + }, + ), + Theme( + name="flexoki", + primary="#205EA6", + secondary="#DA702C", + accent="#205EA6", + warning="#D0A215", + error="#D14D41", + success="#879A39", + foreground="#CECDC3", + background="#100F0F", + surface="#1C1B1A", + panel="#282726", + dark=True, + variables={ + "border": "#282726", + "input-cursor-foreground": "#5E409D", + "input-cursor-background": "#FFFCF0", + "input-selection-background": "#6F6E69 35%", + "button-color-foreground": "#FFFCF0", + }, + ), + Theme( + name="monokai", + primary="#AE81FF", + secondary="#66D9EF", + accent="#AE81FF", + warning="#E6DB74", + error="#F92672", + success="#A6E22E", + foreground="#F8F8F2", + background="#272822", + surface="#2e2e2e", + panel="#3E3D32", + dark=True, + variables={ + "border": "#3E3D32", + "foreground-muted": "#797979", + "input-selection-background": "#575b6190", + "button-color-foreground": "#272822", + }, + ), + Theme( + name="solarized-dark", + primary="#268bd2", + secondary="#2AA198", + accent="#268bd2", + warning="#B58900", + error="#DC322F", + success="#859900", + foreground="#839496", + background="#002b36", + surface="#073642", + panel="#073642", + dark=True, + variables={ + "border": "#586e75", + "border-blurred": "#2f4a52", + "button-color-foreground": "#fdf6e3", + "footer-background": "#268bd2", + "footer-key-foreground": "#fdf6e3", + "footer-description-foreground": "#fdf6e3", + "input-selection-background": "#073642", + }, + ), + Theme( + name="tokyo-night", + primary="#BB9AF7", + secondary="#7AA2F7", + accent="#BB9AF7", + warning="#E0AF68", + error="#F7768E", + success="#9ECE6A", + foreground="#C0CAF5", + background="#1A1B26", + surface="#24283B", + panel="#414868", + dark=True, + variables={ + "border": "#414868", + "button-color-foreground": "#24283B", + }, + ), + Theme( + name="gruvbox", + primary="#85A598", + secondary="#FABD2F", + accent="#85A598", + warning="#FE8019", + error="#FB4934", + success="#B8BB26", + foreground="#EBDBB2", + background="#282828", + surface="#3c3836", + panel="#504945", + dark=True, + variables={ + "border": "#504945", + "block-cursor-foreground": "#fbf1c7", + "input-selection-background": "#689d6a40", + "button-color-foreground": "#282828", + }, + ), + Theme( + name="nord", + primary="#88C0D0", + secondary="#81A1C1", + accent="#88C0D0", + warning="#EBCB8B", + error="#BF616A", + success="#A3BE8C", + foreground="#ECEFF4", + background="#2E3440", + surface="#3B4252", + panel="#434C5E", + dark=True, + variables={ + "border": "#434C5E", + "block-cursor-background": "#88C0D0", + "block-cursor-foreground": "#2E3440", + "block-cursor-text-style": "none", + "footer-key-foreground": "#88C0D0", + "input-selection-background": "#81a1c1 35%", + "button-color-foreground": "#2E3440", + "button-focus-text-style": "reverse", + }, + ), + Theme( + name="textual-dark", + primary="#0178D4", + secondary="#004578", + accent="#0178D4", + warning="#ffa62b", + error="#ba3c5b", + success="#4EBF71", + foreground="#e0e0e0", + background="#121212", + surface="#1e1e1e", + panel="#2d2d2d", + dark=True, + variables={ + "border": "#2d2d2d", + }, + ), +] + +SQLIT_TEXTAREA_THEMES: dict[str, TextAreaTheme] = { + "sqlit-light": TextAreaTheme( + name="sqlit-light", + syntax_styles={ + "keyword": Style(color="#D73A49", bold=True), + "keyword.operator": Style(color="#D73A49"), + "string": Style(color="#032F62"), + "string.special": Style(color="#032F62"), + "comment": Style(color="#6A737D", italic=True), + "number": Style(color="#005CC5"), + "float": Style(color="#005CC5"), + "operator": Style(color="#D73A49"), + "punctuation": Style(color="#24292E"), + "punctuation.bracket": Style(color="#24292E"), + "punctuation.delimiter": Style(color="#24292E"), + "function": Style(color="#6F42C1"), + "function.call": Style(color="#6F42C1"), + "type": Style(color="#005CC5"), + "variable": Style(color="#24292E"), + "constant": Style(color="#005CC5"), + "identifier": Style(color="#24292E"), + }, + ), + "hackerman": TextAreaTheme( + name="hackerman", + syntax_styles={ + "keyword": Style(color="#00FF21", bold=True), + "keyword.operator": Style(color="#00FF21"), + "string": Style(color="#7CFC00"), + "string.special": Style(color="#7CFC00"), + "comment": Style(color="#228B22", italic=True), + "number": Style(color="#00FA9A"), + "float": Style(color="#00FA9A"), + "operator": Style(color="#32CD32"), + "punctuation": Style(color="#149414"), + "punctuation.bracket": Style(color="#149414"), + "punctuation.delimiter": Style(color="#149414"), + "function": Style(color="#39FF14"), + "function.call": Style(color="#39FF14"), + "type": Style(color="#00FF7F"), + "variable": Style(color="#90EE90"), + "constant": Style(color="#00FA9A"), + "identifier": Style(color="#00FF21"), + }, + ), +} + + +class ThemeAppProtocol(Protocol): + theme: str + available_themes: set[str] + + @property + def query_input(self) -> Any: ... + + def register_theme(self, theme: Theme) -> None: ... + + def _apply_theme_safe(self, theme_name: str) -> None: ... + + def set_interval( + self, + interval: float, + callback: Any, + *, + name: str | None = None, + repeat: int = 0, + pause: bool = False, + ) -> Any: ... + + def notify( + self, + message: str, + *, + title: str = "", + severity: str = "information", + timeout: float | None = None, + markup: bool = True, + ) -> None: ... + + def suspend(self) -> AbstractContextManager[None]: ... + + +class ThemeManager: + """Centralized theme handling for the app.""" + + def __init__(self, app: ThemeAppProtocol) -> None: + self._app = app + self._custom_theme_names: set[str] = set() + self._custom_theme_paths: dict[str, Path] = {} + self._light_theme_names: set[str] = set(LIGHT_THEME_NAMES) + self._omarchy_theme_watcher: Timer | None = None + self._omarchy_last_theme_name: str | None = None + + def register_builtin_themes(self) -> None: + for theme in SQLIT_THEMES: + self._app.register_theme(theme) + + def register_textarea_themes(self) -> None: + for textarea_theme in SQLIT_TEXTAREA_THEMES.values(): + self._app.query_input.register_theme(textarea_theme) + + def initialize(self) -> dict: + settings = load_settings() + self.load_custom_themes(settings) + self._init_omarchy_theme(settings) + self.apply_textarea_theme(self._app.theme) + return settings + + def on_theme_changed(self, new_theme: str) -> None: + settings = load_settings() + settings["theme"] = new_theme + save_settings(settings) + self.apply_textarea_theme(new_theme) + + def apply_omarchy_theme(self) -> None: + matched_theme = get_matching_textual_theme(self._app.available_themes) + self._app._apply_theme_safe(matched_theme) + + def on_omarchy_theme_change(self) -> None: + current_name = get_current_theme_name() + if current_name is None: + return + + if current_name != self._omarchy_last_theme_name: + self._omarchy_last_theme_name = current_name + self.apply_omarchy_theme() + + def apply_textarea_theme(self, theme_name: str) -> None: + try: + if theme_name in SQLIT_TEXTAREA_THEMES: + self._app.query_input.theme = theme_name + elif theme_name in self._light_theme_names: + self._app.query_input.theme = "sqlit-light" + else: + self._app.query_input.theme = "css" + except Exception: + pass + + def get_custom_theme_names(self) -> set[str]: + return set(self._custom_theme_names) + + def add_custom_theme(self, theme_name: str) -> str: + path, expected_name = self._resolve_custom_theme_entry(theme_name) + CUSTOM_THEME_DIR.mkdir(parents=True, exist_ok=True) + if not path.exists(): + self._write_custom_theme_template(path, expected_name or path.stem) + self._app.notify( + f"Created theme template: {path}", + title="Theme Template", + severity="information", + ) + path = path.resolve() + + theme_name = self._register_custom_theme_path(path, expected_name) + settings = load_settings() + theme_paths = settings.get(CUSTOM_THEME_SETTINGS_KEY, []) + if not isinstance(theme_paths, list): + theme_paths = [] + entry_value = theme_name if expected_name else str(path) + theme_paths = self._dedupe_custom_theme_entries(theme_paths, theme_name) + if entry_value not in theme_paths: + theme_paths.append(entry_value) + settings[CUSTOM_THEME_SETTINGS_KEY] = theme_paths + save_settings(settings) + return theme_name + + def open_custom_theme_in_editor(self, theme_name: str) -> None: + path = self.get_custom_theme_path(theme_name) + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") + if editor: + command = shlex.split(editor) + [str(path)] + try: + with self._app.suspend(): + subprocess.run(command, check=False) + except Exception as exc: + raise ValueError(f"Failed to open editor '{editor}': {exc}") from exc + self._reload_custom_theme(path, theme_name) + return + + if sys.platform.startswith("darwin"): + command = ["open", str(path)] + elif os.name == "nt": + command = ["cmd", "/c", "start", "", str(path)] + else: + command = ["xdg-open", str(path)] + + try: + subprocess.Popen(command) + except Exception as exc: + raise ValueError(f"Failed to open {path}: {exc}") from exc + self._app.notify( + "Theme file opened. Reselect the theme after saving to reload.", + title="Theme Edit", + severity="information", + ) + + def get_custom_theme_path(self, theme_name: str) -> Path: + path = self._custom_theme_paths.get(theme_name) + if path is None: + raise ValueError(f'"{theme_name}" is not a custom theme.') + return path + + def load_custom_themes(self, settings: dict) -> None: + theme_paths = settings.get(CUSTOM_THEME_SETTINGS_KEY, []) + if not isinstance(theme_paths, list): + return + for theme_path in theme_paths: + if not isinstance(theme_path, str) or not theme_path.strip(): + continue + try: + path, expected_name = self._resolve_custom_theme_entry(theme_path) + self._register_custom_theme_path(path, expected_name) + except Exception as exc: + print( + f"[sqlit] Failed to load custom theme {theme_path}: {exc}", + file=sys.stderr, + ) + + def _register_custom_theme_path(self, path: Path, expected_name: str | None = None) -> str: + path = path.expanduser() + if not path.exists(): + raise ValueError(f"Theme file not found: {path}") + theme = self._load_custom_theme(path, expected_name) + self._app.register_theme(theme) + self._custom_theme_names.add(theme.name) + self._custom_theme_paths[theme.name] = path.resolve() + if not theme.dark: + self._light_theme_names.add(theme.name) + return theme.name + + def _init_omarchy_theme(self, settings: dict) -> None: + saved_theme = settings.get("theme") + if not is_omarchy_installed(): + self._app._apply_theme_safe(saved_theme or DEFAULT_THEME) + return + + matched_theme = get_matching_textual_theme(self._app.available_themes) + self._omarchy_last_theme_name = get_current_theme_name() + if ( + isinstance(saved_theme, str) + and saved_theme in self._app.available_themes + and saved_theme != matched_theme + ): + self._app._apply_theme_safe(saved_theme) + return + + self._app._apply_theme_safe(matched_theme) + self._start_omarchy_watcher() + + def _start_omarchy_watcher(self) -> None: + if self._omarchy_theme_watcher is not None: + return + self._omarchy_theme_watcher = self._app.set_interval(2.0, self.on_omarchy_theme_change) + + def _stop_omarchy_watcher(self) -> None: + if self._omarchy_theme_watcher is not None: + self._omarchy_theme_watcher.stop() + self._omarchy_theme_watcher = None + + def _reload_custom_theme(self, path: Path, theme_name: str) -> None: + expected_name = theme_name if theme_name in self._custom_theme_names else None + theme = self._load_custom_theme(path, expected_name) + self._app.register_theme(theme) + self._custom_theme_names.add(theme.name) + self._custom_theme_paths[theme.name] = path.resolve() + if not theme.dark: + self._light_theme_names.add(theme.name) + elif theme.name in self._light_theme_names: + self._light_theme_names.remove(theme.name) + + if self._app.theme == theme.name: + self._app.theme = theme.name + else: + self.apply_textarea_theme(self._app.theme) + + def _load_custom_theme(self, path: Path, expected_name: str | None) -> Theme: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise ValueError(f"Failed to read theme JSON: {exc}") from exc + + if not isinstance(payload, dict): + raise ValueError("Theme file must contain a JSON object.") + + theme_data = payload.get("theme", payload) + if not isinstance(theme_data, dict): + raise ValueError('Theme file "theme" must be a JSON object.') + + theme_kwargs = {key: theme_data[key] for key in CUSTOM_THEME_FIELDS if key in theme_data} + name = theme_kwargs.get("name") + primary = theme_kwargs.get("primary") + + if not isinstance(name, str) or not name.strip(): + raise ValueError('Theme JSON must include a non-empty "name".') + if not isinstance(primary, str) or not primary.strip(): + raise ValueError('Theme JSON must include a non-empty "primary" color.') + + theme_kwargs["name"] = name.strip() + if "variables" in theme_kwargs and not isinstance(theme_kwargs["variables"], dict): + raise ValueError('Theme "variables" must be a JSON object.') + if expected_name and theme_kwargs["name"] != expected_name: + raise ValueError( + f'Theme name "{theme_kwargs["name"]}" does not match file name "{expected_name}".' + ) + + try: + return Theme(**theme_kwargs) + except Exception as exc: + raise ValueError(f"Failed to create theme: {exc}") from exc + + def _resolve_custom_theme_entry(self, theme_entry: str) -> tuple[Path, str | None]: + entry = theme_entry.strip() + if not entry: + raise ValueError("Theme name is required.") + + if entry.startswith(("~", "/")) or Path(entry).is_absolute(): + return Path(entry).expanduser(), None + + name = Path(entry).stem + file_name = f"{name}.json" + return CUSTOM_THEME_DIR / file_name, name + + def _write_custom_theme_template(self, path: Path, theme_name: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + template = { + "_note": "Customize colors in the theme object then reselect the theme.", + "theme": { + "name": theme_name, + "dark": True, + "primary": "#3b82f6", + "secondary": "#22c55e", + "accent": "#38bdf8", + "warning": "#f59e0b", + "error": "#ef4444", + "success": "#22c55e", + "foreground": "#e2e8f0", + "background": "#0f172a", + "surface": "#111827", + "panel": "#1f2937", + "variables": { + "border": "#334155", + "input-selection-background": "#3b82f6 25%", + }, + }, + } + path.write_text(json.dumps(template, indent=2) + "\n", encoding="utf-8") + + @staticmethod + def _dedupe_custom_theme_entries(entries: list, theme_name: str) -> list[str]: + cleaned: list[str] = [] + for entry in entries: + if not isinstance(entry, str): + continue + value = entry.strip() + if not value: + continue + entry_name = None + if not value.startswith(("~", "/")) and not Path(value).is_absolute(): + entry_name = Path(value).stem + if entry_name == theme_name: + continue + if value not in cleaned: + cleaned.append(value) + return cleaned diff --git a/sqlit/ui/protocols.py b/sqlit/ui/protocols.py index f7897250..08e569d1 100644 --- a/sqlit/ui/protocols.py +++ b/sqlit/ui/protocols.py @@ -154,6 +154,22 @@ def theme(self, value: str) -> None: """Set the theme.""" ... + def get_custom_theme_names(self) -> set[str]: + """Return custom theme names.""" + ... + + def add_custom_theme(self, theme_name: str) -> str: + """Add a custom theme by name.""" + ... + + def get_custom_theme_path(self, theme_name: str) -> Any: + """Get the custom theme path for a theme name.""" + ... + + def open_custom_theme_in_editor(self, theme_name: str) -> None: + """Open a custom theme in an external editor.""" + ... + # === SSMSTUI widget properties === @property diff --git a/sqlit/ui/screens/theme.py b/sqlit/ui/screens/theme.py index 2cfe0987..f8940a8b 100644 --- a/sqlit/ui/screens/theme.py +++ b/sqlit/ui/screens/theme.py @@ -4,39 +4,168 @@ from textual.app import ComposeResult from textual.binding import Binding +from textual.containers import Container from textual.screen import ModalScreen -from textual.widgets import OptionList +from textual.widgets import Input, OptionList, Static from textual.widgets.option_list import Option from ...widgets import Dialog -THEME_LABELS = { - "sqlit": "Sqlit", +# Light themes (listed first) +LIGHT_THEMES = { "sqlit-light": "Sqlit Light", - "textual-dark": "Textual Dark", "textual-light": "Textual Light", + "solarized-light": "Solarized Light", + "catppuccin-latte": "Catppuccin Latte", + "rose-pine-dawn": "Rose Pine Dawn", +} + +# Dark themes +DARK_THEMES = { + "textual-ansi": "Terminal Default", + "sqlit": "Sqlit", + "textual-dark": "Textual Dark", "nord": "Nord", "gruvbox": "Gruvbox", "tokyo-night": "Tokyo Night", - "solarized-light": "Solarized Light", "solarized-dark": "Solarized Dark", "monokai": "Monokai", "flexoki": "Flexoki", - "catppuccin-latte": "Catppuccin Latte", + "catppuccin-mocha": "Catppuccin Mocha", "rose-pine": "Rose Pine", "rose-pine-moon": "Rose Pine Moon", - "rose-pine-dawn": "Rose Pine Dawn", - "catppuccin-mocha": "Catppuccin Mocha", "dracula": "Dracula", + "hackerman": "Hackerman", + "everforest": "Everforest", + "kanagawa": "Kanagawa", + "matte-black": "Matte Black", + "ristretto": "Ristretto", + "osaka-jade": "Osaka Jade", } +# Combined for backwards compatibility and building the full list +THEME_LABELS = {**LIGHT_THEMES, **DARK_THEMES} + + +class CustomThemeScreen(ModalScreen[str | None]): + """Modal screen for adding a custom theme by name.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "submit", "Add", show=False), + ] + + CSS = """ + CustomThemeScreen { + align: center middle; + background: transparent; + } + + #custom-theme-dialog { + width: 60; + height: auto; + max-height: 14; + } + + #custom-theme-description { + margin-bottom: 1; + color: $text-muted; + height: auto; + } + + #custom-theme-container { + border: solid $panel; + background: $surface; + padding: 0; + margin-top: 0; + height: 3; + border-title-align: left; + border-title-color: $text-muted; + border-title-background: $surface; + border-title-style: none; + } + + #custom-theme-container.focused { + border: solid $primary; + border-title-color: $primary; + } + + #custom-theme-container Input { + border: none; + height: 1; + padding: 0; + background: $surface; + } + + #custom-theme-container Input:focus { + border: none; + background-tint: $foreground 5%; + } + """ + + def __init__(self, *, initial_name: str = ""): + super().__init__() + self.initial_name = initial_name + + def compose(self) -> ComposeResult: + shortcuts = [("Add", ""), ("Cancel", "")] + with Dialog(id="custom-theme-dialog", title="Add Theme", shortcuts=shortcuts): + yield Static( + "Enter theme name (template created in ~/.slit/themes/.json):", + id="custom-theme-description", + ) + container = Container(id="custom-theme-container") + container.border_title = "Theme Name" + with container: + yield Input( + value=self.initial_name, + placeholder="my-theme", + id="custom-theme-input", + ) + + def on_mount(self) -> None: + self.query_one("#custom-theme-input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "custom-theme-input": + value = event.value.strip() + self.dismiss(value or None) + + def on_descendant_focus(self, event) -> None: + try: + container = self.query_one("#custom-theme-container") + container.add_class("focused") + except Exception: + pass + + def on_descendant_blur(self, event) -> None: + try: + container = self.query_one("#custom-theme-container") + container.remove_class("focused") + except Exception: + pass + + def action_submit(self) -> None: + value = self.query_one("#custom-theme-input", Input).value.strip() + self.dismiss(value or None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def check_action(self, action: str, parameters: tuple) -> bool | None: + if self.app.screen is not self: + return False + return super().check_action(action, parameters) + class ThemeScreen(ModalScreen[str | None]): - """Modal screen for theme selection.""" + """Modal screen for theme selection with live preview.""" BINDINGS = [ Binding("escape", "cancel", "Cancel"), Binding("enter", "select_option", "Select"), + Binding("n", "new_theme", "New"), + Binding("e", "edit_theme", "Edit"), ] CSS = """ @@ -46,11 +175,12 @@ class ThemeScreen(ModalScreen[str | None]): } #theme-dialog { - width: 40; + width: 52; + overflow-y: hidden; } #theme-list { - height: auto; + height: 16; max-height: 16; border: none; } @@ -63,44 +193,164 @@ class ThemeScreen(ModalScreen[str | None]): def __init__(self, current_theme: str): super().__init__() self.current_theme = current_theme - self._theme_ids: list[str] = [] + self._original_theme = current_theme # Store for restore on cancel + + def _format_theme_label(self, theme_id: str) -> str: + return THEME_LABELS.get(theme_id) or " ".join(part.capitalize() for part in theme_id.split("-")) + + def _build_theme_list( + self, + ) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[tuple[str, str]]]: + """Build categorized theme lists. - def _build_theme_list(self) -> list[tuple[str, str]]: + Returns: + Tuple of (custom_themes, light_themes, dark_themes) where each is a list of (id, name) tuples. + """ available = set(self.app.available_themes) - available.discard("textual-ansi") - ordered: list[tuple[str, str]] = [] + custom: list[tuple[str, str]] = [] + light: list[tuple[str, str]] = [] + dark: list[tuple[str, str]] = [] seen: set[str] = set() - for theme_id, theme_name in THEME_LABELS.items(): + # Add custom themes first + try: + custom_ids = sorted(self.app.get_custom_theme_names()) + except Exception: + custom_ids = [] + for theme_id in custom_ids: + if theme_id in available: + custom.append((theme_id, self._format_theme_label(theme_id))) + seen.add(theme_id) + + # Add light themes first + for theme_id, theme_name in LIGHT_THEMES.items(): if theme_id in available: - ordered.append((theme_id, theme_name)) + light.append((theme_id, theme_name)) seen.add(theme_id) + # Add dark themes + for theme_id, theme_name in DARK_THEMES.items(): + if theme_id in available: + dark.append((theme_id, theme_name)) + seen.add(theme_id) + + # Add any unknown themes to dark section for theme_id in sorted(available - seen): - theme_name = " ".join(part.capitalize() for part in theme_id.split("-")) - ordered.append((theme_id, theme_name)) + dark.append((theme_id, self._format_theme_label(theme_id))) - return ordered + return custom, light, dark def compose(self) -> ComposeResult: - shortcuts = [("Select", ""), ("Cancel", "")] + shortcuts = [("New", "n"), ("Select", "")] with Dialog(id="theme-dialog", title="Select Theme", shortcuts=shortcuts): - options = [] - themes = self._build_theme_list() - self._theme_ids = [theme_id for theme_id, _ in themes] - for theme_id, theme_name in themes: - prefix = "> " if theme_id == self.current_theme else " " - options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + options: list[Option] = [] + custom_themes, light_themes, dark_themes = self._build_theme_list() + + # Add custom themes section + if custom_themes: + options.append(Option("─ Custom ─", disabled=True)) + for theme_id, theme_name in custom_themes: + prefix = "> " if theme_id == self.current_theme else " " + options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + + # Add light themes section + if light_themes: + options.append(Option("─ Light ─", disabled=True)) + for theme_id, theme_name in light_themes: + prefix = "> " if theme_id == self.current_theme else " " + options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + + # Add dark themes section + if dark_themes: + options.append(Option("─ Dark ─", disabled=True)) + for theme_id, theme_name in dark_themes: + prefix = "> " if theme_id == self.current_theme else " " + options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + yield OptionList(*options, id="theme-list") def on_mount(self) -> None: option_list = self.query_one("#theme-list", OptionList) option_list.focus() # Highlight current theme - for i, theme_id in enumerate(self._theme_ids): - if theme_id == self.current_theme: - option_list.highlighted = i - break + self._highlight_current_theme(option_list) + self._update_shortcuts() + + def _highlight_current_theme(self, option_list: OptionList) -> None: + try: + option_list.highlighted = option_list.get_option_index(self.current_theme) + except Exception: + pass + + def _rebuild_options(self) -> None: + option_list = self.query_one("#theme-list", OptionList) + options: list[Option] = [] + custom_themes, light_themes, dark_themes = self._build_theme_list() + + if custom_themes: + options.append(Option("─ Custom ─", disabled=True)) + for theme_id, theme_name in custom_themes: + prefix = "> " if theme_id == self.current_theme else " " + options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + + if light_themes: + options.append(Option("─ Light ─", disabled=True)) + for theme_id, theme_name in light_themes: + prefix = "> " if theme_id == self.current_theme else " " + options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + + if dark_themes: + options.append(Option("─ Dark ─", disabled=True)) + for theme_id, theme_name in dark_themes: + prefix = "> " if theme_id == self.current_theme else " " + options.append(Option(f"{prefix}{theme_name}", id=theme_id)) + + option_list.set_options(options) + self._highlight_current_theme(option_list) + self._update_shortcuts() + + def _update_shortcuts(self, theme_id: str | None = None) -> None: + dialog = self.query_one("#theme-dialog", Dialog) + if theme_id is None: + option_list = self.query_one("#theme-list", OptionList) + if option_list.highlighted is not None: + try: + option = option_list.get_option_at_index(option_list.highlighted) + theme_id = option.id + except Exception: + theme_id = None + + show_edit = False + if theme_id: + try: + show_edit = theme_id in self.app.get_custom_theme_names() + except Exception: + show_edit = False + + shortcuts = [("New", "n"), ("Select", "")] + if show_edit: + shortcuts.insert(1, ("Edit", "e")) + + def format_key(key: str) -> str: + if key.startswith("<") and key.endswith(">"): + return key + return f"<{key}>" + + dialog.border_subtitle = "\u00a0·\u00a0".join( + f"{action}: [bold]{format_key(key)}[/]" for action, key in shortcuts + ) + + def on_option_list_option_highlighted( + self, event: OptionList.OptionHighlighted + ) -> None: + """Apply theme live as user browses options.""" + theme_id = event.option.id + if theme_id and theme_id in self.app.available_themes: + try: + self.app.theme = theme_id + except Exception: + pass # Ignore errors during preview + self._update_shortcuts(theme_id) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: self.dismiss(event.option.id) @@ -111,5 +361,50 @@ def action_select_option(self) -> None: option = option_list.get_option_at_index(option_list.highlighted) self.dismiss(option.id) + def action_new_theme(self) -> None: + def on_theme_name_selected(name: str | None) -> None: + if not name: + return + try: + theme_name = self.app.add_custom_theme(name) + except Exception as exc: + from .error import ErrorScreen + + self.app.push_screen(ErrorScreen("Theme Load Failed", str(exc))) + return + + self.current_theme = theme_name + try: + self.app.theme = theme_name + except Exception: + pass + self._rebuild_options() + + self.app.push_screen(CustomThemeScreen(), on_theme_name_selected) + + def action_edit_theme(self) -> None: + option_list = self.query_one("#theme-list", OptionList) + if option_list.highlighted is None: + return + try: + option = option_list.get_option_at_index(option_list.highlighted) + except Exception: + return + theme_id = option.id + if not theme_id or option.disabled: + return + try: + self.app.open_custom_theme_in_editor(theme_id) + except Exception as exc: + from .error import ErrorScreen + + self.app.push_screen(ErrorScreen("Theme Edit Failed", str(exc))) + def action_cancel(self) -> None: + # Restore original theme on cancel + if self._original_theme in self.app.available_themes: + try: + self.app.theme = self._original_theme + except Exception: + pass self.dismiss(None) diff --git a/tests/ui/keybindings/test_contextual.py b/tests/ui/keybindings/test_contextual.py index 22c6bc92..f32838af 100644 --- a/tests/ui/keybindings/test_contextual.py +++ b/tests/ui/keybindings/test_contextual.py @@ -67,8 +67,8 @@ async def test_edit_connection_blocked_when_query_focused(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI() diff --git a/tests/ui/test_connect_action.py b/tests/ui/test_connect_action.py index 2508e1f2..62941f0c 100644 --- a/tests/ui/test_connect_action.py +++ b/tests/ui/test_connect_action.py @@ -28,8 +28,8 @@ async def test_connection_picker_select_highlights_in_tree(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI() @@ -61,8 +61,8 @@ async def test_connection_picker_fuzzy_search_selects_correct_connection(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI() @@ -130,8 +130,8 @@ def mock_detect(): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), patch( "sqlit.services.docker_detector.detect_database_containers", mock_detect, @@ -180,8 +180,8 @@ def mock_detect(): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), patch( "sqlit.services.docker_detector.detect_database_containers", mock_detect, @@ -243,8 +243,8 @@ def mock_detect(): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), patch( "sqlit.services.docker_detector.detect_database_containers", mock_detect, @@ -291,8 +291,8 @@ def mock_detect(): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), patch( "sqlit.services.docker_detector.detect_database_containers", mock_detect, diff --git a/tests/ui/test_query_history.py b/tests/ui/test_query_history.py index ee347505..ce0901ff 100644 --- a/tests/ui/test_query_history.py +++ b/tests/ui/test_query_history.py @@ -23,8 +23,8 @@ async def test_cursor_position_remembered_when_switching_queries(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI() @@ -73,8 +73,8 @@ async def test_cursor_position_at_end_for_new_query(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI() @@ -101,8 +101,8 @@ async def test_cursor_position_for_multiline_query(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI() @@ -137,8 +137,8 @@ async def test_cursor_cache_handles_same_query_text(self): with ( patch("sqlit.app.load_connections", mock_connections.load_all), - patch("sqlit.app.load_settings", mock_settings.load_all), - patch("sqlit.app.save_settings", mock_settings.save_all), + patch("sqlit.theme_manager.load_settings", mock_settings.load_all), + patch("sqlit.theme_manager.save_settings", mock_settings.save_all), ): app = SSMSTUI()