| name | add-dialog |
| description | Guide for adding a new dialog or window to the Leap Monitor GUI. Covers ZoomMixin font-zoom setup, dialog geometry persistence, theme integration, the Cancel-bottom-left button-row convention, and the prefs persistence model that ad-hoc dialogs tend to get wrong. Use when creating a new monitor QDialog or window. |
Add a New Monitor Dialog
Guide for adding a new dialog/window to the Leap Monitor GUI — covering the non-obvious wiring (zoom, geometry, theme, prefs persistence) that ad-hoc implementations tend to get wrong.
Live dialogs live under src/leap/monitor/dialogs/. They inherit from QDialog, mix in ZoomMixin, and interact with MonitorWindow for shared state (themes, prefs, tooltips).
Core Pattern
from PyQt5.QtWidgets import QDialog
from leap.monitor.dialogs.zoom_mixin import ZoomMixin
from leap.monitor.pr_tracking.config import (
load_dialog_geometry, save_dialog_geometry,
)
from leap.monitor.themes import current_theme
class MyDialog(ZoomMixin, QDialog):
_DEFAULT_SIZE = (800, 500)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('My Dialog')
self.resize(*self._DEFAULT_SIZE)
saved = load_dialog_geometry('my_dialog')
if saved:
self.resize(saved[0], saved[1])
self._init_zoom(pref_key='my_dialog_font_size')
def done(self, result):
save_dialog_geometry('my_dialog', self.width(), self.height())
super().done(result)
Every resizable dialog must:
- Subclass
ZoomMixin (first in MRO, before QDialog).
- Set a
_DEFAULT_SIZE class attribute so _reset_window_size can restore sane defaults.
- Load/save geometry via
load_dialog_geometry(key) / save_dialog_geometry(key, w, h).
- Call
self._init_zoom(...) at the end of __init__.
Trivial info/warning popups (one-off QMessageBox / QInputDialog) don't need this — the global PopupZoomManager handles their font.
Button Row Layout — Cancel bottom-left, primary bottom-right
Project convention for every monitor QDialog with a Cancel button: add Cancel first, then addStretch(), then the primary action(s) on the right:
btn_row = QHBoxLayout()
cancel_btn = QPushButton('Cancel')
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(cancel_btn)
btn_row.addStretch()
ok_btn = QPushButton('OK')
ok_btn.setDefault(True)
ok_btn.clicked.connect(self.accept)
btn_row.addWidget(ok_btn)
layout.addLayout(btn_row)
Do not use QDialogButtonBox(Ok | Cancel) for new dialogs — on macOS it groups Cancel next to OK on the right, which violates the convention. For 3-button cases (e.g. Cancel + secondary + primary), keep Cancel on the outside-left and group the other two on the right of the stretch — see _mixins/actions_menu_mixin.py and dialogs/git_changes_dialog.py:CommitListDialog. Close-labeled dismissal buttons (one-button viewer dialogs like WhatsNewDialog, NotesDialog) are not covered by this rule — they're a different paradigm ("I'm done viewing" vs "discard my edits").
Zoom: Single-Target vs Split
Simple dialogs (form with inputs/buttons only) use a single zoom target:
self._init_zoom(pref_key='my_dialog_font_size')
Content-heavy dialogs (dialogs with a QTextEdit / QListWidget / QTreeView / QTableWidget) must use split zoom so users can enlarge content without blowing up the chrome:
self._editor = QTextEdit()
self._list = QListWidget()
self._init_zoom(
pref_key='my_dialog_font_size',
content_pref_key='my_dialog_text_font_size',
content_widgets=[self._editor, self._list],
)
If content widgets are rebuilt dynamically (e.g., message cards regenerated on save), pass a callable instead of a list — the mixin re-resolves it on every zoom event:
self._init_zoom(
pref_key='my_dialog_font_size',
content_pref_key='my_dialog_text_font_size',
content_widgets=lambda: self._current_cards,
)
After rebuilding the content widgets, call self._zoom_reapply_content() so new widgets render at the saved size.
Close Hooks
- If the dialog closes via
accept() / reject() (OK / Cancel / Escape): override done() and save there. ZoomMixin.done() handles zoom flush automatically.
- If it closes via
closeEvent() or the X button: override closeEvent() and explicitly call self._zoom_flush(). done() is not called for those paths.
Do both if either is possible (Notes dialog, QueueEditDialog do this).
Theme Integration
Never hardcode colors. Use current_theme():
from leap.monitor.themes import current_theme
t = current_theme()
self._label.setStyleSheet(f'color: {t.text_muted};')
For cell/button styles used inside tables, use the helpers in monitor/ui/table_helpers.py (close_btn_style, active_btn_style, menu_btn_style).
Prefs Persistence — CRITICAL
This is where ad-hoc dialogs silently break. Read carefully.
The Model
monitor_prefs.json has two classes of keys:
| Class | Owner | Updated via | Examples |
|---|
| Monitor-owned | MonitorWindow.self._prefs (cached in memory at startup) | self._prefs[key] = X; self._save_prefs() | main_font_size, window_geometry, column_widths, row_order, row_colors, aliases, theme, include_bots |
| Dialog-owned | Read/written via helpers (no monitor cache) | load_monitor_prefs() → update → save_monitor_prefs() | notes_font_size, send_position, send_comments_filter, run_session_include_completed, all ZoomMixin *_font_size keys |
The Trap
MonitorWindow._prefs is populated at startup from load_monitor_prefs(). If a dialog modifies a key on disk but MonitorWindow._prefs still has the stale startup value, the next unrelated monitor write (main-window zoom, theme change, window resize) would clobber the dialog's save.
The Guarantee
MonitorWindow._save_prefs refreshes dialog-owned keys from disk before writing. This uses two mechanisms:
- Pattern match: any key ending in
_font_size or _font_family (except main_font_size) is treated as dialog-owned.
- Explicit list:
MonitorWindow._DIALOG_OWNED_KEYS — add your key here if it doesn't fit the pattern.
Rules for New Dialog Prefs
When adding a new dialog pref:
- If it's a
*_font_size or *_font_family — just use the name, it's auto-covered.
- Otherwise, add the key name to
MonitorWindow._DIALOG_OWNED_KEYS in app.py.
- Never touch
MonitorWindow._prefs[key] for a dialog-owned key.
- Always read via
load_monitor_prefs() and write via save_monitor_prefs() (or a purpose-built load_X/save_X helper in pr_tracking/config.py).
Example — add a "my_dialog_last_tab" key:
def load_my_dialog_last_tab() -> int:
return load_monitor_prefs().get('my_dialog_last_tab', 0)
def save_my_dialog_last_tab(tab: int) -> None:
prefs = load_monitor_prefs()
prefs['my_dialog_last_tab'] = tab
save_monitor_prefs(prefs)
_DIALOG_OWNED_KEYS: frozenset[str] = frozenset({
'run_session_include_completed',
'save_preset_include_completed',
'send_position',
'send_comments_filter',
'send_comments_mode',
'preset_editor_last_name',
'my_dialog_last_tab',
})
Why This Matters
Before this pattern, dialogs would save to disk, then a main-window event (e.g., Cmd+scroll on the table) would call _save_prefs which wrote MonitorWindow._prefs (with stale dialog values) back to disk — silently reverting your save. A user would zoom in a dialog, switch themes, and watch their zoom change "mysteriously" revert. Symptoms look like "save isn't working" but both save and load work in isolation. Don't let this bug come back.
MonitorWindow-Owned Keys
Conversely, if your key is monitor-owned (rare for dialogs — usually only applies when MonitorWindow itself needs the value for rendering):
- Update
self._prefs[key] first, then call self._save_prefs() (NOT save_monitor_prefs(self._prefs) directly).
- Direct
save_monitor_prefs(self._prefs) bypasses the dialog-owned refresh and re-introduces the stale-cache bug.
Font-Size Cascade Gotcha (Qt Quirk)
If any widget in your dialog has its own setStyleSheet(...) with color/background/border/padding but not font-size, Qt blocks the ancestor-level font-size cascade from reaching it. That widget will render at the default size regardless of your dialog-level zoom stylesheet.
Fix: bake font-size into the widget's own stylesheet whenever you set one:
self._label.setStyleSheet('font-weight: bold;')
self._label.setFont(my_bold_font)
self._label.setStyleSheet(
f'font-weight: bold; font-size: {self._zoom_font_size}pt;'
)
ZoomMixin handles this for widgets that don't set their own stylesheet. For widgets that do, you need to re-apply font-size whenever their stylesheet is rewritten (see NotesDialog._apply_buttons_font_size for the marker-based pattern that survives multiple zoom deltas).
Storage Directories
If your dialog reads/writes files under .storage/<subdir>/:
- Add the constant in
utils/constants.py (alongside QUEUE_DIR, SOCKET_DIR, HISTORY_DIR).
- Add a
.mkdir() call in ensure_storage_dirs() in utils/constants.py.
- Add the path to the
ensure-storage Makefile target.
CLAUDE.md Registration
When your dialog is user-visible and non-trivial, add an entry to CLAUDE.md:
- Project Structure tree — list the file under
src/leap/monitor/dialogs/ with a one-line description.
- Key Classes table —
| MyDialog | monitor/dialogs/my_dialog.py | What it does |.
Testing
Dialogs aren't easy to unit-test (Qt requires a display). The practical checks:
- AST parse after every edit (
python3 -c "import ast; ast.parse(open(...).read())").
- Runtime import (
poetry run python -c "from leap.monitor.dialogs.my_dialog import MyDialog").
- Manual verification via
make run-monitor — open the dialog, use every control, close, reopen, verify prefs persist.
- Prefs regression test — zoom in the dialog, make some change in the main window (Cmd+scroll the table, switch themes), close the dialog, reopen. Your zoom should still be there. If not, you forgot to register a dialog-owned key.
Anti-Patterns Checklist
- ❌
save_monitor_prefs(self._prefs) from anywhere that isn't _save_prefs itself — bypasses dialog-owned refresh.
- ❌
self._prefs['dialog_owned_key'] = X from MonitorWindow — will be overwritten by the refresh.
- ❌ Hardcoded colors in stylesheets — use
current_theme().
- ❌ Dialogs without
_DEFAULT_SIZE — breaks "reset window sizes".
- ❌ Widget stylesheets with color/weight but no font-size — silently blocks the zoom cascade.
- ❌
setStyleSheet('...') that overwrites a previously-applied zoom stylesheet — use the marker split pattern (existing.split(MARKER)[0]) to preserve it.
- ❌ Missing
closeEvent flush when the dialog can close via the X button — zoom changes lost.
- ❌ Hidden-but-layout-allocated widgets that silently still take space — use
setVisible(False), not opacity tricks.