This commit is contained in:
Ignacio Serantes
2026-03-25 12:18:19 +01:00
parent 0349155fd2
commit 56ef674d4a
9 changed files with 641 additions and 455 deletions

View File

@@ -172,14 +172,14 @@ class FastTagManager:
def __init__(self, viewer):
self.viewer = viewer
self.main_win = viewer.main_win
self.controller = viewer.controller
def show_menu(self):
"""Builds and shows a context menu for quickly adding/removing tags."""
if not self.main_win or not self.controller.get_current_path():
controller = self.viewer.controller
if not self.main_win or not controller or not controller.get_current_path():
return
current_path = self.controller.get_current_path()
current_path = controller.get_current_path()
try:
raw_tags = os.getxattr(current_path, XATTR_NAME).decode('utf-8')
current_tags = {t.strip() for t in raw_tags.split(',') if t.strip()}
@@ -233,10 +233,11 @@ class FastTagManager:
return
tag_name = action.text()
is_checked = action.isChecked()
current_path = self.controller.get_current_path()
controller = self.viewer.controller
current_path = controller.get_current_path() if controller else None
if not current_path:
return
self.controller.toggle_tag(tag_name, is_checked)
controller.toggle_tag(tag_name, is_checked)
self.viewer.update_status_bar()
if self.main_win:
if is_checked:
@@ -289,18 +290,23 @@ class FilmStripWidget(QListWidget):
allowing the user to quickly jump to an image by clicking its thumbnail.
It also supports dragging files out of the application.
"""
def __init__(self, controller, parent=None):
def __init__(self, viewer, parent=None):
"""
Initializes the FilmStripWidget.
Args:
controller (ImageController): The controller managing the image list.
viewer (ImageViewer): The viewer that owns this filmstrip.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.controller = controller
self.viewer = viewer
self.setDragEnabled(True)
@property
def controller(self):
"""Returns the controller of the active pane in the viewer."""
return self.viewer.controller
def startDrag(self, supportedActions):
"""
Initiates a drag-and-drop operation for the selected image(s).
@@ -312,7 +318,7 @@ class FilmStripWidget(QListWidget):
urls = []
for item in items:
row = self.row(item)
if 0 <= row < len(self.controller.image_list):
if self.controller and 0 <= row < len(self.controller.image_list):
path = self.controller.image_list[row]
urls.append(QUrl.fromLocalFile(path))
@@ -332,6 +338,63 @@ class FilmStripWidget(QListWidget):
drag.exec(Qt.CopyAction)
def _get_selected_paths(self):
"""Helper to get paths of all selected items."""
return [item.data(Qt.UserRole)
for item in self.selectedItems() if item.data(Qt.UserRole)]
def contextMenuEvent(self, event):
"""Shows a context menu for the selected items."""
selected_items = self.selectedItems()
if not selected_items:
return
menu = QMenu(self)
# Clipboard Submenu
clipboard_menu = menu.addMenu(
QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD)
# Copy Image
action_copy_image = clipboard_menu.addAction(
QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE)
action_copy_image.triggered.connect(self._copy_image_to_clipboard)
if len(selected_items) > 1:
action_copy_image.setEnabled(False)
# Copy Path
action_copy_path = clipboard_menu.addAction(
QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
action_copy_path.triggered.connect(self._copy_path_to_clipboard)
# Copy Directory Path
action_copy_dir = clipboard_menu.addAction(
QIcon.fromTheme("folder"), UITexts.CONTEXT_MENU_COPY_DIR)
action_copy_dir.triggered.connect(self._copy_dir_to_clipboard)
menu.exec(event.globalPos())
def _copy_image_to_clipboard(self):
"""Copies the currently selected image to the system clipboard."""
paths = self._get_selected_paths()
if len(paths) == 1 and paths[0] and os.path.exists(paths[0]):
img = QImage(paths[0])
if not img.isNull():
QApplication.clipboard().setImage(img)
def _copy_path_to_clipboard(self):
"""Copies the file path(s) of the selected image(s) to the clipboard."""
paths = self._get_selected_paths()
if paths:
QApplication.clipboard().setText("\n".join(paths))
def _copy_dir_to_clipboard(self):
"""Copies the directory path(s) of the selected image(s) to the clipboard."""
paths = self._get_selected_paths()
if paths:
dir_paths = sorted(list(set(os.path.dirname(p) for p in paths)))
QApplication.clipboard().setText("\n".join(dir_paths))
class FaceCanvas(QLabel):
"""
@@ -1222,8 +1285,8 @@ class ImagePane(QWidget):
self.scroll_area = QScrollArea()
self.scroll_area.setAlignment(Qt.AlignCenter)
self.scroll_area.setStyleSheet("background-color: #000; border: none;")
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setWidget(self.canvas)
layout.addWidget(self.scroll_area)
@@ -1413,7 +1476,7 @@ class ImageViewer(QWidget):
# self.canvas = FaceCanvas(self) ... Moved to ImagePane
# self.scroll_area.setWidget(self.canvas)
self.filmstrip = FilmStripWidget(self.controller)
self.filmstrip = FilmStripWidget(self)
self.filmstrip.setSpacing(2)
self.filmstrip.itemClicked.connect(self.on_filmstrip_clicked)
@@ -1564,7 +1627,6 @@ class ImageViewer(QWidget):
pane.scrolled.connect(self._sync_scroll)
pane.zoom_manager.zoomed.connect(self._sync_zoom)
self.filmstrip.controller = pane.controller
self.populate_filmstrip()
self.sync_filmstrip_selection(pane.controller.index)
self.update_status_bar()
@@ -1618,6 +1680,10 @@ class ImageViewer(QWidget):
if count == current_panes:
return
if count > 1 and self.slideshow_manager.is_running():
self.slideshow_manager.stop()
self.update_title()
if count > current_panes:
# Add panes
base_controller = self.active_pane.controller
@@ -1726,6 +1792,9 @@ class ImageViewer(QWidget):
"properties": self.show_properties,
"toggle_visibility": self.toggle_main_window_visibility,
"toggle_crop": self.toggle_crop_mode,
"copy_image": self.copy_image_to_clipboard,
"copy_path": self.copy_file_path_to_clipboard,
"copy_dir_path": self.copy_dir_path_to_clipboard,
"save_crop": self.save_cropped_image,
"compare_1": lambda: self.set_comparison_mode(1),
"compare_2": lambda: self.set_comparison_mode(2),
@@ -1735,6 +1804,11 @@ class ImageViewer(QWidget):
def _execute_action(self, action):
"""Executes the method corresponding to the action name."""
if self.slideshow_manager.is_running():
allowed_actions = ('slideshow', 'slideshow_reverse', 'close', 'fullscreen')
if action not in allowed_actions:
return
if action in self._actions:
self._actions[action]()
@@ -2383,6 +2457,81 @@ class ImageViewer(QWidget):
if self.main_win:
self.main_win.update_metadata_for_path(path, metadata)
def copy_image_to_clipboard(self):
"""Copies the currently displayed image to the system clipboard."""
if self.controller and not self.controller.pixmap_original.isNull():
QApplication.clipboard().setImage(self.controller.pixmap_original.toImage())
def copy_file_path_to_clipboard(self):
"""Copies the current image's file path to the system clipboard."""
if self.controller:
path = self.controller.get_current_path()
if path:
QApplication.clipboard().setText(path)
def copy_dir_path_to_clipboard(self):
"""Copies the directory path of the current image to the system clipboard."""
if self.controller:
path = self.controller.get_current_path()
if path:
QApplication.clipboard().setText(os.path.dirname(path))
def _populate_open_with_menu(self, menu):
"""Populates the 'Open With' submenu."""
if self.main_win:
path = self.controller.get_current_path()
if path:
self.main_win.populate_open_with_submenu(menu, path)
def _build_menu_from_data(self, target_menu, items):
"""Builds a menu or submenu from a list of dictionary items."""
for item in items:
if item == "separator":
target_menu.addSeparator()
continue
# Handle dynamic submenus that need to be populated by a function
if "dynamic_submenu" in item:
icon = QIcon.fromTheme(item.get("icon", ""))
submenu = target_menu.addMenu(icon, item["text"])
item["dynamic_submenu"](submenu)
continue
action_name = item.get("action")
display_text = item["text"]
# Add shortcut string to display text if available
if action_name and "submenu" not in item and \
action_name in self.action_to_shortcut:
key, mods = self.action_to_shortcut[action_name]
try:
mod_val = int(mods)
except TypeError:
mod_val = mods.value
seq = QKeySequence(mod_val | key)
shortcut_str = seq.toString(QKeySequence.NativeText)
if shortcut_str:
display_text += f"\t{shortcut_str}"
icon = QIcon.fromTheme(item.get("icon", ""))
if "submenu" in item:
submenu = target_menu.addMenu(icon, item["text"])
self._build_menu_from_data(submenu, item["submenu"])
else:
action = target_menu.addAction(icon, display_text)
slot = item.get("slot")
if action_name:
action.triggered.connect(
lambda checked=False, name=action_name:
self._execute_action(name))
elif slot:
action.triggered.connect(slot)
if item.get("checkable"):
action.setCheckable(True)
action.setChecked(item.get("checked", False))
def restore_scroll_for_pane(self, pane, config):
"""
Applies the saved scrollbar positions from a layout configuration.
@@ -2451,11 +2600,15 @@ class ImageViewer(QWidget):
def toggle_slideshow(self):
"""Starts or stops the automatic slideshow timer."""
if len(self.panes) > 1:
return
self.slideshow_manager.toggle(reverse=False)
self.update_view(resize_win=False)
def toggle_slideshow_reverse(self):
"""Starts or stops the automatic reverse slideshow timer."""
if len(self.panes) > 1:
return
self.slideshow_manager.toggle(reverse=True)
self.update_view(resize_win=False)
@@ -2604,168 +2757,148 @@ class ImageViewer(QWidget):
"""Builds and returns the general viewer context menu."""
menu = QMenu(self)
# Add "Open With" submenu
if self.main_win:
path = self.controller.get_current_path()
if path:
open_submenu = menu.addMenu(QIcon.fromTheme("document-open"),
UITexts.CONTEXT_MENU_OPEN)
self.main_win.populate_open_with_submenu(open_submenu, path)
menu.addSeparator()
menu_structure = []
if self.slideshow_manager.is_running():
is_fwd = self.slideshow_manager.is_forward()
is_rev = self.slideshow_manager.is_reverse()
menu_structure = [
{"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd
else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow",
"icon": "media-playback-stop" if is_fwd else "media-playback-start"},
{"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev
else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW,
"action": "slideshow_reverse",
"icon": "media-playback-stop" if is_rev else "media-seek-backward"},
{"text": UITexts.VIEWER_MENU_SET_INTERVAL,
"slot": self.set_slideshow_interval, "icon": "preferences-system-time"}
]
menu_items = [
{"text": UITexts.VIEWER_MENU_TAGS, "action": "fast_tag",
"icon": "document-properties"},
"separator",
{"text": UITexts.DETECT_FACES, "action": "detect_faces",
"icon": "edit-image-face-recognize"},
"separator",
{"text": UITexts.DETECT_PETS, "action": "detect_pets",
"icon": "edit-image-face-recognize"},
"separator",
{"text": UITexts.DETECT_BODIES, "action": "detect_bodies",
"icon": "edit-image-face-recognize"},
"separator",
{"text": UITexts.VIEWER_MENU_ROTATE, "icon": "transform-rotate",
"submenu": [
{"text": UITexts.VIEWER_MENU_ROTATE_LEFT,
"action": "rotate_left", "icon": "object-rotate-left"},
{"text": UITexts.VIEWER_MENU_ROTATE_RIGHT,
"action": "rotate_right", "icon": "object-rotate-right"}
]},
"separator",
{"text": UITexts.VIEWER_MENU_FLIP, "icon": "transform-flip", "submenu": [
{"text": UITexts.VIEWER_MENU_FLIP_H,
"action": "flip_horizontal",
"icon": "object-flip-horizontal"},
{"text": UITexts.VIEWER_MENU_FLIP_V,
"action": "flip_vertical", "icon": "object-flip-vertical"}
]},
"separator",
{"text": UITexts.VIEWER_MENU_RENAME, "action": "rename",
"icon": "edit-rename"},
"separator",
{"text": UITexts.VIEWER_MENU_FIT_SCREEN,
"slot": self.zoom_manager.toggle_fit_to_screen,
"icon": "zoom-fit-best"},
"separator",
{"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop",
"icon": "transform-crop", "checkable": True}, # checked updated later
"separator",
{"text": UITexts.VIEWER_MENU_COMPARE, "icon": "view-grid", "submenu": [
{"text": UITexts.VIEWER_MENU_COMPARE_1,
"action": "compare_1", "icon": "view-restore"},
{"text": UITexts.VIEWER_MENU_COMPARE_2,
"action": "compare_2", "icon": "view-split-left-right"},
{"text": UITexts.VIEWER_MENU_COMPARE_4,
"action": "compare_4", "icon": "view-grid"},
else:
# Build the normal menu structure
is_fwd = self.slideshow_manager.is_forward()
is_rev = self.slideshow_manager.is_reverse()
slideshow_submenu = [
{"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd
else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow",
"icon": "media-playback-stop" if is_fwd else "media-playback-start"},
{"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev
else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW,
"action": "slideshow_reverse",
"icon": "media-playback-stop" if is_rev else "media-seek-backward"},
{"text": UITexts.VIEWER_MENU_SET_INTERVAL,
"slot": self.set_slideshow_interval, "icon": "preferences-system-time"}
]
menu_structure = [
{"text": UITexts.CONTEXT_MENU_OPEN, "icon": "document-open",
"dynamic_submenu": self._populate_open_with_menu},
"separator",
{"text": UITexts.VIEWER_MENU_LINK_PANES,
"action": "link_panes", "icon": "object-link",
"checkable": True, "checked": self.panes_linked}
]},
"separator",
]
{"text": UITexts.VIEWER_MENU_TAGS,
"action": "fast_tag", "icon": "document-properties"},
{"text": UITexts.VIEWER_MENU_DETECT_AREAS,
"icon": "edit-image-face-recognize", "submenu": [
{"text": UITexts.VIEWER_MENU_DETECT_FACES,
"action": "detect_faces"},
{"text": UITexts.VIEWER_MENU_DETECT_PETS, "action": "detect_pets"},
]},
"separator",
{"text": UITexts.VIEWER_MENU_MANIPULATE,
"icon": "transform", "submenu": [
{"text": UITexts.VIEWER_MENU_ROTATE,
"icon": "transform-rotate", "submenu": [
{"text": UITexts.VIEWER_MENU_ROTATE_LEFT,
"action": "rotate_left", "icon": "object-rotate-left"},
{"text": UITexts.VIEWER_MENU_ROTATE_RIGHT,
"action": "rotate_right", "icon": "object-rotate-right"}
]},
{"text": UITexts.VIEWER_MENU_FLIP,
"icon": "transform-flip", "submenu": [
{"text": UITexts.VIEWER_MENU_FLIP_H,
"action": "flip_horizontal", "icon": "object-flip-horizontal"},
{"text": UITexts.VIEWER_MENU_FLIP_V,
"action": "flip_vertical", "icon": "object-flip-vertical"}
]}
]},
{"text": UITexts.VIEWER_MENU_ZOOM, "icon": "zoom", "submenu": [
{"text": UITexts.VIEWER_MENU_ZOOM_IN,
"action": "zoom_in", "icon": "zoom-in"},
{"text": UITexts.VIEWER_MENU_ZOOM_OUT,
"action": "zoom_out", "icon": "zoom-out"},
"separator",
{"text": UITexts.VIEWER_MENU_FIT_SCREEN,
"slot": self.zoom_manager.toggle_fit_to_screen,
"icon": "zoom-fit-best"},
]},
"separator",
{"text": UITexts.VIEWER_MENU_RENAME,
"action": "rename", "icon": "edit-rename"},
{"text": UITexts.CONTEXT_MENU_CLIPBOARD,
"icon": "edit-copy", "submenu": [
{"text": UITexts.VIEWER_MENU_COPY_IMAGE,
"action": "copy_image", "icon": "image-x-generic"},
{"text": UITexts.VIEWER_MENU_COPY_PATH,
"action": "copy_path", "icon": "document-properties"},
{"text": UITexts.CONTEXT_MENU_COPY_DIR,
"action": "copy_dir_path", "icon": "folder"},
]},
{"text": UITexts.VIEWER_MENU_CROP,
"action": "toggle_crop", "icon": "transform-crop", "checkable": True,
"checked": self.crop_mode},
"separator",
{"text": UITexts.VIEWER_MENU_COMPARE, "icon": "view-grid", "submenu": [
{"text": UITexts.VIEWER_MENU_COMPARE_1,
"action": "compare_1", "icon": "view-restore"},
{"text": UITexts.VIEWER_MENU_COMPARE_2,
"action": "compare_2", "icon": "view-split-left-right"},
{"text": UITexts.VIEWER_MENU_COMPARE_4,
"action": "compare_4", "icon": "view-grid"},
"separator",
{"text": UITexts.VIEWER_MENU_LINK_PANES,
"action": "link_panes", "icon": "object-link",
"checkable": True, "checked": self.panes_linked}
]},
"separator",
]
if self.movie:
is_paused = self.movie.state() == QMovie.Paused_
pause_text = (UITexts.VIEWER_MENU_RESUME_ANIMATION if is_paused
else UITexts.VIEWER_MENU_PAUSE_ANIMATION)
pause_icon = ("media-playback-start" if is_paused
else "media-playback-pause")
menu_items.append({"text": pause_text, "action": "toggle_animation",
"icon": pause_icon})
if self.movie:
is_paused = self.movie.state() == QMovie.Paused
pause_text = UITexts.VIEWER_MENU_RESUME_ANIMATION \
if is_paused else UITexts.VIEWER_MENU_PAUSE_ANIMATION
pause_icon = "media-playback-start" \
if is_paused else "media-playback-pause"
menu_structure.append(
{"text": pause_text, "action": "toggle_animation",
"icon": pause_icon})
is_fwd_slideshow = self.slideshow_manager.is_forward()
is_rev_slideshow = self.slideshow_manager.is_reverse()
menu_structure.extend([
{"text": UITexts.VIEWER_MENU_SLIDESHOW,
"icon": "view-presentation", "submenu": slideshow_submenu},
"separator",
{"text": UITexts.SHOW_FACES,
"action": "toggle_faces", "icon": "edit-image-face-show",
"checkable": True,
"checked": self.controller.show_faces if self.controller else False},
{"text": UITexts.VIEWER_MENU_SHOW_FILMSTRIP,
"action": "toggle_filmstrip", "icon": "view-filmstrip",
"checkable": True, "checked": self.filmstrip.isVisible()},
{"text": UITexts.VIEWER_MENU_SHOW_STATUSBAR,
"action": "toggle_statusbar", "icon": "view-bottom-panel",
"checkable": True, "checked": self.status_bar_container.isVisible()},
"separator",
{"text": UITexts.VIEWER_MENU_EXIT_FULLSCREEN
if self.isFullScreen() else UITexts.VIEWER_MENU_ENTER_FULLSCREEN,
"action": "fullscreen", "icon": "view-fullscreen"
if not self.isFullScreen() else "view-restore"},
"separator",
{"text": "Show/hide main window",
"action": "toggle_visibility", "icon": "view-restore"},
"separator",
{"text": UITexts.CONTEXT_MENU_PROPERTIES,
"action": "properties", "icon": "document-properties"}
])
slideshow_submenu = [
{"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow
else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow",
"icon": "media-playback-stop" if is_fwd_slideshow
else "media-playback-start"},
{"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev_slideshow
else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW,
"action": "slideshow_reverse",
"icon": "media-playback-stop" if is_rev_slideshow
else "media-seek-backward"},
{"text": UITexts.VIEWER_MENU_SET_INTERVAL,
"slot": self.set_slideshow_interval, "icon": "preferences-system-time"}
]
menu_items.extend([
{"text": UITexts.VIEWER_MENU_SLIDESHOW, "icon": "view-presentation",
"submenu": slideshow_submenu},
"separator",
{"text": UITexts.SHOW_FACES, "action": "toggle_faces",
"icon": "edit-image-face-show",
"checkable": True, "checked": self.controller.show_faces},
{"text": UITexts.VIEWER_MENU_SHOW_FILMSTRIP,
"action": "toggle_filmstrip", "icon": "view-filmstrip", "checkable": True,
"checked": self.filmstrip.isVisible()},
{"text": UITexts.VIEWER_MENU_SHOW_STATUSBAR,
"action": "toggle_statusbar", "icon": "view-bottom-panel",
"checkable": True,
"checked": self.status_bar_container.isVisible()},
"separator",
{"text": UITexts.VIEWER_MENU_EXIT_FULLSCREEN
if self.isFullScreen() else UITexts.VIEWER_MENU_ENTER_FULLSCREEN,
"action": "fullscreen",
"icon": "view-fullscreen" if not self.isFullScreen() else "view-restore"},
"separator",
{"text": "Show/hide main window",
"action": "toggle_visibility",
"icon": "view-restore"},
"separator",
{"text": UITexts.CONTEXT_MENU_PROPERTIES, "action": "properties",
"icon": "document-properties"}
])
def build_actions(target_menu, items):
for item in items:
if item == "separator":
target_menu.addSeparator()
continue
action_name = item.get("action")
display_text = item["text"]
# Only add shortcut to final actions, not to submenus
if action_name and "submenu" not in item and \
action_name in self.action_to_shortcut:
key, mods = self.action_to_shortcut[action_name]
# Handle both Qt.KeyboardModifier (enum) and Qt.KeyboardModifiers
# (flags) by ensuring we have an integer value for the modifier
# before the bitwise OR.
try:
mod_val = int(mods)
except TypeError:
mod_val = mods.value
seq = QKeySequence(mod_val | key)
shortcut_str = seq.toString(QKeySequence.NativeText)
if shortcut_str:
display_text += f"\t{shortcut_str}"
icon = QIcon.fromTheme(item.get("icon", ""))
if "submenu" in item:
submenu = target_menu.addMenu(icon, item["text"])
build_actions(submenu, item["submenu"])
else:
action = target_menu.addAction(icon, display_text)
slot = item.get("slot")
if action_name:
action.triggered.connect(
lambda checked=False, name=action_name:
self._execute_action(name))
elif slot:
action.triggered.connect(slot)
if item.get("checkable"):
action.setCheckable(True)
action.setChecked(item.get("checked", False))
build_actions(menu, menu_items)
self._build_menu_from_data(menu, menu_structure)
return menu