polish: row numbers, ping indicator, frozen column, keyboard nav

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:37:50 -04:00
co-authored by Claude Sonnet 4.6
parent b7e7411c1b
commit 6cff9557b3
5 changed files with 191 additions and 9 deletions
+9
View File
@@ -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:
+10 -1
View File
@@ -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:
+97 -1
View File
@@ -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()