diff --git a/PLAN.md b/PLAN.md index 4cb0c3b..d7d103b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,4 +1,4 @@ -# SQLiteView 書き込み対応計画 +# SQLiteView 開発計画 ## Context @@ -83,3 +83,75 @@ SQLiteView は INSERT/UPDATE/DELETE/CREATE/DROP/BEGIN/COMMIT/ROLLBACK 等の書 - `build_deb.sh`: `tomllib`(Python 3.11+)→ `grep`/`sed` に置換(Python 3.10互換性復元) - `_strip_sql_noise()`: ダブルクォート識別子の除去を追加(セキュリティ強化) - エラーパステスト6件追加(空クエリ、無効SQL、未接続操作、存在しないファイル、文字列リテラル内WHERE) + +--- + +## フェーズ2: UI/UX 改善(完了) + +### Context + +機能は十分だが、UIスタイリングが一切なくシステムネイティブテーマのまま。ダークモード非対応、SQLエディタのフォント未指定、ショートカットも最低限(Ctrl+O/Q のみ)。開発者ツールとしての使い勝手を底上げする。 + +### 変更対象ファイル + +**新規作成:** +- `src/sqliteviewer/theme.py` — Theme enum, QSS読み込み/適用/永続化 (~60行) +- `src/sqliteviewer/resources/light.qss` — ライトテーマ QSS (~180行) +- `src/sqliteviewer/resources/dark.qss` — ダークテーマ QSS (~180行) + +**変更:** +- `src/sqliteviewer/app.py` — 起動時テーマ適用 (+3行) +- `src/sqliteviewer/mainwindow.py` — View メニュー、テーマ切替、フォント、ショートカット (+~40行) +- `src/sqliteviewer/sql_highlighter.py` — テーマ連動カラースキーム追加 (+~25行) + +### Step 5: テーマ基盤 + ダークモード(完了) + +- [x] `theme.py` を作成 — `Theme` enum (LIGHT/DARK)、`load_theme()`/`apply_theme()`/`save_theme_preference()`/`load_theme_preference()` + > `StrEnum`(3.11+) → `str, Enum`(3.10+) に修正済み(`requires-python >= 3.10` 互換) +- [x] `light.qss` を作成 — GitHub風パレット (bg `#ffffff`, fg `#24292e`, accent `#0366d6`) + - 対象: QMainWindow, QTableView, QTabWidget, QListWidget, QPlainTextEdit, QTextEdit, QPushButton, QSplitter, QMenuBar, QMenu, QStatusBar, QScrollBar, QHeaderView + - ボタン hover/pressed 効果、タブ選択インジケーター、スリムスクロールバー、角丸4px +- [x] `dark.qss` を作成 — VS Code dark 風パレット (bg `#1e1e1e`, fg `#d4d4d4`, accent `#4fc1ff`) +- [x] `app.py` を変更 — `window.show()` 前に `apply_theme(load_theme_preference())` を呼ぶ +- [x] `mainwindow.py` に View メニュー追加 — "Toggle Dark Mode" (Ctrl+D) +- [x] `sql_highlighter.py` に `set_color_scheme()` 追加 — ライト (GitHub風) / ダーク (VS Code風) の2パレットを切替、`rehighlight()` で即反映 +- [x] `mainwindow.py` でハイライター参照を保持し、テーマ切替時に色同期 + > テーマ設定は `QSettings("SQLiteViewer", "App")` の `theme` キーへ保存し、起動時に `app.py` で先に適用する。 + +### Step 6: モノスペースフォント(完了) + +- [x] `QFontDatabase.systemFont(SystemFont.FixedFont)` でモノスペースフォント取得、11pt +- [x] `query_editor` と `schema_view` に適用 +- [x] フォント設定後に `setTabStopDistance` を再計算 + +### Step 7: キーボードショートカット(完了) + +- [x] `Ctrl+Enter` / `F5` — クエリ実行 (`QShortcut` で query_editor にバインド) +- [x] `Ctrl+R` — テーブルリフレッシュ (View メニューにアクション追加) +- [x] Run Query ボタンにツールチップ `"Execute SQL (Ctrl+Enter)"` 追加 + +### 設計判断 + +- **QSS方式** — QPaletteより柔軟 (hover/角丸/スクロールバー)、外部ライブラリ不要 +- **テーマ2種のみ** — Light/Dark。レジストリパターンは over-engineering +- **QFontDatabase.systemFont** — フォント名ハードコードより確実なクロスプラットフォーム対応 +- **QShortcut** — eventFilter やサブクラス化より軽量 + +### 検証方法 + +```bash +# 回帰テスト +uv run python -m pytest tests/ -v + +# 手動テスト +uv run python -m sqliteviewer +# → View > Toggle Dark Mode で切替確認 +# → SQLエディタがモノスペースフォントか確認 +# → Ctrl+Enter / F5 でクエリ実行確認 +# → Ctrl+R でテーブルリフレッシュ確認 +# → アプリ再起動後にテーマ維持されるか確認 +``` + +## 現状 + +フェーズ2まで実装済み。SQLiteView はライト/ダークテーマ切替、固定幅フォントの SQL エディタ/スキーマ表示、`Ctrl+Enter`・`F5`・`Ctrl+R` の操作に対応した。レビューで `StrEnum`(3.11+) の Python 3.10 非互換を検出し `str, Enum` に修正済み。 diff --git a/src/sqliteviewer/app.py b/src/sqliteviewer/app.py index 3a1fa09..a5ac2e4 100644 --- a/src/sqliteviewer/app.py +++ b/src/sqliteviewer/app.py @@ -9,6 +9,7 @@ from PyQt6.QtWidgets import QApplication from .mainwindow import MainWindow +from .theme import apply_theme, load_theme_preference def run(initial_path: Optional[str] = None) -> int: @@ -22,6 +23,7 @@ def run(initial_path: Optional[str] = None) -> int: app.setOrganizationName("SQLiteView") owns_app = True + apply_theme(load_theme_preference(), app) window = MainWindow() window.show() diff --git a/src/sqliteviewer/mainwindow.py b/src/sqliteviewer/mainwindow.py index 1fbe070..e474a61 100644 --- a/src/sqliteviewer/mainwindow.py +++ b/src/sqliteviewer/mainwindow.py @@ -7,7 +7,7 @@ from typing import Optional from PyQt6.QtCore import QSettings, Qt -from PyQt6.QtGui import QAction, QCloseEvent +from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QApplication, QFileDialog, @@ -32,6 +32,7 @@ from .database import DatabaseError, DatabaseService, QueryResult from .resources import load_icon from .sql_highlighter import SqlHighlighter +from .theme import SETTINGS_GROUP, Theme, apply_theme, load_theme_preference, save_theme_preference MAX_RECENT_FILES = 5 @@ -47,8 +48,9 @@ def __init__(self) -> None: self.setWindowIcon(load_icon()) self.database_service = DatabaseService() - self.settings = QSettings("SQLiteViewer", "App") + self.settings = QSettings(*SETTINGS_GROUP) self.query_result: Optional[QueryResult] = None + self.current_theme = load_theme_preference() self.table_list = QListWidget() self.table_list.itemSelectionChanged.connect(self._on_table_selected) @@ -64,8 +66,16 @@ def __init__(self) -> None: self.query_editor = QPlainTextEdit() self.query_editor.setPlaceholderText("Write a SQL statement…") - self.query_editor.setTabStopDistance(4 * self.query_editor.fontMetrics().horizontalAdvance(' ')) - SqlHighlighter(self.query_editor.document()) + self.highlighter = SqlHighlighter(self.query_editor.document()) + self.highlighter.set_color_scheme(self.current_theme) + + fixed_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + fixed_font.setPointSize(11) + self.query_editor.setFont(fixed_font) + self.schema_view.setFont(fixed_font) + tab_stop = 4 * self.query_editor.fontMetrics().horizontalAdvance(" ") + self.query_editor.setTabStopDistance(tab_stop) + self.schema_view.setTabStopDistance(tab_stop) self.query_result_view = QTableView() self.query_result_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) @@ -117,11 +127,12 @@ def _build_ui(self) -> None: query_layout.addWidget(self.query_editor) button_bar = QHBoxLayout() - run_button = QPushButton("Run Query") - run_button.clicked.connect(self._run_query) + self.run_button = QPushButton("Run Query") + self.run_button.setToolTip("Execute SQL (Ctrl+Enter)") + self.run_button.clicked.connect(self._run_query) export_button = QPushButton("Export Results") export_button.clicked.connect(self._export_results) - button_bar.addWidget(run_button) + button_bar.addWidget(self.run_button) button_bar.addWidget(export_button) button_bar.addStretch(1) query_layout.addLayout(button_bar) @@ -159,16 +170,46 @@ def _build_menus(self) -> None: exit_action.triggered.connect(QApplication.instance().quit) file_menu.addAction(exit_action) + view_menu = menubar.addMenu("&View") + + self.toggle_dark_mode_action = QAction("Toggle Dark Mode", self) + self.toggle_dark_mode_action.setShortcut("Ctrl+D") + self.toggle_dark_mode_action.setCheckable(True) + self.toggle_dark_mode_action.setChecked(self.current_theme == Theme.DARK) + self.toggle_dark_mode_action.toggled.connect(self._toggle_dark_mode) + view_menu.addAction(self.toggle_dark_mode_action) + + refresh_action = QAction("Refresh Tables", self) + refresh_action.setShortcut("Ctrl+R") + refresh_action.triggered.connect(self._refresh_tables) + view_menu.addAction(refresh_action) + help_menu = menubar.addMenu("&Help") about_action = QAction("About", self) about_action.triggered.connect(self._show_about_dialog) help_menu.addAction(about_action) + self._install_shortcuts() + + def _install_shortcuts(self) -> None: + for shortcut_key in ("Ctrl+Return", "Ctrl+Enter", "F5"): + shortcut = QShortcut(QKeySequence(shortcut_key), self.query_editor) + shortcut.activated.connect(self._run_query) + def _open_dialog(self) -> None: path, _ = QFileDialog.getOpenFileName(self, "Open SQLite Database", str(Path.home()), "SQLite Database (*.db *.sqlite *.sqlite3);;All Files (*)") if path: self.open_database(path) + def _toggle_dark_mode(self, checked: bool) -> None: + self._set_theme(Theme.DARK if checked else Theme.LIGHT) + + def _set_theme(self, theme: Theme) -> None: + self.current_theme = theme + apply_theme(theme) + save_theme_preference(theme) + self.highlighter.set_color_scheme(theme) + def open_database(self, path: str) -> None: try: self.database_service.open(path) diff --git a/src/sqliteviewer/resources/dark.qss b/src/sqliteviewer/resources/dark.qss new file mode 100644 index 0000000..79df5af --- /dev/null +++ b/src/sqliteviewer/resources/dark.qss @@ -0,0 +1,154 @@ +QMainWindow, QWidget { + background-color: #1e1e1e; + color: #d4d4d4; +} + +QStatusBar { + background-color: #252526; + color: #9da3a9; + border-top: 1px solid #333333; +} + +QMenuBar { + background-color: #252526; + color: #d4d4d4; + border-bottom: 1px solid #333333; +} + +QMenuBar::item { + padding: 6px 10px; + background: transparent; +} + +QMenuBar::item:selected, +QMenu::item:selected { + background-color: #09395a; + color: #ffffff; + border-radius: 4px; +} + +QMenu { + background-color: #252526; + color: #d4d4d4; + border: 1px solid #333333; + padding: 6px; +} + +QMenu::item { + padding: 6px 22px 6px 10px; + margin: 2px 0; +} + +QMenu::separator { + height: 1px; + background: #333333; + margin: 6px 0; +} + +QListWidget, +QTableView, +QPlainTextEdit, +QTextEdit { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #333333; + border-radius: 4px; + selection-background-color: #264f78; + selection-color: #ffffff; + gridline-color: #2d2d30; +} + +QPlainTextEdit, +QTextEdit { + background-color: #1b1b1c; +} + +QHeaderView::section { + background-color: #252526; + color: #c5c5c5; + border: none; + border-bottom: 1px solid #333333; + border-right: 1px solid #2d2d30; + padding: 6px 8px; +} + +QPushButton { + background-color: #2d2d30; + color: #d4d4d4; + border: 1px solid #3f3f46; + border-radius: 4px; + padding: 6px 12px; +} + +QPushButton:hover { + background-color: #35353a; + border-color: #4fc1ff; +} + +QPushButton:pressed { + background-color: #41414a; +} + +QTabWidget::pane { + border: 1px solid #333333; + border-radius: 4px; + top: -1px; +} + +QTabBar::tab { + background-color: #252526; + color: #9da3a9; + padding: 8px 14px; + margin-right: 4px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +QTabBar::tab:selected { + background-color: #1e1e1e; + color: #ffffff; + border-bottom: 2px solid #4fc1ff; +} + +QTabBar::tab:hover:!selected { + background-color: #2d2d30; +} + +QSplitter::handle { + background-color: #333333; + width: 2px; + height: 2px; +} + +QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 2px; +} + +QScrollBar:horizontal { + background: transparent; + height: 10px; + margin: 2px; +} + +QScrollBar::handle:vertical, +QScrollBar::handle:horizontal { + background: #4a4a4f; + border-radius: 4px; + min-height: 24px; + min-width: 24px; +} + +QScrollBar::handle:vertical:hover, +QScrollBar::handle:horizontal:hover { + background: #5a5a60; +} + +QScrollBar::add-line, +QScrollBar::sub-line, +QScrollBar::add-page, +QScrollBar::sub-page { + background: none; + border: none; +} diff --git a/src/sqliteviewer/resources/light.qss b/src/sqliteviewer/resources/light.qss new file mode 100644 index 0000000..ea32b87 --- /dev/null +++ b/src/sqliteviewer/resources/light.qss @@ -0,0 +1,154 @@ +QMainWindow, QWidget { + background-color: #ffffff; + color: #24292e; +} + +QStatusBar { + background-color: #f6f8fa; + color: #586069; + border-top: 1px solid #d0d7de; +} + +QMenuBar { + background-color: #f6f8fa; + color: #24292e; + border-bottom: 1px solid #d0d7de; +} + +QMenuBar::item { + padding: 6px 10px; + background: transparent; +} + +QMenuBar::item:selected, +QMenu::item:selected { + background-color: #ddf4ff; + color: #24292e; + border-radius: 4px; +} + +QMenu { + background-color: #ffffff; + color: #24292e; + border: 1px solid #d0d7de; + padding: 6px; +} + +QMenu::item { + padding: 6px 22px 6px 10px; + margin: 2px 0; +} + +QMenu::separator { + height: 1px; + background: #d8dee4; + margin: 6px 0; +} + +QListWidget, +QTableView, +QPlainTextEdit, +QTextEdit { + background-color: #ffffff; + color: #24292e; + border: 1px solid #d0d7de; + border-radius: 4px; + selection-background-color: #c8e1ff; + selection-color: #24292e; + gridline-color: #eaeef2; +} + +QPlainTextEdit, +QTextEdit { + background-color: #fdfefe; +} + +QHeaderView::section { + background-color: #f6f8fa; + color: #57606a; + border: none; + border-bottom: 1px solid #d0d7de; + border-right: 1px solid #eaeef2; + padding: 6px 8px; +} + +QPushButton { + background-color: #f6f8fa; + color: #24292e; + border: 1px solid #d0d7de; + border-radius: 4px; + padding: 6px 12px; +} + +QPushButton:hover { + background-color: #eef2f6; + border-color: #afb8c1; +} + +QPushButton:pressed { + background-color: #e1e4e8; +} + +QTabWidget::pane { + border: 1px solid #d0d7de; + border-radius: 4px; + top: -1px; +} + +QTabBar::tab { + background-color: #f6f8fa; + color: #57606a; + padding: 8px 14px; + margin-right: 4px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +QTabBar::tab:selected { + background-color: #ffffff; + color: #24292e; + border-bottom: 2px solid #0366d6; +} + +QTabBar::tab:hover:!selected { + background-color: #eef2f6; +} + +QSplitter::handle { + background-color: #eaeef2; + width: 2px; + height: 2px; +} + +QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 2px; +} + +QScrollBar:horizontal { + background: transparent; + height: 10px; + margin: 2px; +} + +QScrollBar::handle:vertical, +QScrollBar::handle:horizontal { + background: #c1c7cd; + border-radius: 4px; + min-height: 24px; + min-width: 24px; +} + +QScrollBar::handle:vertical:hover, +QScrollBar::handle:horizontal:hover { + background: #959da5; +} + +QScrollBar::add-line, +QScrollBar::sub-line, +QScrollBar::add-page, +QScrollBar::sub-page { + background: none; + border: none; +} diff --git a/src/sqliteviewer/sql_highlighter.py b/src/sqliteviewer/sql_highlighter.py index bd4e85b..822b3f4 100644 --- a/src/sqliteviewer/sql_highlighter.py +++ b/src/sqliteviewer/sql_highlighter.py @@ -5,10 +5,27 @@ from PyQt6.QtCore import QRegularExpression from PyQt6.QtGui import QColor, QFont, QTextCharFormat, QSyntaxHighlighter +from .theme import Theme + class SqlHighlighter(QSyntaxHighlighter): """Applies formatting rules to highlight SQL keywords and tokens.""" + COLOR_SCHEMES = { + Theme.LIGHT: { + "keyword": "#005cc5", + "comment": "#6a737d", + "string": "#22863a", + "number": "#b31d28", + }, + Theme.DARK: { + "keyword": "#4fc1ff", + "comment": "#6a9955", + "string": "#ce9178", + "number": "#b5cea8", + }, + } + KEYWORDS = { # Read / query "SELECT", @@ -109,18 +126,14 @@ class SqlHighlighter(QSyntaxHighlighter): def __init__(self, document) -> None: super().__init__(document) self.keyword_format = QTextCharFormat() - self.keyword_format.setForeground(QColor("#005cc5")) self.keyword_format.setFontWeight(QFont.Weight.Bold) self.comment_format = QTextCharFormat() - self.comment_format.setForeground(QColor("#6a737d")) self.comment_format.setFontItalic(True) self.string_format = QTextCharFormat() - self.string_format.setForeground(QColor("#22863a")) self.number_format = QTextCharFormat() - self.number_format.setForeground(QColor("#b31d28")) self.comment_expression = QRegularExpression(r"--[^\n]*") self.string_expression = QRegularExpression(r"'([^']|'')*'") @@ -129,6 +142,17 @@ def __init__(self, document) -> None: pattern_str = r"\b(" + "|".join(self.KEYWORDS) + r")\b" self.keyword_pattern = QRegularExpression(pattern_str) self.keyword_pattern.setPatternOptions(QRegularExpression.PatternOption.CaseInsensitiveOption) + self.set_color_scheme(Theme.LIGHT) + + def set_color_scheme(self, theme: Theme) -> None: + """Update token colors for the selected theme.""" + + colors = self.COLOR_SCHEMES[theme] + self.keyword_format.setForeground(QColor(colors["keyword"])) + self.comment_format.setForeground(QColor(colors["comment"])) + self.string_format.setForeground(QColor(colors["string"])) + self.number_format.setForeground(QColor(colors["number"])) + self.rehighlight() def highlightBlock(self, text: str) -> None: # noqa: N802 (Qt API signature) self._apply_regex(self.keyword_pattern, text, self.keyword_format) diff --git a/src/sqliteviewer/theme.py b/src/sqliteviewer/theme.py new file mode 100644 index 0000000..7777846 --- /dev/null +++ b/src/sqliteviewer/theme.py @@ -0,0 +1,59 @@ +"""Theme helpers for the SQLite viewer.""" + +from __future__ import annotations + +from enum import Enum + +from PyQt6.QtCore import QSettings +from PyQt6.QtWidgets import QApplication + +from .resources import resource_path + + +class Theme(str, Enum): + """Supported application themes.""" + + LIGHT = "light" + DARK = "dark" + + +SETTINGS_GROUP = ("SQLiteViewer", "App") +_THEME_KEY = "theme" + + +def load_theme(theme: Theme) -> str: + """Load the QSS content for the requested theme.""" + + stylesheet_path = resource_path(f"{theme.value}.qss") + with open(stylesheet_path, encoding="utf-8") as stylesheet_file: + return stylesheet_file.read() + + +def apply_theme(theme: Theme, app: QApplication | None = None) -> Theme: + """Apply the requested theme to the current application.""" + + current_app = app or QApplication.instance() + if current_app is None: + raise RuntimeError("QApplication must exist before applying a theme.") + + current_app.setStyleSheet(load_theme(theme)) + current_app.setProperty("theme", theme.value) + return theme + + +def save_theme_preference(theme: Theme) -> None: + """Persist the theme preference.""" + + settings = QSettings(*SETTINGS_GROUP) + settings.setValue(_THEME_KEY, theme.value) + + +def load_theme_preference() -> Theme: + """Load the persisted theme preference.""" + + settings = QSettings(*SETTINGS_GROUP) + stored_theme = settings.value(_THEME_KEY, Theme.LIGHT.value, type=str) + try: + return Theme(stored_theme) + except ValueError: + return Theme.LIGHT