From 854b4b4b5431d528fbab59ec4306749c72a13c6e Mon Sep 17 00:00:00 2001 From: Zoey Ahmed Date: Sat, 13 Sep 2025 13:10:40 -0400 Subject: [PATCH] meta: Add scale comparison frame widget Co-authored-by: Jan Willem Eriks --- upscaler/before-after-page.blp | 35 + upscaler/before_after_page.py | 81 ++ upscaler/composite-frame.blp | 38 + upscaler/composite_frame.py | 1026 ++++++++++++++++++++ upscaler/icons/dialog-warning-symbolic.svg | 4 + upscaler/icons/zoom-toggle-symbolic.svg | 2 + upscaler/meson.build | 4 + upscaler/queue_row.py | 3 + upscaler/shortcuts-dialog.blp | 43 + upscaler/style-dark.css | 4 + upscaler/style.css | 15 + upscaler/upscaler.gresource.xml.in | 5 + upscaler/window.blp | 106 +- upscaler/window.py | 228 ++++- 14 files changed, 1587 insertions(+), 7 deletions(-) create mode 100644 upscaler/before-after-page.blp create mode 100644 upscaler/before_after_page.py create mode 100644 upscaler/composite-frame.blp create mode 100644 upscaler/composite_frame.py create mode 100644 upscaler/icons/dialog-warning-symbolic.svg create mode 100644 upscaler/icons/zoom-toggle-symbolic.svg create mode 100644 upscaler/style-dark.css diff --git a/upscaler/before-after-page.blp b/upscaler/before-after-page.blp new file mode 100644 index 0000000..3b887b6 --- /dev/null +++ b/upscaler/before-after-page.blp @@ -0,0 +1,35 @@ +using Gtk 4.0; +using Adw 1; + +template $BeforeAfterPage : Stack { + visible-child-name: bind $_get_visible_child_name(template.valid_paths) as ; + notify::before-path => $_on_before_path_notify(); + notify::after-path => $_on_after_path_notify(); + + StackPage { + name: "frame"; + child: ScrolledWindow { + child: $CompositeFrame frame { + zoom-changed => $_on_zoom_changed(); + }; + }; + } + StackPage { + name: "warning"; + + child: Adw.StatusPage { + icon-name: "dialog-warning"; + title: _("Failed to Open Before/After Page"); + description: _("Check the original image is not corrupted or missing"); + child: Button { + halign: center; + label: _("Reload"); + clicked => $_on_before_path_notify(); + styles [ + "suggested-action", + "pill" + ] + }; + }; + } +} diff --git a/upscaler/before_after_page.py b/upscaler/before_after_page.py new file mode 100644 index 0000000..1940697 --- /dev/null +++ b/upscaler/before_after_page.py @@ -0,0 +1,81 @@ +# before_after_window.py +# +# Copyright 2025 The Upscaler Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import TYPE_CHECKING, Any, cast + +from gi.repository import GLib, GObject, Gdk, Gtk + +from upscaler.composite_frame import CompositeFrame +from upscaler.logger import logger + +if TYPE_CHECKING: + from upscaler.window import Window + + +@Gtk.Template.from_resource("/io/gitlab/theevilskeleton/Upscaler/before-after-page.ui") +class BeforeAfterPage(Gtk.Stack): + """Holds the comparison frame in a scrolled window, and provides ways to interface with frame to other classes.""" + + __gtype_name__ = "BeforeAfterPage" + + frame: CompositeFrame = Gtk.Template.Child() + valid_paths = GObject.Property(type=bool, default=True) + before_path = GObject.Property(type=str) + after_path = GObject.Property(type=str) + + def __init__(self, **kwargs: Any) -> None: + """Initialize Before/After Page.""" + super().__init__(**kwargs) + + @Gtk.Template.Callback() + def _on_before_path_notify(self, *_args: Any) -> None: + """Try to set the original texture when it either changes or the user requests to reload it.""" + try: + self.frame.before_image = Gdk.Texture.new_from_filename(self.before_path) + self.valid_paths = True + except GLib.GError: # type: ignore [misc] + self.valid_paths = False + + @Gtk.Template.Callback() + def _on_after_path_notify(self, *_args: Any) -> None: + """Change the after texture of composite frame when the path changes.""" + self.frame.after_image = Gdk.Texture.new_from_filename(self.after_path) + + @Gtk.Template.Callback() + def _on_zoom_changed(self, _source: Any, zoom: int) -> None: + """Change the properties of zoom box in main window when zoom has changed.""" + self.get_toplevel_window().can_zoom_out = zoom > self.frame.get_zoom_to_fit() + self.get_toplevel_window().can_zoom_in = zoom < self.frame.zoom_max_threshold + self.get_toplevel_window().set_zoom_toggled( + zoom != self.frame.get_zoom_to_fit() + ) + + @Gtk.Template.Callback() + def _get_visible_child_name(self, _source: Any, valid: bool) -> str: + return "frame" if valid else "warning" + + def get_frame(self) -> CompositeFrame: + """Manipulate frame from window.py using kbd shortcuts.""" + return self.frame + + def get_toplevel_window(self) -> "Window": + """Get first toplevel window.""" + toplevels = Gtk.Window.get_toplevels() + if not (window := toplevels.get_item(0)): + logger.exception("Can not find applications window!") + return cast("Window", window) diff --git a/upscaler/composite-frame.blp b/upscaler/composite-frame.blp new file mode 100644 index 0000000..9353954 --- /dev/null +++ b/upscaler/composite-frame.blp @@ -0,0 +1,38 @@ +using Gtk 4.0; + +template $CompositeFrame : Widget { + notify => $_on_property_changed(); + notify::zoom-request => $_on_zoom_changed(); + + GestureClick click_controller { + released => $_on_click_released(); + } + GestureDrag drag_controller { + drag-begin => $_on_drag_begin(); + drag-update => $_on_drag_update(); + drag-end => $_on_drag_end(); + } + GestureZoom zoom_controller { + begin => $_on_zoom_begin(); + scale-changed => $_on_zoom_update(); + end => $_on_zoom_end(); + } + + EventControllerScroll zoom_scroll_controller { + scroll => $_on_zoom_scroll(); + flags: vertical; + } + + EventControllerScroll slider_scroll_controller { + scroll-begin => $_on_slider_scroll_begin(); + scroll => $_on_slider_scroll(); + scroll-end => $_on_slider_scroll_end(); + flags: horizontal; + } + + EventControllerMotion motion_controller { + enter => $_on_motion_enter(); + motion => $_on_motion_update(); + leave => $reset_cursor_position(); + } +} diff --git a/upscaler/composite_frame.py b/upscaler/composite_frame.py new file mode 100644 index 0000000..e62b376 --- /dev/null +++ b/upscaler/composite_frame.py @@ -0,0 +1,1026 @@ +# composite_frame.py +# +# Copyright 2025 Zoey Ahmed +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import math +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, cast + +from gi.repository import Adw, GObject, Gdk, Graphene, Gsk, Gtk, Pango + +from upscaler.logger import logger + +if TYPE_CHECKING: + from upscaler.window import Window + + +class States(GObject.GEnum): + """Stores the different ways the user can be manipulating the frame.""" + + NONE = 0 # No user control + SLIDING = 1 + DRAGGING = 2 + ZOOMING = 3 # Only for touch devices + + +@Gtk.Template.from_resource("/io/gitlab/theevilskeleton/Upscaler/composite-frame.ui") +class CompositeFrame(Gtk.Widget, Gtk.Scrollable): # type: ignore[misc] + """Widget for display the before/after frame, with a built-in slider for controlling visibility of each half.""" + + __gtype_name__ = "CompositeFrame" + + # Define GObject Properties For Before/After Page + slider_position = GObject.Property( + type=float, minimum=0.0, maximum=1.0, default=0.5 + ) + + # Cached Textures + before_texture: Gdk.Texture | None = None + after_texture: Gdk.Texture | None = None + + # Implement properties inherited from Gtk.Scrollable + vscroll_policy = GObject.Property( + type=Gtk.ScrollablePolicy, default=Gtk.ScrollablePolicy.MINIMUM + ) + hscroll_policy = GObject.Property( + type=Gtk.ScrollablePolicy, default=Gtk.ScrollablePolicy.MINIMUM + ) + vadjustment_value: Gtk.Adjustment | None = None + hadjustment_value: Gtk.Adjustment | None = None + + # This must be preserved if the paintable needs to change size, + # e.g. window size or zoom factor is changed + h_scroll_pos_value: float = 0 + v_scroll_pos_value: float = 0 + + # Properties Controlling zoom and other states of motion + initial_zoom: float = -1 + zoom_value: float = -1 + + zoom_min_threshold = 0.2 + zoom_max_threshold = 15 + + zoom_min = 0.05 + zoom_max = 50 + zoom_request = GObject.Property( + type=float, minimum=0.0, maximum=zoom_max, default=0.0 + ) + state: States = States.NONE + last_drag_offset: tuple[float, float] | None = None + pointer_position: tuple[float, float] | None = None + + # Constants for the factors of zoom for different form factors/fits + ZOOM_TO_FIT = 0.0 + ZOOM_FACTOR_BUTTON = 1.5 + ZOOM_FACTOR_WHEEL = 1.3 + ZOOM_FACTOR_SCROLL_SURFACE = 1.005 + SLIDER_SCROLL_FACTOR = 0.0018 + # Constants for hover animation + HOVER_ALPHA: float = 0.7 + NON_HOVER_ALPHA: float = 0.4 + + # Constants for slider dimensions + slider_width = 4 + slider_circle_radius = 30 + slider_arrow_offset = 6 + + # Constants for text inscriptions + margin = 6 + inscription_x_margin = 20 + inscription_y_margin = 44 + + # Get accent color + style_manager = Adw.StyleManager().get_default() + accent_color = style_manager.get_accent_color_rgba() + accent_color_blurred = accent_color + accent_color_blurred.alpha = 0.7 + + # Define colors for other parts of the composition + shadow_color = Gdk.RGBA() + shadow_color.parse("rgba(0, 0, 0, 0.15)") + white_color = Gdk.RGBA() + white_color.parse("rgba(255,255,255,0.8)") + + # Get cursor types for changing cursor on different actions + default_cursor: Gdk.Cursor = cast("Gdk.Cursor", Gdk.Cursor.new_from_name("default")) + slider_cursor: Gdk.Cursor = cast( + "Gdk.Cursor", Gdk.Cursor.new_from_name("ew-resize") + ) + grab_cursor: Gdk.Cursor = cast("Gdk.Cursor", Gdk.Cursor.new_from_name("grab")) + + def __init__(self, **kwargs: Any) -> None: + """Initialize Composite Frame widget, including its animations.""" + super().__init__(**kwargs) + + self.style_manager.connect("notify", self._on_style_changed) + + # Create animation for hovering. + hover_target = Adw.CallbackAnimationTarget.new(self._on_property_changed) + self.hover_animation = Adw.TimedAnimation.new( + self, self.NON_HOVER_ALPHA, self.NON_HOVER_ALPHA, 125, hover_target + ) + + # Create animation for rubber-banding zoom + rubberband_target = Adw.CallbackAnimationTarget.new( + lambda zoom: self.set_zoom(zoom) + ) + self.rubberband_animation = Adw.TimedAnimation( + widget=self, + duration=200, + easing=Adw.Easing.EASE_IN_OUT, + target=rubberband_target, + ) + + # Create animation for linear zoom (zooming in use keybinds). + zoom_target = Adw.CallbackAnimationTarget.new( + lambda zoom: self.zoom_to_point(zoom, None) + ) + self.linear_zoom_animation = Adw.TimedAnimation( + widget=self, + duration=200, + target=zoom_target, + ) + + # Implement properties for before/after texture + @GObject.Property(type=Gdk.Texture) + def before_image(self) -> Gdk.Texture: + """Return texture of original image.""" + return cast("Gdk.Texture", self.before_texture) + + @GObject.Property(type=Gdk.Texture) + def after_image(self) -> Gdk.Texture: + """Return texture of upscaled image.""" + return cast("Gdk.Texture", self.after_texture) + + @before_image.setter # type: ignore[no-redef] + def before_image(self, texture: Gdk.Texture) -> None: + """Set texture as the original texture.""" + self.before_texture = texture + self.queue_resize() + + @after_image.setter # type: ignore[no-redef] + def after_image(self, texture: Gdk.Texture) -> None: + """Set texture as the upscaled texture.""" + self.after_texture = texture + self.queue_resize() + + # Implement properties for zoom + scrolling interface + @GObject.Property(type=float, default=-1, flags=GObject.ParamFlags.READABLE) + def zoom(self) -> float: + """Return factor of current zoom.""" + return self.zoom_value + + @GObject.Property(type=float, default=0.5, minimum=0.0, maximum=1.0) + def v_scroll_pos(self) -> float: + """Return current relative position of vertical scrollbar.""" + return self.v_scroll_pos_value + + @GObject.Property(type=float, default=0.5, minimum=0.0, maximum=1.0) + def h_scroll_pos(self) -> float: + """Return current relative position of horizontal scrollbar.""" + return self.h_scroll_pos_value + + @GObject.Property(type=Gtk.Adjustment) + def vadjustment(self) -> Gtk.Adjustment: + """Return adjustment controlling vertical scrollbar.""" + return cast("Gtk.Adjustment", self.vadjustment_value) + + @GObject.Property(type=Gtk.Adjustment) + def hadjustment(self) -> Gtk.Adjustment: + """Return adjustment controlling horizontal scrollbar.""" + return cast("Gtk.Adjustment", self.hadjustment_value) + + @vadjustment.setter # type: ignore[no-redef] + def vadjustment(self, vadjustment: Gtk.Adjustment) -> None: + """Set a new adjustent controlling vertical scrollbar.""" + self.vadjustment_value = vadjustment + # Redraw on scrollbar change + if self.vadjustment_value is not None: + self.vadjustment_value.connect( + "value-changed", + lambda *args: self.set_v_scroll( + self.get_normalized_adjustment_value(args[0]) + ), + ) + + @hadjustment.setter # type: ignore[no-redef] + def hadjustment(self, hadjustment: Gtk.Adjustment) -> None: + """Set a new adjustent controlling horizontal scrollbar.""" + self.hadjustment_value = hadjustment + # Redraw on scrollbar change + if self.hadjustment_value is not None: + self.hadjustment_value.connect( + "value-changed", + lambda *args: self.set_h_scroll( + self.get_normalized_adjustment_value(args[0]) + ), + ) + + @v_scroll_pos.setter # type: ignore[no-redef] + def v_scroll_pos(self, value: float) -> None: + """Set new relative position of vertical scrollbar.""" + if self.v_scroll_pos_value != value: + self.v_scroll_pos_value = min(max(value, 0.0), 1.0) + # The widget size is dependent on the zoom + self.queue_allocate() + + @h_scroll_pos.setter # type: ignore[no-redef] + def h_scroll_pos(self, value: float) -> None: + """Set new relative position of horizontal scrollbar.""" + if self.h_scroll_pos_value != value: + self.h_scroll_pos_value = min(max(value, 0.0), 1.0) + # The widget size is dependent on the zoom + self.queue_allocate() + + def set_v_scroll(self, v_scroll: float) -> None: + """Safely set new relative position of vertical scrollbar.""" + self.v_scroll_pos = min(max(v_scroll, 0), 1) + + def set_h_scroll(self, h_scroll: float) -> None: + """Safely set new relative position of horizontal scrollbar.""" + self.h_scroll_pos = min(max(h_scroll, 0), 1) + + # Implement template callbacks + @Gtk.Template.Callback() + def _on_property_changed(self, *_args: Any) -> None: + """When a property changes, queue a new redraw.""" + self.queue_draw() + + @Gtk.Template.Callback() + def _on_zoom_changed(self, *_args: Any) -> None: + """When zoom changes, set the new zoom to the requested value.""" + self.set_zoom(self.zoom_request) + + @Gtk.Template.Callback() + def _on_click_released( + self, + gesture: Gtk.GestureClick, + _n_presses: int, + x: float, + y: float, + ) -> None: + """Reset state + cursor state when cursor is released.""" + self.state = States.NONE + if ( + cast("Gdk.Device", gesture.get_device()).get_source() + == Gdk.InputSource.TOUCHSCREEN + ): + # There is no 'hover' possible in touchscreen devices + self.set_default_cursor() + return + + if self.pointer_position is None: # If cursor is released off window + return + + if self.get_on_slider(x, y): + self.set_slider_cursor() + else: + self.set_default_cursor() + + @Gtk.Template.Callback() + def _on_drag_begin(self, gesture: Gtk.GestureDrag, x: float, y: float) -> None: + """Start slider drag sequence.""" + if self.state is not States.NONE: + return + + if ( + cast("Gdk.Device", gesture.get_device()).get_source() + == Gdk.InputSource.TOUCHSCREEN + ): + # For touchscreen devices, disable scrolling to allow interacting with the slider + cast("Gtk.ScrolledWindow", self.get_parent()).set_kinetic_scrolling(False) + + if self.get_on_slider(x, y): + self.state = States.SLIDING + self.set_slider_cursor() + + # Claim the gesture state to avoid conflicting with the gesture in the navigation page. + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + if ( + cast("Gdk.Device", gesture.get_device()).get_source() + != Gdk.InputSource.TOUCHSCREEN + ): + # Touch devices can send multiple drag-start signals for each finger, resulting in weird and unpredictable slider movement, so only move the slider on a drag event for touch devices. + self.set_slider_pos(x) + + elif self.get_can_drag(): + self.state = States.DRAGGING + self.last_drag_offset = (0.0, 0.0) + self.set_cursor(self.grab_cursor) + # Claim the gesture state to avoid conflicting with the gesture in the navigation page. + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + @Gtk.Template.Callback() + def _on_drag_update( + self, gesture: Gtk.GestureDrag, delta_x: float, delta_y: float + ) -> None: + """Update slider drag position.""" + if self.hadjustment is None or self.vadjustment is None: + pass + + if self.state is States.SLIDING: + # We are moving the slider so move it to the pointer position, + (active, start_x, start_y) = gesture.get_start_point() + self.set_slider_pos(delta_x + start_x) + elif self.state is States.DRAGGING and self.last_drag_offset is not None: + # Claim the gesture state to avoid conflicting with the gesture in the navigation page. + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + self.hadjustment.set_value( + self.hadjustment.get_value() - (delta_x - self.last_drag_offset[0]) + ) + self.vadjustment.set_value( + self.vadjustment.get_value() - (delta_y - self.last_drag_offset[1]) + ) + self.last_drag_offset = (delta_x, delta_y) + + @Gtk.Template.Callback() + def _on_drag_end(self, *_args: Any) -> None: + """Reset drag offset.""" + self.last_drag_offset = None + self.state = States.NONE if self.state is not States.ZOOMING else States.ZOOMING + + @Gtk.Template.Callback() + def _on_zoom_begin(self, *_args: Any) -> None: + """Set state to zooming, and remove kinetic scrolling of parent page, when zoom starts.""" + self.state = States.ZOOMING + self.initial_zoom = self.zoom + cast("Gtk.ScrolledWindow", self.get_parent()).set_kinetic_scrolling(False) + + @Gtk.Template.Callback() + def _on_zoom_update(self, gesture: Gtk.GestureZoom, scale: float) -> None: + """Update zoom position/point when zoom updates.""" + if self.state is States.ZOOMING: + zoom = self.initial_zoom * scale + (active, x, y) = gesture.get_bounding_box_center() + self.zoom_to_point(zoom, (x, y)) + + @Gtk.Template.Callback() + def _on_zoom_end(self, *_args: Any) -> None: + """Reset initial zoom + state when the user stops zooming.""" + self.state = States.NONE + self.initial_zoom = -1 + + # Rubberband if over/under the zoom thresholds. + if self.zoom_request > self.zoom_max_threshold: + self.rubberband_animation.set_value_from(self.zoom_request) + self.rubberband_animation.set_value_to(self.zoom_max_threshold) + self.rubberband_animation.play() + + if self.zoom_request < max(self.get_zoom_to_fit(), self.zoom_min_threshold): + self.rubberband_animation.set_value_from(self.zoom_request) + self.rubberband_animation.set_value_to( + max(self.get_zoom_to_fit(), self.zoom_min_threshold) + ) + self.rubberband_animation.play() + + @Gtk.Template.Callback() + def _on_zoom_scroll( + self, event_scroll: Gtk.EventControllerScroll, _delta_x: float, delta_y: float + ) -> bool: + """Zoom with Ctrl + horizontal scrolls on either a mouse wheel or horizontal touchpad swipes.""" + input_source = cast( + "Gdk.Device", event_scroll.get_current_event_device() + ).get_source() + + if ( + input_source in {Gdk.InputSource.MOUSE, Gdk.InputSource.TRACKPOINT} + ) and event_scroll.get_current_event_state() == Gdk.ModifierType.CONTROL_MASK: + zoom_factor = math.exp( + -delta_y + * math.log( + self.ZOOM_FACTOR_WHEEL + if event_scroll.get_unit() is Gdk.ScrollUnit.WHEEL + else self.ZOOM_FACTOR_SCROLL_SURFACE + ) + ) + + zoom = self.zoom * zoom_factor + self.zoom_to_point(zoom, self.pointer_position) + + # The event had been handled here instead of in ScrolledWindow parent + return True + + return False + + @Gtk.Template.Callback() + def _on_slider_scroll_begin( + self, + event_scroll: Gtk.EventControllerScroll, + ) -> None: + input_source = cast( + "Gdk.Device", event_scroll.get_current_event_device() + ).get_source() + + if ( + input_source in {Gdk.InputSource.TOUCHPAD, Gdk.InputSource.TRACKPOINT} + ) and event_scroll.get_current_event_state() == Gdk.ModifierType.CONTROL_MASK: + self.state = States.DRAGGING + self.start_slider_position = ( + self.slider_position * self.get_content_size()[0] + ) + + @Gtk.Template.Callback() + def _on_slider_scroll( + self, event_scroll: Gtk.EventControllerScroll, delta_x: float, _delta_y: float + ) -> bool: + """Start slider scroll sequence. + + Check if the user is both holding down Ctrl + On a touchpad/trackpoint, + then move slider left/right depending on the delta since last scroll. + """ + input_source = cast( + "Gdk.Device", event_scroll.get_current_event_device() + ).get_source() + + # Slider control using Ctrl + Scroll is only handled for touchpads and trackpoints + if ( + input_source in {Gdk.InputSource.TOUCHPAD, Gdk.InputSource.TRACKPOINT} + ) and event_scroll.get_current_event_state() == Gdk.ModifierType.CONTROL_MASK: + self.set_default_cursor() + # Delta needs to be reversed so slider goes in the + # Same direction as users swipes + self.slider_position = min( + max(self.slider_position + (-delta_x * self.SLIDER_SCROLL_FACTOR), 0), 1 + ) + self.state = States.DRAGGING + + # The event had been handled here instead of in ScrolledWindow parent + return True + return False + + @Gtk.Template.Callback() + def _on_slider_scroll_end(self, *_args: Any) -> None: + """Reset state + starting slider position back to default.""" + # Check if the user is zooming into image before resetting state, as + # to not prematurely end zoom. + self.state = States.NONE if self.state != States.ZOOMING else States.ZOOMING + + self.start_slider_position = None + + @Gtk.Template.Callback() + def _on_motion_enter( + self, _event_motion: Gtk.EventControllerMotion, x: float, y: float + ) -> None: + """Activates when user cursor first enters window, to set initial pointer position.""" + self.pointer_position = (x, y) + if self.get_on_slider(x, y): + self.set_slider_cursor() + + @Gtk.Template.Callback() + def _on_motion_update( + self, _event_motion: Gtk.EventControllerMotion, x: float, y: float + ) -> None: + """Update motion sequence. + + Change point position + check if the users cursor is + now/was on slider or not, and updates accordingly. + + """ + if self.state is not States.NONE: + # We are already handling input, therefore no cursor update + # needed, only reset position + self.pointer_position = (x, y) + return + + # It is possible to have no pointer position if the user has just come in from a different page + if not self.pointer_position: + return + + # Check if we were previously hovering over slider + (old_x, old_y) = self.pointer_position + was_on_slider = self.get_on_slider(old_x, old_y) + + # Check if we are now hovering over slider + self.pointer_position = (x, y) + is_on_slider = self.get_on_slider(x, y) + + if was_on_slider and not is_on_slider: + # We left the slider, reset cursor + self.set_default_cursor() + elif not was_on_slider and is_on_slider: + # We entered the slider, set slider cursor + self.set_slider_cursor() + + @Gtk.Template.Callback() + def reset_cursor_position(self, *_args: Any) -> None: + """Reset cursor + hover positions if the cursor leaves the window.""" + self.set_default_cursor() + self.pointer_position = None + + def _on_style_changed(self, *_args: Any) -> None: + """Change internal accent color. + + Change the internal accent color + to the new one exposed in Adw.StyleManager. + + """ + self.accent_color = self.style_manager.get_accent_color_rgba() + self.accent_color_blurred = self.accent_color + self.accent_color_blurred.alpha = 0.7 + self._on_property_changed() + + # Overrides for build in do_foo methods + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + """Draw composite frame + text inscriptions.""" + if self.before_image is None or self.after_image is None: + return + + self.draw_composited_frame(snapshot) + self.draw_slider(snapshot) + self.emit( + "zoom_changed", self.zoom_value + ) # Communicate with parent window that zoom may have changed while repainting + + def do_measure( + self, orientation: Gtk.Orientation, _for_size: int + ) -> tuple[int, int, int, int]: + """Return image width/height as natural and min size of composite widget.""" + if self.before_image is None or self.after_image is None: + return (-1, -1, -1, -1) + + image_width, image_height = self.get_image_size() + return ( + (image_width, image_width, -1, -1) + if orientation == Gtk.Orientation.HORIZONTAL + else (image_height, image_height, -1, -1) + ) + + def do_size_allocate(self, width: int, height: int, _baseline: int) -> None: + """Allocate size of widget and reconfigure scrollbar adjustments.""" + if ( + self.hadjustment is None + or self.vadjustment is None + or self.before_image is None + or self.after_image is None + ): + return + + image_width, image_height = self.get_image_size() + + if self.zoom_request == self.ZOOM_TO_FIT: + self.zoom_value = self.get_zoom_to_fit() + self.notify("zoom") + + self.hadjustment.configure( + self.h_scroll_pos * (image_width - width), + 0, + image_width, + width * 0.1, + width * 0.9, + min(width, image_width), + ) + + self.vadjustment.configure( + self.v_scroll_pos * (image_height - height), + 0, + image_height, + width * 0.1, + width * 0.9, + min(height, image_height), + ) + + # Draw Routines + def draw_composited_frame(self, snapshot: Gtk.Snapshot) -> None: + """Draw before/after images. + + Creates the composited before/after frame by placing upscaled texture + above original texture, for frame right of the slider. + Also handles drawing text inscriptions in the frame. + + """ + image_width, image_height = self.get_image_size() + content_width, content_height = self.get_content_size() + page_width, page_height = self.get_page_size() + + # Center composited image to center of the page + snapshot.translate( + Graphene.Point().init( + (page_width - content_width) / 2, + (page_height - content_height) / 2, + ) + ) + + # Create composite to hold the final comparison frame in + composite_image_rect = Graphene.Rect().init( + -(self.h_scroll_pos * (image_width - page_width)), + -(self.v_scroll_pos * (image_height - page_height)), + image_width, + image_height, + ) + + # Draw original image's texture first + snapshot.append_texture( + self.get_toplevel_window().get_directional_item( + self.before_texture, self.after_texture + ), + composite_image_rect, + ) + + # Save current state of the copy + snapshot.save() + + # Draw before text inscription + before_layout = self.get_pango_layout( + cast( + "str", + self.get_toplevel_window().get_directional_item("Before", "After"), + ) + ) + self.draw_inscription( + snapshot, + before_layout, + self.inscription_x_margin, + self.inscription_y_margin, + ) + + # Return to previous state + snapshot.restore() + + # Draw upscaled image above original by clipping the + # original image right of the slider + after_clip = Graphene.Rect().init( + round(content_width * self.slider_position), + 0, + image_width - math.floor(content_width * self.slider_position), + image_height, + ) + snapshot.push_clip(after_clip) + snapshot.append_texture( + self.get_toplevel_window().get_directional_item( + self.after_texture, self.before_texture + ), + composite_image_rect, + ) + + # Draw After Text Inscription + after_layout = self.get_pango_layout( + cast( + "str", + self.get_toplevel_window().get_directional_item("After", "Before"), + ) + ) + self.draw_inscription( + snapshot, + after_layout, + content_width + - after_layout.get_pixel_size()[0] + - self.inscription_x_margin, + self.inscription_y_margin, + ) + + # Pop the final composition + snapshot.pop() + + def draw_inscription( + self, + snapshot: Gtk.Snapshot, + inscription_layout: Pango.Layout, + x: int, + y: int, + ) -> None: + """Routine for drawing before/after inscriptions from a Pango Layout.""" + layout_x, layout_y = inscription_layout.get_pixel_size() + + # Create Background Of Text + text_background = Graphene.Rect().init( + x - self.margin, + y - layout_y - (self.margin * 3), + layout_x + (self.margin * 2), + layout_y + (self.margin * 2), + ) + background_clip = Gsk.RoundedRect() + background_clip.init_from_rect(text_background, radius=6) + + # Append colored background + snapshot.push_rounded_clip(background_clip) + snapshot.append_color(self.accent_color_blurred, text_background) + snapshot.pop() + + # Add inscription + snapshot.translate(Graphene.Point().init(x, (y - layout_y - (self.margin * 2)))) + snapshot.append_layout(inscription_layout, self.white_color) + + def draw_slider(self, snapshot: Gtk.Snapshot) -> None: + """Draw straight line with a circle in the middle, with 2 arrows on either side of it.""" + content_width, content_height = self.get_content_size() + page_width, page_height = self.get_page_size() + + # Draw Rounded Rectangular Slider + upper_slider_rect = Graphene.Rect().init( + round(content_width * self.slider_position) + - math.floor(self.slider_width / 2), + 0, + self.slider_width, + round(content_height / 2) - self.slider_circle_radius, + ) + + slider_rounder = Gsk.RoundedRect() + slider_rounder.init_from_rect(upper_slider_rect, 0) + snapshot.append_outset_shadow( + slider_rounder, self.shadow_color, 0, self.slider_width + 1, 2, 1 + ) + snapshot.append_color(self.accent_color, upper_slider_rect) + + lower_slider_rect = Graphene.Rect().init( + round(content_width * self.slider_position) + - math.floor(self.slider_width / 2), + round(content_height / 2) + self.slider_circle_radius, + self.slider_width, + round(content_height / 2) - self.slider_circle_radius, + ) + + slider_rounder = Gsk.RoundedRect() + slider_rounder.init_from_rect(lower_slider_rect, 0) + snapshot.append_outset_shadow( + slider_rounder, self.shadow_color, 0, self.slider_width - 2, 2, 1 + ) + snapshot.append_color(self.accent_color, lower_slider_rect) + + # Draw Center Circle + slider_circle = Graphene.Rect().init( + round(content_width * self.slider_position) + - self.slider_circle_radius + + (self.slider_width / 2), + round(content_height / 2) - self.slider_circle_radius, + self.slider_circle_radius * 2, + self.slider_circle_radius * 2, + ) + + slider_circle_rounder = Gsk.RoundedRect() + slider_circle_rounder.init_from_rect(slider_circle, 100) + snapshot.push_rounded_clip(slider_circle_rounder) + snapshot.append_color(self.accent_color_blurred, slider_circle) + snapshot.pop() + snapshot.append_outset_shadow( + slider_circle_rounder, self.shadow_color, 0, 0, 3, 1 + ) + + snapshot.append_border( + slider_circle_rounder, + [ + self.slider_width, + self.slider_width, + self.slider_width, + self.slider_width, + ], + [ + self.accent_color, + self.accent_color, + self.accent_color, + self.accent_color, + ], + ) + + # Draw < > in cricle + layout_arrow = self.create_pango_layout() + layout_arrow.set_font_description(Pango.font_description_from_string("Bold 18")) + + layout_arrow.set_text( + cast("str", self.get_toplevel_window().get_directional_item(">", "<")) + ) + + snapshot.translate( + Graphene.Point().init( + round(content_width * self.slider_position) + + (self.slider_width / 2) + + self.slider_arrow_offset, + round(content_height / 2) + - round(layout_arrow.get_pixel_size()[1] / 2) + - 2, + ) + ) + + snapshot.append_layout(layout_arrow, self.white_color) + + layout_arrow.set_text( + cast("str", self.get_toplevel_window().get_directional_item("<", ">")) + ) + snapshot.translate( + Graphene.Point().init( + -layout_arrow.get_pixel_size()[0] + - self.slider_arrow_offset * 2 + + (self.slider_width / 2), + 0, + ) + ) + snapshot.append_layout(layout_arrow, self.white_color) + + # Helper Methods + + def get_toplevel_window(self) -> "Window": + """Get first toplevel window.""" + toplevels = Gtk.Window.get_toplevels() + if not (window := toplevels.get_item(0)): + logger.exception("Can not find applications window!") + return cast("Window", window) + + def get_pango_layout(self, text: str) -> Pango.Layout: + """Return the Pango form to be used in inscriptions.""" + layout_inscription = self.create_pango_layout() + layout_inscription.set_font_description( + Pango.font_description_from_string("Bold 11") + ) + layout_inscription.set_text(_(text)) + return layout_inscription + + def get_texture_size(self) -> tuple[int, int]: + """Return original texture width/height.""" + return ( + cast("Gdk.Texture", self.before_image).get_width(), + cast("Gdk.Texture", self.before_image).get_height(), + ) + + def get_image_size(self) -> tuple[int, int]: + """Return natural width-by-height dimensions for the before-after composition.""" + texture_width, texture_height = self.get_texture_size() + return (round(texture_width * self.zoom), round(texture_height * self.zoom)) + + def get_content_size(self) -> tuple[int, int]: + """Return width-by-height dimensions that the composition will be drawn at.""" + image_width, image_height = self.get_image_size() + page_width, page_height = self.get_page_size() + return (min(image_width, page_width), min(image_height, page_height)) + + def get_page_size(self) -> tuple[int, int]: + """Return width-by-height dimensions of this widget.""" + return (self.get_width(), self.get_height()) + + def get_zoom_to_fit(self) -> float: + """Return the zoom factor needed to fit the composite frame into the center of page.""" + if self.before_image is None: + return -1 + + image_width, image_height = self.get_texture_size() + page_width, page_height = self.get_page_size() + + return min( + (image_width / page_width), + (page_width / image_width), + (image_height / page_height), + (page_height / image_height), + ) + + def get_content_pos(self, pointer_pos: tuple[float, float]) -> tuple[float, float]: + """Return coordinates of cursor on content.""" + x, y = pointer_pos + content_width, content_height = self.get_content_size() + page_width, page_height = self.get_page_size() + + if content_width < page_width: + x = x - (page_width - content_width) / 2 + if content_height < page_height: + y = y - (page_height - content_height) / 2 + return x, y + + def get_frame_pos( + self, pointer_pos: tuple[float, float], zoom: float + ) -> tuple[float, float]: + """Upper-left coordinate of composite frame.""" + pointer_x, pointer_y = pointer_pos + + page_width, page_height = self.get_page_size() + image_width, image_height = self.get_texture_size() + + zoomed_img_width = image_width * zoom + zoomed_img_height = image_height * zoom + + content_x = ( + ((page_width - zoomed_img_width) / 2) + if zoomed_img_width < page_width + else -(self.h_scroll_pos * (math.ceil(zoomed_img_width) - page_width)) + ) + + content_y = ( + ((page_height - zoomed_img_height) / 2) + if zoomed_img_height < page_height + else -(self.v_scroll_pos * (math.ceil(zoomed_img_height) - page_height)) + ) + + x = (pointer_x - content_x) * (image_width / zoomed_img_width) + y = (pointer_y - content_y) * (image_height / zoomed_img_height) + return x, y + + def get_on_slider(self, x: float, y: float) -> bool: + """Get if cursor is above the current slider position.""" + content_width, content_height = self.get_content_size() + + cursor_x, cursor_y = self.get_content_pos((x, y)) + slider_x = round(content_width * self.slider_position) - (self.slider_width + 4) + return cast( + "bool", slider_x <= cursor_x <= slider_x + ((self.slider_width + 4) * 2) + ) + + def get_can_drag(self) -> bool: + """Get if user can scroll in at least one direction.""" + if self.hadjustment is None or self.vadjustment is None: + return False + return cast( + "bool", + self.hadjustment.get_page_size() != self.hadjustment.get_upper() + or self.vadjustment.get_page_size() != self.vadjustment.get_upper(), + ) + + def get_normalized_adjustment_value(self, adjustment: Gtk.Adjustment) -> float: + """Get the normalized representation for an Adjustment.""" + upper = adjustment.get_upper() - adjustment.get_page_size() + return 0 if upper == 0 else adjustment.get_value() / upper + + def set_slider_pos(self, x: float) -> None: + """Set slider position for given x-axis value.""" + content_width = self.get_content_size()[0] + cursor_x = self.get_content_pos((x, -1))[0] + self.slider_position = min(max((cursor_x / content_width), 0), 1) + + def animate_zoom(self, zoom: float) -> None: + """Animate zooming in to given zoom level.""" + self.linear_zoom_animation.set_value_from(self.zoom) + self.linear_zoom_animation.set_value_to( + min(max(zoom, self.zoom_min, self.get_zoom_to_fit()), self.zoom_max) + ) + self.linear_zoom_animation.play() + + def zoom_to_point(self, zoom: float, point: tuple[float, float] | None) -> None: + """Zoom into composite while staying at the same relative spot.""" + page_width, page_height = self.get_page_size() + image_width, image_height = self.get_image_size() + texture_width, texture_height = self.get_texture_size() + + old_zoom = self.zoom + self.zoom_request = min( + max(zoom, self.zoom_min, self.get_zoom_to_fit()), self.zoom_max + ) + + if (point is None) or (self.zoom_request < 1): + # If no point is provided, or zoom is less then 1, zoom to the centre of the screen + point = (round(page_width / 2), round(page_height / 2)) + + (old_image_x, old_image_y) = self.get_frame_pos(point, old_zoom) + (new_image_x, new_image_y) = self.get_frame_pos(point, self.zoom) + + # Compute the difference in image pixels between the new and current positions + image_dx_norm = (new_image_x - old_image_x) / texture_width + image_dy_norm = (new_image_y - old_image_y) / texture_height + + # Update horizontal scroll position if content width exceeds widget width + if page_width < image_width: + h_scroll_pos_frac = 1.0 - page_width / image_width + self.set_h_scroll(self.h_scroll_pos - image_dx_norm / h_scroll_pos_frac) + + # Update vertical scroll position if content height exceeds widget height + if page_height < image_height: + v_scroll_pos_frac = 1.0 - page_height / image_height + self.set_v_scroll(self.v_scroll_pos - image_dy_norm / v_scroll_pos_frac) + + self.emit( + "zoom_changed", self.zoom_value + ) # Communicate with parent window that zoom has changed + + def set_hover(self, target: float) -> None: + """Play hover animation if target is not the same as the current hover animation value.""" + if self.hover_animation.get_value_to() != target: + self.hover_animation.set_value_from(self.hover_animation.get_value()) + self.hover_animation.set_value_to(target) + self.hover_animation.play() + + def set_zoom(self, zoom: float) -> None: + """Set zoom to new value if it not currently in zoom to fit mode.""" + if zoom != self.ZOOM_TO_FIT: + # the correct zoom for ZOOM_TO_FIT will be calculated in do_size_allocate() + self.zoom_value = min(max(zoom, self.zoom_min), float(self.zoom_max)) + self.notify("zoom") + self.queue_resize() + + def set_slider_cursor(self) -> None: + """Change cursor to slider cursor + holver alpha.""" + self.set_cursor(self.slider_cursor) + self.set_hover(self.HOVER_ALPHA) + + def set_default_cursor(self) -> None: + """Change cursor to default cursor + non holver alpha.""" + self.set_cursor(self.default_cursor) + self.set_hover(self.NON_HOVER_ALPHA) + + @GObject.Signal( + arg_types=[ + float, + ] + ) + def zoom_changed(self, *_args: Any) -> None: + """Emit when zoom value has changed.""" diff --git a/upscaler/icons/dialog-warning-symbolic.svg b/upscaler/icons/dialog-warning-symbolic.svg new file mode 100644 index 0000000..0b8cbe5 --- /dev/null +++ b/upscaler/icons/dialog-warning-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/upscaler/icons/zoom-toggle-symbolic.svg b/upscaler/icons/zoom-toggle-symbolic.svg new file mode 100644 index 0000000..1d5344a --- /dev/null +++ b/upscaler/icons/zoom-toggle-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/upscaler/meson.build b/upscaler/meson.build index 18e24ff..f78fae2 100644 --- a/upscaler/meson.build +++ b/upscaler/meson.build @@ -10,6 +10,8 @@ blueprints = custom_target('blueprints', 'queue-row.blp', 'dimension-label.blp', 'scale-spin-button.blp', + 'composite-frame.blp', + 'before-after-page.blp', ), output: '.', command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], @@ -63,6 +65,8 @@ upscaler_sources = [ 'queue_row.py', 'dimension_label.py', 'scale_spin_button.py', + 'composite_frame.py', + 'before_after_page.py', ] gnome.compile_resources('upscaler', diff --git a/upscaler/queue_row.py b/upscaler/queue_row.py index 16873a2..added67 100644 --- a/upscaler/queue_row.py +++ b/upscaler/queue_row.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any from gi.repository import Adw, GObject, Gtk +from upscaler.before_after_page import BeforeAfterPage from upscaler.logger import logger from upscaler.media import MediaFile @@ -42,6 +43,8 @@ class QueueRow(Adw.PreferencesRow): button_save: Gtk.Button = Gtk.Template.Child() button_open: Gtk.Button = Gtk.Template.Child() + compare_page = GObject.Property(type=BeforeAfterPage) + def __init__( self, media_file: MediaFile, diff --git a/upscaler/shortcuts-dialog.blp b/upscaler/shortcuts-dialog.blp index 6dabd98..aa22073 100644 --- a/upscaler/shortcuts-dialog.blp +++ b/upscaler/shortcuts-dialog.blp @@ -25,4 +25,47 @@ Adw.ShortcutsDialog shortcuts_dialog { action-name: "app.paste-image"; } } + + Adw.ShortcutsSection { + title: C_("shortcut window", "Image Comparison"); + + Adw.ShortcutsItem { + title: C_("shortcut window", "Previous Image"); + action-name: "app.prev-before-after-page"; + } + + Adw.ShortcutsItem { + title: C_("shortcut window", "Next Image"); + action-name: "app.next-before-after-page"; + } + } + + Adw.ShortcutsSection { + Adw.ShortcutsItem { + title: C_("shortcut window", "Reset Zoom"); + action-name: "app.zoom-fit"; + } + + Adw.ShortcutsItem { + title: C_("shortcut window", "Zoom In"); + action-name: "app.zoom-in-plus"; + } + + Adw.ShortcutsItem { + title: C_("shortcut window", "Zoom Out"); + action-name: "app.zoom-out"; + } + } + + Adw.ShortcutsSection { + Adw.ShortcutsItem { + title: C_("shortcut window", "Move Slider Left"); + action-name: "app.adjust-slider-left"; + } + + Adw.ShortcutsItem { + title: C_("shortcut window", "Move Slider Right"); + action-name: "app.adjust-slider-right"; + } + } } diff --git a/upscaler/style-dark.css b/upscaler/style-dark.css new file mode 100644 index 0000000..b8c1006 --- /dev/null +++ b/upscaler/style-dark.css @@ -0,0 +1,4 @@ +:root { + /* Taken from Loupe */ + --osd-background-color: rgba(0, 0, 0, 0.65); +} diff --git a/upscaler/style.css b/upscaler/style.css index 6a15e07..e0270b2 100644 --- a/upscaler/style.css +++ b/upscaler/style.css @@ -1,3 +1,8 @@ +:root { + /* Taken from Loupe */ + --osd-background-color: rgba(240, 240, 240, 0.85); +} + picture { border-radius: 12px; filter: drop-shadow(0 0 3px alpha(black, 0.4)); @@ -74,3 +79,13 @@ scalecomparisonframe:dir(rtl) { .font-size-smaller { font-size: smaller; } + +.zoom-box { + border-radius: 12px; + background-color: var(--osd-background-color); + padding: 6px; +} + +.osd-background { + background-color: var(--osd-background-color); +} diff --git a/upscaler/upscaler.gresource.xml.in b/upscaler/upscaler.gresource.xml.in index 430a67d..31ac9ab 100644 --- a/upscaler/upscaler.gresource.xml.in +++ b/upscaler/upscaler.gresource.xml.in @@ -7,6 +7,8 @@ icons/plus-symbolic.svg icons/copy-symbolic.svg icons/cross-small-circle-outline-symbolic.svg + icons/zoom-toggle-symbolic.svg + icons/dialog-warning-symbolic.svg shortcuts-dialog.ui @@ -15,8 +17,11 @@ queue-row.ui dimension-label.ui scale-spin-button.ui + composite-frame.ui + before-after-page.ui ../data/@app_id@.metainfo.xml style.css + style-dark.css style-hc.css style-hc-dark.css diff --git a/upscaler/window.blp b/upscaler/window.blp index 85417af..f412fb6 100644 --- a/upscaler/window.blp +++ b/upscaler/window.blp @@ -128,7 +128,7 @@ template $Window: Adw.ApplicationWindow { ListBox completed_list_box { visible: false; - row-activated => $_on_open_file_external(); + row-activated => $_on_open_compare_page(); styles [ "boxed-list", @@ -154,6 +154,110 @@ template $Window: Adw.ApplicationWindow { }; } + Adw.NavigationPage { + title: bind $_get_before_after_title(before_after_stack.visible-child) as ; + tag: "before-after"; + can-pop: true; + + child: Adw.ToolbarView { + [top] + Adw.HeaderBar { + styles ["osd"] + } + + content: Overlay { + [overlay] + Button comparison_previous_button { + margin-start: 24; + valign: center; + halign: start; + tooltip-text: _("Previous"); + icon-name: 'go-previous-symbolic'; + sensitive: bind $_get_cmpr_prev_button_sensitive( + before_after_stack.visible-child, + before_after_stack.pages, + template.comparison_pages + ) as ; + + clicked => $_go_to_prev_cmpr_page(); + + styles [ + "osd", + "osd-background", + "image-button", + ] + } + + [overlay] + Button comparison_next_button { + margin-end: 24; + valign: center; + halign: end; + tooltip-text: _("Next"); + icon-name: 'go-next-symbolic'; + sensitive: bind $_get_cmpr_next_button_sensitive( + before_after_stack.visible-child, + before_after_stack.pages, + template.comparison_pages + ) as ; + + clicked => $_go_to_next_cmpr_page(); + + styles [ + "osd-background", + "osd", + "image-button", + ] + } + + [overlay] + Box comparison_zoom_controls { + valign: end; + halign: end; + margin-end: 24; + margin-bottom: 24; + + styles [ + "zoom-box", + ] + + Revealer zoom_revealer { + reveal-child: bind zoom_toggle.active bidirectional; + transition-type: slide_right; + Box { + spacing: 6; + margin-end: 6; + margin-start: 6; + Button zoom_out_button { + icon-name: "minus"; + clicked => $_on_zoom_out(); + sensitive: bind template.can_zoom_out; + styles ["flat"] + } + Button zoom_in_button { + icon-name: "plus"; + clicked => $_on_zoom_in(); + sensitive: bind template.can_zoom_in; + styles ["flat"] + } + } + } + + ToggleButton zoom_toggle { + icon-name: "zoom-toggle"; + active: bind zoom_revealer.child-revealed; + toggled => $_on_zoom_toggled(); + styles ["flat"] + } + } + + child: Adw.ViewStack before_after_stack { + enable-transitions: true; + }; + }; + }; + } + Adw.NavigationPage { title: _("Options"); tag: "upscaling-options"; diff --git a/upscaler/window.py b/upscaler/window.py index 764b2f3..2ad360b 100644 --- a/upscaler/window.py +++ b/upscaler/window.py @@ -30,9 +30,10 @@ from typing import TYPE_CHECKING, Any, Literal, cast import vulkan from PIL import Image, ImageChops, ImageOps -from gi.repository import Adw, GLib, Gdk, Gio, Gtk, Pango +from gi.repository import Adw, GLib, GObject, Gdk, Gio, Gtk, Pango from upscaler import ALG_WARNINGS, APP_ID +from upscaler.before_after_page import BeforeAfterPage from upscaler.exceptions import ( AlgorithmFailedError, AlgorithmWarning, @@ -84,6 +85,12 @@ class Window(Adw.ApplicationWindow): image_carousel: Adw.Carousel = Gtk.Template.Child() previous_button: Gtk.Button = Gtk.Template.Child() next_button: Gtk.Button = Gtk.Template.Child() + before_after_stack: Adw.ViewStack = Gtk.Template.Child() + zoom_revealer: Gtk.Revealer = Gtk.Template.Child() + + comparison_pages = GObject.Property(type=int, minimum=0) + can_zoom_out = GObject.Property(type=bool, default=True) + can_zoom_in = GObject.Property(type=bool, default=True) def __init__(self, **kwargs: Any) -> None: """Initialize application window.""" @@ -97,6 +104,55 @@ class Window(Adw.ApplicationWindow): self.application.create_action("open", self._open_file, ("o",)) self.application.create_action("paste-image", self._on_paste, ("v",)) + # Before/After View Accels + self.application.create_action( + "prev-before-after-page", + self._on_prev_page_shortcut, + self.get_directional_item( + ( + "Left", + "Page_Up", + ), + ( + "Right", + "Page_Down", + ), + ), + ) + + self.application.create_action( + "next-before-after-page", + self._on_next_page_shortcut, + self.get_directional_item( + ( + "Right", + "Page_Down", + ), + ( + "Left", + "Page_Up", + ), + ), + ) + self.application.create_action( + "adjust-slider-right", self._on_adjust_slider_right, ("Down",) + ) + self.application.create_action( + "adjust-slider-left", self._on_adjust_slider_left, ("Up",) + ) + self.application.create_action( + "zoom-in-plus", self._on_zoom_in, ("plus", "plus") + ) + self.application.create_action( + "zoom-in-equal", self._on_zoom_in, ("equal", "equal") + ) + self.application.create_action( + "zoom-out", self._on_zoom_out, ("minus", "minus") + ) + self.application.create_action( + "zoom-fit", self._on_zoom_to_fit, ("0", "0") + ) + settings = Gio.Settings.new("io.gitlab.theevilskeleton.Upscaler") settings.bind("width", self, "default-width", Gio.SettingsBindFlags.DEFAULT) settings.bind("height", self, "default-height", Gio.SettingsBindFlags.DEFAULT) @@ -595,11 +651,10 @@ class Window(Adw.ApplicationWindow): queue_row.button_save.set_visible(False) queue_row.button_open.set_visible(True) - queue_row.set_activatable(True) queue_row.destination_path = destination_path queue_row.button_open.connect( "clicked", - lambda *_args: self._on_open_file_external(queue_row), + lambda *_args: self.on_open_file_external(queue_row), ) dialog = Gtk.FileDialog.new() @@ -635,6 +690,8 @@ class Window(Adw.ApplicationWindow): queue_row.add_css_class("success") queue_row.button_copy.set_visible(True) queue_row.button_save.set_visible(True) + queue_row.set_activatable(True) + queue_row.set_selectable(True) queue_row.button_save.connect( "clicked", self._on_save_clicked_cb, @@ -646,6 +703,14 @@ class Window(Adw.ApplicationWindow): queue_row, ) + queue_row.compare_page = BeforeAfterPage( + before_path=str(queue_row.media_file.get_preferred_input_path()), + after_path=str(queue_row.destination_path), + ) + self.before_after_stack.add(queue_row.compare_page) + self.comparison_pages = len( + cast("list[Gtk.StackPage]", self.before_after_stack.get_pages()) + ) notification.set_title(_("Upscaling Completed")) notification.set_default_action_and_target(action_name, output_file_variant) @@ -789,9 +854,8 @@ class Window(Adw.ApplicationWindow): if self.image_carousel.get_n_pages() == 0: self.main_nav_view.pop() - @Gtk.Template.Callback() - def _on_open_file_external(self, *args: QueueRow) -> None: - queue_row = args[-1] + def on_open_file_external(self, queue_row: QueueRow) -> None: + """Open queue row's upscaled image in external program.""" try: self.application.open_file_in_external_program(queue_row.destination_path) except FileNotFoundError: @@ -799,6 +863,158 @@ class Window(Adw.ApplicationWindow): toast = Adw.Toast(title=title, timeout=0) self.toast.add_toast(toast) + @Gtk.Template.Callback() + def _on_open_compare_page( + self, _source: Gtk.ListBox, row: QueueRow, *_args: Any + ) -> None: + self.before_after_stack.set_visible_child(row.compare_page) + self.main_nav_view.push_by_tag("before-after") + + @Gtk.Template.Callback() + def _get_before_after_title( + self, _source: Any, visible_page: BeforeAfterPage + ) -> str: + if visible_page is None: + return _("Before/After Page") + + return Path(visible_page.before_path).name + + @Gtk.Template.Callback() + def _get_cmpr_prev_button_sensitive( + self, + _source: Any, + _visible_child: BeforeAfterPage, + pages: Gtk.SelectionModel, + _n_pages: int, + ) -> bool: + """Set button to sensitive if current page is not the first page in view-stack.""" + return not pages.is_selected(0) + + @Gtk.Template.Callback() + def _get_cmpr_next_button_sensitive( + self, + _source: Any, + _visible_child: BeforeAfterPage, + pages: Gtk.SelectionModel, + n_pages: int, + ) -> bool: + """Set next button sensitivity. + + Set button to sensitive if view-stack is neither empty not displaying final + page as current visible child. + + """ + return False if n_pages == 0 else (not pages.is_selected(n_pages - 1)) + + @Gtk.Template.Callback() + def _go_to_prev_cmpr_page(self, *_args: Any) -> None: + """Reset cursor + state of current page and set page before to be the visible child in view-stack.""" + # As motion as ended on current page, on_motion_leave is called + # To reset its cursor + state to not have it linger onto previous page + cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).frame.reset_cursor_position() + self.before_after_stack.set_visible_child(self.get_page_from_offset(-1)) + + @Gtk.Template.Callback() + def _go_to_next_cmpr_page(self, *_args: Any) -> None: + """Reset cursor + state of current page and set page before to be the visible child in view-stack.""" + cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).frame.reset_cursor_position() + self.before_after_stack.set_visible_child(self.get_page_from_offset(1)) + + def _on_prev_page_shortcut(self, *_args: Any) -> None: + if ( + not self.before_after_stack.get_pages().is_selected(0) + and self.main_nav_view.get_visible_page_tag() == "before-after" + ): + self._go_to_prev_cmpr_page() + + def _on_next_page_shortcut(self, *_args: Any) -> None: + if ( + self.comparison_pages != 0 + and not self.before_after_stack.get_pages().is_selected( + self.comparison_pages - 1 + ) + and self.main_nav_view.get_visible_page_tag() == "before-after" + ): + self._go_to_next_cmpr_page() + + def get_page_from_offset(self, offset: int, *_args: Any) -> BeforeAfterPage: + """Return page in viewstack that is in the position away from the offset, relative to current visible child.""" + current_page = cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ) + pages = [ + page.get_child() + for page in cast("list[Gtk.StackPage]", self.before_after_stack.get_pages()) + ] + return cast("BeforeAfterPage", pages[pages.index(current_page) + offset]) + + def _on_adjust_slider_right(self, *_args: Any) -> None: + """Adjust slider position 0.1 to right for currently visible comparison frame.""" + if self.main_nav_view.get_visible_page_tag() == "before-after": + current_frame = cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).get_frame() + current_frame.slider_position = min(current_frame.slider_position + 0.1, 1) + + def _on_adjust_slider_left(self, *_args: Any) -> None: + """Adjust slider position 0.1 to right for currently visible comparison frame.""" + if self.main_nav_view.get_visible_page_tag() == "before-after": + current_frame = cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).get_frame() + current_frame.slider_position = max(current_frame.slider_position - 0.1, 0) + + @Gtk.Template.Callback() + def _on_zoom_in(self, *_args: Any) -> None: + """Zoom into currently visible child by a factor of 1.5.""" + if self.main_nav_view.get_visible_page_tag() == "before-after": + current_frame = cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).get_frame() + current_frame.animate_zoom(current_frame.zoom * 1.5) + + @Gtk.Template.Callback() + def _on_zoom_out(self, *_args: Any) -> None: + """Zoom out currently visible child by a factor of 1.5.""" + if self.main_nav_view.get_visible_page_tag() == "before-after": + current_frame = cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).get_frame() + current_frame.animate_zoom(current_frame.zoom / 1.5) + + def _on_zoom_to_fit(self, *_args: Any) -> None: + """Set zoom of currently visible child to fit the window size.""" + if self.main_nav_view.get_visible_page_tag() == "before-after": + current_frame = cast( + "BeforeAfterPage", self.before_after_stack.get_visible_child() + ).get_frame() + current_frame.animate_zoom(current_frame.get_zoom_to_fit()) + + @Gtk.Template.Callback() + def _on_zoom_toggled(self, zoom_toggle: Any, *_args: Any) -> None: + """Set zoom to fit window size.""" + if not zoom_toggle.get_active(): + self._on_zoom_to_fit() + + def get_directional_item(self, ltr_value: Any, rtl_value: Any) -> Any: + """Return correct item for current reading direction. + + Use for items where they need to change depending on the current reading direction. + + Arguments: + ltr_value: Returned if an LTR (Left-To-Right) language is in use. + rtl_value: Returned if an RTL (Right-To-Left) language is in use. + """ + return ltr_value if self.get_direction() in {0, 1} else rtl_value + + def set_zoom_toggled(self, active: bool) -> None: + """Reveal zoom revealer child (the control box).""" + self.zoom_revealer.set_reveal_child(active) + def do_close_request(self) -> bool: """Prompt user to stop the algorithm if it's running.""" if not self.busy: -- GitLab