diff --git a/data/halftone.gresource.xml b/data/halftone.gresource.xml index 95f16bb..c631855 100644 --- a/data/halftone.gresource.xml +++ b/data/halftone.gresource.xml @@ -23,5 +23,8 @@ icons/scalable/actions/image-adjust-size-symbolic.svg icons/scalable/actions/sheet-show-bottom-symbolic.svg icons/scalable/actions/sidebar-show-left-symbolic.svg + icons/scalable/actions/zoom-in-symbolic.svg + icons/scalable/actions/zoom-original-symbolic.svg + icons/scalable/actions/zoom-out-symbolic.svg diff --git a/data/icons/scalable/actions/zoom-in-symbolic.svg b/data/icons/scalable/actions/zoom-in-symbolic.svg new file mode 100644 index 0000000..44b0814 --- /dev/null +++ b/data/icons/scalable/actions/zoom-in-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/scalable/actions/zoom-original-symbolic.svg b/data/icons/scalable/actions/zoom-original-symbolic.svg new file mode 100644 index 0000000..152d633 --- /dev/null +++ b/data/icons/scalable/actions/zoom-original-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/scalable/actions/zoom-out-symbolic.svg b/data/icons/scalable/actions/zoom-out-symbolic.svg new file mode 100644 index 0000000..5becfa5 --- /dev/null +++ b/data/icons/scalable/actions/zoom-out-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/ui/image_view.blp b/data/ui/image_view.blp index ec97e64..b2f54ab 100644 --- a/data/ui/image_view.blp +++ b/data/ui/image_view.blp @@ -89,6 +89,16 @@ template $HalftoneImageView : Adw.Bin { ] } + Gtk.Button zoom_reset_button { + icon-name: "zoom-original-symbolic"; + tooltip-text: _("Reset Zoom"); + action-name: "zoom.best-fit"; + + styles [ + "flat" + ] + } + Gtk.Button zoom_in_button { icon-name: "zoom-in-symbolic"; tooltip-text: _("Zoom In"); @@ -104,15 +114,11 @@ template $HalftoneImageView : Adw.Bin { ] } - child: Gtk.ScrolledWindow viewer_scrolled_window { + child: Gtk.ScrolledWindow scrolled_window { vexpand: true; hexpand: true; - Gtk.Viewport image_viewport { - scroll-to-focus: false; - - child: $HalftoneImage image_widget {}; - } + child: $HalftoneImage image_widget {}; }; }; }; diff --git a/data/ui/shortcuts_dialog.blp b/data/ui/shortcuts_dialog.blp index 95d3a7e..0135d6b 100644 --- a/data/ui/shortcuts_dialog.blp +++ b/data/ui/shortcuts_dialog.blp @@ -20,6 +20,11 @@ Adw.ShortcutsDialog shortcuts_dialog { accelerator: "plus plus"; } + Adw.ShortcutsItem { + title: C_("shortcut window", "Reset Zoom"); + accelerator: "0 0"; + } + Adw.ShortcutsItem { title: C_("shortcut window", "Zoom Out"); accelerator: "minus minus"; diff --git a/halftone/views/dither_page.py b/halftone/views/dither_page.py index 9999c5d..2a74280 100644 --- a/halftone/views/dither_page.py +++ b/halftone/views/dither_page.py @@ -301,7 +301,7 @@ def _set_texture(self, path: str, as_original: bool = False) -> None: texture = GlyGtk4.frame_get_texture(frame) - current_scale = image_widget.scale + current_zoom = image_widget.zoom current_scaling_filter = image_widget.scaling_filter image_widget.texture = texture @@ -311,7 +311,7 @@ def _set_texture(self, path: str, as_original: bool = False) -> None: self.image_options_view.original_texture = texture else: self.updated_texture = texture - image_widget.scale = current_scale + image_widget.zoom = current_zoom image_widget.scaling_filter = current_scaling_filter def _get_output_format_suffix(self) -> str: diff --git a/halftone/views/image.py b/halftone/views/image.py index 000d106..5cd2820 100644 --- a/halftone/views/image.py +++ b/halftone/views/image.py @@ -1,20 +1,43 @@ +# Copyright (c) 2021-2025, Loupe Authors +# Copyright (c) 2019-2025, Komikku Authors # Copyright 2025, tfuxu # SPDX-License-Identifier: GPL-3.0-or-later -# NOTE: This code is mostly a port of the `Image Scaling` example from gtk4-demo: https://gitlab.gnome.org/GNOME/gtk/-/blob/main/demos/gtk-demo/imageview.c - import math from gi.repository import GLib, GObject, Gdk, Graphene, Gsk, Gtk +from halftone.backend.logger import Logger + +logging = Logger() -class HalftoneImage(Gtk.Widget): +# Max zoom level 800% +MAX_ZOOM_LEVEL: float = 8.0 + +class HalftoneImage(Gtk.Widget, Gtk.Scrollable): # pyright: ignore __gtype_name__ = "HalftoneImage" + # Displayed texture, will be None only in initial view _texture: Gdk.Texture | None - _scaling_filter = Gsk.ScalingFilter.LINEAR + # Currently applied scaling filter (available: LINEAR, NEAREST, TRILINEAR) + _scaling_filter: Gsk.ScalingFilter = Gsk.ScalingFilter.LINEAR + + # Current zoom level + _zoom: float = 1.0 - _scale = 1.0 + # Always fit image into window, causes `zoom` to change automatically + _best_fit: bool = True + + # Horizontal scrolling + _hadjustment: Gtk.Adjustment = Gtk.Adjustment() + # Vertical scrolling + _vadjustment: Gtk.Adjustment = Gtk.Adjustment() + + # Current pointer position, needed for aimed zooming + pointer_position: tuple[float, float] | None = None + + # Stores current widget dimensions, used for automatic best-fit + widget_dimensions: tuple[int, int] = (0, 0) def __init__( self, @@ -24,8 +47,26 @@ def __init__( super().__init__(**kwargs) self.set_accessible_role(Gtk.AccessibleRole.IMG) + self.set_overflow(Gtk.Overflow.HIDDEN) + self._texture = texture + self._setup_controllers() + + """ + Setup methods + """ + + def _setup_controllers(self) -> None: + # Needed for having the current cursor position available + motion_controller = Gtk.EventControllerMotion.new() + + motion_controller.connect("enter", self._on_motion_enter) + motion_controller.connect("motion", self._on_motion) + motion_controller.connect("leave", self._on_motion_leave) + + self.add_controller(motion_controller) + """ Properties """ @@ -37,23 +78,72 @@ def texture(self) -> Gdk.Texture | None: @texture.setter def set_texture(self, texture: Gdk.Texture) -> None: self._texture = texture - self._scale = 1.0 + self._zoom = 1.0 self._scaling_filter = Gsk.ScalingFilter.LINEAR + self._best_fit = True + self.widget_dimensions = (0, 0) - self.queue_resize() - - self.notify("scale") + self.notify("zoom") self.notify("scaling_filter") - @GObject.Property(type=float, minimum=1.0/1024.0, maximum=1024.0, default=1.0) - def scale(self) -> float: - return self._scale + self.queue_allocate() + + # TODO: Check if not having a minimum value can create issues + @GObject.Property(type=float, default=1.0, maximum=MAX_ZOOM_LEVEL) + def zoom(self) -> float: + return self._zoom + + @zoom.setter + def set_zoom(self, zoom: float) -> None: + self._set_zoom_aiming(zoom, self.pointer_position) + + @GObject.Property(type=bool, default=True) + def best_fit(self) -> bool: + return self._best_fit + + @best_fit.setter + def set_best_fit(self, best_fit: bool) -> None: + self._best_fit = best_fit + + self.queue_draw() + + def set_hadjustment(self, hadjustment: Gtk.Adjustment | None = None) -> None: + adjustment = hadjustment + + if adjustment is None: + adjustment = Gtk.Adjustment() + + adjustment.connect("value-changed", lambda _: self.queue_draw()) + + self._hadjustment = adjustment + self._configure_adjustments() + + @GObject.Property(type=Gtk.Adjustment, setter=set_hadjustment) + def hadjustment(self) -> Gtk.Adjustment: + return self._hadjustment + + def set_vadjustment(self, vadjustment: Gtk.Adjustment | None = None) -> None: + adjustment = vadjustment + + if adjustment is None: + adjustment = Gtk.Adjustment() - @scale.setter - def set_scale(self, scale: float) -> None: - self._scale = scale + adjustment.connect("value-changed", lambda _: self.queue_draw()) - self.queue_resize() + self._vadjustment = adjustment + self._configure_adjustments() + + @GObject.Property(type=Gtk.Adjustment, setter=set_vadjustment) + def vadjustment(self) -> Gtk.Adjustment: + return self._vadjustment + + @GObject.Property(type=Gtk.ScrollablePolicy, default=Gtk.ScrollablePolicy.MINIMUM) + def vscroll_policy(self) -> Gtk.ScrollablePolicy: + return Gtk.ScrollablePolicy.MINIMUM + + @GObject.Property(type=Gtk.ScrollablePolicy, default=Gtk.ScrollablePolicy.MINIMUM) + def hscroll_policy(self) -> Gtk.ScrollablePolicy: + return Gtk.ScrollablePolicy.MINIMUM @GObject.Property(type=Gsk.ScalingFilter, default=Gsk.ScalingFilter.LINEAR) def scaling_filter(self) -> Gsk.ScalingFilter: @@ -63,19 +153,19 @@ def scaling_filter(self) -> Gsk.ScalingFilter: def set_scaling_filter(self, filter: Gsk.ScalingFilter) -> None: self._scaling_filter = filter - self.queue_resize() + self.queue_draw() @GObject.Property(type=bool, default=True) def can_zoom_in(self) -> bool: - return self._scale < 1024.0 + return self._zoom < MAX_ZOOM_LEVEL @GObject.Property(type=bool, default=True) def can_zoom_out(self) -> bool: - return self._scale > 1.0/1024.0 + return self._zoom > self._get_best_fit_zoom_level() @GObject.Property(type=bool, default=False) def can_reset_zoom(self) -> bool: - return self._scale != 1.0 + return self._zoom != self._get_best_fit_zoom_level() """ Signals @@ -90,37 +180,55 @@ def zoom_changed(self): """ # Here's where the zoom magic happens - def do_snapshot(self, snapshot: Gdk.Snapshot) -> None: - width = self.get_width() - height = self.get_height() + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + widget_width = float(self.get_width()) + widget_height = float(self.get_height()) if self.texture is None: return - new_width = self.scale * self.texture.get_width() - new_height = self.scale * self.texture.get_height() + texture_width = self.image_displayed_width() + texture_height = self.image_displayed_height() + + hadjustment: Gtk.Adjustment = self.hadjustment + vadjustment: Gtk.Adjustment = self.vadjustment - x = (width - math.ceil(new_width)) / 2 - y = (height - math.ceil(new_height)) / 2 + self._configure_adjustments() - snapshot.push_clip(Graphene.Rect().alloc().init(0, 0, width, height)) snapshot.save() - snapshot.translate(Graphene.Point().alloc().init(x, y)) - snapshot.translate(Graphene.Point().alloc().init(new_width / 2, new_height / 2)) - snapshot.translate(Graphene.Point().alloc().init(-new_width / 2, -new_height / 2)) + + # Apply the scrolling position to the image + x = -(hadjustment.get_value() - (hadjustment.get_upper() - texture_width) / 2) + snapshot.translate(Graphene.Point().init(int(x), 0)) + y = -(vadjustment.get_value() - (vadjustment.get_upper() - texture_height) / 2) + snapshot.translate(Graphene.Point().init(0, int(y))) + + # Centering in widget when no scrolling (black bars around image) + snapshot.translate( + Graphene.Point().init( + max((widget_width - texture_width) // 2, 0), + max((widget_height - texture_height) // 2, 0), + ) + ) + + scale_factor = self.get_scale_factor() + if scale_factor != 1: + snapshot.scale(1 / scale_factor, 1 / scale_factor) + + # Create texture boundaries + bounds_width = int(texture_width) * scale_factor + bounds_height = int(texture_height) * scale_factor + bounds = Graphene.Rect().alloc().init(0, 0, bounds_width, bounds_height) + snapshot.append_scaled_texture( self.texture, self.scaling_filter, - Graphene.Rect().alloc().init(0, 0, new_width, new_height) + bounds ) snapshot.restore() - snapshot.pop() - self.emit("zoom-changed") - # NOTE: Needed for Gtk.Viewport to implement scrollability - # TODO: Interface with `Gtk.Scrollable` to enable more capabilities def do_measure( self, orientation: Gtk.Orientation, @@ -140,47 +248,305 @@ def do_measure( else: size = height - minimum = natural = math.ceil(self.scale * size) + minimum = 0 + natural = math.ceil(size * self.zoom) return (minimum, natural, baseline, baseline) + def do_size_allocate(self, width: int, height: int, baseline: int) -> None: + if self.best_fit: + # Ensure there is an actual size change + if self.widget_dimensions != (width, height): + self._configure_best_fit() + + self.widget_dimensions = width, height + self._configure_adjustments() + """ Callbacks """ + def _on_motion_enter( + self, + _controller: Gtk.EventControllerMotion, + x: float, + y: float + ) -> None: + self.pointer_position = (x, y) + logging.debug(f"on_motion_enter value: {self.pointer_position}") + + def _on_motion( + self, + _controller: Gtk.EventControllerMotion, + x: float, + y: float + ) -> None: + self.pointer_position = (x, y) + logging.debug(f"on_motion value: {self.pointer_position}") + + def _on_motion_leave(self, _controller: Gtk.EventControllerMotion) -> None: + self.pointer_position = None + logging.debug(f"on_motion_leave value: {self.pointer_position}") + def on_zoom( self, _widget: Gtk.Widget, action_name: str, _parameter: GLib.Variant | None ) -> None: - scale: float + zoom: float match action_name: case "zoom.in": - scale = min(1024.0, self.scale * math.sqrt(2)) + zoom = self.zoom + 0.1 case "zoom.out": - scale = max(1.0/1024.0, self.scale / math.sqrt(2)) - case "zoom.reset": - scale = 1.0 + zoom = self.zoom - 0.1 + case "zoom.best-fit": + zoom = self._get_best_fit_zoom_level() case _: raise ValueError( "Invalid action name provided. Make sure it's from \"zoom.*\" namespace." ) - self.scale = scale + self.zoom_to(zoom) """ Public methods """ def zoom_to(self, zoom: float) -> None: - self.scale = zoom + """ + Zoom to specific level aiming at the current pointer position. + """ + + self._set_zoom_aiming(zoom, self.pointer_position) + + def zoom_in_cursor(self) -> None: + """ + Zoom in a step aiming at the current pointer position. + + Used by shortcuts. + """ + + zoom = self.zoom + 0.1 + self._set_zoom_aiming(zoom, self.pointer_position) + + def zoom_in_center(self) -> None: + """ + Zoom in a step aiming at the center of the viewport. + + Used by buttons. + """ + + zoom = self.zoom + 0.1 + self._set_zoom_aiming(zoom, None) + + def zoom_out_cursor(self) -> None: + """ + Zoom out a step aiming at the current pointer position. + + Used by shortcuts. + """ - def zoom_in(self) -> None: - self.scale = min(1024.0, self.scale * math.sqrt(2)) + zoom = self.zoom - 0.1 + self._set_zoom_aiming(zoom, self.pointer_position) - def zoom_out(self) -> None: - self.scale = max(1.0/1024.0, self.scale / math.sqrt(2)) + def zoom_out_center(self) -> None: + """ + Zoom out a step aiming at the center of the viewport. + + Used by buttons. + """ + + zoom = self.zoom - 0.1 + self._set_zoom_aiming(zoom, None) + + def zoom_best_fit(self) -> None: + """ + Zoom to best fit. + + Used by shortcuts and buttons. + """ + + self._set_zoom_aiming(self._get_best_fit_zoom_level(), self.pointer_position) + + def image_displayed_width(self) -> float: + """ + Image width with current zoom factor. + """ + + texture: Gdk.Texture | None = self.texture + if texture is None: + return 0.0 + + return texture.get_width() * self.zoom + + def image_displayed_height(self) -> float: + """ + Image height with current zoom factor. + """ + + texture: Gdk.Texture | None = self.texture + if texture is None: + return 0.0 + + return texture.get_height() * self.zoom + + def max_hadjustment_value(self) -> float: + return max(self.image_displayed_width() - self.get_width(), 0.0) + + def max_vadjustment_value(self) -> float: + return max(self.image_displayed_height() - self.get_height(), 0.0) + + """ + Private methods + """ + + # TODO: Create a sub-function that handles the `position` parameter being None + def _set_zoom_aiming(self, zoom: float, position: tuple[float, float] | None) -> None: + """ + Set zoom level aiming for given position or center if not available. + """ + + # Clamp the provided zoom value between the current best-fit and max zoom. + zoom = max(self._get_best_fit_zoom_level(), min(zoom, MAX_ZOOM_LEVEL)) + + if zoom == self.zoom: + return + + borders = self._get_borders() + zoom_ratio = self.zoom / zoom + + logging.debug(f"Setting zoom level to: {zoom}") + + if zoom <= self._get_best_fit_zoom_level(): + zoom = self._get_best_fit_zoom_level() + self.best_fit = True + else: + self.best_fit = False + + self._zoom = zoom + self._configure_adjustments() + + # Aim at the viewport center if `position` is None + x = self.get_width() // 2 + y = self.get_height() // 2 + + if position: + x = position[0] + y = position[1] + + hadjustment: Gtk.Adjustment = self.hadjustment + vadjustment: Gtk.Adjustment = self.vadjustment + + h_value = max((x + hadjustment.get_value() - borders[0]) / zoom_ratio - x, 0) + hadjustment.set_value(h_value) + + v_value = max((y + vadjustment.get_value() - borders[1]) / zoom_ratio - y, 0) + vadjustment.set_value(v_value) + + self.notify("zoom") + + self.queue_allocate() + self.queue_draw() + + def _configure_adjustments(self) -> None: + """ + Configures scrollbars for current situation. + """ + + if self.texture is None: + return + + hadjustment: Gtk.Adjustment = self.hadjustment + content_width = self.image_displayed_width() + widget_width = float(self.get_width()) + + hadjustment.configure( + # value + max(min(hadjustment.get_value(), self.max_hadjustment_value()), 0.0), + # lower + 0.0, + # upper + content_width, + # arrow button and shortcut step + widget_width * 0.1, + # page up/down step + widget_width * 0.9, + # page size + min(widget_width, content_width) + ) + + vadjustment: Gtk.Adjustment = self.vadjustment + content_height = self.image_displayed_height() + widget_height = float(self.get_height()) + + vadjustment.configure( + # value + max(min(vadjustment.get_value(), self.max_vadjustment_value()), 0.0), + # lower + 0.0, + # upper + content_height, + # arrow button and shortcut step + widget_height * 0.1, + # page up/down step + widget_height * 0.9, + # page size + min(widget_height, content_height) + ) + + def _configure_best_fit(self) -> None: + """ + Sets respective output values if best-fit is active. + """ + + # Calculate new zoom value for best fit + if self.best_fit: + best_fit_level = self._get_best_fit_zoom_level() + self.zoom = best_fit_level + self._configure_adjustments() + + def _get_borders(self) -> tuple[float, float]: + """ + Returns the width of horizontal (left, right) and vertical (top, bottom) bars. + """ + + widget_width = float(self.get_width()) + + if widget_width > self.image_displayed_width(): + hborder = (widget_width - self.image_displayed_width()) / 2.0 + else: + hborder = 0.0 + + widget_height = float(self.get_height()) + + if widget_height > self.image_displayed_height(): + vborder = (widget_height - self.image_displayed_height()) / 2.0 + else: + vborder = 0.0 + + return (hborder, vborder) + + def _get_best_fit_zoom_level(self) -> float: + """ + Calculates the zoom level that will make the image fit in widget. + """ + + texture: Gdk.Texture | None = self.texture + if texture is None: + return 0.0 + + image_width = texture.get_width() / self.get_scale_factor() + image_height = texture.get_height() / self.get_scale_factor() + + texture_aspect_ratio = image_width / image_height + widget_aspect_ratio = self.get_width() / self.get_height() + + max_zoom_factor = 1.0 + + if texture_aspect_ratio > widget_aspect_ratio: + default_zoom = min(self.get_width() / image_width, max_zoom_factor) + else: + default_zoom = min(self.get_height() / image_height, max_zoom_factor) - def reset_zoom(self) -> None: - self.scale = 1.0 + return default_zoom diff --git a/halftone/views/image_view.py b/halftone/views/image_view.py index 200ac87..8052d9e 100644 --- a/halftone/views/image_view.py +++ b/halftone/views/image_view.py @@ -19,15 +19,16 @@ class HalftoneImageView(Adw.Bin): toggle_sheet_button: Gtk.Button = Gtk.Template.Child() - viewer_scrolled_window: Gtk.ScrolledWindow = Gtk.Template.Child() + scrolled_window: Gtk.ScrolledWindow = Gtk.Template.Child() image_widget: HalftoneImage = Gtk.Template.Child() def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) - self.last_x: float = 0.0 - self.last_y: float = 0.0 + # Required for calculating delta while moving window around + self.last_drag_value: tuple[float, float] | None = None + # Targeted zoom level, might differ from the actual `zoom` self.zoom_target: float = 1.0 self._setup_controllers() @@ -48,7 +49,7 @@ def _setup_controllers(self) -> None: scroll_controller.connect("scroll", self.on_scroll) scroll_controller.connect("scroll-end", self.on_scroll_end) - self.viewer_scrolled_window.add_controller(scroll_controller) + self.scrolled_window.add_controller(scroll_controller) def _setup_gestures(self) -> None: # Drag for moving image around @@ -59,7 +60,7 @@ def _setup_gestures(self) -> None: drag_gesture.connect("drag-update", self.on_drag_update) drag_gesture.connect("drag-end", self.on_drag_end) - self.viewer_scrolled_window.add_controller(drag_gesture) + self.scrolled_window.add_controller(drag_gesture) # Zoom using 2-finger pinch/zoom gestures zoom_gesture = Gtk.GestureZoom.new() @@ -67,12 +68,13 @@ def _setup_gestures(self) -> None: zoom_gesture.connect("begin", self.on_zoom_begin) zoom_gesture.connect("scale-changed", self.on_zoom_scale_changed) - self.viewer_scrolled_window.add_controller(zoom_gesture) + self.scrolled_window.add_controller(zoom_gesture) def _setup_actions(self) -> None: """ `zoom.*` action group """ self.install_action("zoom.in", None, self.image_widget.on_zoom) + self.install_action("zoom.best-fit", None, self.image_widget.on_zoom) self.install_action("zoom.out", None, self.image_widget.on_zoom) # Add bindings for `zoom.*` action group @@ -87,6 +89,12 @@ def _setup_actions(self) -> None: ] self._add_shortcuts("zoom.in", zoom_in_shortcuts) + zoom_reset_shortcuts = [ + (Gdk.KEY_0, []), + (Gdk.KEY_0, [Gdk.ModifierType.CONTROL_MASK]), + ] + self._add_shortcuts("zoom.best-fit", zoom_reset_shortcuts) + zoom_out_shortcuts = [ (Gdk.KEY_minus, []), (Gdk.KEY_minus, [Gdk.ModifierType.CONTROL_MASK]), @@ -125,9 +133,9 @@ def on_scroll( return Gdk.EVENT_PROPAGATE if y < 0.0: - self.image_widget.zoom_in() + self.image_widget.zoom_in_cursor() else: - self.image_widget.zoom_out() + self.image_widget.zoom_out_cursor() # Do not propagate event to scrolled window return Gdk.EVENT_STOP @@ -148,19 +156,29 @@ def on_drag_begin(self, gesture: Gtk.GestureDrag, _x: float, _y: float) -> None: gesture.set_state(Gtk.EventSequenceState.DENIED) return - self.image_widget.set_cursor_from_name("grabbing") - self.last_x = 0.0 - self.last_y = 0.0 + if self.image_widget.can_zoom_out: + self._cancel_deceleration() + self.image_widget.set_cursor_from_name("grabbing") + self.last_drag_value = (0.0, 0.0) + else: + # Allow for different events when not scrollable + gesture.set_state(Gtk.EventSequenceState.DENIED) def on_drag_update(self, _gesture: Gtk.GestureDrag, x: float, y: float) -> None: - self._update_adjustment(x, y) - self.last_x = x - self.last_y = y + if self.image_widget.can_zoom_out: + if self.last_drag_value: + self.scrolled_window.get_hadjustment().set_value( + self.scrolled_window.get_hadjustment().get_value() - x + self.last_drag_value[0] + ) + self.scrolled_window.get_vadjustment().set_value( + self.scrolled_window.get_vadjustment().get_value() - y + self.last_drag_value[1] + ) + + self.last_drag_value = (x, y) def on_drag_end(self, _gesture: Gtk.GestureDrag, _x: float, _y: float) -> None: self.image_widget.set_cursor(None) - self.last_x = 0.0 - self.last_y = 0.0 + self.last_drag_value = None def on_zoom_begin( self, @@ -168,22 +186,22 @@ def on_zoom_begin( _sequence: Gdk.EventSequence ) -> None: self._cancel_deceleration() - self.zoom_target = self.image_widget.scale + self.zoom_target = self.image_widget.zoom - def on_zoom_scale_changed(self, _gesture: Gtk.GestureZoom, scale: float) -> None: - self.image_widget.scale = self.zoom_target * scale + def on_zoom_scale_changed(self, _gesture: Gtk.GestureZoom, zoom: float) -> None: + self.image_widget.zoom = self.zoom_target * zoom def on_zoom_changed(self, *args: Any) -> None: - current_filter = self.image_widget.scaling_filter nearest = Gsk.ScalingFilter.NEAREST - linear = Gsk.ScalingFilter.LINEAR + trilinear = Gsk.ScalingFilter.TRILINEAR - if self.image_widget.scale >= 1.0 and current_filter is not nearest: + if self.image_widget.zoom <= 1.0: + self.image_widget.scaling_filter = trilinear + else: self.image_widget.scaling_filter = nearest - elif self.image_widget.scale < 1.0 and current_filter is not linear: - self.image_widget.scaling_filter = linear self.action_set_enabled("zoom.in", self.image_widget.can_zoom_in) + self.action_set_enabled("zoom.best-fit", self.image_widget.can_reset_zoom) self.action_set_enabled("zoom.out", self.image_widget.can_zoom_out) """ @@ -191,16 +209,14 @@ def on_zoom_changed(self, *args: Any) -> None: """ def _cancel_deceleration(self) -> None: - self.viewer_scrolled_window.set_kinetic_scrolling(False) - self.viewer_scrolled_window.set_kinetic_scrolling(True) + """ + Cancel kinetic scrolling movements, needed for some gestures. - def _update_adjustment(self, x: float, y: float) -> None: - self.viewer_scrolled_window.get_hadjustment().set_value( - self.viewer_scrolled_window.get_hadjustment().get_value() - x + self.last_x - ) - self.viewer_scrolled_window.get_vadjustment().set_value( - self.viewer_scrolled_window.get_vadjustment().get_value() - y + self.last_y - ) + If deceleration is not canceled, gestures become buggy. + """ + + self.scrolled_window.set_kinetic_scrolling(False) + self.scrolled_window.set_kinetic_scrolling(True) def _add_shortcuts( self,