From 6cff9557b33b47046d40dfb4a9cf4fababd8997a Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 21 May 2026 17:37:50 -0400 Subject: [PATCH] polish: row numbers, ping indicator, frozen column, keyboard nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResultsPanel: _RowNumberProxy keeps vertical header numbers sequential in visual order (sort-stable); both views get a 48 px wide row-number header - MainWindow: _PingWorker pings each active connection every 30 s via test_connection(); status dot (green/red) shown in status bar with tooltip - TableViewer: πŸ“Œ toggle freezes first column using dual-view overlay (_frozen_view child widget) with synced scroll, row heights, and resize event-filter; column 0 is hidden in the main view while frozen - RowDialog: OK set as default button; first field focused on open - ConnectionDialog: OK set as default; Enter in password field submits Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 10 ++-- app/main_window.py | 72 ++++++++++++++++++++++++++- app/ui/connection_dialog.py | 9 ++++ app/ui/results_panel.py | 11 ++++- app/ui/table_viewer.py | 98 ++++++++++++++++++++++++++++++++++++- 5 files changed, 191 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c870263..ca1818c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,10 @@ All four drivers have a `_where(where: dict) β†’ (clause_str, params)` static he - File open/save/recent: Ctrl+O open SQL file, Ctrl+S save, File β†’ Open Recent (persisted to `~/.dbclient/recent_files.json`, max 10) - Ctrl+W to close current workspace tab - Catppuccin FrappΓ© theme: full palette upgrade from Mocha; all colors updated in `resources/style.qss`, syntax highlighter, completer popup, and inline Python stylesheets +- Row numbers column in results grid: `_RowNumberProxy` shows sequential visual-order numbers in the vertical header (sort-stable); fixed 48 px wide header in both `ResultsPanel` and `TableViewer` +- Connection ping indicator: 30-second `_PingWorker` (QThread) pings each active connection via `test_connection()`; status dot (● green / ● red) shown in status bar right side with per-connection tooltip +- Frozen first column in `TableViewer`: πŸ“Œ toggle button in toolbar; dual-view overlay (`_frozen_view` child of `_table_view`) with synced vertical scroll and row heights; event-filter updates geometry on resize +- Keyboard navigation in dialogs: OK set as default button (Enter submits) in `RowDialog` and `ConnectionDialog`; focus jumps to first input field on open; Enter in password field submits `ConnectionDialog` ### Not Yet Implemented **High impact:** @@ -153,9 +157,3 @@ All four drivers have a `_where(where: dict) β†’ (clause_str, params)` static he - Global schema search: search across all tables/columns/procedures in a database - Light theme: Catppuccin Latte or similar, toggle from Preferences - Schema diff: compare two database schemas and show structural differences - -**Small polish:** -- Row numbers column in results grid (virtual, not from DB) -- Connection ping indicator: periodic heartbeat dot in tab / status bar -- Frozen / pinned columns in table viewer (keep PK column always visible while scrolling) -- Keyboard navigation in RowDialog / connection dialogs (Tab order, Enter to confirm) diff --git a/app/main_window.py b/app/main_window.py index b2237ff..9d28101 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -15,7 +15,7 @@ from PyQt6.QtWidgets import ( QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget, QPushButton, QApplication, QMenu, QInputDialog, QFileDialog, ) -from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal from PyQt6.QtGui import QAction, QColor, QKeySequence from app.ui.schema_browser import SchemaBrowser @@ -41,6 +41,23 @@ from app.utils.logger import get_logger _log = get_logger(__name__) +class _PingWorker(QThread): + """Background thread that pings one connection and emits the result.""" + pinged = pyqtSignal(str, bool) # (profile_id, ok) + + def __init__(self, pid: str, driver, parent=None): + super().__init__(parent) + self._pid = pid + self._driver = driver + + def run(self): + try: + ok, _ = self._driver.test_connection() + self.pinged.emit(self._pid, ok) + except Exception: + self.pinged.emit(self._pid, False) + + class MainWindow(QMainWindow): def __init__(self): @@ -53,11 +70,19 @@ class MainWindow(QMainWindow): self._active_drivers: dict = {} # profile_id β†’ ConnectionProfile (all loaded profiles, connected or not) self._all_profiles: dict = {} + # ping state + self._ping_results: dict = {} # profile_id β†’ bool + self._ping_workers: list = [] # keep QThread refs alive self._build_ui() self._build_menus() self._build_status_bar() + # Periodic connection heartbeat (every 30 s) + self._ping_timer = QTimer(self) + self._ping_timer.timeout.connect(self._ping_all_connections) + self._ping_timer.start(30_000) + # Load saved profiles after the window is shown QTimer.singleShot(0, self._load_saved_profiles) @@ -234,6 +259,12 @@ class MainWindow(QMainWindow): self._conn_lbl.setMinimumWidth(160) sb.addPermanentWidget(self._conn_lbl) + self._ping_lbl = QLabel("") + self._ping_lbl.setFixedWidth(16) + self._ping_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._ping_lbl.setToolTip("Connection heartbeat") + sb.addPermanentWidget(self._ping_lbl) + def _set_status(self, msg: str): self._status_lbl.setText(msg) @@ -404,6 +435,7 @@ class MainWindow(QMainWindow): else: self._schema_browser.remove_connection(profile_id, keep_saved=False) self._all_profiles.pop(profile_id, None) + self._ping_results.pop(profile_id, None) delete_profile(profile_id) self._set_status(f"Connection '{profile.name}' deleted.") @@ -484,6 +516,44 @@ class MainWindow(QMainWindow): preview = value[:60] + ("…" if len(value) > 60 else "") self._cell_lbl.setText(f" {preview}") + # ── Connection heartbeat ────────────────────────────────────────────────── + + def _ping_all_connections(self): + # Drop finished workers + self._ping_workers = [w for w in self._ping_workers if w.isRunning()] + if not self._active_drivers: + self._ping_lbl.setText("") + return + for pid, driver in list(self._active_drivers.items()): + # Skip if a ping for this connection is already in flight + if any(getattr(w, "_pid", None) == pid for w in self._ping_workers): + continue + worker = _PingWorker(pid, driver) + worker.pinged.connect(self._on_ping_result) + worker.start() + self._ping_workers.append(worker) + + def _on_ping_result(self, pid: str, ok: bool): + if pid not in self._active_drivers: + return + self._ping_results[pid] = ok + all_ok = all(self._ping_results.get(p, True) for p in self._active_drivers) + color = "#a6d189" if all_ok else "#e78284" + self._ping_lbl.setText("●") + self._ping_lbl.setStyleSheet(f"color: {color}; font-size: 11pt;") + lines = [] + for p_id in self._active_drivers: + profile = self._all_profiles.get(p_id) + name = profile.name if profile else p_id + icon = "βœ…" if self._ping_results.get(p_id, True) else "❌" + lines.append(f"{icon} {name}") + self._ping_lbl.setToolTip("Connections:\n" + "\n".join(lines)) + if not ok: + profile = self._all_profiles.get(pid) + name = profile.name if profile else pid + _log.warning("Heartbeat failed for '%s'", name) + self._set_status(f"⚠️ Connection heartbeat failed: {name}") + # ── File open / save ────────────────────────────────────────────────────── def _open_sql_file(self, filepath: str = None): diff --git a/app/ui/connection_dialog.py b/app/ui/connection_dialog.py index 909a62f..78abb21 100644 --- a/app/ui/connection_dialog.py +++ b/app/ui/connection_dialog.py @@ -88,6 +88,9 @@ class ConnectionDialog(QDialog): ) bbox.accepted.connect(self._accept) bbox.rejected.connect(self.reject) + ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok) + if ok_btn: + ok_btn.setDefault(True) btn_row = QHBoxLayout() btn_row.addWidget(self._test_btn) @@ -167,6 +170,12 @@ class ConnectionDialog(QDialog): self._timeout.setSuffix(" sec") form.addRow("Timeout:", self._timeout) + # Enter in password submits the dialog + self._password.returnPressed.connect(self._accept) + + # Focus starts on the name field + self._name.setFocus() + return w def _build_ssl_tab(self) -> QWidget: diff --git a/app/ui/results_panel.py b/app/ui/results_panel.py index 821b86d..b8e1c34 100644 --- a/app/ui/results_panel.py +++ b/app/ui/results_panel.py @@ -19,6 +19,14 @@ from PyQt6.QtGui import QColor, QFont from app.models.result_table_model import ResultTableModel +class _RowNumberProxy(QSortFilterProxyModel): + """Proxy that shows sequential visual-order row numbers in the vertical header.""" + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: + return str(section + 1) + return super().headerData(section, orientation, role) + + class ResultsPanel(QWidget): status_message = pyqtSignal(str) @@ -86,7 +94,7 @@ class ResultsPanel(QWidget): def _make_table_tab(self, cols: list, rows: list) -> tuple: model = ResultTableModel() model.set_data(cols, rows) - proxy = QSortFilterProxyModel() + proxy = _RowNumberProxy() proxy.setSourceModel(model) table = QTableView() @@ -99,6 +107,7 @@ class ResultsPanel(QWidget): QHeaderView.ResizeMode.Interactive) table.horizontalHeader().setStretchLastSection(True) table.verticalHeader().setDefaultSectionSize(24) + table.verticalHeader().setFixedWidth(48) table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) table.customContextMenuRequested.connect( lambda pos, t=table, p=proxy, m=model: diff --git a/app/ui/table_viewer.py b/app/ui/table_viewer.py index def00ff..a241e09 100644 --- a/app/ui/table_viewer.py +++ b/app/ui/table_viewer.py @@ -16,7 +16,7 @@ from PyQt6.QtWidgets import ( QMenu, QApplication, ) from PyQt6.QtCore import ( - Qt, QAbstractTableModel, QModelIndex, pyqtSignal, + Qt, QAbstractTableModel, QModelIndex, pyqtSignal, QEvent, ) from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut @@ -267,8 +267,15 @@ class RowDialog(QDialog): ) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) + ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok) + if ok_btn: + ok_btn.setDefault(True) root.addWidget(bbox) + # Focus the first input field so the user can start typing immediately + if self._fields: + next(iter(self._fields.values())).setFocus() + @property def values(self) -> dict: """Return {col: value_or_None} for all fields. @@ -380,6 +387,13 @@ class TableViewer(QWidget): tb.addWidget(self._edit_btn) tb.addWidget(self._delete_btn) tb.addStretch() + + self._freeze_btn = QPushButton("πŸ“Œ") + self._freeze_btn.setFixedWidth(34) + self._freeze_btn.setToolTip("Freeze first column (keep it visible while scrolling)") + self._freeze_btn.setCheckable(True) + self._freeze_btn.clicked.connect(self._toggle_freeze) + tb.addWidget(self._freeze_btn) root.addLayout(tb) # ── Table view ───────────────────────────────────────────────────────── @@ -395,6 +409,7 @@ class TableViewer(QWidget): QHeaderView.ResizeMode.Interactive) self._table_view.horizontalHeader().setStretchLastSection(True) self._table_view.verticalHeader().setDefaultSectionSize(24) + self._table_view.verticalHeader().setFixedWidth(48) self._table_view.setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu) self._table_view.customContextMenuRequested.connect(self._context_menu) @@ -415,6 +430,40 @@ class TableViewer(QWidget): self._delete_selected) QShortcut(QKeySequence("Ins"), self._table_view, self._add_row) + # ── Frozen first-column overlay ─────────────────────────────────────── + self._freeze_active = False + self._frozen_view = QTableView(self._table_view) + self._frozen_view.setModel(self._model) + self._frozen_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._frozen_view.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._frozen_view.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._frozen_view.setAlternatingRowColors(True) + self._frozen_view.verticalHeader().hide() + self._frozen_view.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Fixed) + self._frozen_view.setStyleSheet( + "QTableView { border: none; border-right: 2px solid #8caaee; }") + self._frozen_view.hide() + + # Sync vertical scroll between the two views + self._table_view.verticalScrollBar().valueChanged.connect( + self._frozen_view.verticalScrollBar().setValue) + self._frozen_view.verticalScrollBar().valueChanged.connect( + self._table_view.verticalScrollBar().setValue) + + # Sync row heights + self._table_view.verticalHeader().sectionResized.connect( + lambda row, _, h: self._frozen_view.setRowHeight(row, h)) + + # Update frozen geometry when column 0 is resized + self._table_view.horizontalHeader().sectionResized.connect( + lambda col, _old, _new: self._update_frozen_geometry() if col == 0 else None) + + # Event filter to handle main view resize + self._table_view.installEventFilter(self) + root.addWidget(self._table_view, 1) # ── Pagination bar (prominent, always visible) ───────────────────────── @@ -505,11 +554,58 @@ class TableViewer(QWidget): self._refresh_action_states() self._table_view.resizeColumnsToContents() self._table_view.horizontalHeader().setStretchLastSection(True) + if self._freeze_active and self._model.columnCount() > 0: + self._apply_freeze() def _on_error(self, msg: str): self._total_lbl.setText(f"Error: {msg[:80]}") self.status_message.emit(f"Error: {msg}") + # ── Frozen column ────────────────────────────────────────────────────────── + + def _toggle_freeze(self, checked: bool): + self._freeze_active = checked + if checked and self._model.columnCount() > 0: + self._apply_freeze() + elif not checked: + self._unapply_freeze() + + def _apply_freeze(self): + col_count = self._model.columnCount() + if col_count == 0: + return + self._frozen_view.setSelectionModel(self._table_view.selectionModel()) + for c in range(col_count): + self._frozen_view.setColumnHidden(c, c != 0) + self._frozen_view.resizeColumnToContents(0) + self._table_view.setColumnHidden(0, True) + self._update_frozen_geometry() + self._frozen_view.show() + self._frozen_view.raise_() + + def _unapply_freeze(self): + self._frozen_view.hide() + if self._model.columnCount() > 0: + self._table_view.setColumnHidden(0, False) + + def _update_frozen_geometry(self): + if not self._freeze_active: + return + fw = self._table_view.frameWidth() + vh_w = self._table_view.verticalHeader().width() + col0_w = self._frozen_view.columnWidth(0) + self._frozen_view.setGeometry( + fw + vh_w, + fw, + col0_w, + self._table_view.height() - 2 * fw, + ) + + def eventFilter(self, obj, event): + if obj is self._table_view and event.type() == QEvent.Type.Resize: + self._update_frozen_geometry() + return False + def _refresh_pagination(self): ps = self._page_size if self._page_size > 0 else max(self._total, 1) rows_loaded = self._model.rowCount()