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)