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 Apply All / Apply / Reset All / Reset / Defaults All / Defaults. 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):
def is_dirty(self) -> bool: ...
def save_changes(self) -> None: ...
def drop_changes(self) -> None: ...
def seed_defaults(self) -> None: ...
seed_defaults() (#342) fills the widgets with the section's factory values — what a fresh
install shows — as a staged edit, the way drop_changes() fills them with the saved ones; nothing is
persisted until Apply. The dialog calls it once at registration to learn each frame's defaults
([[appendices.settings-pages#dirty-state-ui]]) and again on the toolbar's Defaults / Defaults All.
A page does not name itself (#277). It used to carry a title property, which put the tree's
labels in a dozen classes that each knew only themselves, so no single place could be read — or
sorted — to see what the tree would look like. add_page takes the title instead, in two overloads:
def add_page(self, title: str, page: SettingsPage, /) -> None: ...
def add_page(self, group: str, title: str, page: SettingsPage, /) -> None: ...
Positional, in that order, so a run of registrations reads the way the tree does and sorts as plain
lines of text. The dialog stores the title on the row itself (TITLE_ROLE), apart from the row's
displayed text, which can carry a dirty badge ([[appendices.settings-pages#dirty-state-ui]]) — so
everything that has to recognize a row rather than draw it reads that role instead of unpicking a
prefix.
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(title, page); the dialog itself is the widget of a plain CDockWidget on the
outer CDockManager, tabbed beside the Documents dock and closed until asked for (#307). It carries no
chrome of its own — whether it reopens with the window is whether it was open when the window closed,
which that manager's saved layout already records ([[appendices.qt-ads#structural-layout]]), the same deal the Log
and Tasks docks get.
1. The category tree (#76, #277)
[[[appendices.settings-pages#category-groups]]]
Today the tree is a flat list, in alphabetical order, with one group (#277, #294, #298):
"Checksums" (ChecksumsPage, #242), "Descriptions" (DescriptionsPage), "Files" (FilesPage, #226,
291, #298), "Identity" (IdentityPage, #99), "Images" (a group, below), "Logs" (LogsPage, #200),
"Root Catalog" (RootCatalogPage, #457), "Session" (SessionPage, #65), "Shortcuts" (ShortcutsPage, #344),
"System Integration", "Tasks" (TasksPage, #202) and "Videos" (VideosPage, #225). "Images" nests three children: "Display" (ImagesDisplayPage), "Sidecar
Extensions" (ImagesFilesPage) and "Sidecar Names" (ScreenshotPatternsPage, #53, #287).
"System Integration" is one page on every platform with 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) — all three sharing that one title, which is
why all three registrations sit at the same point in the order. 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 order is the registration order, not a rule the dialog applies: add_page appends, and
MainWindow.__register_settings_pages makes its calls alphabetically — platform pages included, each
if sys.platform block sitting at its own page's position rather than after everything else. Ordering
that a caller can see and change beats a QSortFilterProxyModel layer over the existing filter proxy,
which would also complicate the current-row and restore-on-show paths (#228, #230) to take the decision
away from the one place that has the context to make it.
"General" is the one exception to alphabetical order, at any level. A group whose pages would read as one broad page plus a set of specific ones names the broad one "General" and registers it first, ahead of where its title would sort — "Scrapers/General" before "Scrapers/ArtStation", were Scrapers ever to split. No group has a "General" page today; the rule is written down so the first one does not reinvent it.
"Images" is the one group in use (#294) — the group= machinery kept dormant since #277 for "the
next tree that wants a tier" finally has one. Until #277 the four pages a resource type owns —
Descriptions, Files, Images, Videos — nested under a Plugins group row. What that bought
was a word the reader has to know before they can look under it: "Videos" is findable by its own name,
and "Plugins" only hides it behind an implementation term. So they were promoted to top-level rows —
all but Images, which #294 puts back under a group of its own, because unlike "Plugins", "Images" is
already the word the reader has.
The test a top-level row passes is that 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.
Legacy screenshot rules pass it the same way (#53): converting a .tc happens to a resource of any
type, and the import wizard that reads them is reached from File as well.
One page per subject, not per owner — split along how that subject is asked about, once "Images"
had two questions worth asking separately (#294). "Images/Display" answers how an image is shown:
the viewer surface and thumbnail strips, the Content Images dock's row-height clamp, its Show banners box and
the box hiding a top folder named like its zip (#392)
and whether a maximized image shows its info overlay, on what backdrop, whether a double-click closes it and whether its close selects the image back in the dock (#221) — all ImageViewerSettings — and the
width cap on an image embedded in a description (MarkdownRenderingSettings). The other two pages describe one bucket, a resource's
sidecar images — the image files kept beside it, which its thumbnail strip curates from and its
description references with ![](). "Images/Sidecar Extensions" says which files count as one
(ReferenceImagesSettings, #222; today read by the reference-images count), and "Images/Sidecar
Names" (ScreenshotPatternsPage, #53, #287) how their names are recognized. The width cap and the
extension list both arrived from elsewhere — the cap was a block on Descriptions, the extension list a
"Reference Images" page holding nothing but that list — 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. "Files"/"Screenshots" and "File Extensions"/"File Names" were tried for the two sidecar
pages and dropped: the first pair read as every file, or the screenshots themselves; the second lost
that they are about images at all. "Sidecar" is the specs' own word, kept over a plainer one because it
says where these images live.
"Files" is a flat top-level page, not a group, holding two settings objects that share no subject
but "a file on disk" (#226, #291, #298, #312): how a deleted file is deleted and whether that is asked
about (DeletionSettings), and the filename globs a content scan leaves out (ExcludedFilesSettings).
The deletion policy is three boxes -- Move deleted files to the Recycle Bin, if possible first, then
Clear backups without asking and Delete images without asking under a caption saying a permanent
delete is confirmed unless -- and nothing else gates a user-facing delete (#312). The order is the
dependency: the bin box decides when a delete is permanent, and only a permanent one is ever asked
about, so the two boxes under the caption matter only once the first has made one. A question is only ever asked for a permanent delete: with the bin on and reachable for the
file, it goes there with no question at all; a permanent delete, whether from the start (bin off) or as
the fallback when no bin was reachable (the once-per-operation question AskingDeleter asks, #301), is
confirmed unless the without asking box for that kind of file -- .orig conversion backups, or a
screenshot -- says not to. That replaced #301's own "delete permanently without asking when the Recycle
Bin is not available" knob and the bulk dialog's per-batch copy of it: one box per kind of file now
silences both the up-front confirmation and the no-bin one. Every such confirmation carries its kind's
box (#313), worded verbatim as it is on this page -- ticked and answered Yes, it writes the same
setting, so the reset is unticking it here; ticked and answered No, nothing is written, since a No with
"don't ask" would mean never delete, which the flag cannot express. One gate, confirm_delete
(delete_confirmation.py), answers for every surface, and AskingDeleter puts its refusal-point
question through the same box. The bulk backups dialog is the one surface whose delete runs with no
window to ask from: on Windows, where a drive's bin is exact and cheap to query, it asks the no-bin
question once per batch before enqueueing, naming a mixed selection's split; elsewhere the refused
row's message names the box instead. The Recycle Bin choice used to be a block on
Images/Sidecar Extensions, back when RehuDocumentImageOrganizer's screenshot delete was its only
consumer (#291) -- but once a .tc conversion's discarded backup and a discarded conversion-backups set
started reading it too (#298), a name under "Images" was the wrong subject for a choice that no longer
has anything to do with images. It moved to "Files" instead, its frame first on the page, above the
excluded-file-patterns editor (formerly the standalone "Excluded Files" page, #226) it shares that page
with now.
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/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.
The group tier (#277, #294)
Kept dormant since #277 until "Images" needed a tier of its own (#294). The tree is two levels deep
at most: add_page("Images", "Sidecar Extensions", page) nests the page's row under that group's row, creating
the group's row on first use; the two-argument add_page("Videos", page) — what every other caller
uses — leaves it a top-level row of its own. Group names are plural: a group holds pages.
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.
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.
- Defaults All / Defaults — call
seed_defaults()the same two ways (#342). A staged edit like typing: the frames turn dirty and Apply commits, so nothing here persists on its own.
What "saved" or "dropped" actually means is entirely up to each page. Two shapes exist today:
- Staged-edit pages (
DescriptionsPage, "Descriptions";ImagesDisplayPage, "Images/Display";ImagesFilesPage, "Images/Sidecar Extensions";FilesPage, "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).ImagesDisplayPagewrites the two settings objects the single "Images" page used to for how an image is shown (#294) -- the viewer surface and the description image-width cap -- each saved whole because that is the unit its ownsave()takes.FilesPagelikewise writes two, but they never shared a page before #298: the Recycle Bin choice and the excluded-file patterns, filed together only because both are about a file on disk rather than about any one resource type. WritingMarkdownRenderingSettingsonImagesDisplayPagere-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.ImagesDisplayPagewrites 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.
FilesPage's excluded-patterns block 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.
ImagesFilesPage'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).
Normalizing never drops a row the user can still fix. A pattern that does not parse — an uncompilable regex on Images / Sidecar Names, an unknown placeholder on a Locations page — is kept and flagged across Apply, so a typo is corrected in place rather than retyped; the settings object keeps two views of the list, the stored one the page stages against and the effective one everything else reads, and only the effective one skips such a row (#322).
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, a checkableQAbstractButton— a Browse… or Register push button holds nothing, #342 —QSpinBox, and anyItemListEditor, read as every cell'sEditRolevalue — so a derived, read-only column such as the try-it table's slot never paints its frame when a pattern above it changes, #287) at construction, anddirty_frames()compares the live values against that snapshot. A Try it frame is no exception: its sample (the Locations record, the Sidecar Names filenames) is a setting like the patterns it previews — staged in its widgets, dirtying its frame and the page, committed by Apply or "Apply changes as they're made", put back by Reset and Defaults. Only the preview beside it never waits: it expands the patterns with the sample as typed, applied or not. A control whose value the built-in types cannot express says so itself through theValueControlprotocol (settings_value()/set_settings_value()), and is read and written like any other —ColorSwatchButton(settings/ui/color_swatch_button.py), the lightbox backdrop swatch, is the first: while the page kept that colour in an attribute beside a plain button, the frame never tinted and its buttons never enabled (#342).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.
Per-frame Apply, Reset and Defaults (#342). Every frame with something to reset carries up to three
right-aligned icon-only tool buttons in its header row: Apply commits that frame's edits and only
those, Reset writes the frame's saved values back into its widgets, Defaults its factory
values. The two restores are staged edits, not commits — the dirty comparison simply finds the frame
back at (Reset) or away from (Defaults) its clean state. #77's SettingsFrameOverlay was removed because
its per-frame Apply could only ever save the whole page (no widget says which settings field it maps
to, so a partial commit is not derivable from the widgets) and so read as scoped when it was not. What
makes both honest now is the snapshot SettingsFrameFilter already keeps per frame, pairing each value
widget with its value: restoring is the inverse of the reader that took it
(restore_saved/restore_defaults, writing by type — setText, setPlainText, setValue,
setChecked, an ItemListEditor's rows through its model, a ValueControl through its own setter), and
a one-frame commit is staged around the whole-page save (apply_frame): every other frame's
edits are parked — its widgets put back to their saved baseline — for the duration of save_changes(),
the baseline is resynced, and the parked edits are written back, where the dirty comparison finds them
again. What was persisted is exactly this frame's edits on top of what was already saved; what is on
screen is exactly what was typed; the page stays badged dirty until every frame is applied. The page
still only ever saves itself whole, so the toolbar's Apply is the same commit for all frames at once,
not a loop over them. The one thing parking cannot reach is state a page keeps off its widgets — and
writing a widget back can fire a handler that overwrites that state with the parked value
(DescriptionsPage's textChanged copies the editor into the draft slot of the engine shown, so
parking its engine frame would have replaced an unsaved CSS edit with the saved text, silently). Such
a page takes over its own buttons instead, below.
The factory values come from a second snapshot: add_page calls the page's seed_defaults(), has
the filter capture_defaults(), then calls drop_changes() and resync_baseline() — so the page is
never left showing anything but what it arrived with, and frames_at_defaults() mirrors dirty_frames()
against the other reference point. Enablement rides the same poll: Apply and Reset while the frame
differs from its saved values, Defaults while it differs from its defaults; every button starts disabled,
since the poll refreshes only the pages on screen. A frame with no value widget (has_values() — Windows
Integration's Register/Unregister, push buttons that hold nothing) gets no row; nor does one without a
<frame>_label to build the row around (the Scrapers table frame). The row itself
(SettingsFrameHeader, settings/ui/settings_frame_header.py) is injected by the dialog, not declared
by the page: it takes the label's place in the frame's layout via QLayout.replaceWidget — which
lands it wherever the label sat, a box item or a QFormLayout's spanning row alike — and adopts the
label, so every page gained the buttons with no .ui change. Its buttons wear NOT_A_CAPTION_PROPERTY,
so the filter never searches them (they are tool buttons, which the snapshot never counts anyway). A list
editor inside a headed frame has its own restore button hidden (item_actions.reset_action): the
row's Defaults now says the same thing, from one place. A page the generic path cannot see all of —
an editor the filter cannot read (a table model), or state kept off its widgets — takes over all three
buttons by satisfying FrameRestoringPage (apply_frame(frame) / reset_frame(frame) /
restore_frame_defaults(frame)), which the dialog prefers when present. DescriptionsPage is the one
that does: its two frames map one-to-one onto MarkdownRenderingSettings and
DescriptionEditorSettings, so each hook saves, reverts or re-seeds one object exactly, hidden CSS
draft included — which also makes its engine frame's Reset clear the page where a widget-only restore
could not.
The toolbar's Defaults / Defaults All call seed_defaults() on the current page(s) / every
page — the widgets change, the poll marks frames dirty, Apply commits as usual. They enable while some
frame with values is away from its defaults.
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 the title add_page was
given, kept on the row under TITLE_ROLE ([[appendices.settings-pages#overview]]) — so nothing has to
strip a marker back off the text to recognize a row (#277). For a grouped page, what is persisted is a
"<group>/<title>" path rather than the bare title (#294) — two children under different groups may
share a title, and only the path tells them apart. __item_for_title tries that path first and falls
back to matching the whole stored string as a bare title, which is what lets an ini saved before #294
still land somewhere: a pre-path grouped title finds its page by the old scan, and a pre-#294 flat title
now folded into a group ("Images") finds the group row it became.
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("Its Title", ItsPage())— at its alphabetical position among the existing calls, since registration order is tree order ([[appendices.settings-pages#category-groups]]). The page itself declares no title. Nesting it under a group uses the three-argument overload instead,self.__settings_dialog.add_page("Its Group", "Its Title", ItsPage())— "Images" is the one group in use today (#294). The first page registered is the initially-selected one. - 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. - Name the header label
<frame>_labeland make it the frame's first layout item (aQFormLayout's spanning row 0 counts). That name is how the dialog finds the label to build the per-frame Apply/Reset/Defaults row around ([[appendices.settings-pages#dirty-state-ui]]); a frame whose first row is something else gets no buttons, which is right only for a frame with nothing to reset. - Implement
seed_defaults()by filling the widgets from a freshly constructed, unloaded settings object —VideosSettings(),LogsSettings()— which holds exactly the field defaults, dataclass orSimplePropertyQObjectalike. Share the widget-filling body withdrop_changes()(a private__show(settings)taking either object), so the two can never disagree about which widget shows which field. A status label or registry table reflects saved state and is left alone. - Keep every staged value in a widget. A value a page holds in an attribute beside its widget is
invisible to the frame snapshot: the frame never tints, its buttons never enable, and the change
still rides along with another frame's Apply. Where none of the built-in control types fits, give the
widget
settings_value()/set_settings_value()(ValueControl) —ColorSwatchButtonis the model. - 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
ContentSizedTableViewunderItemListEditorfor a list whose entries are more than one field, rather than packing them into one string with a separator.ScreenshotPatternsPage's Try it table — a sample filename beside the read-only slot the pattern list assigns it, re-evaluated as either table changes (#287) — is the worked example: a smallQAbstractTableModelover the domain objects supplies the columns, and everything about how the list is edited still comes fromItemListEditor, so it behaves exactly as aStringListEditordoes. An inserted row is abandoned only when it is left with every editable cell empty, so its cells can be filled in any order (#387); a row that is kept but incomplete is flagged, and whether it counts as a value is the model's call.AuthorsListEditor(#97) is the same construction in a document field. - 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). - Mark a widget that holds no setting with the dynamic property
scratch(SCRATCH_PROPERTY,settings_frame_filter.py) — a search box, or an editor that follows a table's selection. The frame snapshot passes over it and everything inside it, so typing in it does not tint the frame dirty or enable Apply. Set it in the.uias a dynamic property (Designer shows it), or in code for a widget built at runtime.ShortcutsPage(#344) is the worked example: its search box, and the selected command's scope combo and key buttons, are scratch, and the table itself is the frame's one value, aValueControlwhose value is the draft keymap. The editor frame below the table has only scratch controls, so the dialog gives it no header; the page builds its ownSettingsFrameHeaderthere, whose Apply / Reset / Defaults act on the selected command alone. - A page whose staged values can be invalid to save implements
can_save() -> bool(SaveGatedPage,settings_page.py; optional, a page without it can always save). The dialog keeps Apply and Apply All disabled — and each frame's own Apply — while a dirty page answers no, and auto-apply skips it, so the page stays dirty, showing what it holds, until it is fixed or reset. The page should also refuse in its ownsave_changes.ShortcutsPageanswers no while two commands share a key in overlapping scopes -- a safety net only, since every edit that would take another command's key asks to reassign it first. - 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 — ImagesFilesPage's extension list is read
only when an enumeration runs, and FilesPage's excluded-patterns block only when a size scan or a
checksum run does, so a plain dataclass carries each and there is nothing to watch either change.
ScreenshotPatternsSettings used to be the same shape, until an open document's image strip,
lightbox and Markdown view started resolving screenshots with the configured patterns rather than
the shipped defaults (#281): a saved pattern change now has to move files into and out of an open
document's screenshot set, so it applies this recipe too — the subscriber is RehuDocumentModel,
which rebuilds its scanner in __make_image_scanner and announces it through image_scanner_changed,
the seam the strip, the selector and the Markdown view already rebind on for a .tc → .rehu
conversion. 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.
A setting that is also applied from outside the dialog (#457): the Root Catalog menu's Automatically
preview the current rehu writes the shared RootCatalogSettings the moment it is clicked, with no Apply behind it,
so the saved value can change while its page is open. The page is the ordinary staged shape, and says so through
the optional ExternallySavedPage protocol: it emits saved_changed whenever the shared setting changes, and the
dialog answers with SettingsFrameFilter.rebase -- a frame with no edit shows the new saved value and stays clean
(nothing was typed there), a frame with an edit keeps it, now compared with the new saved value, so an edit that
equals what the menu just applied stops being one and Reset restores the new saved value. The menu follows an
applied page the same way every other live consumer does, through the setting's _changed signal.
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/rehuco_agent_tests/conftest.py, which clears the cache and mocks persistent_settings()
around every test. The session, the window state and the default layouts live in JSON files, not the .ini (#404):
the autouse isolate_state_files fixture keeps those in memory, patching state_file.read_state_file /
write_state_file, and a test that must see what was written takes it as state_files. 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_legacy_screenshots_settings /
isolate_shared_videos_settings the plain-dataclass counterparts, all sitting right beside it.
A page may carry more than one settings object: the Scrapers page also holds the web search engines
(#388) — a WebSearchSettings list of name + URL-template rows ({query} is where the URL-encoded
words of the location name go), staged in a WebSearchEnginesEditor whose first column is a radio
button. The active flag lives on the row and the model keeps exactly one on, so it moves, duplicates and
deletes with its row and the frame's Reset/Defaults restore it with the rows. Its
isolate_shared_web_search_settings sits beside the others, and the document toolbar's Search the Web
action searches through the active engine.
7. The state files (#404)
[[[appendices.settings-pages#state-files]]]
What is large or grows with use does not live in the .ini: the document session, the window's geometry and dock
layout, and the default dock layouts are JSON files under the app's own config folder (config_folder(), the
rehuco-agent/ folder beside the .ini), next to task-queue.json ([[appendices.task-queue#lifetime]]).
settings/state_file.py reads and writes all three.
Common shape. One JSON object per file, "version" first, then the fields below. A blob (Qt's saveGeometry()
and saveState(), a document's dock-layout state) is spelled base64 so JSON can hold it; a layout tree
(QtAdsLayout) is a nested object, stored as it is. Writes are atomic (borco_core.atomic_write_text), whole-file, and
made when the owner closes, never on progress. A write that fails is logged, never raised.
Lenient on read. A file that is missing, unreadable, not JSON, not an object, or of another version than the
build reads is treated as if it were absent: the settings object is left (or reset) to its empty defaults, the cause
is logged, and the app starts. A missing file is not logged. A file that existed but read as absent is moved aside as
<name>.bak by the next write to it, so a newer build's file, or one a virus scanner briefly locked, is not lost to
the first save (#478). Inside a readable file, a field of the wrong type, a damaged base64 blob or a malformed list
entry costs only itself.
document-session.json
DocumentSessionSettings, version 1. Which .rehu files were open, in most-recently-used order.
| Field | Type | Meaning |
|---|---|---|
focused_path |
string | POSIX-style path of the document that had the focus; "" for none |
docks_layout |
object | DocumentsDock.save_state()'s layout tree (splits and tabs between the open documents); anything but an object reads as none |
items |
list of objects | each {"path": string, "open": bool, "state": base64}; an entry with no non-empty string path is skipped, and open is open only when it is the JSON true |
state is the document's own dock-layout state (DocumentWidget.save_state(), cbor2-encoded). On save, every open
entry is kept and the closed ones are cut to the newest few (MAXIMUM_REMEMBERED_FILES in all). Paths are stored as
saved and are not resolved on load, so a share that is down cannot stall the start.
main-window.json
MainWindowSettings, version 1. The main window as it was closed.
| Field | Type | Meaning |
|---|---|---|
geometry |
base64 | QMainWindow.saveGeometry(); empty restores nothing |
outer_layout |
object | the outer dock manager's layout tree (Documents, Log, Tasks, Settings and the rest), with the Log dock's filters and the Tasks dock's nested layout folded into their entries; anything but an object reads as none |
toolbars_state |
base64 | QMainWindow.saveState() for the toolbar area; Qt checks its own version (TOOLBARS_STATE_VERSION) and keeps the default on a mismatch |
layouts/<group>.json
DefaultLayoutSettings, version 1; one file per group, so the sets never meet. default_layout.json holds the
Documents dock's saved default layouts and preview_layout.json the preview's last arrangement of each type; a host
with defaults of its own names another group.
| Field | Type | Meaning |
|---|---|---|
states |
object | resource type's main key ("" for a document with no type) to a base64 DocumentSubDocks.save_layout_state() blob; a type whose blob is empty or damaged has no default |
A type with no entry has no default: layouts are never inherited across types.