Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SQLiteView 書き込み対応計画
# SQLiteView 開発計画

## Context

Expand Down Expand Up @@ -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` に修正済み。
2 changes: 2 additions & 0 deletions src/sqliteviewer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()

Expand Down
55 changes: 48 additions & 7 deletions src/sqliteviewer/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Comment on lines +194 to +197
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

QShortcutのデフォルトのコンテキストはQt.ShortcutContext.WindowShortcutです。これは、親ウィジェットを含むウィンドウがアクティブである限りショートカットがトリガーされることを意味します。現在の実装では、query_editorにフォーカスがない状態(例えば、テーブルリストを選択している状態)でF5Ctrl+Enterを押してもクエリが実行されてしまい、意図しない操作につながる可能性があります。

ショートカットのコンテキストをQt.ShortcutContext.WidgetShortcutに設定することで、query_editorがフォーカスを持っている場合にのみショートカットが有効になります。これにより、より予測可能で安全な動作になります。

Suggested change
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 _install_shortcuts(self) -> None:
for shortcut_key in ("Ctrl+Return", "Ctrl+Enter", "F5"):
shortcut = QShortcut(QKeySequence(shortcut_key), self.query_editor)
shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
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)
Expand Down
154 changes: 154 additions & 0 deletions src/sqliteviewer/resources/dark.qss
Original file line number Diff line number Diff line change
@@ -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;
}
Loading