Settings Pages — Managing App-Wide Configuration
[[[appendices.settings-pages]]]
Overview
[[[appendices.settings-pages#overview]]]
SettingsDialog (rehuco_agent/settings/ui/settings_dialog.py, #47) is a VLC-preferences-style
shell: a filterable category tree on the left, the selected category's page on the right, and a
toolbar with Save all / Save current page / Drop all / Drop current page. It holds no settings
content itself — every category is a SettingsPage (settings/ui/settings_page.py), a
@runtime_checkable Protocol an ordinary .ui-backed QWidget satisfies structurally, the same
style already used for the field toolkit's StatefulWidget/FieldModel ([[plugins#field-toolkit]]):
class SettingsPage(Protocol):
title: str
def is_dirty(self) -> bool: ...
def save_changes(self) -> None: ...
def drop_changes(self) -> None: ...
Two-level filtering (#67). The one filter box drives two nested filters off the same text, and a
page implements neither — both are the dialog's job, driven by a SettingsFrameFilter
(settings/ui/settings_frame_filter.py) the dialog builds for each page in add_page:
- Page level (
CategoryFilterProxyModel) — the category tree shows every page whosetitleor any frame's gathered text contains the filter text (and, per the group rules in [[appendices.settings-pages#category-groups]], every page of a matching group). - Frame level — a page groups its controls into labeled
QFrames; the currently-shown page hides every frame not matching the text, so a crowded page collapses to just the group being searched for. The frame is the smallest unit shown or hidden — never a single control inside one.
SettingsFrameFilter discovers a page's top-level QFrames and gathers each one's searchable
text by introspection — walking its child widgets for user-visible captions (QLabel text, button
text, QGroupBox titles) once, at construction. So a page needs no hand-maintained term list: the
filter tracks whatever the .ui actually says, including renamed labels and translations, and never
recomputes per keystroke. (Only exact-type QFrames count as groups, so a QFrame subclass like a
decorative rule isn't mistaken for one.)
A "Show full page if title matches" WrappingCheckBox (borco_pyside.widgets) sits under the
filter box. When checked, text matching a page's title shows that page in full — every frame —
regardless of which individual frames also match; when unchecked, the title is ignored for frame
visibility and only the frames whose text matches are shown (so a title-only match shows no frames).
Either way, a page still appears in the tree on a title or frame match. The dialog re-runs the
current page's frame filter whenever the filter text or the toggle changes, and whenever a different
page becomes current.
Only the pages scroll (#229). add_page wraps each page in a widget-resizable QScrollArea of its
own and stacks that, so a tall page scrolls while a short one sits still. One per page, not one
around the stack: a QStackedWidget reports its tallest page's height as its own, so a shared scroll
area would scroll a two-row page by the longest page's length. Everything around the stack — the
toolbar, the filter box, both toggles and the category tree — is chrome that stays put however small the
dialog gets, since a control the user cannot reach is a setting they cannot change. The tree is not
wrapped either: a QTreeView already is a scroll area, and nesting them gives two sets of scrollbars
and a tree that can be scrolled out of its own viewport.
MainWindow.__register_settings_pages constructs each page and calls
SettingsDialog.add_page(page); the dialog itself lives inside a floating-first, dockable
DockableDialog on the outer CDockManager (#47's dockable-dialog framework — out of scope here).
1. Category groups (#76)
[[[appendices.settings-pages#category-groups]]]
The category tree is two levels deep at most: add_page(page, group="Plugins") nests the page's
row under that group's row, creating the group's row on first use; add_page(page) leaves it a
top-level row of its own. Today Plugins holds "Descriptions" (DescriptionsPage), "Excluded
Files" (ExcludedFilesPage, #226), "Images" (ImagesPage) and "Videos" (VideosPage, #225) —
registered in that alphabetical
order. Group names are plural — a group holds pages, and Plugins is where a resource type's own
settings go.
Top-level is for what is about the app rather than about a resource type: "Identity"
(IdentityPage, #99), "Logs" (LogsPage, #200), "Tasks" (TasksPage, #202), "Checksums"
(ChecksumsPage, #242), and "System Integration" — one page on every platform, a different class behind
each (RegistryPage on Windows, DesktopIntegrationPage on Linux, and SystemIntegrationPage on macOS,
which registers nothing because the association comes from the app bundle). macOS has that page at all
only because the tray block lives on it (#205): a setting deciding what the window's close button does has
to be reachable wherever there is a window. The test each of them passes is the same: a reader looking for it has
no plugin name to guess. Checksums govern every resource type and the sweep that reads them is reached
from File rather than from a document, so filing them under a plugin would hide them behind a word the
reader never thought of.
One page per subject, not per owner. "Images" gathers every image-shaped setting whichever object
owns it: the viewer surface and thumbnail strips (ImageViewerSettings), the width cap on an image
embedded in a description (MarkdownRenderingSettings), and which archive entries a reference-images
resource counts as its images (ReferenceImagesSettings, #222). The last two arrived from elsewhere —
the cap was a block on Descriptions, the extension list a "Reference Images" page holding nothing but
that list. Both were filed where the code owned them, so finding either meant knowing which plugin
or settings object to look under, when what the reader had was the word "images". A page whose one
block is a list is also a tree row that costs a click to learn it holds one thing.
A group row carries no page of its own — it is a header. Selecting it shows everything under it at once, in one scrolling column, each page's contribution under its own title as a heading — since the tree selection no longer names what you are looking at once several pages are shown together (#230).
The column takes blocks, not pages (SettingsBlockColumn, settings/ui/settings_block_column.py).
The block — a page's top-level labelled QFrame, the same unit the filter shows and hides — is the
row here, and a page is simply the view that shows its own blocks when its own row is selected. Two
things fall out of that which the alternative had to be patched into:
- A heading follows its blocks. A page the filter empties contributes nothing, so there is no
heading left standing over a gap promising settings that filtered out.
sync_headingsshows a heading exactly while some block under it is visible. - Nothing spreads. A page's layout is written for being shown alone — zero margins, a trailing spacer, and sometimes one block stretched to fill what is left ([[appendices.settings-pages#adding-a-page]]). Carrying whole pages into the column brought all of that with them: each page's spacer claimed a share of the height and pushed the pages apart, and the stretch had to be argued back down with a size policy. Taking only the blocks leaves that layout where it is right — on the page's own view — and the column supplies the single trailing stretch itself.
So a block fills when alone and packs when it isn't, without either view knowing about the other:
DescriptionsPage stretches its engine block so the CSS editor fills the page, and in a group column
that same block takes its natural height. The dialog reads that stretch once at registration
(__record_blocks) and re-applies it when the block comes home, because a box layout keeps stretch per
item and a widget taken out leaves its factor behind.
A block has one parent, so moving between the two views is always a move, never a copy. Everything that walks pages (Save all / Drop all, and Save/Drop current page on a selected group row) recurses one level into groups, so a grouped page is never skipped.
A second WrappingCheckBox, "Show full group if title matches", sits under the page-level one and
makes the tree filter group-aware:
- checked — text matching a group's own title shows every page under it, even pages whose own title/fields don't match;
- unchecked — filtering stays page-scoped: a group's title has no say in its pages' visibility (so a group-title-only match shows nothing at all).
A page matching the filter on its own merits is shown either way, whatever its group. A group row is
shown exactly when at least one page under it is: Qt hides a rejected parent's whole subtree, so
CategoryFilterProxyModel.filterAcceptsRow accepts a group row on its children's behalf (a group
can never be shown empty, and a page can never be hidden by its group alone). A filtered-out group row
takes its leaves' expansion state with it, so the dialog re-expands the tree after every re-filter —
otherwise a page could survive the filter yet stay unseen.
The whole filter state persists across restarts — the filter text and both toggles — via
SettingsDialogSettings (settings/settings_dialog_settings.py). The dialog restores it in
__init__, before wiring the widgets' signals up, so seeding them doesn't immediately re-save what
was just loaded; the proxy is seeded by hand for the same reason (no signal to ride in on). Each
page's own frame filter needs no seeding: a page is frame-filtered when it becomes current, and the
first page added becomes current immediately.
Saving is SettingsDialog.save_filter_state(), called from MainWindow.closeEvent alongside the
app's other at-shutdown saves (window state, session, recent files, theme) — the dialog lives in a
dock, so it has no close/done path of its own to save from the way UnsavedChangesDialog (a real
QDialog) does from done(). Saving per keystroke instead would mean one ini write per character
typed into the filter box, for no gain.
Testing note: because construction alone touches persistent storage, the autouse
isolate_settings_dialog_settings fixture in packages/rehuco-agent/tests/conftest.py patches the
dialog's persistent_settings() — otherwise any test building one (directly, or via MainWindow)
would read and overwrite the developer's real settings file, and leak toggle state into later tests.
2. Save/Drop: what the toolbar actions do
[[[appendices.settings-pages#save-drop-actions]]]
The dialog shell dispatches, it never interprets:
- Save all / Drop all — call
save_changes()/drop_changes()on every registered page, in tree order (a group's pages together, at the group's own position). - Save current page / Drop current page — call it on the selected leaf page, or on every page under a selected group row (#230) — the same one-level recursion Save all / Drop all already do, now also driven by what the tree's current row is rather than always every page.
What "saved" or "dropped" actually means is entirely up to each page. Two shapes exist today:
- Staged-edit pages (
DescriptionsPage, "Descriptions";ImagesPage, "Images";ExcludedFilesPage, "Excluded Files";VideosPage, "Videos") — edits live in local widget/draft state untilsave_changes()pushes them somewhere permanent;drop_changes()discards the draft and reloads the fields from whatever is currently saved (a revert, not a no-op).ImagesPageis the one page writing three settings objects, one per block, each saved whole because that is the unit its ownsave()takes. WritingMarkdownRenderingSettingsthere re-persists the engine and CSS unchanged: what the shared object holds is already the last-saved pair, so aDescriptionsPageedit still staged is neither picked up nor clobbered.ImagesPagewrites a reactive singleton (ImageViewerSettings, §5's recipe), because applying it has to show its own effect on what is already on screen: every open document's image strip resizes and takes up the chosen layout — one row or wrapped ([[plugins#tutorial-plugin]]) — and every open maximized viewer resizes and shows or hides its own thumbnail row. Onlymodestays read-at-open-time — it decides where the next viewer is built, and nothing already up can follow a change to it. One of its values is deliberately only a starting point:strip_visibledecides whether a maximized screenshot opens with its thumbnail row the first time a document shows one ([[plugins#tutorial-plugin]]). Toggling that row inside a viewer never writes back here — it is that document's own view state, and lives in its saved layout beside which tabs it has open — because a shared setting would let one document's toggle decide how every other document's viewer opens. Applying it does reach the viewers currently open, so the effect is visible where the user is looking, and each of those documents then remembers the applied value as if its toggle had been clicked by hand. A document with no viewer open resolves the setting afresh whenever it opens one, so it is never seeded with a stale default from whenever it happened to be constructed. - Mixed pages ("System Integration") — its registration buttons (Register/Unregister) already took
effect on the OS the moment they were clicked, so nothing of theirs is ever staged; the tray checkbox
beside them (#205) is staged like any other control.
is_dirty()/save_changes()/drop_changes()therefore answer for the tray block alone. The page was purely immediate-effect until #205 merged Tray into it, which is what turned a page that could never be dirty into one that can.
is_dirty() is what drives all of the dialog's dirty-state UI — the tree badges, the Apply/Reset
enablement, and auto-apply ([[appendices.settings-pages#dirty-state-ui]]).
3. How a page persists its own changes
[[[appendices.settings-pages#persisting-changes]]]
There is no generic persistence layer in the dialog shell — persisting is entirely each page's own
job, via whatever save_changes() does. DescriptionsPage's flow is the concrete pattern to
follow for a new staged-edit page:
__sync_current_css_draft()folds the visible CSS editor's text into whichever draft slot (__markdown_css_draft/__mistletoe_css_draft) matches the currently-selected engine radio — the two engines' CSS stay independent even though they share one editor widget.- The staged values (engine, both CSS drafts, image-width) are written onto the shared
MarkdownRenderingSettingssingleton (§5 below). settings.save(persistent_settings())writes the now-current values to the on-diskQSettingsini.
ExcludedFilesPage adds a fourth step to that flow, because saving can change what it saved: blank and
duplicate patterns are dropped, and an emptied list resolves back to the shipped defaults
([[data-model#checksums]]). It therefore reloads itself from the saved set afterwards — a page still
showing what was typed would disagree with what every scan actually reads, which is the same
one-predicate discipline the field locks follow. A page whose save_changes() normalizes owes the user
the normalized result on screen.
ImagesPage's extension block does the same, over the same StringListEditor, and the two are worth
reading side by side because what each of them normalizes is different: a pattern is matched
verbatim, so only blanks and duplicates go; a format also loses its leading dot and its casing, so
BMP comes back .bmp. Both rules live on the settings object, never in the widget — the widget holds
what was typed, which is what lets one editor serve two blocks that disagree (#231).
It also lost a Default/Custom radio pair in the same slice, whose flag said which half was in effect. The pair went because the empty-list fallback already draws that distinction — a list naming nothing is "whatever this app ships", and the editor's Reset fills the list with that set on request. The old keys are simply not read: an installation that had set a custom list gets the shipped formats back and sets it again, which was the cheaper trade than carrying a compatibility path for a preference two clicks deep.
VideosPage reloads itself after saving for the same reason, over the same rule: its video-extension
list normalizes exactly as the reference-images one does, and the two share it
(settings/extension_lists.py) rather than each restating a rule that differs only in which set an
emptied list falls back to. Its other block is the duration probe ([[field-schema#duration-size]]),
which adds two things neither list page needs. First, each backend's own settings are stored side by
side — an engine key naming a registry member, and ffprobe_executable kept whether or not ffprobe
is the selected backend — so switching to MediaInfo and back does not lose a path that was typed; that is
the markdown_css/mistletoe_css arrangement, and the page keeps both halves too, disabling the path
row under the other backend rather than hiding or clearing it. An engine naming a backend this build
does not have (an .ini written by a newer version) selects the default rather than raising, the way
ImageViewerSettings already treats an unrecognized mode. Second, the page reports whether the
selected backend can actually run, asking the probe itself (unavailable_reason) about the staged
choice as it is typed: a scan under an unusable backend raises rather than measuring 0
([[field-schema#duration-size]]), so an ffprobe path pointing at nothing has to be visible here rather
than surfacing as a row that refuses to compute.
The "System Integration" pages have no local settings dataclass of their own: Register/Unregister write
straight to the OS when clicked (rehuco_agent.windows_registration on Windows), so the registration half
leaves save_changes() nothing to do. What it does save is the tray checkbox, through TrayBlock
(settings/ui/tray_block.py, #205) into the shared TraySettings.
TrayBlock is not a widget, which is the part worth knowing: the block itself is a plain QFrame
declared in each of the three pages' own .ui, because SettingsFrameFilter counts only exact-QFrame
direct children of the page as blocks and deliberately excludes subclasses
([[appendices.settings-pages#category-groups]]) — a TrayBlock(QFrame) widget would have been invisible to
the filter, the group column and the dirty highlight alike. So the markup is duplicated per page and only
the behavior is shared, which is the half that reads and writes persistent storage and therefore the half
where three copies would drift into a real defect rather than a cosmetic one.
4. Dirty-state UI: badges, highlight, auto-apply (#77)
[[[appendices.settings-pages#dirty-state-ui]]]
is_dirty() is consumed at two granularities the dialog derives without any page-specific wiring:
- Page level —
SettingsDialogreads a page's ownis_dirty(). A dirty page's category-tree row is prefixed withDIRTY_DOCK_MARKER(documents/document_dock.py), the same glyph a dirty document tab uses, so the two affordances read as one idiom. Apply/Reset (current page and all) are enabled only while there is something for them to act on: current-page actions trackis_dirty()of the selected row's page(s), the "all" actions track whether any registered page is dirty. - Frame level — no page reports this;
SettingsPage.is_dirty()only ever answers for the whole page.SettingsFrameFilterderives it generically instead: it snapshots every frame's recognized control values (QLineEdit,QPlainTextEdit,QAbstractButton,QSpinBox,StringListEditor) at construction, anddirty_frames()compares the live values against that snapshot.resync_baseline()adopts the current values as the new clean state — the dialog calls it right after everysave_changes()/drop_changes(), ordirty_frames()would keep comparing against the previous clean state and report a just-settled page as still dirty. This snapshot approach needs no per-page changes and matches what every staged-edit page's ownis_dirty()already checks, with one accepted gap:DescriptionsPagekeeps the other engine's CSS draft off-widget while its own is shown, invisible to a widget-only snapshot — harmless, since the highlight is a visual aid andis_dirty()stays the badge/enablement source of truth.
A dirty frame gets a low-alpha pink background (DIRTY_BACKGROUND, fields/colors.py) -- a visual
aid only, naming nothing to click. The mechanics are the field toolkit's own WARNING_STYLESHEET
idiom: every block wears a QFrame[dirty="true"] property-selector stylesheet
(DIRTY_FRAME_STYLESHEET) from registration, and toggling the dirty dynamic property (plus an
unpolish/polish) is all that turns the tint on and off -- behind a changed-guard, so the poll leaves
unchanged frames entirely alone instead of re-parsing a stylesheet per tick. A background-only rule,
deliberately: it composes with the native StyledPanel border, so a dirty frame keeps the platform
look, where any QSS border rule would replace the OS-drawn panel wholesale. An earlier
version of this slice also floated a per-frame SettingsFrameOverlay with its own Apply/Reset buttons
in each dirty frame's corner; it was removed (#77) because those buttons could only ever act on the
whole page (nothing generic can tell which settings field a widget maps to, so a true per-frame
partial commit isn't derivable from the widgets alone), and a page with several dirty frames at once
showed several Apply/Reset pairs that all did the same whole-page thing -- reading as scoped to the
frame they sat in when they weren't. The toolbar's own Apply/Reset is the one true way to commit or
discard a page's changes.
A dialog-wide "Apply changes as they're made" WrappingCheckBox drives auto-apply: while checked,
a page found dirty on the next poll tick is committed immediately. It lives in the toolbar, added via
toolbar.addWidget() in __init__ rather than the .ui -- Designer has no way to drop a plain widget
onto a QToolBar (only actions), so it's built in code the same way ImageLightbox's own overlay
chrome is.
All of this is driven by a QTimer poll (DIRTY_POLL_INTERVAL_MS, 200 ms), started in showEvent and
stopped in hideEvent — nothing here needs to react faster than a human notices, and a poll is simpler
than wiring a change signal through every field-widget type each page happens to use. The dialog also
refreshes once synchronously after every add_page and after every toolbar commit, so the visible
state never waits a whole tick to catch up with an explicit action.
Dirty-marker identity note: a tree row's displayed text carries the badge, but its identity
(what restore_selected_page/save_filter_state compare and persist) is always the unbadged title —
SettingsDialog.__unbadged strips the marker before either reads a row's text.
5. Adding a new settings page
[[[appendices.settings-pages#adding-a-page]]]
- One
.ui+ one.pyper page, flat underrehuco_agent/settings/ui/(no further subdirectory — matches the one-file-per-unit convention already used forfields/*.py). - Implement
SettingsPagestructurally: an ordinary.ui-backedQWidgetsubclass ([[appendices.code-conventions]]), no base class to inherit. - Register it in
MainWindow.__register_settings_pagesviaself.__settings_dialog.add_page(...)— the first page added becomes the initially-selected one. Passgroup="..."to nest it under a category group ([[appendices.settings-pages#category-groups]]); leave it off for a top-level row. - A platform-gated page (like
RegistryPage— "System Integration", Windows-only) is imported lazily inside theif sys.platform == "win32":branch, and takes whatever app-level data it needs (e.g.ARCHIVE_EXTENSIONS) as a constructor parameter rather than importing it back frommain_window.py—main_window.pyalready imports the page module (even if lazily) to construct it, so a module-level import the other way round is a cyclic import (confirmed empirically the first timeRegistryPagetried it). - Group the page's controls into labeled top-level
QFrames in the.ui(a bold headerQLabelplus the controls). That is all a page does for filtering — the dialog discovers the frames and gathers their searchable text by introspection (§Overview), so a page implements nofield_labelsorapply_filterand keeps no term list. The frame is the smallest filterable unit — don't split a group's controls across separate frames expecting them to hide independently. - Give the page's root layout zero margins (the stack already provides padding) and end it with a
vertical spacer so frames stack at the top rather than stretching to fill. If one frame holds a
control that should grow (e.g.
DescriptionsPage's CSS editor), stretch that frame's layout item so it — not the spacer — takes the slack when shown, while the spacer keeps a lone remaining frame top-aligned. Set that stretch in the controller aftersetupUi()(main_layout.setStretch), not in the.ui: the currentpyside6-uicmistranslates a box-layoutstretchproperty. A page adds no scroll area of its own —add_pagealready gives it one, handing it the viewport's width and as much height as it asks for (§Overview). - A control offering one choice per entry of a registry core owns is built in the page's
__init__from that registry, into an empty mount widget declared in the.ui—ChecksumsPage's algorithm radios overCHECKSUM_ALGORITHMS(#242) are the first. Hand-listing them in the.uidrifts silently the moment core edits its set, and an algorithm added there would simply be unofferable. Build them before the page is registered:SettingsFrameFiltergathers a frame's searchable text once, atadd_page, so a control created later is invisible to the filter. A__init__-built control is fine; one built on first show is not. - Use
StringListEditor(borco_pyside.widgets) to edit a list of strings, rather than building a list and a column of buttons by hand — or, worse, a comma-separatedQLineEdit, which cannot hold a value containing the separator and makes changing one entry mean retyping the lot (#231). It is the list plus two action columns: insert/edit/delete/reset and top/up/down/bottom, each with a key (Ins,F2,Del,Ctrl+Home/Up/Down/End), armed on the view alone so an open in-place editor keeps its ownDel.with_ordering=False(orset_ordering_visible) hides the ordering half for a list whose order carries nothing. It ships no icons: callapply_item_action_icons(rehuco_agent/item_action_icons.py) to dress it in this app's set, and set itsdefaultsto whatever Reset should restore. It holds what was typed and normalizes nothing — that stays on the settings object, where two pages can (and do) normalize differently (§3). It is aQStringListModelunderItemListEditor, the shared machinery theauthorsrecord rows are built on too (#97), which is why a list edited on a settings page and one edited in a document behave identically. - Use
ContentSizedListView(borco_pyside.widgets) for any other list, not a plainQListView(StringListEditoralready uses one inside, over aQStringListModel). A list that scrolls inside a page that scrolls gives two vertical scrollbars and a list the reader has to scroll to before they can scroll in; this one is sized to its rows (one row as the floor) and lets the page's scroll area do the scrolling (#229). - Use
WrappingLabel(borco_pyside.widgets) for a paragraph of explanatory text, not aQLabelwithwordWrapon. A plain wrappingQLabelhints as though its text were one wide line, and the frame around it is sized from that hint — so the paragraph paints past the border (#226, fixed in #229).
6. Making the rest of the app react to a saved change
[[[appendices.settings-pages#reacting-to-changes]]]
A page's save_changes() only updates its own data (in memory and on disk) — it has no idea who
else in the app cares, and it never reaches into DocumentsDock or a specific viewer directly. The
live-update wiring instead lives on the settings data side:
- Unlike every other settings section in this app (
MainWindowSettings,DocumentSessionSettings, … — plain@dataclasses withload/save),MarkdownRenderingSettingsis a reactiveQObjectbuilt fromborco_pyside.core.SimplePropertyfields, each paired with a<name>_changedSignal(engine_changed,markdown_css_changed,mistletoe_css_changed,max_image_width_changed). shared_markdown_rendering_settings()(functools.lru_cache(maxsize=1)) is the single, process-wide instance every reader and writer uses. Constructing a freshMarkdownRenderingSettings()per consumer would give each an independently-updating copy, defeating the whole point — there would be nothing left to "share."- A consumer that needs to track live changes —
DescriptionField.__wire_rendering_settings, wired frommake_viewer— connects to every relevant_changedsignal and re-applies the full current state in one call (MarkdownView.apply_rendering_settings) on any of them, so a Save touching several fields at once re-renders exactly once, not once per changed field. save_changes()assigningsettings.engine = ...etc. on the shared singleton is what actually fires these signals (SimplePropertyonly emits when a value genuinely changes). Persisting to disk (settings.save(persistent_settings())) is a separate step after that — the live update to already-open viewers doesn't depend on, or wait for, theQSettingswrite.
Recipe for a new page that needs this same "already-open X reacts to Save" behavior: give its
settings a reactive QObject (not a plain dataclass) with SimpleProperty fields and matching
_changed signals, expose it through one module-level functools.lru_cache(maxsize=1)-wrapped
accessor, and have consumers subscribe to the signals they care about instead of re-reading the
value on every use. Not every block needs this at all — ImagesPage's extension list is read
only when an enumeration runs, and ExcludedFilesPage's pattern list only when a size scan or a checksum
run does, so a plain dataclass carries each and there is nothing to watch either change;
RegistryPage's actions land directly on the OS, so there is no other part of the app that needs to be
told a save happened.
Testing note: the lru_cached singleton persists across test functions within one process, and
would otherwise leak state between tests (or read the developer's real on-disk settings) — see the
autouse isolate_shared_markdown_rendering_settings fixture in
packages/rehuco-agent/tests/conftest.py, which clears the cache and mocks persistent_settings()
around every test. A new page with its own shared settings object needs the equivalent, reactive or
not — isolate_shared_image_viewer_settings is the second reactive one, and
isolate_shared_identity_settings / isolate_shared_reference_images_settings /
isolate_shared_excluded_files_settings / isolate_shared_videos_settings the plain-dataclass
counterparts, all sitting right beside it.