diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 000000000..66d3d858f
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,26 @@
+name: Add Issue to Kanban Board
+
+on:
+ issues:
+ types: [opened]
+
+jobs:
+ add-to-kanban:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Add issue to Kanban board
+ uses: actions/github-script@0.7.0
+ with:
+ script: |
+ const issueNumber = context.payload.issue.number;
+ const projectId = '1';
+ const columnId = '1';
+ const octokit = new Octokit({ auth: process.env.PAT });
+ await octokit.projects.createCard({
+ column_id: columnId,
+ content_id: issueNumber,
+ content_type: 'Issue'
+ });
+ env:
+ PAT: ${{ secrets.PAT }}
diff --git a/artwork/mag_glass_in.svg b/artwork/mag_glass_in.svg
new file mode 100644
index 000000000..edb80ecf4
--- /dev/null
+++ b/artwork/mag_glass_in.svg
@@ -0,0 +1,85 @@
+
+
+
+
diff --git a/artwork/mag_glass_out.svg b/artwork/mag_glass_out.svg
new file mode 100644
index 000000000..26310fc70
--- /dev/null
+++ b/artwork/mag_glass_out.svg
@@ -0,0 +1,85 @@
+
+
+
+
diff --git a/nion/swift/DisplayPanel.py b/nion/swift/DisplayPanel.py
index 1a79c4ac2..19831f929 100644
--- a/nion/swift/DisplayPanel.py
+++ b/nion/swift/DisplayPanel.py
@@ -3066,7 +3066,8 @@ def preview(ui_settings: UISettings.UISettings, display_item: DisplayItem.Displa
display_data_delta.mark_changed()
display_canvas_item.update_display_data_delta(display_data_delta)
with drawing_context.saver():
- frame_width, frame_height = width, int(width / display_canvas_item.default_aspect_ratio)
+ frame_width, frame_height = width, height
+ display_canvas_item._prepare_render()
display_canvas_item.repaint_immediate(drawing_context, Geometry.IntSize(height=frame_height, width=frame_width))
shape = Geometry.IntSize(height=frame_height, width=frame_width)
return drawing_context, shape
diff --git a/nion/swift/DocumentController.py b/nion/swift/DocumentController.py
index d40365f42..b642eaf2f 100755
--- a/nion/swift/DocumentController.py
+++ b/nion/swift/DocumentController.py
@@ -3278,7 +3278,8 @@ def execute(self, context: Window.ActionContext) -> Window.ActionResult:
Window.register_action(SetToolModeAction("wedge", _("Wedge"), "wedge_icon.png", _("Wedge tool for creating wedge masks")))
Window.register_action(SetToolModeAction("ring", _("Ring"), "annular_ring.png", _("Ring tool for creating ring masks")))
Window.register_action(SetToolModeAction("lattice", _("Lattice"), "lattice_icon.png", _("Lattice tool for creating periodic lattice masks")))
-
+Window.register_action(SetToolModeAction("zoom-in", _("Zoom In"), "mag_glass_in.png", _("Zoom in on image")))
+Window.register_action(SetToolModeAction("zoom-out", _("Zoom Out"), "mag_glass_out.png", _("Zoom out on image")))
class WorkspaceChangeSplits(Window.Action):
# this is for internal testing only. since it requires passing the splitter and splits,
@@ -3497,15 +3498,19 @@ def execute(self, context: Window.ActionContext) -> Window.ActionResult:
workspace_controller = window.workspace_controller
display_panels = context.display_panels
selected_display_panel = context.selected_display_panel
- if workspace_controller and display_panels and selected_display_panel:
- h = self.get_int_property(context, "horizontal_count")
- v = self.get_int_property(context, "vertical_count")
- h = max(1, min(8, h))
- v = max(1, min(8, v))
- display_panels = workspace_controller.apply_layouts(selected_display_panel, display_panels, h, v)
- action_result = Window.ActionResult(Window.ActionStatus.FINISHED)
- action_result.results["display_panels"] = list(display_panels)
- return action_result
+ if workspace_controller:
+ if display_panels and selected_display_panel:
+ h = self.get_int_property(context, "horizontal_count")
+ v = self.get_int_property(context, "vertical_count")
+ h = max(1, min(8, h))
+ v = max(1, min(8, v))
+ display_panels = workspace_controller.apply_layouts(selected_display_panel, display_panels, h, v)
+ action_result = Window.ActionResult(Window.ActionStatus.FINISHED)
+ action_result.results["display_panels"] = list(display_panels)
+ return action_result
+
+ # no selected panel, cannot split
+ return Window.ActionResult(Window.ActionStatus.FINISHED)
raise ValueError("Missing workspace controller")
def is_enabled(self, context: Window.ActionContext) -> bool:
diff --git a/nion/swift/ExportDialog.py b/nion/swift/ExportDialog.py
index 0f24810dc..9a1a55695 100644
--- a/nion/swift/ExportDialog.py
+++ b/nion/swift/ExportDialog.py
@@ -202,14 +202,16 @@ def __init__(self, display_item: DisplayItem.DisplayItem, display_size: Geometry
self.width_model = Model.PropertyModel(display_size.width)
self.height_model = Model.PropertyModel(display_size.height)
-
self.int_converter = Converter.IntegerToStringConverter()
-
+ self.units = Model.PropertyModel(0)
u = Declarative.DeclarativeUI()
+
width_row = u.create_row(u.create_label(text=_("Width (in)"), width=80), u.create_line_edit(text="@binding(width_model.value, converter=int_converter)"), spacing=12)
- height_row = u.create_row(u.create_label(text=_("Height (in)"), width=80), u.create_line_edit(text="@binding(height_model.value, converter=int_converter)"), spacing=12)
- main_page = u.create_column(width_row, height_row, spacing=12, margin=12)
+ height_row = u.create_row(u.create_label(text=_("Height: "), width=80), u.create_line_edit(text="@binding(height_model.value, converter=int_converter)"),
+ u.create_combo_box(items=["Inches", "Centimeters","Pixels"],
+ current_index="@binding(units.value)"), spacing=12)
+ main_page = u.create_column(height_row, spacing=12, margin=12)
self.ui_view = main_page
def close(self) -> None:
@@ -233,17 +235,25 @@ def __init__(self, document_controller: DocumentController.DocumentController, d
display_item.display_properties = display_properties
if display_item.display_data_shape and len(display_item.display_data_shape) == 2:
- display_size = Geometry.IntSize(height=4, width=4)
+ display_size = Geometry.IntSize(height=10, width=10)
else:
display_size = Geometry.IntSize(height=3, width=4)
handler = ExportSVGHandler(display_item, display_size)
def ok_clicked() -> bool:
- dpi = 96
- width_px = (handler.width_model.value or display_size.width) * dpi
- height_px = (handler.height_model.value or display_size.height) * dpi
+ pixels_per_unit: float = 96.0
+ if handler.units.value ==1:
+ pixels_per_unit = 37.7953
+ elif handler.units.value ==2:
+ pixels_per_unit = 1
+ display_shape: tuple = display_item.display_data_shape
+ display_item_ratio = display_shape[1]/display_shape[0]
+
+
+ height_px = int((handler.height_model.value)) * pixels_per_unit
+ width_px = int(height_px*display_item_ratio)
ui = document_controller.ui
filter = "SVG File (*.svg);;All Files (*.*)"
export_dir = ui.get_persistent_string("export_directory", ui.get_document_location())
@@ -254,7 +264,18 @@ def ok_clicked() -> bool:
if path:
ui.set_persistent_string("export_directory", selected_directory)
display_shape = Geometry.IntSize(height=height_px, width=width_px)
+
document_controller.export_svg_file(DisplayPanel.DisplayPanelUISettings(ui), display_item, display_shape, pathlib.Path(path))
+
+ drawing_context, shape = DisplayPanel.preview(DisplayPanel.DisplayPanelUISettings(ui), display_item, display_shape.width, display_shape.height)
+ view_box = Geometry.IntRect(Geometry.IntPoint(), shape)
+
+ svg = drawing_context.to_svg(shape, view_box)
+
+ with Utility.AtomicFileWriter(pathlib.Path(path)) as fp:
+ fp.write(svg)
+
+
return True
def cancel_clicked() -> bool:
diff --git a/nion/swift/ImageCanvasItem.py b/nion/swift/ImageCanvasItem.py
index aa74af6d9..a5abae600 100644
--- a/nion/swift/ImageCanvasItem.py
+++ b/nion/swift/ImageCanvasItem.py
@@ -639,6 +639,66 @@ async def _reactor_loop(self, r: Stream.ValueChangeStreamReactorInterface[MouseP
change_display_properties_task.commit()
+class ZoomMouseHandler(MouseHandler):
+ def __init__(self, image_canvas_item: ImageCanvasItem, event_loop: asyncio.AbstractEventLoop, is_zooming_in: bool) -> None:
+ super().__init__(image_canvas_item, event_loop)
+ self.cursor_shape = "mag_glass"
+ self._is_zooming_in = is_zooming_in
+
+ 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
+
+ # preliminary setup for the tracking loop.
+ mouse_pos, modifiers = value_change_value
+ start_drag_pos = mouse_pos
+
+ start_drag_pos_norm = image_canvas_item.convert_pixel_to_normalised(start_drag_pos)
+
+ #document_controller = image_canvas_item.__document_controller
+ #document_model = document_controller.document_model
+ #display_item = document_model.get_display_item_for_data_item(image_canvas_item.data_item)
+
+ with (delegate.create_change_display_properties_task() as change_display_properties_task):
+ # 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
+ end_drag_pos = mouse_pos
+ if (self._is_zooming_in and
+ ((abs(start_drag_pos[0] - end_drag_pos[0]) > 3) or (abs(start_drag_pos[1] - end_drag_pos[1]) > 3))):
+ image_canvas_item._apply_selection_zoom(start_drag_pos, end_drag_pos)
+ else:
+ image_canvas_item._apply_fixed_zoom(self._is_zooming_in, start_drag_pos)
+ break
+ if value_change.value is not None:
+ # Not released for the zoom target, we could do with drawing a rectangle
+ mouse_pos, modifiers = value_change.value
+ assert start_drag_pos
+ #if crop_region:
+ #display_item.remove_graphic(crop_region)
+ #else:
+ #crop_region = Graphics.RectangleGraphic()
+
+ #end_drag_pos_norm = image_canvas_item.convert_pixel_to_normalised(mouse_pos)
+ #crop_region.bounds = (start_drag_pos_norm, end_drag_pos_norm)
+ #display_item.add_graphic(crop_region)
+
+ # 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)
@@ -1072,15 +1132,83 @@ 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: tuple[int, int]) -> 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)
+ norm_coord = tuple(ele1 / ele2 for ele1, ele2 in zip(mapped, self.__data_shape))
+ return Geometry.FloatPoint(norm_coord[0], norm_coord[1]) # y,x
+
+ #Apply a zoom factor to the widget, optionally focussed on a specific point
+ def _apply_fixed_zoom(self, zoom_in: bool, coord: tuple[int, int] = None):
+ # print('Applying zoom factor {0}, at coordinate {1},{2}'.format(zoom_in, coord[0], coord[1]))
+ if coord:
+ #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:
+ mapped = self.map_widget_to_image(coord)
+ norm_coord = tuple(ele1 / ele2 for ele1, ele2 in zip(mapped, self.__data_shape))
+ self._set_image_canvas_position(norm_coord)
+
+ # ensure that at least half of the image is always visible
+ new_image_norm_center_0 = max(min(norm_coord[0], 1.0), 0.0)
+ new_image_norm_center_1 = 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_0, new_image_norm_center_1)
+ self._set_image_canvas_position(new_image_canvas_position)
+
+ if zoom_in:
+ self.zoom_in()
+ else:
+ self.zoom_out()
+
+ def _apply_selection_zoom(self, coord1: tuple[int, int], coord2: tuple[int, int]):
+ # print('Applying zoom factor {0}, at coordinate {1},{2}'.format(zoom_in, coord[0], coord[1]))
+ assert coord1
+ assert coord2
+ # print('from {0} to {1}'.format(coord1, coord2))
+ widget_mapping = ImageCanvasItemMapping.make(self.__data_shape, self.__composite_canvas_item.canvas_bounds, list())
+ if widget_mapping:
+ coord1_mapped = self.map_widget_to_image(coord1)
+ coord2_mapped = self.map_widget_to_image(coord2)
+ norm_coord1 = tuple(ele1 / ele2 for ele1, ele2 in zip(coord1_mapped, self.__data_shape))
+ norm_coord2 = tuple(ele1 / ele2 for ele1, ele2 in zip(coord2_mapped, self.__data_shape))
+ # print('norm from {0} to {1}'.format(norm_coord1, norm_coord2))
+
+ norm_coord = tuple((ele1 + ele2)/2 for ele1, ele2 in zip(norm_coord1, norm_coord2))
+ self._set_image_canvas_position(norm_coord)
+ # image now centered on middle of selection, need to calculate new zoom level required
+ # selection size in widget pixels
+ selection_size_screen_space = tuple(
+ abs(ele1 - ele2) for ele1, ele2 in zip(coord1, coord2)) # y,x
+ # print(selection_size_screen_space)
+ widget_width = self.__composite_canvas_item.canvas_bounds.width / self.__image_zoom
+ widget_height = self.__composite_canvas_item.canvas_bounds.height / self.__image_zoom
+ # print(widget_width)
+ # print(widget_height)
+ widget_width_factor = widget_width / selection_size_screen_space[1]
+ widget_height_factor = widget_height / selection_size_screen_space[0]
+ widget_overall_factor = max(widget_height_factor, widget_width_factor)
+ # print('factor {0}'.format(widget_overall_factor))
+ # print('old zoom {0}'.format(self.__image_zoom))
+ self.__apply_display_properties_command({"image_zoom": widget_overall_factor * self.__image_zoom, "image_canvas_mode": "custom"})
+ # print('new zoom {0}'.format(self.__image_zoom))
+ # print(self.__composite_canvas_item.canvas_bounds)
+
+
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:
@@ -1104,6 +1232,16 @@ 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-in":
+ assert not self.__mouse_handler
+ assert self.__event_loop
+ self.__mouse_handler = ZoomMouseHandler(self, self.__event_loop, True)
+ self.__mouse_handler.mouse_pressed(Geometry.IntPoint(y=y, x=x), modifiers)
+ elif delegate.tool_mode == "zoom-out":
+ assert not self.__mouse_handler
+ assert self.__event_loop
+ self.__mouse_handler = ZoomMouseHandler(self, self.__event_loop, False)
+ 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
@@ -1114,6 +1252,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:
@@ -1126,7 +1265,15 @@ 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":
+
+ # Should probably wrap this into a function of 'Non-Toggle' UI elements
+ if delegate.tool_mode == "hand":
+ pass
+ elif delegate.tool_mode == "zoom-in":
+ pass
+ elif delegate.tool_mode == "zoom-out":
+ pass
+ else:
delegate.tool_mode = "pointer"
return True
@@ -1156,6 +1303,7 @@ def mouse_position_changed(self, x: int, y: int, modifiers: UserInterface.Keyboa
image_position = widget_mapping.map_point_widget_to_image(mouse_pos)
if delegate.image_mouse_position_changed(image_position, modifiers):
return True
+
if delegate.tool_mode == "pointer":
self.cursor_shape = self.__mouse_handler.cursor_shape if self.__mouse_handler else "arrow"
elif delegate.tool_mode == "line":
@@ -1176,6 +1324,11 @@ 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-in":
+ self.cursor_shape = "mag_glass"
+ elif delegate.tool_mode == "zoom-out":
+ self.cursor_shape = "mag_glass"
+
# x,y already have transform applied
self.__last_mouse = mouse_pos.to_int_point()
self.__update_cursor_info()
diff --git a/nion/swift/resources/mag_glass_in.png b/nion/swift/resources/mag_glass_in.png
new file mode 100644
index 000000000..f199d0daf
Binary files /dev/null and b/nion/swift/resources/mag_glass_in.png differ
diff --git a/nion/swift/resources/mag_glass_out.png b/nion/swift/resources/mag_glass_out.png
new file mode 100644
index 000000000..c9fa607d3
Binary files /dev/null and b/nion/swift/resources/mag_glass_out.png differ
diff --git a/nion/swift/test/ImageCanvasItem_test.py b/nion/swift/test/ImageCanvasItem_test.py
index 6341b2710..855e98ced 100644
--- a/nion/swift/test/ImageCanvasItem_test.py
+++ b/nion/swift/test/ImageCanvasItem_test.py
@@ -291,6 +291,25 @@ 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_on_one_image_of_multiple_displays(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-in"
+ display_panel.display_canvas_item.simulate_press((100, 125))
+
+ document_controller.tool_mode = "zoom-out"
+ display_panel.display_canvas_item.simulate_press((125, 100))
if __name__ == '__main__':
logging.getLogger().setLevel(logging.DEBUG)