Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zoombutton #1073

Closed
wants to merge 37 commits into from
Closed
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
be0caf6
Adding zoom in/out buttons to toolbar.
Tiomat85 May 14, 2024
a5c13c6
Adding svg files, and fixing an issue with the glass_in having an ext…
Tiomat85 May 15, 2024
3eca6b2
Fixed zoom-by-drag scaling.
Tiomat85 May 17, 2024
45833be
Made it return True if the release is handled by the base class
Tiomat85 May 17, 2024
94b264e
Merge branch 'nion-software:master' into zoombutton
Tiomat85 Jun 19, 2024
8058d57
Update ImageCanvasItem.py
Tiomat85 Jun 19, 2024
b7c3cd3
Changed icons
Tiomat85 Jun 20, 2024
01ebe1f
Remove unused files
Tiomat85 Jun 20, 2024
623072c
Missed reference
Tiomat85 Jun 20, 2024
901fc1d
Fixes as per comments
Tiomat85 Jul 8, 2024
617ba24
Zoom Out Error
Tiomat85 Jul 8, 2024
72ed328
Types
Tiomat85 Jul 8, 2024
6495018
Updates to align with new requirements setup.
Tiomat85 Sep 16, 2024
fd5e5f4
Removing unnecessary files and whitespace
Tiomat85 Sep 17, 2024
21469fc
Merge branch 'nion-software:master' into zoombutton
Tiomat85 Sep 30, 2024
6f1a70d
Adding zoom in/out buttons to toolbar.
Tiomat85 May 14, 2024
853ac50
Adding svg files, and fixing an issue with the glass_in having an ext…
Tiomat85 May 15, 2024
7761dff
Fixed zoom-by-drag scaling.
Tiomat85 May 17, 2024
f740530
Made it return True if the release is handled by the base class
Tiomat85 May 17, 2024
64c4d88
Update ImageCanvasItem.py
Tiomat85 Jun 19, 2024
6ff51a5
Changed icons
Tiomat85 Jun 20, 2024
deef9bc
Missed reference and removed unused files
Tiomat85 Jun 20, 2024
1cd42b3
Fixes as per comments
Tiomat85 Jul 8, 2024
439c3ab
Zoom Out Error
Tiomat85 Jul 8, 2024
214b008
Types
Tiomat85 Jul 8, 2024
44c0da4
Updates to align with new requirements setup
Tiomat85 Sep 16, 2024
6955eef
Icon Update and bug fixes on Zoom Tool
Tiomat85 Sep 30, 2024
08cb7d2
Merge branch 'zoombutton' of https://github.com/Phasefocus/nionswift …
Tiomat85 Sep 30, 2024
1f02377
Removed Merge Duplicated Code
Tiomat85 Sep 30, 2024
02cd65b
Fix typing issue with inputs
Tiomat85 Sep 30, 2024
2c1da07
Fix bug that had disabled keyboard zooming
Tiomat85 Oct 1, 2024
3379976
Tidying code from review
Tiomat85 Oct 2, 2024
26fa492
Adjusting Test to check accuracy
Tiomat85 Oct 2, 2024
3dd78a5
Refactor ZoomMouseHandler
Tiomat85 Oct 3, 2024
b5f9fc1
Fixed Zoom Unit Tests
Tiomat85 Oct 3, 2024
5836671
Adding Zoom keybind 'z'
Tiomat85 Oct 3, 2024
e3248f2
Fixing mypy type verification issues and removed unused function
Tiomat85 Oct 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions artwork/zoom.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions nion/swift/DocumentController.py
Original file line number Diff line number Diff line change
Expand Up @@ -3285,6 +3285,7 @@ def execute(self, context: Window.ActionContext) -> Window.ActionResult:

Window.register_action(SetToolModeAction("pointer", _("Pointer"), "pointer_icon.png", _("Pointer tool for selecting graphics")))
Window.register_action(SetToolModeAction("hand", _("Hand"), "hand_icon.png", _("Hand tool for dragging images within panel")))
Window.register_action(SetToolModeAction("zoom", _("Zoom"), "zoom.png", _("Zoom in/out on image")))
Window.register_action(SetToolModeAction("line", _("Line"), "line_icon.png", _("Line tool for making line regions on images")))
Window.register_action(SetToolModeAction("ellipse", _("Ellipse"), "ellipse_icon.png", _("Ellipse tool for making ellipse regions on images")))
Window.register_action(SetToolModeAction("rectangle", _("Rectangle"), "rectangle_icon.png", _("Rectangle tool for making rectangle regions on images")))
Expand Down
148 changes: 142 additions & 6 deletions nion/swift/ImageCanvasItem.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,43 @@ async def _reactor_loop(self, r: Stream.ValueChangeStreamReactorInterface[MouseP
change_display_properties_task.commit()


class ZoomMouseHandler(MouseHandler):
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved
def __init__(self, image_canvas_item: ImageCanvasItem, event_loop: asyncio.AbstractEventLoop) -> None:
super().__init__(image_canvas_item, event_loop)

async def _reactor_loop(self, r: Stream.ValueChangeStreamReactorInterface[MousePositionAndModifiers],
image_canvas_item: ImageCanvasItem) -> None:
delegate = image_canvas_item.delegate
assert delegate

# get the beginning mouse position
value_change = await r.next_value_change()
value_change_value = value_change.value
assert value_change.is_begin
assert value_change_value is not None
image_position: typing.Optional[Geometry.FloatPoint] = None
mouse_pos, modifiers = value_change_value

if modifiers.alt:
is_zooming_in = False
else:
is_zooming_in = True
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved

with (delegate.create_change_display_properties_task() as change_display_properties_task):
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved
# mouse tracking loop. wait for values and update the image position.
while True:
value_change = await r.next_value_change()
if value_change.is_end:
if value_change.value is not None:
mouse_pos, modifiers = value_change.value
image_canvas_item.apply_fixed_zoom(is_zooming_in, mouse_pos)
break

# if the image position was set, it means the user moved the image. perform the task.
if image_position:
change_display_properties_task.commit()


class CreateGraphicMouseHandler(MouseHandler):
def __init__(self, image_canvas_item: ImageCanvasItem, event_loop: asyncio.AbstractEventLoop, graphic_type: str) -> None:
super().__init__(image_canvas_item, event_loop)
Expand Down Expand Up @@ -1232,15 +1269,67 @@ def _update_image_canvas_position(self, widget_delta: Geometry.FloatSize) -> Geo
self._set_image_canvas_position(new_image_canvas_position)
return new_image_canvas_position

def convert_pixel_to_normalised(self, coord: Geometry.IntPoint) -> Geometry.FloatPoint:
if coord:
widget_mapping = ImageCanvasItemMapping.make(self.__data_shape, self.__composite_canvas_item.canvas_bounds,
list())
if widget_mapping:
mapped = self.map_widget_to_image(coord)
if (mapped is not None) and (self.__data_shape is not None):
norm_coord = tuple(mapped_coord / shape_dim for mapped_coord, shape_dim in zip(iter(mapped), iter(self.__data_shape)))
return Geometry.FloatPoint(norm_coord[0], norm_coord[1]) # y,x
return Geometry.FloatPoint(-1, -1)

# Apply a zoom factor to the widget, optionally focused on a specific point
def apply_fixed_zoom(self, zoom_in: bool, coord: typing.Optional[Geometry.IntPoint]) -> None:
zoom_factor = 0.25
self.set_custom_mode() # Put us into custom canvas mode
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_custom_mode and zoom_in add an undo command, which is not what we want. This is going to take some refactoring, but essentially we want to calculate everything and then have a single command at the end of the calculation. It's also a strange way to implement this overall function by setting of the image position followed by calling zoom_in. Why not just calculate the desired mode, position and zoom and set it directly using __apply_display_properties_command?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was having some problems with setting all 3 parameters in one go, that depending on the previous mode 'strange' it would act irratically. Further investigation seems that it may have been a red herring and it was the transition to 'custom' causing problems. See external discussion and related to Issue 1169.

if coord and zoom_in:
# Coordinate specified, so needing to recenter to that point before we adjust zoom levels
widget_mapping = ImageCanvasItemMapping.make(self.__data_shape, self.__composite_canvas_item.canvas_bounds, list())
if widget_mapping:
# coord is (y,x) IntPoint
mapped = self.map_widget_to_image(coord) # mapped is (y,x) IntPoint
if mapped is not None and self.__data_shape is not None and self.scroll_area_canvas_item.canvas_size is not None:
mapped_center = self.map_widget_to_image(Geometry.IntPoint(self.scroll_area_canvas_item.canvas_size.height // 2, self.scroll_area_canvas_item.canvas_size.width // 2))
if mapped_center is not None:
mapped_vector = (mapped_center[0] - mapped[0], mapped_center[1] - mapped[1])
if zoom_in:
vector_scaling = zoom_factor / (1 + zoom_factor)
else:
vector_scaling = zoom_factor

scaled_mapped_vector = (mapped_vector[0] * vector_scaling, mapped_vector[1] * vector_scaling)
if zoom_in:
new_mapped_center = (mapped_center[0] - scaled_mapped_vector[0], mapped_center[1] - scaled_mapped_vector[1])
else:
new_mapped_center = (mapped_center[0] + scaled_mapped_vector[0], mapped_center[1] + scaled_mapped_vector[1])

norm_coord = tuple(new_mapped_coord / shape_dim for new_mapped_coord, shape_dim in zip(iter(new_mapped_center), iter(self.__data_shape)))
self._set_image_canvas_position(Geometry.FloatPoint(norm_coord[0], norm_coord[1]))

# ensure that at least half of the image is always visible
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved
new_image_norm_center_y = max(min(norm_coord[0], 1.0), 0.0)
new_image_norm_center_x = max(min(norm_coord[1], 1.0), 0.0)
# save the new image norm center
new_image_canvas_position = Geometry.FloatPoint(new_image_norm_center_y, new_image_norm_center_x)
self._set_image_canvas_position(new_image_canvas_position)
if zoom_in:
self.zoom_in(1 + zoom_factor)
else:
self.zoom_out(1 + zoom_factor)

def mouse_clicked(self, x: int, y: int, modifiers: UserInterface.KeyboardModifiers) -> bool:
if super().mouse_clicked(x, y, modifiers):
return True
delegate = self.delegate
widget_mapping = self.mouse_mapping

if delegate and widget_mapping:
# now let the image panel handle mouse clicking if desired
image_position = widget_mapping.map_point_widget_to_image(Geometry.FloatPoint(y, x))
return delegate.image_clicked(image_position, modifiers)

return False

def mouse_pressed(self, x: int, y: int, modifiers: UserInterface.KeyboardModifiers) -> bool:
Expand All @@ -1264,6 +1353,11 @@ def mouse_pressed(self, x: int, y: int, modifiers: UserInterface.KeyboardModifie
assert self.__event_loop
self.__mouse_handler = HandMouseHandler(self, self.__event_loop)
self.__mouse_handler.mouse_pressed(Geometry.IntPoint(y=y, x=x), modifiers)
elif delegate.tool_mode == "zoom":
assert not self.__mouse_handler
assert self.__event_loop
self.__mouse_handler = ZoomMouseHandler(self, self.__event_loop)
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved
self.__mouse_handler.mouse_pressed(Geometry.IntPoint(y=y, x=x), modifiers)
elif delegate.tool_mode in graphic_type_map.keys():
assert not self.__mouse_handler
assert self.__event_loop
Expand All @@ -1274,6 +1368,7 @@ def mouse_pressed(self, x: int, y: int, modifiers: UserInterface.KeyboardModifie
def mouse_released(self, x: int, y: int, modifiers: UserInterface.KeyboardModifiers) -> bool:
if super().mouse_released(x, y, modifiers):
return True

delegate = self.delegate
widget_mapping = self.mouse_mapping
if not delegate or not widget_mapping:
Expand All @@ -1286,7 +1381,12 @@ def mouse_released(self, x: int, y: int, modifiers: UserInterface.KeyboardModifi
if self.__mouse_handler:
self.__mouse_handler.mouse_released(Geometry.IntPoint(y, x), modifiers)
self.__mouse_handler = None
if delegate.tool_mode != "hand":

if delegate.tool_mode == "hand":
pass
elif delegate.tool_mode == "zoom":
pass
else:
delegate.tool_mode = "pointer"
return True

Expand Down Expand Up @@ -1336,6 +1436,9 @@ def mouse_position_changed(self, x: int, y: int, modifiers: UserInterface.Keyboa
self.cursor_shape = "cross"
elif delegate.tool_mode == "hand":
self.cursor_shape = "hand"
elif delegate.tool_mode == "zoom":
self.cursor_shape = "cross"

# x,y already have transform applied
self.__last_mouse = mouse_pos.to_int_point()
self.__update_cursor_info()
Expand Down Expand Up @@ -1476,11 +1579,44 @@ def set_one_to_one_mode(self) -> None:
def set_two_to_one_mode(self) -> None:
self.__apply_display_properties_command({"image_zoom": 0.5, "image_position": (0.5, 0.5), "image_canvas_mode": "2:1"})

def zoom_in(self) -> None:
self.__apply_display_properties_command({"image_zoom": self.__image_zoom * 1.25, "image_canvas_mode": "custom"})

def zoom_out(self) -> None:
self.__apply_display_properties_command({"image_zoom": self.__image_zoom / 1.25, "image_canvas_mode": "custom"})
def set_custom_mode(self) -> None:
if self.image_canvas_mode != "custom" and self.canvas_bounds and self.__data_shape:
new_zoom = 1.0
if self.image_canvas_mode == "fit":
# defaults to zoom of 1
pass
elif self.image_canvas_mode == "fill":
# needs to be zoomed in a bit
x_zoom = self.canvas_bounds.width / self.__data_shape[1]
y_zoom = self.canvas_bounds.height / self.__data_shape[0]
new_zoom = max(y_zoom, x_zoom)
pass
elif self.image_canvas_mode == "1:1":
# noticably less than 1.0, zoomed out a bit
x_zoom = self.__data_shape[1] / self.canvas_bounds.width
y_zoom = self.__data_shape[0] / self.canvas_bounds.height
new_zoom = max(y_zoom, x_zoom)
elif self.image_canvas_mode == "2:1":
# zoomed out a lot
x_zoom = self.canvas_bounds.width / self.__data_shape[1]
y_zoom = self.canvas_bounds.height / self.__data_shape[0]
new_zoom = max(y_zoom, x_zoom)
new_zoom /= 2

self.__apply_display_properties_command({"image_zoom": new_zoom, "image_canvas_mode": "custom"})


def zoom_in(self, factor: typing.Optional[float] = None) -> None:
if factor is None:
factor = 1.25
self.__apply_display_properties_command({"image_zoom": self.__image_zoom * factor,
"image_canvas_mode": "custom"})

def zoom_out(self, factor: typing.Optional[float] = None) -> None:
if factor is None:
factor = 1.25
self.__apply_display_properties_command({"image_zoom": self.__image_zoom / factor,
"image_canvas_mode": "custom"})

def move_left(self, amount: float = 10.0) -> None:
self.apply_move_command(Geometry.FloatSize(0.0, amount))
Expand Down
3 changes: 3 additions & 0 deletions nion/swift/ToolbarPanel.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ def __init__(self, *, document_controller: DocumentController.DocumentController
else:
bottom_row_items.append(radio_button)

if len(tool_actions) % 2 == 1:
bottom_row_items.append(u.create_spacing(32))

top_row = u.create_row(*top_row_items)
bottom_row = u.create_row(*bottom_row_items)

Expand Down
Binary file added nion/swift/resources/zoom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions nion/swift/test/ImageCanvasItem_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from nion.swift.test import TestContext
from nion.ui import DrawingContext
from nion.ui import TestUI
from nion.ui.UserInterface import KeyboardModifiers
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved


class TestImageCanvasItemClass(unittest.TestCase):
Expand Down Expand Up @@ -291,6 +292,27 @@ def test_hand_tool_on_one_image_of_multiple_displays(self):
document_controller.tool_mode = "hand"
display_panel.display_canvas_item.simulate_press((100,125))

def test_zoom_tool_in_and_out_around_clicked_point(self):
# setup
with TestContext.create_memory_context() as test_context:
document_controller = test_context.create_document_controller()
document_model = document_controller.document_model
display_panel = document_controller.selected_display_panel
data_item = DataItem.DataItem(numpy.zeros((10, 10)))
document_model.append_data_item(data_item)
display_item = document_model.get_display_item_for_data_item(data_item)
copy_display_item = document_model.get_display_item_copy_new(display_item)
display_panel.set_display_panel_display_item(copy_display_item)
header_height = display_panel.header_canvas_item.header_height
display_panel.root_container.layout_immediate((1000 + header_height, 1000))
# run test
document_controller.tool_mode = "zoom"
display_panel.display_canvas_item.simulate_press((100, 125))
display_panel.display_canvas_item.simulate_release((100, 125))

cmeyer marked this conversation as resolved.
Show resolved Hide resolved
document_controller.tool_mode = "zoom"
display_panel.display_canvas_item.simulate_press((125, 100), KeyboardModifiers.control)
display_panel.display_canvas_item.simulate_release((125, 100), KeyboardModifiers.control)
Tiomat85 marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, this test is not testing anything nor even doing the zoom. As a quick explanation, the zoom tool is using an async reactor to handle the mouse tracking; but the test would need to call document_controller.periodic() after each mouse click to run the reactor. In addition, the keyboard modifiers values being passed KeyboardModifiers.control are actually pointers to abstract functions (not even calling the functions - just pointers to them).

Here is a test which actually performs the zoom and checks if it nominally works correctly (it fails unfortunately). We'll need several more tests like this to cover the other cases:

    def test_zoom_tool_in_and_out_around_clicked_point(self):
        with TestContext.create_memory_context() as test_context:
            document_controller = test_context.create_document_controller()
            document_model = document_controller.document_model
            display_panel = document_controller.selected_display_panel
            data_item = DataItem.DataItem(numpy.zeros((50, 50)))
            document_model.append_data_item(data_item)
            display_item = document_model.get_display_item_for_data_item(data_item)
            display_panel.set_display_panel_display_item(display_item)
            header_height = display_panel.header_canvas_item.header_height
            display_panel.root_container.layout_immediate((100 + header_height, 100))
            # run test. each data pixel will be spanned by two display pixels. clicking at an odd value
            # will be a click in the center of a data pixel. zoom should expand and contract around that
            # pixel. there is also a check to make sure zooming is not just ending up with the same exact
            # zoom, which would also succeed.
            self.assertEqual((25, 25), display_panel.display_canvas_item.map_widget_to_image(Geometry.IntPoint(51, 51)))
            self.assertEqual((30, 30), display_panel.display_canvas_item.map_widget_to_image(Geometry.IntPoint(61, 61)))
            document_controller.tool_mode = "zoom"
            display_panel.display_canvas_item.simulate_click((26, 26))
            # the zoom tool runs asynchronously, so give it a slice of async time.
            document_controller.periodic()
            # check results
            self.assertEqual((25, 25), display_panel.display_canvas_item.map_widget_to_image(Geometry.IntPoint(51, 51)))
            self.assertNotEqual((30, 30), display_panel.display_canvas_item.map_widget_to_image(Geometry.IntPoint(61, 61)))
            # zoom out is done by passing the alt key to the canvas item.
            display_panel.display_canvas_item.simulate_click((26, 26), CanvasItem.KeyboardModifiers(alt=True))
            document_controller.periodic()
            # zoom in at the center of a data pixel followed by zoom out in the same spot should end up in the same original mapping.
            self.assertEqual((25, 25), display_panel.display_canvas_item.map_widget_to_image(Geometry.IntPoint(51, 51)))
            self.assertEqual((30, 30), display_panel.display_canvas_item.map_widget_to_image(Geometry.IntPoint(61, 61)))

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking this further, I don't think the current layout system is going to be able to support proper zoom. We'll need something like the test above, but there will be limitations because the layout uses the canvas size for zooming, and canvas size is rounded to integer boundaries. Let's do the best we can with the current limitations, but zooming in/out multiple times is going to be off center due to rounding errors. We will fix that in a future PR by changing the layout and composition to accommodate fractional coordinates via scaling and translation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, you can see this if you zoom in really far (single digit displayed pixels on a full size canvas) you can really see that. I think there are two distinct problems I identified:
Since it renders entire pixels at large zooms, even with the zoom factor being saved correctly number of pixels rendered doesn't always agree.
Again, when zoomed in a lot the 'coordinate' clicked becomes a more complex question. Was the click on the top left of a pixel, the center of the pixel, or does it need to have 'full resolution' of where abouts in the pixel did you click.


if __name__ == '__main__':
logging.getLogger().setLevel(logging.DEBUG)
Expand Down