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
This commit is contained in:
pax 2026-04-10 15:15:21 -05:00
parent 8e9dda8671
commit 87be4eb2a6
2 changed files with 250 additions and 264 deletions

View File

@ -0,0 +1,246 @@
"""Single-post and multi-select right-click context menus."""
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QApplication, QMenu
if TYPE_CHECKING:
from .main_window import BooruApp
class ContextMenuHandler:
"""Builds and dispatches context menus for the thumbnail grid."""
def __init__(self, app: BooruApp) -> None:
self._app = app
@staticmethod
def _is_child_of_menu(action, menu) -> bool:
parent = action.parent()
while parent:
if parent == menu:
return True
parent = getattr(parent, 'parent', lambda: None)()
return False
def show_single(self, index: int, pos) -> None:
if index < 0 or index >= len(self._app._posts):
return
post = self._app._posts[index]
menu = QMenu(self._app)
open_browser = menu.addAction("Open in Browser")
open_default = menu.addAction("Open in Default App")
menu.addSeparator()
save_as = menu.addAction("Save As...")
from ..core.config import library_folders
save_lib_menu = menu.addMenu("Save to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator()
save_lib_folders = {}
for folder in library_folders():
a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = None
if self._app._post_actions.is_post_saved(post.id):
unsave_lib = menu.addAction("Unsave from Library")
copy_clipboard = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags")
menu.addSeparator()
fav_action = None
bm_folder_actions: dict[int, str] = {}
bm_unfiled = None
bm_new = None
if self._app._post_actions.is_current_bookmarked(index):
fav_action = menu.addAction("Remove Bookmark")
else:
fav_menu = menu.addMenu("Bookmark as")
bm_unfiled = fav_menu.addAction("Unfiled")
fav_menu.addSeparator()
for folder in self._app._db.get_folders():
a = fav_menu.addAction(folder)
bm_folder_actions[id(a)] = folder
fav_menu.addSeparator()
bm_new = fav_menu.addAction("+ New Folder...")
menu.addSeparator()
bl_menu = menu.addMenu("Blacklist Tag")
if post.tag_categories:
for category, tags in post.tag_categories.items():
cat_menu = bl_menu.addMenu(category)
for tag in tags[:30]:
cat_menu.addAction(tag)
else:
for tag in post.tag_list[:30]:
bl_menu.addAction(tag)
bl_post_action = menu.addAction("Blacklist Post")
action = menu.exec(pos)
if not action:
return
if action == open_browser:
self._app._open_in_browser(post)
elif action == open_default:
self._app._open_in_default(post)
elif action == save_as:
self._app._post_actions.save_as(post)
elif action == save_lib_unsorted:
self._app._post_actions.save_to_library(post, None)
elif action == save_lib_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self._app, "New Folder", "Folder name:")
if ok and name.strip():
try:
from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e:
QMessageBox.warning(self._app, "Invalid Folder Name", str(e))
return
self._app._post_actions.save_to_library(post, name.strip())
elif id(action) in save_lib_folders:
self._app._post_actions.save_to_library(post, save_lib_folders[id(action)])
elif action == unsave_lib:
self._app._preview._current_post = post
self._app._post_actions.unsave_from_preview()
elif action == copy_clipboard:
self._app._copy_file_to_clipboard()
elif action == copy_url:
QApplication.clipboard().setText(post.file_url)
self._app._status.showMessage("URL copied")
elif action == copy_tags:
QApplication.clipboard().setText(post.tags)
self._app._status.showMessage("Tags copied")
elif fav_action is not None and action == fav_action:
self._app._post_actions.toggle_bookmark(index)
elif bm_unfiled is not None and action == bm_unfiled:
self._app._post_actions.toggle_bookmark(index, None)
elif bm_new is not None and action == bm_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self._app, "New Bookmark Folder", "Folder name:")
if ok and name.strip():
try:
self._app._db.add_folder(name.strip())
except ValueError as e:
QMessageBox.warning(self._app, "Invalid Folder Name", str(e))
return
self._app._post_actions.toggle_bookmark(index, name.strip())
elif id(action) in bm_folder_actions:
self._app._post_actions.toggle_bookmark(index, bm_folder_actions[id(action)])
elif self._is_child_of_menu(action, bl_menu):
tag = action.text()
self._app._db.add_blacklisted_tag(tag)
self._app._db.set_setting("blacklist_enabled", "1")
if self._app._preview._current_path and tag in post.tag_list:
from ..core.cache import cached_path_for
cp = str(cached_path_for(post.file_url))
if cp == self._app._preview._current_path:
self._app._preview.clear()
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
self._app._popout_ctrl.window.stop_media()
self._app._status.showMessage(f"Blacklisted: {tag}")
self._app._search_ctrl.remove_blacklisted_from_grid(tag=tag)
elif action == bl_post_action:
self._app._db.add_blacklisted_post(post.file_url)
self._app._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
self._app._status.showMessage(f"Post #{post.id} blacklisted")
self._app._search_ctrl.do_search()
def show_multi(self, indices: list, pos) -> None:
posts = [self._app._posts[i] for i in indices if 0 <= i < len(self._app._posts)]
if not posts:
return
count = len(posts)
site_id = self._app._site_combo.currentData()
any_bookmarked = bool(site_id) and any(self._app._db.is_bookmarked(site_id, p.id) for p in posts)
any_unbookmarked = bool(site_id) and any(not self._app._db.is_bookmarked(site_id, p.id) for p in posts)
any_saved = any(self._app._post_actions.is_post_saved(p.id) for p in posts)
any_unsaved = any(not self._app._post_actions.is_post_saved(p.id) for p in posts)
menu = QMenu(self._app)
save_menu = None
save_unsorted = None
save_new = None
save_folder_actions: dict[int, str] = {}
if any_unsaved:
from ..core.config import library_folders
save_menu = menu.addMenu(f"Save All to Library ({count})")
save_unsorted = save_menu.addAction("Unfiled")
for folder in library_folders():
a = save_menu.addAction(folder)
save_folder_actions[id(a)] = folder
save_menu.addSeparator()
save_new = save_menu.addAction("+ New Folder...")
unsave_lib_all = None
if any_saved:
unsave_lib_all = menu.addAction(f"Unsave All from Library ({count})")
if (any_unsaved or any_saved) and (any_unbookmarked or any_bookmarked):
menu.addSeparator()
fav_all = None
if any_unbookmarked:
fav_all = menu.addAction(f"Bookmark All ({count})")
unfav_all = None
if any_bookmarked:
unfav_all = menu.addAction(f"Remove All Bookmarks ({count})")
if any_unsaved or any_saved or any_unbookmarked or any_bookmarked:
menu.addSeparator()
batch_dl = menu.addAction(f"Download All ({count})...")
copy_urls = menu.addAction("Copy All URLs")
action = menu.exec(pos)
if not action:
return
if fav_all is not None and action == fav_all:
self._app._post_actions.bulk_bookmark(indices, posts)
elif save_unsorted is not None and action == save_unsorted:
self._app._post_actions.bulk_save(indices, posts, None)
elif save_new is not None and action == save_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self._app, "New Folder", "Folder name:")
if ok and name.strip():
try:
from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e:
QMessageBox.warning(self._app, "Invalid Folder Name", str(e))
return
self._app._post_actions.bulk_save(indices, posts, name.strip())
elif id(action) in save_folder_actions:
self._app._post_actions.bulk_save(indices, posts, save_folder_actions[id(action)])
elif unsave_lib_all is not None and action == unsave_lib_all:
self._app._post_actions.bulk_unsave(indices, posts)
elif action == batch_dl:
from .dialogs import select_directory
dest = select_directory(self._app, "Download to folder")
if dest:
self._app._post_actions.batch_download_posts(posts, dest)
elif unfav_all is not None and action == unfav_all:
if site_id:
for post in posts:
self._app._db.remove_bookmark(site_id, post.id)
for idx in indices:
if 0 <= idx < len(self._app._grid._thumbs):
self._app._grid._thumbs[idx].set_bookmarked(False)
self._app._grid._clear_multi()
self._app._status.showMessage(f"Removed {count} bookmarks")
if self._app._stack.currentIndex() == 1:
self._app._bookmarks_view.refresh()
elif action == copy_urls:
urls = "\n".join(p.file_url for p in posts)
QApplication.clipboard().setText(urls)
self._app._status.showMessage(f"Copied {count} URLs")

View File

@ -64,6 +64,7 @@ from .search_controller import SearchController
from .media_controller import MediaController
from .popout_controller import PopoutController
from .post_actions import PostActionsController
from .context_menus import ContextMenuHandler
log = logging.getLogger("booru")
@ -131,6 +132,7 @@ class BooruApp(QMainWindow):
self._media_ctrl = MediaController(self)
self._popout_ctrl = PopoutController(self)
self._post_actions = PostActionsController(self)
self._context = ContextMenuHandler(self)
self._main_window_save_timer = QTimer(self)
self._main_window_save_timer.setSingleShot(True)
self._main_window_save_timer.setInterval(300)
@ -316,8 +318,8 @@ class BooruApp(QMainWindow):
self._grid = ThumbnailGrid()
self._grid.post_selected.connect(self._on_post_selected)
self._grid.post_activated.connect(self._media_ctrl.on_post_activated)
self._grid.context_requested.connect(self._on_context_menu)
self._grid.multi_context_requested.connect(self._on_multi_context_menu)
self._grid.context_requested.connect(self._context.show_single)
self._grid.multi_context_requested.connect(self._context.show_multi)
self._grid.nav_past_end.connect(self._search_ctrl.on_nav_past_end)
self._grid.nav_before_start.connect(self._search_ctrl.on_nav_before_start)
self._stack.addWidget(self._grid)
@ -913,268 +915,6 @@ class BooruApp(QMainWindow):
def _close_preview(self) -> None:
self._preview.clear()
# -- Context menu --
def _on_context_menu(self, index: int, pos) -> None:
if index < 0 or index >= len(self._posts):
return
post = self._posts[index]
menu = QMenu(self)
open_browser = menu.addAction("Open in Browser")
open_default = menu.addAction("Open in Default App")
menu.addSeparator()
save_as = menu.addAction("Save As...")
# Save to Library submenu — folders come from the library
# filesystem, not the bookmark folder DB.
from ..core.config import library_folders
save_lib_menu = menu.addMenu("Save to Library")
save_lib_unsorted = save_lib_menu.addAction("Unfiled")
save_lib_menu.addSeparator()
save_lib_folders = {}
for folder in library_folders():
a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = None
if self._post_actions.is_post_saved(post.id):
unsave_lib = menu.addAction("Unsave from Library")
copy_clipboard = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags")
menu.addSeparator()
# Bookmark action: when not yet bookmarked, offer "Bookmark as"
# with a submenu of bookmark folders so the user can file the
# new bookmark in one click. Bookmark folders come from the DB
# (separate name space from library folders). When already
# bookmarked, the action collapses to a flat "Remove Bookmark"
# — re-filing an existing bookmark belongs in the bookmarks tab
# right-click menu's "Move to Folder" submenu.
fav_action = None
bm_folder_actions: dict[int, str] = {}
bm_unfiled = None
bm_new = None
if self._post_actions.is_current_bookmarked(index):
fav_action = menu.addAction("Remove Bookmark")
else:
fav_menu = menu.addMenu("Bookmark as")
bm_unfiled = fav_menu.addAction("Unfiled")
fav_menu.addSeparator()
for folder in self._db.get_folders():
a = fav_menu.addAction(folder)
bm_folder_actions[id(a)] = folder
fav_menu.addSeparator()
bm_new = fav_menu.addAction("+ New Folder...")
menu.addSeparator()
bl_menu = menu.addMenu("Blacklist Tag")
if post.tag_categories:
for category, tags in post.tag_categories.items():
cat_menu = bl_menu.addMenu(category)
for tag in tags[:30]:
cat_menu.addAction(tag)
else:
for tag in post.tag_list[:30]:
bl_menu.addAction(tag)
bl_post_action = menu.addAction("Blacklist Post")
action = menu.exec(pos)
if not action:
return
if action == open_browser:
self._open_in_browser(post)
elif action == open_default:
self._open_in_default(post)
elif action == save_as:
self._post_actions.save_as(post)
elif action == save_lib_unsorted:
self._post_actions.save_to_library(post, None)
elif action == save_lib_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
# _save_to_library → saved_folder_dir() does the mkdir
# and the path-traversal check; we surface the same error
# message it would emit so a bad name is reported clearly.
try:
from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e))
return
self._post_actions.save_to_library(post, name.strip())
elif id(action) in save_lib_folders:
self._post_actions.save_to_library(post, save_lib_folders[id(action)])
elif action == unsave_lib:
self._preview._current_post = post
self._post_actions.unsave_from_preview()
elif action == copy_clipboard:
self._copy_file_to_clipboard()
elif action == copy_url:
QApplication.clipboard().setText(post.file_url)
self._status.showMessage("URL copied")
elif action == copy_tags:
QApplication.clipboard().setText(post.tags)
self._status.showMessage("Tags copied")
elif fav_action is not None and action == fav_action:
# Currently bookmarked → flat "Remove Bookmark" path.
self._post_actions.toggle_bookmark(index)
elif bm_unfiled is not None and action == bm_unfiled:
self._post_actions.toggle_bookmark(index, None)
elif bm_new is not None and action == bm_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:")
if ok and name.strip():
# Bookmark folders are DB-managed; add_folder validates
# the name and is the same call the bookmarks tab uses.
try:
self._db.add_folder(name.strip())
except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e))
return
self._post_actions.toggle_bookmark(index, name.strip())
elif id(action) in bm_folder_actions:
self._post_actions.toggle_bookmark(index, bm_folder_actions[id(action)])
elif self._is_child_of_menu(action, bl_menu):
tag = action.text()
self._db.add_blacklisted_tag(tag)
self._db.set_setting("blacklist_enabled", "1")
# Clear preview if the previewed post has this tag
if self._preview._current_path and tag in post.tag_list:
from ..core.cache import cached_path_for
cp = str(cached_path_for(post.file_url))
if cp == self._preview._current_path:
self._preview.clear()
if self._popout_ctrl.window and self._popout_ctrl.window.isVisible():
self._popout_ctrl.window.stop_media()
self._status.showMessage(f"Blacklisted: {tag}")
self._search_ctrl.remove_blacklisted_from_grid(tag=tag)
elif action == bl_post_action:
self._db.add_blacklisted_post(post.file_url)
self._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
self._status.showMessage(f"Post #{post.id} blacklisted")
self._search_ctrl.do_search()
@staticmethod
def _is_child_of_menu(action, menu) -> bool:
parent = action.parent()
while parent:
if parent == menu:
return True
parent = getattr(parent, 'parent', lambda: None)()
return False
def _on_multi_context_menu(self, indices: list, pos) -> None:
"""Context menu for multi-selected posts.
Library and bookmark actions are split into independent
save/unsave and bookmark/remove-bookmark pairs (mirroring the
single-post menu's separation), with symmetric conditional
visibility: each action only appears when the selection actually
contains posts the action would affect. Save All to Library
appears only when at least one post is unsaved; Unsave All from
Library only when at least one is saved; Bookmark All only when
at least one is unbookmarked; Remove All Bookmarks only when at
least one is bookmarked.
"""
posts = [self._posts[i] for i in indices if 0 <= i < len(self._posts)]
if not posts:
return
count = len(posts)
site_id = self._site_combo.currentData()
any_bookmarked = bool(site_id) and any(self._db.is_bookmarked(site_id, p.id) for p in posts)
any_unbookmarked = bool(site_id) and any(not self._db.is_bookmarked(site_id, p.id) for p in posts)
any_saved = any(self._post_actions.is_post_saved(p.id) for p in posts)
any_unsaved = any(not self._post_actions.is_post_saved(p.id) for p in posts)
menu = QMenu(self)
# Library section
save_menu = None
save_unsorted = None
save_new = None
save_folder_actions: dict[int, str] = {}
if any_unsaved:
from ..core.config import library_folders
save_menu = menu.addMenu(f"Save All to Library ({count})")
save_unsorted = save_menu.addAction("Unfiled")
for folder in library_folders():
a = save_menu.addAction(folder)
save_folder_actions[id(a)] = folder
save_menu.addSeparator()
save_new = save_menu.addAction("+ New Folder...")
unsave_lib_all = None
if any_saved:
unsave_lib_all = menu.addAction(f"Unsave All from Library ({count})")
# Bookmark section
if (any_unsaved or any_saved) and (any_unbookmarked or any_bookmarked):
menu.addSeparator()
fav_all = None
if any_unbookmarked:
fav_all = menu.addAction(f"Bookmark All ({count})")
unfav_all = None
if any_bookmarked:
unfav_all = menu.addAction(f"Remove All Bookmarks ({count})")
# Always-shown actions
if any_unsaved or any_saved or any_unbookmarked or any_bookmarked:
menu.addSeparator()
batch_dl = menu.addAction(f"Download All ({count})...")
copy_urls = menu.addAction("Copy All URLs")
action = menu.exec(pos)
if not action:
return
if fav_all is not None and action == fav_all:
self._post_actions.bulk_bookmark(indices, posts)
elif save_unsorted is not None and action == save_unsorted:
self._post_actions.bulk_save(indices, posts, None)
elif save_new is not None and action == save_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
try:
from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e))
return
self._post_actions.bulk_save(indices, posts, name.strip())
elif id(action) in save_folder_actions:
self._post_actions.bulk_save(indices, posts, save_folder_actions[id(action)])
elif unsave_lib_all is not None and action == unsave_lib_all:
self._post_actions.bulk_unsave(indices, posts)
elif action == batch_dl:
from .dialogs import select_directory
dest = select_directory(self, "Download to folder")
if dest:
self._post_actions.batch_download_posts(posts, dest)
elif unfav_all is not None and action == unfav_all:
if site_id:
for post in posts:
self._db.remove_bookmark(site_id, post.id)
for idx in indices:
if 0 <= idx < len(self._grid._thumbs):
self._grid._thumbs[idx].set_bookmarked(False)
self._grid._clear_multi()
self._status.showMessage(f"Removed {count} bookmarks")
if self._stack.currentIndex() == 1:
self._bookmarks_view.refresh()
elif action == copy_urls:
urls = "\n".join(p.file_url for p in posts)
QApplication.clipboard().setText(urls)
self._status.showMessage(f"Copied {count} URLs")
def _open_post_id_in_browser(self, post_id: int, site_id: int | None = None) -> None:
"""Open the post page in the system browser. site_id selects which
site's URL/api scheme to use; defaults to the currently selected