Widgets Module
August 16, 2026 · View on GitHub
Widgets are UI components that receive ServiceLocator& via constructor injection. This is the largest src/ module, containing the main window, editor windows, dialogs, and the split-pane view area framework.
2 Suffix Convention
Many existing files here carry a 2 suffix (e.g., MainWindow2, NotebookExplorer2). This is a historical artifact of the now-complete migration off the legacy singleton architecture — the pre-migration counterparts have been removed, and the suffix is simply the retained name for those existing classes (renaming them would be needless churn). New code does NOT get a suffix unless the name genuinely conflicts with an existing type. Since the migration is finished, a brand-new widget with no 1/legacy counterpart should use the plain name (e.g. EncodingButton, not EncodingButton2). Never introduce a 3 suffix. See Widget Construction Pattern below for the constructor-injection pattern all widgets follow.
Hiding the QToolButton Menu Indicator
Plain-text status-bar / toolbar QToolButtons that open an InstantPopup menu
(e.g. the status bar "Spelling" menu, the EncodingButton, the toolbar theme
switcher) must NOT show the built-in dropdown-arrow menu indicator. Use the
single shared mechanism — the dynamic property:
btn->setProperty("NoMenuIndicator", true);
Every bundled theme's src/data/extra/themes/<theme>/interface.qss styles this
away with QToolButton[NoMenuIndicator="true"]::menu-indicator { image: none; },
so all such buttons share one look. Do NOT hand-roll a per-button inline
stylesheet (setStyleSheet("QToolButton::menu-indicator { image: none; }")) —
that bypasses theming and drifts from the shared style. When adding a new theme,
carry the NoMenuIndicator rule forward. Pair the property with
setToolButtonStyle(Qt::ToolButtonTextOnly) + setAutoRaise(true) for the flat,
text-only status-bar look.
Widget Families
MainWindow
MainWindow2— new main window shell with ServiceLocator DI; owns the top-level layout, toolbar, sidebar, and view area
Notebook Explorer
NotebookExplorer2— sidebar explorer for notebook nodes; wires together the MVC triad (model, view, controller)NotebookSelector2— notebook dropdown selector
ViewArea Framework
The split-pane editor area, designed around vxcore workspaces:
ViewArea2— QSplitter tree managing split panes (pure view, no business logic)ViewSplit2— QTabWidget-based split pane; each instance maps 1:1 to a vxcore workspaceViewWindow2— abstract base for file viewer windows; receives aBuffer2in its constructorViewWindowFactory(in../gui/services/) — registry mapping file types toViewWindow2creators
Concrete ViewWindows
MarkdownViewWindow2— Markdown editor with previewTextViewWindow2— plain text editorPdfViewWindow2— PDF viewer. LikeMarkdownViewWindow2, it overridesgetOutlineProvider()and feeds the Outline dock + toolbar popup. The JS↔C++ outline contract is index-based: JS owns the pdf.jsdestobjects and publishes{ name, level, index }per entry, whereindexaddresses a private destination array on the web side and is-1when the entry is not jumpable. It must be carried explicitly —OutlineProvider::makePerfectHeadingsinserts filler headings, so a heading's position is NOT its destination index. Seesrc/data/extra/web/pdf.js/pdfviewercore.jsandtests/widgets/test_pdfviewercore_js.cpp.MindMapViewWindow2— mind map viewerWidgetViewWindow2— generic widget-hosting window
Toolbar
ToolbarHelper2— main window toolbar constructionViewWindowToolbarHelper2— per-view-window toolbar construction
Dialogs (dialogs/)
NewNoteDialog2,NewFolderDialog2,NewNotebookDialog2ManageNotebooksDialog2,ImportFolderDialog2
Each dialog is driven by a corresponding controller in ../controllers/.
Search / Snippet / Tag
SearchPanel2,SnippetPanel2TagExplorer2,TagViewer2,TagPopup2
Dashboard (dashboard/)
The home dashboard shown at vx://home. Pure view layer — all layout logic
and persistence live in DashboardController (../controllers/).
DashboardContent—IViewWindowContenthost atvx://home; owns the boardDashboardBoard— fixed-columnQGridLayoutof stickers; creates/owns itsDashboardController, forwards user gestures as intents, and reacts to controller signals to build/move/remove sticker framesSticker— abstract sticker content widget;CalendarStickeris the concrete built-inStickerFactory(in../gui/services/) — registry mapping sticker type-ids to creators
Edit mode (unlocked): drag-move / drag-resize
Locked is the default and is unchanged: no overlay widget exists at all. Unlocked means edit mode, and that has four normative consequences:
- Each realized sticker gets a
StickerDragOverlaychild of its chrome frame, covering everything below the header band (ViewItem::m_header, handed back bybuildFrame). It swallows every mouse event over the sticker content — clicking a calendar date or a history row while unlocked does nothing — while the header stays live. The header holds only Remove: the Move menu (Move Up/Down/Left/Right +Resize...) andResizeStickerDialogwere deleted, because direct manipulation plus the overlay's keyboard route replaces both.moveSticker()on the controller is still the commit path for a keyboard move. The overlay is created on unlock andhide()+deleteLater()d on lock, in that order (a queued deletion would otherwise leave a swallowing overlay alive across a fast lock/unlock). - The controller is the single source of truth for normalization.
DashboardController::previewStickerGeometryapplies the sameqBoundclampssetStickerGeometryapplies (the setter now calls it), so the ghost (StickerDropIndicator) always shows exactly what a commit would produce. Out-of-bounds drops are clamped; drops onto an occupied region are rejected (ghost renders invalid, release is a no-op — the existing reject-and-noop collision policy).stickerDragTarget()instickerdraggeometry.{h,cpp}is pure math and deliberately does not clamp to board bounds; do not add board knowledge there. - Geometry is committed once, on release — exactly one
setStickerGeometry(and therefore one persist) per gesture.DashboardBoard::cancelDragSession()is the single synchronous, idempotent teardown; it must be called from lock, sticker removal, layout reload /clearAllViews,dragCancelled(Esc or a right-click mid-drag) and the destructor. - The overlay is also the keyboard route, and must stay one. It is
focusable, so Tab reaches every unlocked sticker; arrow keys emit
moveRequestedand Shift+arrowsresizeRequested, which the board commits throughmoveSticker/setStickerGeometry— the same clamp and collision policy as a drag, no ghost. A focused overlay paints a solid border instead of a dashed one, and carries an accessible name (the sticker title) plus a description of the keys. Direct manipulation removed the header Move menu and the resize dialog, which were the only keyboard-operable affordances; do not remove this replacement without providing another one. Keyboard input is inert while a mouse session owns the geometry.
StickerDragOverlay and StickerDropIndicator are leaf presentational widgets:
no ServiceLocator (precedent InlineBanner). The board resolves the accent
(base#info#fg) and invalid (base#error#fg) colors from ThemeService with a
QPalette fallback and injects them; both widgets paint with QPainter and
never touch a stylesheet, so nothing here can trip (or need to dodge) the
hardcoded-color gate. The centre move affordance is the themed move.svg icon,
tinted with the accent and injected the same way (setMoveIcon) — the overlay
cannot resolve a theme icon itself. DashboardBoard::refreshEditModeColors()
re-injects both on a theme change; without it live overlays keep the old accent.
While a drag targets rows below the occupied extent, the board reserves them via
m_dragRowReservation and re-runs applyRowSizing(), otherwise the container
has no height there and the ghost is clipped. The reservation only extends
maxRow; per-row height stays kRowUnitHeight so frames keep matching their
cells exactly.
Other
LocationList2— results list (search hits, backlinks, etc.)FindAndReplaceWidget2— in-editor find/replace barAttachmentPopup2— attachment management popupWordCountPopup2— word/character count displayNotificationButton2/NotificationPopup2— toolbar notification button + popup (see below)
Notification System
The in-app notification UI is the View layer over NotificationService (data/signals only, src/core/services/notificationservice.{h,cpp} — Qt-Widgets-free). Producers are wired in NotificationRouter (src/controllers/notificationrouter.{h,cpp}) and UpdateController; deciding which subsystems emit is out of scope of the widgets.
Three surfaces, one rule each:
NotificationToast(notificationtoast.{h,cpp}) is the transient surface forAttention::Interrupt. It is a plain childQFrameofMainWindow2, anchored bottom-right of the central widget — NOT aQMenuand NOT aQt::Tooltop-level. A child widget cannot take window activation, so an arriving toast can never eat the user's keystrokes; being a child (rather than aQt::Toolwindow) also avoids the Windows native-unmap-on-deactivate quirk, multi-monitor clamping and taskbar overlap.NotificationButton2(QToolButton) lives on the settings toolbar immediately after the Theme button. It paints a red badge withNotificationService::activeCount()and refreshes its bell icon onThemeService::themeChanged. It does not auto-show the popup —showPopup()is called on click, or byMainWindow2forwardingNotificationToast::popupRequested.NotificationPopup2(extendsButtonPopup) is the click-to-open notification centre: messages newest-first with severity icon + title + text + optional collapsible "Details" + optional progress bar + per-message action buttons + Dismiss, in a height-cappedQScrollArea, under the sharedTitleBarholding "Notifications" and Clear All. Do NOT hand-roll a titlebar.
Attention → surface routing (owned by NotificationToast)
The routing table lives INSIDE the toast rather than in MainWindow2, so it is unit-testable against the real widget instead of a duplicated copy. MainWindow2 injects the two window-policy inputs:
setCanShowInWindow(...)— must beisVisible() && !(windowState() & Qt::WindowMinimized).isVisible()alone is not enough: a minimized top-level window is still logically visible, so an in-window child would be "shown" where the user cannot see it.setFallbackSink(...)— where anInterruptgoes when the above is false.MainWindow2routes it toQSystemTrayIcon::showMessage()(title + text only; a balloon carries no actions and cannot be retracted).
| Signal | Condition | Toast action |
|---|---|---|
messageAdded | Interrupt | show (or fall back to the sink) |
messageAdded | Passive | nothing |
messageUpdated | shown id, Interrupt | refresh content, do not restart the timer |
messageUpdated | shown id, Passive | hide (the producer downgraded it; leaving stale interrupting content up would show a stale action button) |
messageUpdated | any other id | nothing |
messageDismissed / messageRemoved | shown id | hide |
messagesCleared | always | hide |
A messageAdded carrying Interrupt is the ONLY thing that raises the toast. A producer that must re-interrupt an ongoing incident calls NotificationService::renotify(), which removes the old generation and posts a new one. See src/core/services/AGENTS.md § NotificationService for why there is no escalation signal.
renotify() always changes the id, so it necessarily runs messageRemoved(old) → hide, then messageAdded(new) → show. Under the current GUI-thread-only producer contract both deliveries are direct and synchronous, so the pair completes before a repaint and no blank frame is rendered. Keep these plain hide()/show() calls — adding a fade, animation or deferred teardown would turn that into a visible flicker. The accepted cost of this two-signal protocol is that a visible popup rebuilds twice and the badge recomputes twice per renotify().
Progress and in-place updates
A message renders a QProgressBar when NotificationMessage::m_progressIndeterminate is
set (range 0..0, busy indicator, wins over the permille) or m_progressPermille >= 0
(range 0..1000, clamped). Permille rather than percent, so a long-running producer with a
byte count still moves the bar smoothly.
The popup rebuilds on messageAdded / messageUpdated / messageDismissed /
messageRemoved / messagesCleared / themeChanged, but only when it is already
visible. messageAdded is in that list deliberately: nothing auto-pops any more, so
without it an open popup would go stale.
NotificationAction::m_dismissOnTrigger (default true) controls whether triggering the
action dismisses the message. Producers that keep updating one message in place set it
false.
m_details is rendered as a collapsible disclosure in the popup list only — never in
the toast or the tray balloon, both of which must stay small. It is the home for the error
blobs that used to be a QMessageBox::setDetailedText (or were lost to qWarning).
Per-severity accent
Rows and the toast set PropertyDefs::c_state (info / warning / error / success),
driven by the shared *[State="..."] rules every theme defines, and tint their severity
icon with @base#<state>#fg. When adding a theme, carry all four states plus a
vnotex--NotificationToast block forward; tests/core/test_theme.cpp
(testInterfaceQssFullyResolved) is the gate.
Lifetime-safety rules (MUST FOLLOW)
NotificationAction::m_callback is arbitrary application code. Neither the popup nor the toast keeps a copy of it across a rebuild:
- Rows are rebuilt from
service.messages()on every show and on the signals above, so a stale row's callback can never fire afterclearAll(). - Action buttons capture only the message id + action index and re-resolve the callback from the current service state at click time (a cleared/dismissed/removed message becomes an inert no-op).
- The callback AND its
m_dismissOnTriggerflag are snapshotted in that same lookup, before the callback runs. Do NOT re-resolve the action index afterwards: a callback may synchronously replace the action vector, so a second lookup would read a different action's flag. - Because a callback may synchronously destroy the widget, every post-callback access to
this/m_servicesis guarded with aQPointer. The callback may also have already triggered a rebuild viamessageUpdated, so nothing after it may touch the row widgets that existed when the lambda started.
Duration controls only auto-hide (Short 3 s, Long 7 s, Persist = capped at 15 s on the toast only, so a toast is never permanently stuck); it does NOT affect memory retention. Messages stay in the in-memory list until dismissed, cleared, or evicted by the retention cap.
Inline Notification Banners
Use InlineBanner (src/widgets/inlinebanner.{h,cpp}) for any inline
notification strip — an in-editor prompt above the content, a "results
truncated" warning above a list, a dialog-level notice. Do NOT hand-roll a
QLabel with an inline stylesheet; that is exactly the drift this class
replaces (it previously existed twice, in LocationList2 and the legacy-image
bar, with copy-pasted #FFF3CD / #856404 hex that looked wrong in all six
dark themes).
InlineBanner is a pure view: a wrapping message plus zero or more trailing
action buttons. It takes no ServiceLocator — it has no service needs, matching
the other leaf presentational widgets (EncodingButton, StatusBar). Consumers
own all policy.
auto *banner = new InlineBanner(InlineBanner::Severity::Warning, tr("..."), this);
connect(banner->addActionButton(tr("Fix It")), &QPushButton::clicked, this, &X::onFix);
addTopWidget(banner); // ViewWindow2 host; or any QLayout elsewhere
addActionButton() returns the QPushButton it created (the
QDialogButtonBox::addButton convention) so the caller connects it directly —
there is no index bookkeeping and no signal the banner has to re-emit.
For a feature-specific banner, subclass it and keep the copy plus the named
intents in the subclass (see LegacyImageMigrationBar). Never put the strings
in the hosting view window.
Theming (do not bypass)
Severity is published as the BannerSeverity dynamic property
(PropertyDefs::c_bannerSeverity, values info / warning / error), set via
WidgetUtils::setPropertyDynamically so the style engine repolishes. Every
bundled theme styles it:
vnotex--InlineBanner { background-color: @base#normal#bg; ... }
vnotex--InlineBanner[BannerSeverity="warning"] { border-left: 3px solid @base#warning#fg; }
The block uses only @base# tokens, because those are the only ones every
theme defines — native has no widgets.qwidget section, and only danger
has a background role, so info/warning/error are carried by the left rule
rather than a tinted fill.
When adding a theme, carry the vnotex--InlineBanner rules forward. A
missing palette key does not fail loudly: Theme::translateStyleByPalette logs
a qWarning and leaves the literal @base#... in the stylesheet, after which
Qt's CSS parser silently drops the declaration. Two data-driven gates in
tests/gui/test_themeservice.cpp cover this for all 12 themes —
interfaceQssFullyResolved (no unresolved token survives) and
interfaceQssStylesInlineBanner (the selector and both severity rules exist).
InlineBanner sets Qt::WA_StyledBackground; without it a bare QFrame
subclass ignores background-color from the global stylesheet.
Note that the primary instance copies themes into <appData>/themes after
acquiring SingleInstanceGuard; rejected secondary launches never mutate the
resource tree. Themes are refreshed only when the per-folder stamp
<appData>/themes/.vnote-extra-version does not match
ConfigMgr2::c_version, so QSS edits reach an existing installation only at the
next version bump (scripts/update_version.py handles that). The stamp is
written by FileUtils2::installVersionedDir only after the folder copied
completely, which is what makes a partial copy retry on the next launch
instead of being remembered as done; deleting the stamp (or the folder) forces a
re-copy at the next start. For local work, run with --watch-themes and edit
the deployed copy.
No Hardcoded Colors in C++
Never put a literal color in a setStyleSheet() call. VNote ships 12
themes, 6 of them dark, and the global stylesheet is applied on QApplication
(main.cpp:725) and re-applied on every theme change. A hardcoded
background-color: #FFF3CD is correct only in whichever theme its author
happened to be running, and it cannot follow a theme switch.
This is enforced: tests/utils/test_hardcoded_color_drift.cpp parses every
.cpp/.h under src/, extracts the string literals (comment-aware, raw-string
aware, and coalescing adjacent literals the way the compiler does), and fails
when one contains both a CSS color property and a literal color value — a
#hex, a numeric rgb()/rgba()/hsl()/hsla()/hsv()/hsva(), or any name in
QColor::colorNames() except transparent.
Use, in order of preference:
| Need | Do this |
|---|---|
| A notification strip | InlineBanner (see above) — already themed |
| Severity-colored text | Set the SeverityText property (PropertyDefs::c_severityText, values info/warning/error) via WidgetUtils::setPropertyDynamically; every interface.qss maps it to @base#{info,warning,error}#fg. An unset/empty value falls back to the normal color. |
| Muted / secondary / hint text | Set the MutedText property (PropertyDefs::c_mutedText, value true). Add italics with QFont::setItalic, not QSS. |
| Anything else static | Add the rule to each theme's interface.qss, selecting on the class name (vnotex--YourWidget) or a dynamic property. This is the only option that re-themes for free. |
| A color computed at runtime | ThemeService::paletteColor("widgets#foo#bg") interpolated with .arg(), as in NavigationMode::generateNavigationLabelStyle. A style string whose color comes from a %N placeholder is not flagged. |
Do NOT use setEnabled(false) to mute text. It advertises
QAccessible::State::unavailable for what is ordinary informative text, and it
does not even work here: every theme styles QLabel { color: ... }
unconditionally with no :disabled variant, so the palette's disabled role
never reaches the label. An attribute selector such as *[MutedText="true"]
outranks the plain type rule, which is why the property works where the disabled
state does not.
MutedText resolves to each theme's dedicated base.muted.fg, not to
base.disabled.fg. The disabled role is tuned for disabled controls and is
far too faint for enabled text — on solarized-light it is #DAD3C2 over
#FDF6E3, about 1.38:1. The base.muted.fg values are derived by blending
the theme's normal foreground toward its background until the WCAG contrast
floor is hit, and TestThemeService::mutedTextIsReadable asserts the resolved
ratio per theme: at least min(4.5, contrast(normal.fg, normal.bg)), and never
more contrast than normal text. (native is skipped — it takes the OS's own
muted color from the system palette at runtime.) When adding a theme, add
base.muted.fg and let that test tell you whether the value is legible.
What is not an offender, and is not flagged:
- A color literal used as data rather than chrome — the mark-node swatch
palette (
marknodedialog2.cpp) and the notebook avatar colors (notebookselector2.cpp) offer colors to the user. - A colorless style string, e.g.
"QLabel { font-style: italic; }". (PreferQFontanyway; and for the menu indicator use theNoMenuIndicatorproperty documented above, not an inline stylesheet.)
Known scope limits, so nobody mistakes a green build for a proof: the gate
covers stylesheet strings only. A QColor painted directly in a
paintEvent or a QStyledItemDelegate is out of scope, and a color assembled
at runtime from a non-literal constant is indistinguishable from the legitimate
paletteColor() pattern without real type analysis. Those still need review.
If you genuinely need a literal, append // hardcoded-color-allow: <reason> to
any line the literal spans. Use it sparingly — every existing case was
removable.
MVC Rule for Widgets
See MVC Rules — Key rule for widgets: All layers receive ServiceLocator& via constructor injection (enables DI and testing).
Widgets are the View layer. They display data and emit signals but MUST NOT modify data directly. Business logic belongs in controllers; data access belongs in services.
ViewArea2 Framework
For ViewArea2 framework design decisions (splitter orientation, session layout persistence, etc.), see Key Design Decisions (ViewArea2 Framework).
The orchestrator for all open/close/split/move operations is ViewAreaController in ../controllers/.
NotebookExplorer2: Sync Button State
NotebookExplorer2::updateSyncButtonState (src/widgets/notebookexplorer2.cpp:1512-1665) paints the per-notebook sync button and Sync Info menu. It classifies the current notebook into one of the 8 sync states (see Sync State Model) and sets the partialSyncConfig QWidget property, which downstream QSS reacts to.
Classifier
partialSyncConfig is set to true for any non-ready state inside the syncEnabled && syncReady branch (notebookexplorer2.cpp:1614-1628):
| State | Detected by | Reason |
|---|---|---|
| S1 (no URL/backend) | `syncEnabled && (backend.isEmpty() | |
| S2 (PAT missing) | syncEnabled && syncReady && !credStore->hasCredentials(id) | Disk-complete but no keychain entry |
| S4 (not registered) | syncEnabled && syncReady && !syncSvc->isSyncRegistered(id) | Disk-complete, PAT present, but vxcore runtime never registered |
| S5 (ready) | syncEnabled && syncReady && hasCredentials && isRegistered | partialSyncConfig=false; tooltip "Sync Now" |
Use SyncCredentialsStore::hasCredentials(id) (cached, paint-safe per W2.T0) instead of retrieveCredentials. Keychain access on every paint event is too expensive. Use SyncService::isSyncRegistered(id) (synchronous runtime query) for S4 detection.
Do not rename or remove the partialSyncConfig property: downstream QSS depends on it.
Tooltip Variants
The button tooltip changes per state to give the user actionable guidance:
| State | Tooltip | Action on click |
|---|---|---|
| S5 | "Sync Now" | Triggers sync |
| S1/S2/S4 (partial) | Includes "credentials" or "initializ" hint | Opens NotebookSyncInfoDialog2 in dialog's auto-detected mode |
| S0 | "Enable Sync" | Opens NotebookSyncInfoDialog2 with setBootstrapMode(true) and empty fields |
| Reconcile error | Existing tooltip + "Last sync init failed: error code %1" appended | Same as the underlying partial/ready state |
The reconcile error code is stored in m_lastReconcileError (in-memory QHash<QString, int>, notebookexplorer2.h:201) populated by SyncService::reconcileFinished (notebookexplorer2.cpp:130-140) and cleared on syncFinished success or notebook switch. No persistence to disk; no toast/modal.
Re-enable UI Affordance for S0
Without this affordance, users who disable sync can never re-enable without recreating the notebook. The Sync button and Sync Info menu remain visible AND clickable for S0 notebooks (notebookexplorer2.cpp:1602-1635):
- Button enabled regardless of
syncEnabled; label changes to "Enable Sync" whensyncEnabled == false. onSyncButtonClickedbranches on!syncEnabled→ opensNotebookSyncInfoDialog2withsetBootstrapMode(true)(dialog hides the Disable button in this mode).- Sync Info menu action enabled regardless of
syncEnabled(was previouslysetEnabled(syncEnabled)).
The dialog's bootstrap-mode dispatch (notebooksyncinfodialog2.cpp accept/apply handlers) routes the user's inputs through NotebookSyncInfoController::bootstrapApply rather than applyChanges, performing the atomic enable that takes a clean S0 to S5 in one shot.
Widget Construction Pattern
The migration off the legacy singleton architecture (VNoteX::getInst(), ConfigMgr::getInst(), the legacy MainWindow/Buffer) is complete; those types have been removed. All widgets, controllers, models, and views take their dependencies via constructor injection with ServiceLocator&. Follow this pattern for new code:
// Receives dependencies via constructor
class MyWidget : public QWidget {
Q_OBJECT
public:
explicit MyWidget(ServiceLocator &p_services, QWidget *p_parent = nullptr);
private:
ServiceLocator &m_services;
void doSomething() {
auto &config = m_services.get<ConfigCoreService>();
auto ¬ebooks = m_services.get<NotebookCoreService>();
}
};
Checklist for a new widget
- Add
ServiceLocator &p_servicesas the first constructor parameter - Store reference:
ServiceLocator &m_services - Resolve dependencies via
m_services.get<XxxCoreService>()— never global state - Have the parent widget pass its
ServiceLocator&down - Register the file in the relevant
CMakeLists.txt - Write a unit test with mock services
Related Modules
../core/AGENTS.md— ServiceLocator, services injected into widgets../controllers/AGENTS.md— Controllers that handle widget-initiated actions../views/AGENTS.md— Views embedded in widgets../gui/AGENTS.md— GUI services and utilities used by widgets../../AGENTS.md— Architecture overview, MVC rules, ViewArea2 design decisions