Skip to content

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 whose title or 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_headings shows 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 until save_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). ImagesDisplayPage writes 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 own save() takes. FilesPage likewise 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. Writing MarkdownRenderingSettings on ImagesDisplayPage re-persists the engine and CSS unchanged: what the shared object holds is already the last-saved pair, so a DescriptionsPage edit still staged is neither picked up nor clobbered. ImagesDisplayPage writes 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. Only mode stays 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_visible decides 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:

  1. __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.
  2. The staged values (engine, both CSS drafts, image-width) are written onto the shared MarkdownRenderingSettings singleton (§5 below).
  3. settings.save(persistent_settings()) writes the now-current values to the on-disk QSettings ini.

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 — SettingsDialog reads a page's own is_dirty(). A dirty page's category-tree row is prefixed with DIRTY_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 track is_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. SettingsFrameFilter derives it generically instead: it snapshots every frame's recognized control values (QLineEdit, QPlainTextEdit, a checkable QAbstractButton — a Browse… or Register push button holds nothing, #342 — QSpinBox, and any ItemListEditor, read as every cell's EditRole value — 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, and dirty_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 the ValueControl protocol (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 every save_changes()/drop_changes(), or dirty_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 own is_dirty() already checks, with one accepted gap: DescriptionsPage keeps 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 and is_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 .py per page, flat under rehuco_agent/settings/ui/ (no further subdirectory — matches the one-file-per-unit convention already used for fields/*.py).
  • Implement SettingsPage structurally: an ordinary .ui-backed QWidget subclass ([[appendices.code-conventions]]), no base class to inherit.
  • Register it in MainWindow.__register_settings_pages via self.__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 the if 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 from main_window.py — main_window.py already 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 time RegistryPage tried it).
  • Group the page's controls into labeled top-level QFrames in the .ui (a bold header QLabel plus 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 no field_labels or apply_filter and 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>_label and make it the frame's first layout item (a QFormLayout'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 or SimpleProperty QObject alike. Share the widget-filling body with drop_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) — ColorSwatchButton is 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 after setupUi() (main_layout.setStretch), not in the .ui: the current pyside6-uic mistranslates a box-layout stretch property. A page adds no scroll area of its own — add_page already 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 over CHECKSUM_ALGORITHMS (#242) are the first. Hand-listing them in the .ui drifts silently the moment core edits its set, and an algorithm added there would simply be unofferable. Build them before the page is registered: SettingsFrameFilter gathers a frame's searchable text once, at add_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-separated QLineEdit, 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 own Del. with_ordering=False (or set_ordering_visible) hides the ordering half for a list whose order carries nothing. It ships no icons: call apply_item_action_icons (rehuco_agent/item_action_icons.py) to dress it in this app's set, and set its defaults to 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 a QStringListModel under ItemListEditor, the shared machinery the authors record 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 ContentSizedTableView under ItemListEditor for 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 small QAbstractTableModel over the domain objects supplies the columns, and everything about how the list is edited still comes from ItemListEditor, so it behaves exactly as a StringListEditor does. 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 plain QListView (StringListEditor already uses one inside, over a QStringListModel). 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 .ui as 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, a ValueControl whose 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 own SettingsFrameHeader there, 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 own save_changes. ShortcutsPage answers 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 a QLabel with wordWrap on. A plain wrapping QLabel hints 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 with load/save), MarkdownRenderingSettings is a reactive QObject built from borco_pyside.core.SimpleProperty fields, each paired with a <name>_changed Signal (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 fresh MarkdownRenderingSettings() 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 from make_viewer — connects to every relevant _changed signal 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() assigning settings.engine = ... etc. on the shared singleton is what actually fires these signals (SimpleProperty only 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, the QSettings write.

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.