394 Commits

Author SHA1 Message Date
pax
27c53cb237 prevent info panel from pushing splitter on long source URLs 2026-04-10 20:05:57 -05:00
pax
93459dfff6 UI overhaul: icon buttons, video controls, popout anchor, layout flip, compact top bar
- Preview/popout toolbar: icon buttons (☆/★, ↓/✕, ⊘, ⊗, ⧉) with QSS
  object names (#_tb_bookmark, #_tb_save, etc.) for theme targeting
- Video controls: QPainter-drawn icons for play/pause, volume/mute;
  text labels for loop/once/next and autoplay
- Popout anchor setting: resize pivot (center/tl/tr/bl/br) controls
  which corner stays fixed on aspect change, works on all platforms
- Hyprland monitor reserved areas: reads waybar exclusive zones from
  hyprctl monitors -j for correct edge positioning
- Layout flip setting: swap grid and preview sides
- Compact top bar: AdjustToContents combos, tighter spacing, named
  containers (#_top_bar, #_nav_bar) for QSS targeting
- Reduced main window minimum size from 900x600 to 740x400
- Trimmed bundled QSS: removed 12 unused widget selectors, added
  popout overlay font-weight/size, regenerated all 12 theme files
- Updated themes/README.md with icon button reference
2026-04-10 19:58:11 -05:00
pax
d7b3c304d7 add B/S keybinds to popout, refactor toggle_save 2026-04-10 18:32:57 -05:00
pax
094a22db25 add B and S keyboard shortcuts for bookmark and save 2026-04-10 18:29:58 -05:00
pax
faf9657ed9 add thumbnail fade-in animation 2026-04-10 18:18:17 -05:00
pax
5261fa176d add search history setting
New setting "Record recent searches" (on by default). When disabled,
searches are not recorded and the Recent section is hidden from the
history dropdown. Saved searches are unaffected.

behavior change: opt-in setting, on by default (preserves existing behavior)
2026-04-10 16:28:43 -05:00
pax
94588e324c add unbookmark-on-save setting
New setting "Remove bookmark when saved to library" (off by default).
When enabled, _maybe_unbookmark runs directly in each save callback
after save_post_file succeeds -- handles DB removal, grid dot, preview
state, popout sync, and bookmarks tab refresh. Wired into all 4 save
paths: save_to_library, bulk_save, save_as, batch_download_to.

behavior change: opt-in setting, off by default
2026-04-10 16:23:54 -05:00
pax
9cc294a16a Revert "add unbookmark-on-save setting"
This reverts commit 08f99a61011532202b22d05750416aa1e754f9c9.
2026-04-10 16:20:26 -05:00
pax
08f99a6101 add unbookmark-on-save setting
New setting "Remove bookmark when saved to library" (off by default).
When enabled, saving a post to the library automatically removes its
bookmark. Handles both single saves (on_bookmark_done) and bulk saves
(on_batch_done). UI toggle in Settings > General.

behavior change: opt-in setting, off by default
2026-04-10 16:19:00 -05:00
pax
de6961da37 fix: move PySide6 imports to lazy in controllers for CI compat
CI installs httpx + Pillow + pytest but not PySide6. The Phase C
tests import pure functions from controller modules, which had
top-level PySide6 imports (QTimer, QPixmap, QApplication, QMessageBox).
Move these to lazy imports inside the methods that need them so the
module-level pure functions remain importable without Qt.
2026-04-10 15:39:50 -05:00
pax
f9977b61e6 fix: restore collateral-damage methods and fix controller init order
1. Move controller construction before _setup_signals/_setup_ui —
   signals reference controller methods at connect time.

2. Restore _post_id_from_library_path, _set_library_info,
   _on_library_selected, _on_library_activated — accidentally deleted
   in the commit 4/6 line-range removals (they lived adjacent to
   methods being extracted and got caught in the sweep).

behavior change: none (restores lost code, fixes startup crash)
2026-04-10 15:24:01 -05:00
pax
b858b4ac43 refactor: cleanup pass — remove dead imports from main_window.py
Remove 11 imports no longer needed after controller extractions:
QMenu, QFileDialog, QScrollArea, QMessageBox, QColor, QObject,
Property, dataclass, download_thumbnail, cache_size_bytes,
evict_oldest, evict_oldest_thumbnails, MEDIA_EXTENSIONS, SearchState.

main_window.py: 1140 -> 1128 lines (final Phase 1 state).

behavior change: none
2026-04-10 15:16:30 -05:00
pax
87be4eb2a6 refactor: extract ContextMenuHandler from main_window.py
Move _on_context_menu, _on_multi_context_menu, _is_child_of_menu into
gui/context_menus.py. Pure dispatch to already-extracted controllers.

main_window.py: 1400 -> 1140 lines.

behavior change: none
2026-04-10 15:15:21 -05:00
pax
8e9dda8671 refactor: extract PostActionsController from main_window.py
Move 26 bookmark/save/library/batch/blacklist methods and _batch_dest
state into gui/post_actions.py. Rewire 8 signal connections and update
popout_controller signal targets.

Extract is_batch_message and is_in_library as pure functions for
Phase 2 tests. main_window.py: 1935 -> 1400 lines.

behavior change: none
2026-04-10 15:13:29 -05:00
pax
0a8d392158 refactor: extract PopoutController from main_window.py
Move 5 popout lifecycle methods (_open_fullscreen_preview,
_on_fullscreen_closed, _navigate_fullscreen, _update_fullscreen,
_update_fullscreen_state) and 4 state attributes (_fullscreen_window,
_popout_active, _info_was_visible, _right_splitter_sizes) into
gui/popout_controller.py.

Rename pass across ALL gui/ files: self._fullscreen_window ->
self._popout_ctrl.window (or self._app._popout_ctrl.window in other
controllers), self._popout_active -> self._popout_ctrl.is_active.
Zero remaining references outside popout_controller.py.

Extract build_video_sync_dict as a pure function for Phase 2 tests.

main_window.py: 2145 -> 1935 lines.

behavior change: none
2026-04-10 15:03:42 -05:00
pax
20fc6f551e fix: restore _update_fullscreen and _update_fullscreen_state
These two methods were accidentally deleted in the commit 4 line-range
removal (they lived between _set_preview_media and _on_image_done).
Restored from pre-commit-4 state.

behavior change: none (restores lost code)
2026-04-10 15:00:42 -05:00
pax
71d426e0cf refactor: extract MediaController from main_window.py
Move 10 media loading methods (_on_post_activated, _on_image_done,
_on_video_stream, _on_download_progress, _set_preview_media,
_prefetch_adjacent, _on_prefetch_progress, _auto_evict_cache,
_image_dimensions) and _prefetch_pause state into
gui/media_controller.py.

Extract compute_prefetch_order as a pure function for Phase 2 tests.
Update search_controller.py cross-references to use media_ctrl.

main_window.py: 2525 -> 2114 lines.

behavior change: none
2026-04-10 14:55:32 -05:00
pax
446abe6ba9 refactor: extract SearchController from main_window.py
Move 21 search/pagination/scroll/blacklist methods and 8 state
attributes (_current_page, _current_tags, _current_rating, _min_score,
_loading, _search, _last_scroll_page, _infinite_scroll) into
gui/search_controller.py.

Extract pure functions for Phase 2 tests: build_search_tags,
filter_posts, should_backfill. Replace inline _filter closures with
calls to the module-level filter_posts function.

Rewire 11 signal connections and update _on_site_changed,
_on_rating_changed, _navigate_preview, _apply_settings to use the
controller. main_window.py: 3068 -> 2525 lines.

behavior change: none
2026-04-10 14:51:17 -05:00
pax
cb2445a90a refactor: extract PrivacyController from main_window.py
Move _toggle_privacy and its lazy state (_privacy_on, _privacy_overlay,
_popout_was_visible) into gui/privacy.py. Rewire menu action, popout
signal, resizeEvent, and keyPressEvent to use the controller.

No behavior change. main_window.py: 3111 -> 3068 lines.
2026-04-10 14:41:10 -05:00
pax
321ba8edfa refactor: extract WindowStateController from main_window.py
Move 6 geometry/splitter persistence methods into gui/window_state.py:
_save_main_window_state, _restore_main_window_state,
_hyprctl_apply_main_state, _hyprctl_main_window,
_save_main_splitter_sizes, _save_right_splitter_sizes.

Extract pure functions for Phase 2 tests: parse_geometry,
format_geometry, build_hyprctl_restore_cmds, parse_splitter_sizes.

Controller uses app-reference pattern (self._app). No behavior change.
main_window.py: 3318 -> 3111 lines.

behavior change: none
2026-04-10 14:39:37 -05:00
pax
d66dc14454 db: fix orphan rows — cascade delete_site, wire up reconcile on startup
delete_site() leaked rows in tag_types, search_history, and
saved_searches; reconcile_library_meta() was implemented but never
called. Add tests for both fixes plus tag cache pruning.
2026-04-10 14:10:57 -05:00
pax
df3b1d06d8 main_window: reset browse tab on site change 2026-04-10 00:37:53 -05:00
pax
127ee4315c popout/window: add right-click context menu
Popout now has a full context menu matching the embedded preview:
Bookmark as (folder submenu) / Unbookmark, Save to Library (folder
submenu), Unsave, Copy File, Open in Default App, Open in Browser,
Reset View (images), and Close Popout. Signals wired to the same
main_window handlers as the embedded preview.
2026-04-10 00:27:44 -05:00
pax
48feafa977 preview_pane: fix bookmark state in context menu, add folder submenu
behavior change: right-click context menu now shows "Unbookmark" when
the post is already bookmarked, and "Bookmark as" with a folder submenu
(Unfiled / existing folders / + New Folder) when not. Previously showed
a stateless "Bookmark" action regardless of state.
2026-04-10 00:27:36 -05:00
pax
9a8e6037c3 settings: update template help text (all tokens work on all sites now) 2026-04-09 23:37:20 -05:00
pax
9b30e742c7 main_window: swap score and media filter positions in toolbar 2026-04-09 23:10:50 -05:00
pax
31089adf7d library: fix thumbnail lookup for templated filenames
Library thumbnails are saved by post_id (_copy_library_thumb uses
f"{post.id}.jpg") but the library viewer looked them up by file
stem (f"{filepath.stem}.jpg"). For digit-stem files (12345.jpg)
these are the same. For templated files (artist_12345.jpg) the
stem is "artist_12345" which doesn't match the thumbnail named
"12345.jpg" — wrong or missing thumbnails.

Fix: resolve post_id from the filename via
get_library_post_id_by_filename, then look up the thumbnail as
f"{post_id}.jpg". Generated thumbnails (for files without a
cached browse thumbnail) also store by post_id now, so
everything stays consistent.
2026-04-09 23:04:02 -05:00
pax
64f0096f32 library: fix tag search for templated filenames
The tag search filter in refresh() used f.stem.isdigit() to
extract post_id — templated filenames like artist_12345.jpg
failed the check and got filtered out even when their post_id
matched the search query.

Fix: look up post_id via db.get_library_post_id_by_filename
first (handles templated filenames), fall back to int(stem) for
legacy digit-stem files. Same pattern as the delete and saved-dot
fixes from earlier in this refactor.
2026-04-09 23:01:58 -05:00
pax
dfe8fd3815 settings: cap thumbnail size at 200px
behavior change: max thumbnail size reduced from 400px to 200px.
2026-04-09 21:33:00 -05:00
pax
84d39b3cda grid: tighten thumbnail spacing from 8px to 2px
behavior change: THUMB_SPACING reduced from 8 to 2, making the grid
denser with less dead space between cells.
2026-04-09 21:19:12 -05:00
pax
19423776bc mpv_gl: add GL pre-warm debug log in ensure_gl_init
Logs when GL render context is actually initialized (not on the no-op
path). Confirms GL init fires once per widget lifetime, not on every
video click. Kept permanently for future debugging.
2026-04-09 20:54:04 -05:00
pax
d9830d0f68 main_window: skip parallel httpx download for streamed videos
behavior change: when streaming=True (uncached video handed directly to
mpv), _load now early-returns instead of running download_image in
parallel. mpv's stream-record option (added in the previous commit)
handles cache population, so the parallel httpx download was a second
TCP+TLS connection to the same CDN URL contending with mpv for
bandwidth. Single connection per uncached video after this commit.
2026-04-09 20:53:23 -05:00
pax
a01ac34944 video_player: add stream-record for cache population during playback
Replaces the parallel httpx download with mpv's stream-record per-file
option. When play_file receives an HTTP URL, it passes stream_record
pointing at a .part temp file alongside the URL. mpv writes the incoming
network stream to disk as it decodes, so a single HTTP connection serves
both playback and cache population.

On clean EOF the .part is promoted to the real cache path via os.replace.
Seeks invalidate the recording (mpv may skip byte ranges), so
_seeked_during_record flags it for discard. stop() and rapid-click
cleanup also discard incomplete .part files.

At this commit both pipelines are active — _load still runs the httpx
download in parallel. Whichever finishes second wins os.replace. The
next commit removes the httpx path.
2026-04-09 20:52:58 -05:00
pax
acfcb88aca mpv_gl: add network streaming tuning options
behavior change: mpv now uses explicit cache=yes, cache_pause=no
(stutter over pause for short clips), 50MiB demuxer buffer cap,
20s read-ahead, and 10s network timeout (down from ~60s default).
Improves first-frame latency on uncached video streams and surfaces
stalled-connection errors faster.
2026-04-09 20:52:22 -05:00
pax
8c5c2e37d3 popout/window: reorder stack switch, drop stop, fix close position
behavior change: _apply_load_video now switches the stack to the video
surface BEFORE calling play_file so mpv's first frame lands on a visible
widget instead of a cleared image viewer. Removes the redundant stop()
call — loadfile("replace") atomically replaces the current file.

Also fixes video position not surviving popout close: StopMedia (part of
CloseRequested effects) destroyed mpv's time_pos before get_video_state
could read it. Now closeEvent snapshots position_ms before dispatching
CloseRequested, and get_video_state returns the snapshot.
2026-04-09 20:51:59 -05:00
pax
510b423327 main_window: skip embedded preview stop() when popout is open
behavior change: _on_video_stream no longer calls stop() on the
embedded preview's mpv when the popout is the visible target. The
embedded preview is hidden and idle — the synchronous command('stop')
round-trip was wasting ~50-100ms on the click-to-first-frame critical
path with no visible benefit. loadfile("replace") in the popout's
play_file handles the media swap atomically.
2026-04-09 20:51:06 -05:00
pax
82e7c77251 main_window: read image dimensions for library popout aspect lock
Library items' Post objects were constructed without width/height
(library_meta doesn't store them), so the popout got 0/0 and
_fit_to_content returned early without setting keep_aspect_ratio.
Videos were unaffected because mpv reports dimensions later via
VideoSizeKnown. Images had no second chance — the aspect lock
was never set, and manual window resizing stretched them freely.

Fix: new _image_dimensions(path) reads the actual pixel size from
the file via QPixmap before constructing the Post. The Post now
carries real width/height. _update_fullscreen moved to run AFTER
Post construction so cp.width/cp.height are populated when the
popout reads them for pre-fit + aspect lock.

Not a regression from the templates refactor — pre-existing gap
in the library display path.
2026-04-09 20:29:15 -05:00
pax
4c490498e0 main_window: set _categories_pending BEFORE set_post renders
The flag was set in _ensure_post_categories_async which runs AFTER
_on_post_selected calls info_panel.set_post. By the time the flag
was True, the flat tags had already rendered. The flash persisted.

Fix: check whether a fetch is needed and set the flag in
_on_post_selected, right before set_post. The info panel sees the
flag and skips the flat-tag fallback on its first render.
2026-04-09 20:07:26 -05:00
pax
a86941decf info_panel: suppress flat-tag flash when category fetch is pending
When a category fetch is about to fire (Rule34/Safebooru.org/
Moebooru on first click), the info panel was rendering the full
flat tag list, then ~200ms later re-rendering with categorized
tags. The re-layout from flat→categorized looked like a visual
hitch.

Fix: new _categories_pending flag on InfoPanel. When True, the
flat-tag fallback branch is skipped — the tags area stays empty
until categories arrive and render in one clean pass.

  _ensure_post_categories_async sets _categories_pending = True
    before scheduling the fetch (or False if no fetcher = Danbooru)
  _on_categories_updated clears _categories_pending = False

Visual result:
  Danbooru/e621:        instant (inline, no flag)
  Gelbooru with auth:   instant (background prefetch beat the click)
  Rule34/SB.org/Moebooru: empty ~200ms → categories appear cleanly
                          (no flat→categorized re-layout)
2026-04-09 20:05:38 -05:00
pax
403c099bed library: clean up library_meta on delete (templated + digit-stem)
The Library tab's single-delete and multi-delete context menu
actions called .unlink() directly, bypassing delete_from_library
entirely. They only extracted post_id from digit-stem filenames
(int(stem) if stem.isdigit()), so templated files like
artist_12345.jpg got deleted from disk but left orphan
library_meta rows that made get_saved_post_ids lie forever.

Fix: resolve post_id via db.get_library_post_id_by_filename first
(handles templated filenames), fall back to int(stem) for legacy
digit-stem files, then call db.remove_library_meta(post_id) after
unlinking. Both single-delete and multi-delete paths are fixed.

This was the last source of orphan library_meta rows. With this
fix + the earlier delete_from_library cleanup, every deletion
path in the app now cleans up its meta row:
  - Library tab single delete (this commit)
  - Library tab multi delete (this commit)
  - Browse/preview "Unsave from Library" (via delete_from_library)
  - Browse multi-select "Unsave All" (via delete_from_library)
  - Bookmarks "Unsave from Library" (via delete_from_library)
  - Bookmarks multi-select "Unsave All" (via delete_from_library)
2026-04-09 19:58:28 -05:00
pax
912be0bc80 main_window: fix last digit-stem _saved_ids in _on_search_done
The primary search result handler (_on_search_done) was still using
the old filesystem walk + stem.isdigit() filter to build the saved-
post-id set. The two other call sites (_on_load_more and the
blacklist rebuild) were fixed in the earlier saved-dot sweep but
this one was missed. Templated filenames like artist_12345.jpg
were invisible, so the saved-dot disappeared after any grid
rebuild (new search, page change, etc).

Fix: use self._db.get_saved_post_ids() (one indexed SELECT,
format-agnostic) like the other two sites already do. Also drops
the saved_dir import that was only needed for the filesystem walk.
2026-04-09 19:56:55 -05:00
pax
1547cbe55a fix: remove early-exit on non-empty tag_categories in ensure path
Two places checked `if post.tag_categories: return` before doing
a full cache-coverage check, causing posts with partial cache
composes (e.g. 5/40 tags from the background prefetch) to get
stuck at low coverage forever:

  ensure_categories: removed the post.tag_categories early exit.
    Now ALWAYS runs try_compose_from_cache first. Only the 100%
    coverage return (True) is trusted as "done." Partial composes
    return False and fall through to the fetch path.

  _ensure_post_categories_async: removed the post.tag_categories
    guard. Danbooru/e621 are filtered by the client.category_fetcher
    is None check instead (they categorize inline, no fetcher).
    For Gelbooru-style sites, always schedules ensure_categories
    regardless of current post state.

Root cause: the partial-compose fix (try_compose_from_cache
populates tag_categories even when cache coverage is <100%)
conflicted with the early-exit guards that assumed non-empty
tag_categories = fully categorized. Now the only "fully done"
signal is try_compose_from_cache returning True (100% coverage).
2026-04-09 19:40:09 -05:00
pax
af9b68273c bookmarks: await save_post_file (now async) via run_on_app_loop
Two bookmark save sites updated for save_post_file's sync→async
signature change:

  _save_bookmark_to_library: wraps the save in an async closure
    and schedules via run_on_app_loop (already imported for the
    thumbnail download path). Fire-and-forget; the source file is
    already cached so the save is near-instant.

  Save As action: same async wrapper pattern. The dialog runs
    synchronously (user picks destination), then the actual file
    copy is scheduled on the async loop.

Neither site passes a category_fetcher — bookmarks don't have a
direct reference to the active BooruClient. The save flow's
ensure_categories check in library_save.py short-circuits (the
fetcher is None), so template rendering uses whatever categories
are already on the post object. For bookmark→library saves, the
user typically hasn't clicked the post in the browse grid, so
categories may be empty — the template falls back to %id% for
category tokens, same as before. Full categorization on the
bookmark save path is a future enhancement (would require passing
the client through from main_window).
2026-04-09 19:21:57 -05:00
pax
e2a666885f main_window: pass category_fetcher to all save_post_file call sites
Four save call sites updated to await save_post_file (now async)
and pass category_fetcher so the template-render ensure check can
fire when needed.

  _bulk_save: creates fetcher once at the top of the async closure,
    shared across all posts in the batch. Probe state persists
    within the batch.
  _save_to_library: creates fetcher per invocation (single post).
  _save_as: wrapped in an async closure (was sync before) since
    save_post_file is now async. Uses bookmark_done/error signals
    for status instead of direct showMessage.
  _batch_download_to: creates fetcher once at the top, shared
    across the batch.

New _get_category_fetcher helper returns the fetcher from a fresh
client (lightweight — shares the global httpx pool) or None if no
site is active.
2026-04-09 19:20:31 -05:00
pax
fa1222a774 main_window: pass db+site_id + ensure categories on info panel display
Three changes:

1. _make_client passes db=self._db, site_id=s.id so Gelbooru and
   Moebooru clients get a CategoryFetcher attached via the factory.

2. _on_post_activated calls _ensure_post_categories_async(post)
   after setting up the preview. If the post has empty categories
   (background prefetch hasn't reached it yet, or cache miss),
   this schedules ensure_categories on the async loop. When it
   completes, it emits categories_updated via the Qt signal.

3. _on_categories_updated slot re-renders the info panel and
   preview pane tag display when the currently-selected post's
   categories arrive. Stale updates (user clicked a different post
   before the fill completed) are silently dropped by the post.id
   check.
2026-04-09 19:17:34 -05:00
pax
9a05286f06 signals: add categories_updated carrying a Post 2026-04-09 19:16:16 -05:00
pax
81fc4d93eb main_window: library tab info panel + preview work for templated files
Two more digit-stem-only callsites I missed in the saved-dot fix
sweep. _set_library_info and _show_library_post both did
'if not stem.isdigit(): return' before consulting library_meta or
building the toolbar Post. Templated files (post-template-refactor
saves like 12345_hatsune_miku.jpg) bailed out silently — clicking
one in the Library tab left the info panel showing the previous
selection's data and the toolbar actions did nothing.

Extracted a small helper _post_id_from_library_path that resolves
either layout: look up library_meta.filename first (templated),
fall back to int(stem) for legacy digit-stem files. Both call sites
go through the helper now.

Same pattern as the find_library_files / _is_post_in_library
fixes from the earlier saved-dot bug. With this commit there are
no remaining "is templated file in the library?" callsites that
fall back to digit-stem matching alone — every check is
format-agnostic via the DB.
2026-04-09 18:25:21 -05:00
pax
a27672b95e main_window: fix browse-side saved-dot indicator + delete cleanup
The browse grid had the same digit-stem-only bug as the bookmark
grid: _saved_ids in two places used a root-only iterdir + isdigit
filter, missing both subfolder saves and templated filenames. The
user only reported the bookmark side, but this side has been
silently broken for any save into a subfolder for a while.

Six changes, all driven by the new db-backed helpers:

  _on_load_more (browse grid append):
    _saved_ids = self._db.get_saved_post_ids()
  After-blacklist rebuild:
    _saved_ids = self._db.get_saved_post_ids()
  _is_post_saved:
    return self._db.is_post_in_library(post_id)
  Bookmark preview lookup find_library_files:
    pass db=self._db so templated names also match
  _unsave_from_preview delete_from_library:
    pass db=self._db so templated names get unlinked AND meta cleaned
  _bulk_unsave delete_from_library:
    same fix
2026-04-09 18:25:21 -05:00
pax
3ef1a0bbd3 bookmarks: fix saved-dot indicator for templated/folder library saves
The dot on bookmark thumbnails uses set_saved_locally(...) and was
driven by find_library_files(post_id) — a digit-stem filesystem
walk that silently failed for any save with a templated filename
(e.g. 12345_hatsune_miku.jpg). The user reported it broken right
after templating landed.

Switch to db.get_saved_post_ids() for the grid refresh: one indexed
SELECT, set membership in O(1) per thumb. Format-agnostic, sees
both digit-stem and templated saves.

The "Unsave from Library" context menu used the same broken
find_library_files check for visibility. Switched to
db.is_post_in_library(post_id), which is the same idea via a
single-row SELECT 1.

Both delete_from_library call sites (single + bulk Unsave All)
now pass db so templated filenames are matched and the meta row
gets cleaned up. Refresh always runs after Unsave so the dot
clears whether the file was on disk or just an orphan meta row.
2026-04-09 18:25:21 -05:00
pax
98ac31079a bookmarks: route Save As action through save_post_file
Sixth and final Phase 2 site migration. The bookmarks context-menu
Save As action now mirrors main_window._save_as: render the template
to populate the dialog default name, then route the actual save
through save_post_file with explicit_name set to whatever the user
typed. Same behavior change as the browse-side Save As — Save As
into saved_dir() now registers library_meta where v0.2.3 didn't.

After this commit the eight save sites in main_window.py and
bookmarks.py all share one implementation. The net diff of Phase 1 +
Phase 2 (excluding the Phase 0 scaffolding) is a deletion in
main_window.py + bookmarks.py even after adding library_save.py,
which is the test for whether the refactor was the right call.
2026-04-09 18:25:21 -05:00