bookmarks: fix save/unsave UX — no flash, correct dot indicators

Save to Library and Unsave from Library are now mutually exclusive
in both single and multi-select context menus (previously both
showed simultaneously).

Replaced full grid refresh() after save/unsave with targeted dot
updates — save_done signal fires per-post after async save completes
and lights the saved dot on just that thumbnail. Unsave clears the
dot inline. Eliminates the visible flash from grid rebuild.

behavior change: context menus show either Save or Unsave, never
both. Saved dots appear without grid flash.
This commit is contained in:
pax 2026-04-11 22:13:06 -05:00
parent 5e8035cb1d
commit 79419794f6

View File

@ -32,6 +32,7 @@ log = logging.getLogger("booru")
class BookmarkThumbSignals(QObject): class BookmarkThumbSignals(QObject):
thumb_ready = Signal(int, str) thumb_ready = Signal(int, str)
save_done = Signal(int) # post_id
class BookmarksView(QWidget): class BookmarksView(QWidget):
@ -48,6 +49,7 @@ class BookmarksView(QWidget):
self._bookmarks: list[Bookmark] = [] self._bookmarks: list[Bookmark] = []
self._signals = BookmarkThumbSignals() self._signals = BookmarkThumbSignals()
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection) self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
self._signals.save_done.connect(self._on_save_done, Qt.ConnectionType.QueuedConnection)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@ -236,6 +238,13 @@ class BookmarksView(QWidget):
if not pix.isNull(): if not pix.isNull():
thumbs[index].set_pixmap(pix) thumbs[index].set_pixmap(pix)
def _on_save_done(self, post_id: int) -> None:
"""Light the saved-locally dot on the thumbnail for post_id."""
for i, fav in enumerate(self._bookmarks):
if fav.post_id == post_id and i < len(self._grid._thumbs):
self._grid._thumbs[i].set_saved_locally(True)
break
def _do_search(self) -> None: def _do_search(self) -> None:
text = self._search_input.text().strip() text = self._search_input.text().strip()
self.refresh(search=text if text else None) self.refresh(search=text if text else None)
@ -290,6 +299,7 @@ class BookmarksView(QWidget):
async def _do(): async def _do():
try: try:
await save_post_file(src, post, dest_dir, self._db) await save_post_file(src, post, dest_dir, self._db)
self._signals.save_done.emit(fav.post_id)
except Exception as e: except Exception as e:
log.warning(f"Bookmark→library save #{fav.post_id} failed: {e}") log.warning(f"Bookmark→library save #{fav.post_id} failed: {e}")
@ -329,25 +339,25 @@ class BookmarksView(QWidget):
menu.addSeparator() menu.addSeparator()
save_as = menu.addAction("Save As...") save_as = menu.addAction("Save As...")
# Save to Library submenu — folders come from the library # Save to Library / Unsave — mutually exclusive based on
# filesystem, not the bookmark folder DB. # whether the post is already in the library.
from ..core.config import library_folders from ..core.config import library_folders
save_lib_menu = None
save_lib_unsorted = None
save_lib_new = None
save_lib_folders = {}
unsave_lib = None
if self._db.is_post_in_library(fav.post_id):
unsave_lib = menu.addAction("Unsave from Library")
else:
save_lib_menu = menu.addMenu("Save to Library") save_lib_menu = menu.addMenu("Save to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled") save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_folders = {}
for folder in library_folders(): for folder in library_folders():
a = save_lib_menu.addAction(folder) a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...") save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = None
# Only show unsave if the post is actually saved. is_post_in_library
# is the format-agnostic DB check — works for digit-stem and
# templated filenames alike.
if self._db.is_post_in_library(fav.post_id):
unsave_lib = menu.addAction("Unsave from Library")
copy_file = menu.addAction("Copy File to Clipboard") copy_file = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL") copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags") copy_tags = menu.addAction("Copy Tags")
@ -373,13 +383,9 @@ class BookmarksView(QWidget):
if action == save_lib_unsorted: if action == save_lib_unsorted:
self._copy_to_library_unsorted(fav) self._copy_to_library_unsorted(fav)
self.refresh()
elif action == save_lib_new: elif action == save_lib_new:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip(): if ok and name.strip():
# Validate the name via saved_folder_dir() which mkdir's
# the library subdir and runs the path-traversal check.
# No DB folder write — bookmark folders are independent.
try: try:
from ..core.config import saved_folder_dir from ..core.config import saved_folder_dir
saved_folder_dir(name.strip()) saved_folder_dir(name.strip())
@ -387,11 +393,9 @@ class BookmarksView(QWidget):
QMessageBox.warning(self, "Invalid Folder Name", str(e)) QMessageBox.warning(self, "Invalid Folder Name", str(e))
return return
self._copy_to_library(fav, name.strip()) self._copy_to_library(fav, name.strip())
self.refresh()
elif id(action) in save_lib_folders: elif id(action) in save_lib_folders:
folder_name = save_lib_folders[id(action)] folder_name = save_lib_folders[id(action)]
self._copy_to_library(fav, folder_name) self._copy_to_library(fav, folder_name)
self.refresh()
elif action == open_browser: elif action == open_browser:
self.open_in_browser_requested.emit(fav.site_id, fav.post_id) self.open_in_browser_requested.emit(fav.site_id, fav.post_id)
elif action == open_default: elif action == open_default:
@ -421,12 +425,11 @@ class BookmarksView(QWidget):
run_on_app_loop(_do_save_as()) run_on_app_loop(_do_save_as())
elif action == unsave_lib: elif action == unsave_lib:
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
# Pass db so templated filenames are matched and the meta
# row gets cleaned up. Refresh on success OR on a meta-only
# cleanup (orphan row, no on-disk file) — either way the
# saved-dot indicator state has changed.
delete_from_library(fav.post_id, db=self._db) delete_from_library(fav.post_id, db=self._db)
self.refresh() for i, f in enumerate(self._bookmarks):
if f.post_id == fav.post_id and i < len(self._grid._thumbs):
self._grid._thumbs[i].set_saved_locally(False)
break
self.bookmarks_changed.emit() self.bookmarks_changed.emit()
elif action == copy_file: elif action == copy_file:
path = fav.cached_path path = fav.cached_path
@ -477,20 +480,24 @@ class BookmarksView(QWidget):
menu = QMenu(self) menu = QMenu(self)
# Save All to Library submenu — folders are filesystem-truth. any_unsaved = any(not self._db.is_post_in_library(f.post_id) for f in favs)
# Conversion from a flat action to a submenu so the user can any_saved = any(self._db.is_post_in_library(f.post_id) for f in favs)
# pick a destination instead of having "save all" silently use
# each bookmark's fav.folder (which was the cross-bleed bug). save_lib_menu = None
save_lib_unsorted = None
save_lib_new = None
save_lib_folder_actions: dict[int, str] = {}
unsave_all = None
if any_unsaved:
save_lib_menu = menu.addMenu(f"Save All ({len(favs)}) to Library") save_lib_menu = menu.addMenu(f"Save All ({len(favs)}) to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled") save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_folder_actions: dict[int, str] = {}
for folder in library_folders(): for folder in library_folders():
a = save_lib_menu.addAction(folder) a = save_lib_menu.addAction(folder)
save_lib_folder_actions[id(a)] = folder save_lib_folder_actions[id(a)] = folder
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...") save_lib_new = save_lib_menu.addAction("+ New Folder...")
if any_saved:
unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library") unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library")
menu.addSeparator() menu.addSeparator()
@ -516,7 +523,6 @@ class BookmarksView(QWidget):
self._copy_to_library(fav, folder_name) self._copy_to_library(fav, folder_name)
else: else:
self._copy_to_library_unsorted(fav) self._copy_to_library_unsorted(fav)
self.refresh()
if action == save_lib_unsorted: if action == save_lib_unsorted:
_save_all_into(None) _save_all_into(None)
@ -534,9 +540,13 @@ class BookmarksView(QWidget):
_save_all_into(save_lib_folder_actions[id(action)]) _save_all_into(save_lib_folder_actions[id(action)])
elif action == unsave_all: elif action == unsave_all:
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
unsaved_ids = set()
for fav in favs: for fav in favs:
delete_from_library(fav.post_id, db=self._db) delete_from_library(fav.post_id, db=self._db)
self.refresh() unsaved_ids.add(fav.post_id)
for i, fav in enumerate(self._bookmarks):
if fav.post_id in unsaved_ids and i < len(self._grid._thumbs):
self._grid._thumbs[i].set_saved_locally(False)
self.bookmarks_changed.emit() self.bookmarks_changed.emit()
elif action == move_none: elif action == move_none:
for fav in favs: for fav in favs: