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,