diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 749318e..c1ecfb8 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -919,9 +919,10 @@ def drag_finished(action: str) -> None: if drag_finished_fn: drag_finished_fn(action) - # TODO - # drag = QtDrag(self.proxy, self.widget, typing.cast(QtMimeData, mime_data), thumbnail, hot_spot_x, hot_spot_y, drag_finished) - # drag.execute() + # a widget here is drawn by a canvas item within the hierarchy of the window displaying it, and that + # hierarchy is what carries a drag: it follows the mouse and delivers the mime data to whatever is under it, + # which may be another canvas item of the same window or something outside it entirely. + self.canvas_item.drag(mime_data, thumbnail, hot_spot_x, hot_spot_y, drag_finished) def map_to_global(self, p: Geometry.IntPoint) -> Geometry.IntPoint: # the canvas item knows where it is within the widget it is drawn in, and that widget knows where it is on the @@ -2759,6 +2760,17 @@ def __window_size_changed(self, width: int, height: int) -> None: self.__current_size = Geometry.IntSize(width=width, height=height) def close(self) -> None: + # the widgets of this window are drawn by canvas items within one canvas widget of the window it wraps, so + # closing this window means closing both: the widgets first, which are what hold on to anything they were + # given, and then that canvas widget, which owns the canvas items drawing them. + canvas_widget = self.__canvas_widget + self.__canvas_widget = None + super().close() + # the canvas items draw on a thread, and closing them is what stops it. they have to stop before the window + # they draw in goes away, or the drawing carries on into a window which is no longer there. + if canvas_widget: + canvas_widget.close() + self.__canvas_item = typing.cast(typing.Any, None) self.__ui.destroy_document_window(self.__window) self.__window = typing.cast(typing.Any, None) diff --git a/nion/ui/Declarative.py b/nion/ui/Declarative.py index 8fba1ae..cedc8be 100755 --- a/nion/ui/Declarative.py +++ b/nion/ui/Declarative.py @@ -1021,6 +1021,9 @@ def create_list_view(self, *, on_escape_pressed: typing.Optional[UICallableIdentifier] = None, on_return_pressed: typing.Optional[UICallableIdentifier] = None, on_item_handle_context_menu: typing.Optional[UICallableIdentifier] = None, + on_item_drag_started: typing.Optional[UICallableIdentifier] = None, + on_can_drop_mime_data: typing.Optional[UICallableIdentifier] = None, + on_drop_mime_data: typing.Optional[UICallableIdentifier] = None, **kwargs: typing.Any) -> UIDescriptionResult: """Create a list view UI description with items, an item component, and the item height. @@ -1044,6 +1047,9 @@ def create_list_view(self, *, on_escape_pressed: callback when escape is pressed, return true if handled (optional) on_return_pressed: callback when return is pressed, return true if handled (optional) on_item_handle_context_menu: callback to display context menu, passes gx, gy, index (optional) + on_item_drag_started: callback when the user starts dragging an item, passes index, x, y, modifiers (optional) + on_can_drop_mime_data: callback asking whether mime data can be dropped, passes mime_data, action, drop_index (optional) + on_drop_mime_data: callback to accept dropped mime data at drop_index, passes mime_data, action, drop_index (optional) Returns: UI description of the list view @@ -1055,6 +1061,17 @@ def create_list_view(self, *, Each item handler is given an `is_selected_model` property, a boolean property model tracking whether its item is selected. An item component can bind to it, e.g. `@binding(is_selected_model.value)`, to display the item differently when it is selected. + + An item can also carry a `tool_tip` property, which is displayed while the mouse rests on that item. Unlike + the tool tip of the list view itself, which is the same wherever the mouse rests on it, this one belongs to + the one item and changes as the mouse moves from item to item. + + The `on_item_drag_started` callback describes the item being dragged as mime data and starts the drag, by + calling `drag` on the widget it is passed. The list takes part in a drop only when it is given + `on_can_drop_mime_data` or `on_drop_mime_data`. A drop lands in the gap between two items, so `drop_index` is + the index the drop would be inserted at: zero for the gap before the first item, and the number of items for + the gap after the last one, which is also what a list with no items at all reports. + `on_drop_mime_data` returns the action taken, or "ignore". """ d: UIDescriptionResult = {"type": "list_view"} if name is not None: @@ -1079,6 +1096,12 @@ def create_list_view(self, *, d["on_return_pressed"] = on_return_pressed if on_item_handle_context_menu is not None: d["on_item_handle_context_menu"] = on_item_handle_context_menu + if on_item_drag_started is not None: + d["on_item_drag_started"] = on_item_drag_started + if on_can_drop_mime_data is not None: + d["on_can_drop_mime_data"] = on_can_drop_mime_data + if on_drop_mime_data is not None: + d["on_drop_mime_data"] = on_drop_mime_data self.__process_common_properties(d, **kwargs) return d @@ -1899,6 +1922,12 @@ def construct_list_view(ui: UserInterface.UserInterface, window: Window.Window, connect_event(widget, widget, d, handler, "on_escape_pressed", []) connect_event(widget, widget, d, handler, "on_return_pressed", []) connect_event(widget, widget, d, handler, "on_item_handle_context_menu", ["x", "y", "gx", "gy", "index"]) + connect_event(widget, widget, d, handler, "on_item_drag_started", ["index", "x", "y", "modifiers"]) + connect_event(widget, widget, d, handler, "on_can_drop_mime_data", ["mime_data", "action", "drop_index"]) + connect_event(widget, widget, d, handler, "on_drop_mime_data", ["mime_data", "action", "drop_index"]) + # a list which cannot take a drop leaves the drag to whatever is drawn behind it, so only one which says how + # to handle a drop takes part in one. + widget.wants_drag_events = "on_can_drop_mime_data" in d or "on_drop_mime_data" in d connect_attributes(widget, d, handler, finishes) return widget diff --git a/nion/ui/GridFlowCanvasItem.py b/nion/ui/GridFlowCanvasItem.py index 335094a..a5dedd4 100644 --- a/nion/ui/GridFlowCanvasItem.py +++ b/nion/ui/GridFlowCanvasItem.py @@ -185,7 +185,8 @@ def is_dropping(self) -> bool: def is_dropping(self, value: bool) -> None: if value != self.is_dropping: self.__is_dropping_model.value = value - self.update() + # the adornments are what draw the drop highlight over the item. + self.__adornments_canvas_item.is_dropping = value def context_menu_event(self, x: int, y: int, gx: int, gy: int) -> bool: grid_flow_canvas_item = self.__grid_flow_canvas_item_ref() @@ -308,8 +309,6 @@ def __init__(self, list_model: ListModel.ListModelLike, selection: Selection.Ind self.__mouse_pressed_for_dragging = False self.__mouse_position: Geometry.IntPoint | None = None self.__mouse_dragging = False - self.__dropping = True - self.__drop_before_index: int | None = None self.__drop_index: int | None = None # initialize with self.batch_update(): @@ -462,7 +461,7 @@ def mouse_position_changed(self, x: int, y: int, modifiers: UserInterface.Keyboa if base_container: base_container._bypass_request_focus() selected_items = [self.__list_model.items[index] for index in self.__selection.indexes] - selected_items = selected_items if self.__mouse_canvas_item.item in selected_items else [self.__mouse_canvas_item] + selected_items = selected_items if self.__mouse_canvas_item.item in selected_items else [self.__mouse_canvas_item.item] if self.__delegate.drag_started_event(GridFlowCanvasItemDragStartedEvent(self.__mouse_canvas_item.item, selected_items, Geometry.IntPoint(x=x, y=y), modifiers)): # once a drag starts, mouse release will not be called; call it here instead self.__mouse_released(x, y, modifiers, False) @@ -611,20 +610,44 @@ def _drop_mime_data(self, mime_data: UserInterface.MimeData, action: str, drop_i return "ignore" def drag_enter(self, mime_data: UserInterface.MimeData) -> str: - self.__dropping = True return "ignore" def drag_move(self, mime_data: UserInterface.MimeData, x: int, y: int) -> str: - mouse_index = self.__get_mouse_index(x, y) - max_index = len(self.__list_model.items) - drop_index = None - if mouse_index >= 0 and mouse_index < max_index: - drop_index = mouse_index - if not self._can_drop_mime_data(mime_data, "move", drop_index): + drop_index = self._get_drop_index(x, y) + if drop_index is not None: + # the point is a possible drop target if either this canvas item can take the drop itself or the delegate + # can. the delegate is asked here, and not only when the drop arrives, so that it is the one deciding + # where a drop lands and so that the drop is shown while the drag is over it. + if not self._can_drop_mime_data(mime_data, "move", drop_index) and not self.__delegate.can_drop_mime_data(mime_data, "copy", drop_index): drop_index = None + self.__set_drop_index(drop_index) + return "ignore" + + def _get_drop_index(self, x: int, y: int) -> int | None: + """Return the index a drop at the given point would land on, or None where it would land nowhere. + + The index is the item the drop lands on. A subclass which lands a drop between its items instead returns the + index the drop would be inserted at, which runs from zero up to and including the number of items. + """ + mouse_index = self.__get_mouse_index(x, y) + return mouse_index if 0 <= mouse_index < len(self.__list_model.items) else None + + def _show_drop_index(self, drop_index: int | None) -> None: + """Show where a drop would land. The item it would land on is the one which shows it.""" + grid_flow_item_canvas_items = self.__grid_flow_item_canvas_items + for index, grid_flow_item_canvas_item in enumerate(grid_flow_item_canvas_items): + grid_flow_item_canvas_item.is_dropping = index == drop_index + + def __set_drop_index(self, drop_index: int | None) -> None: + # only one place shows the drop at a time: the one the drop would land on. if drop_index != self.__drop_index: self.__drop_index = drop_index - return "ignore" + self._show_drop_index(drop_index) + + @property + def _drop_index(self) -> int | None: + """The index a drop would land on while a drag is over this canvas item, or None.""" + return self.__drop_index def __get_mouse_index(self, x: int, y: int) -> int: canvas_item = self.__grid_flow_item_at_point(Geometry.IntPoint(x=x, y=y)) @@ -637,14 +660,12 @@ def __get_mouse_index(self, x: int, y: int) -> int: return mouse_index def drag_leave(self) -> str: - self.__dropping = False - self.__drop_index = None + self.__set_drop_index(None) return "ignore" def drop(self, mime_data: UserInterface.MimeData, x: int, y: int) -> str: drop_index = self.__drop_index - self.__dropping = False - self.__drop_index = None + self.__set_drop_index(None) self.update() if drop_index is not None: internal_drop_result = self._drop_mime_data(mime_data, "move", drop_index) diff --git a/nion/ui/ListCanvasItem.py b/nion/ui/ListCanvasItem.py index 6a873bf..8a7087f 100644 --- a/nion/ui/ListCanvasItem.py +++ b/nion/ui/ListCanvasItem.py @@ -520,11 +520,15 @@ def __init__(self, border_color: typing.Optional[str], list_model: ListModel.ListModelLike, item_width: int | None, - item_height: int | None) -> None: + item_height: int | None, + fills_container: bool = False, + insert_index: int | None = None) -> None: super().__init__(canvas_item, layout_sizing, composer_cache, layout, child_composers, background_color, border_color) self.__list_model = list_model self.__item_width = item_width self.__item_height = item_height + self.__fills_container = fills_container + self.__insert_index = insert_index def _repaint_children(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: Geometry.IntRect, visible_rect: Geometry.IntRect, child_composers: typing.Sequence[CanvasItem.BaseComposer]) -> None: with drawing_context.saver(): @@ -541,17 +545,48 @@ def _repaint_children(self, drawing_context: DrawingContext.DrawingContext, canv with drawing_context.saver(): child_composer.update_layout(Geometry.IntPoint(), child_canvas_rect.size) child_composer.repaint(drawing_context, child_canvas_rect, visible_rect) + self.__repaint_insertion_line(drawing_context, canvas_rect) + + def __repaint_insertion_line(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: Geometry.IntRect) -> None: + # a drop which lands between two items is shown as a line in the gap it would go into. the gap before the + # first item and the gap after the last one are drawn just inside the list, so that neither is half hidden. + insert_index = self.__insert_index + if insert_index is None: + return + line_width = 2.0 + item_count = len(self.__list_model.items) + if self.__item_height: + offset = min(insert_index * self.__item_height, item_count * self.__item_height - line_width) + offset = max(offset, 0.0) + rect = Geometry.FloatRect(Geometry.FloatPoint(y=offset, x=0), Geometry.FloatSize(height=line_width, width=canvas_rect.width)) + elif self.__item_width: + offset = min(insert_index * self.__item_width, item_count * self.__item_width - line_width) + offset = max(offset, 0.0) + rect = Geometry.FloatRect(Geometry.FloatPoint(y=0, x=offset), Geometry.FloatSize(height=canvas_rect.height, width=line_width)) + else: + return + with drawing_context.saver(): + drawing_context.begin_path() + drawing_context.rect(rect.left, rect.top, rect.width, rect.height) + drawing_context.fill_style = "rgba(56, 117, 214, 0.8)" + drawing_context.fill() def _adjust_canvas_bounds(self, canvas_bounds: Geometry.IntRect) -> Geometry.IntRect: # preserve the true (scrollable) extent along the item axis, independent of the size given by the # enclosing scroll area. this allows a scroll area with auto_resize_contents enabled to stretch the # cross axis (e.g. width, for a column of fixed-height rows) to match its own size on every layout, # without collapsing the scrollable axis down to the viewport size and breaking scrolling. + # a list which fills the container takes any room beyond that extent as well, so that the space past the + # last item belongs to the list rather than to whatever the list is displayed in. item_count = len(self.__list_model.items) if self.__item_height: - return Geometry.IntRect(canvas_bounds.origin, Geometry.IntSize(width=canvas_bounds.width, height=item_count * self.__item_height)) + height = item_count * self.__item_height + height = max(height, canvas_bounds.height) if self.__fills_container else height + return Geometry.IntRect(canvas_bounds.origin, Geometry.IntSize(width=canvas_bounds.width, height=height)) elif self.__item_width: - return Geometry.IntRect(canvas_bounds.origin, Geometry.IntSize(width=item_count * self.__item_width, height=canvas_bounds.height)) + width = item_count * self.__item_width + width = max(width, canvas_bounds.width) if self.__fills_container else width + return Geometry.IntRect(canvas_bounds.origin, Geometry.IntSize(width=width, height=canvas_bounds.height)) return canvas_bounds @@ -572,9 +607,68 @@ def __init__(self, list_model: ListModel.ListModelLike, selection: Selection.Ind super().__init__(list_model, selection, layout, item_factory, delegate, key=key, is_shared_selection=is_shared_selection) self.__item_width = item_width self.__item_height = item_height + # whether the list takes any room beyond its items, and whether a drop lands between two items rather than + # on one of them. both are the decision of whatever is displaying the list. + self.__fills_container = False + self.__drops_between_items = False + + @property + def fills_container(self) -> bool: + """Whether the list takes any room it is given beyond the extent of its items. + + A list which does takes the room past its last item as its own, so that a click or a drop there lands on the + list; one which does not is only as big as its items, leaving that room to whatever displays the list. Either + way the list is at least as big as its items, so that a list longer than the room it is given still scrolls. + """ + return self.__fills_container + + @fills_container.setter + def fills_container(self, value: bool) -> None: + if value != self.__fills_container: + self.__fills_container = value + # the extent the list takes is decided as it is composed, so the composer has to be built again. + self._invalidate_composer() + self.update() + + @property + def drops_between_items(self) -> bool: + """Whether a drop lands between two items rather than on one of them. + + A list which reorders or receives items places a drop in the gap it would go into, so the drop index is the + index it would be inserted at: zero for the gap before the first item, and the number of items for the gap + after the last one. A list which does not is told the item the drop lands on instead, and None where the drop + lands on no item at all. + """ + return self.__drops_between_items + + @drops_between_items.setter + def drops_between_items(self, value: bool) -> None: + self.__drops_between_items = value def _get_composition_composer(self, child_composers: typing.Sequence[CanvasItem.BaseComposer], composer_cache: CanvasItem.ComposerCache) -> CanvasItem.BaseComposer: - return ListCanvasItemCompositionComposer(self, self.layout_sizing, composer_cache, self.layout.copy(), child_composers, self.background_color, self.border_color, self._list_model, self.__item_width, self.__item_height) + insert_index = self._drop_index if self.__drops_between_items else None + return ListCanvasItemCompositionComposer(self, self.layout_sizing, composer_cache, self.layout.copy(), child_composers, self.background_color, self.border_color, self._list_model, self.__item_width, self.__item_height, self.__fills_container, insert_index) + + def _get_drop_index(self, x: int, y: int) -> int | None: + if not self.__drops_between_items: + return super()._get_drop_index(x, y) + # the drop goes into the nearer of the two gaps around the point, so the top half of an item takes the gap + # before it and the bottom half the gap after it. a point past the last item takes the gap after that item. + item_count = len(self._list_model.items) + if self.__item_height is not None: + insert_index = int((y + self.__item_height // 2) // self.__item_height) + elif self.__item_width is not None: + insert_index = int((x + self.__item_width // 2) // self.__item_width) + else: + return None + return max(0, min(insert_index, item_count)) + + def _show_drop_index(self, drop_index: int | None) -> None: + if not self.__drops_between_items: + super()._show_drop_index(drop_index) + else: + # the gap the drop would go into is drawn by the list itself rather than marked on an item. + self.update() def _handle_up_arrow(self, key: UserInterface.Key) -> bool: return self._adjust_selection_backward(1, key.modifiers.shift) diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index f848925..7ea9fed 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -831,6 +831,18 @@ def context_menu_event(self, context_menu_event: GridFlowCanvasItem.GridFlowCanv list_view_widget = self.__list_view_widget return list_view_widget._handle_context_menu(context_menu_event.item, context_menu_event.p, context_menu_event.gp) if list_view_widget else False + def drag_started_event(self, drag_started_event: GridFlowCanvasItem.GridFlowCanvasItemDragStartedEvent) -> bool: + list_view_widget = self.__list_view_widget + return list_view_widget._handle_drag_started(drag_started_event.item, drag_started_event.p, drag_started_event.modifiers) if list_view_widget else False + + def can_drop_mime_data(self, mime_data: UserInterface.MimeData, action: str, drop_index: typing.Optional[int]) -> bool: + list_view_widget = self.__list_view_widget + return list_view_widget._handle_can_drop_mime_data(mime_data, action, drop_index) if list_view_widget else False + + def drop_mime_data(self, mime_data: UserInterface.MimeData, action: str, drop_index: typing.Optional[int]) -> str: + list_view_widget = self.__list_view_widget + return list_view_widget._handle_drop_mime_data(mime_data, action, drop_index) if list_view_widget else "ignore" + class ListViewWidget(UserInterface.Widget): """A widget with a list in a scroll bar, where each item is displayed using a canvas item from an item factory. @@ -851,12 +863,20 @@ def __init__(self, ui: UserInterface.UserInterface, list_model: ListModel.ListMo properties: typing.Optional[typing.Mapping[str, typing.Any]] = None) -> None: column_widget = ui.create_column_widget() super().__init__(CompositeWidgetBehavior(column_widget)) + self.__ui = ui self.on_selection_changed: typing.Optional[typing.Callable[[typing.AbstractSet[int]], None]] = None self.on_item_changed: typing.Optional[typing.Callable[[typing.Optional[int]], None]] = None self.on_item_selected: typing.Optional[typing.Callable[[int], bool]] = None self.on_escape_pressed: typing.Optional[typing.Callable[[], bool]] = None self.on_return_pressed: typing.Optional[typing.Callable[[], bool]] = None self.on_item_handle_context_menu: typing.Optional[typing.Callable[..., bool]] = None + # dragging an item out of the list and dropping mime data into it. a list only takes part in a drop once it + # is told to want drag events, since a list which cannot take a drop would otherwise keep the drag from + # reaching whatever is drawn behind it. the drop index these are given is where the drop would be inserted: + # zero before the first item, the number of items after the last one. + self.on_item_drag_started: typing.Optional[typing.Callable[..., bool]] = None + self.on_can_drop_mime_data: typing.Optional[typing.Callable[..., bool]] = None + self.on_drop_mime_data: typing.Optional[typing.Callable[..., str]] = None self.__list_model = list_model # a selection created here reports the changes it makes when items are inserted into or removed from the list, # so that the current index stays in step with the selection as the list changes. a selection passed in is @@ -865,6 +885,12 @@ def __init__(self, ui: UserInterface.UserInterface, list_model: ListModel.ListMo self.__list_canvas_item = ListCanvasItem.ListCanvasItem2(list_model, self.__selection, item_factory, ListViewCanvasItemDelegate(self), item_height=item_height, key=key) + # the list is the whole of what this widget displays, so the room past its last item is part of it: a drop + # there belongs to the list, and lands after the last item rather than nowhere. + self.__list_canvas_item.fills_container = True + # a drop on a list of this kind puts an item into the list, so it lands in the gap between two items, which + # is what lets it land before the first item, after the last one, or in a list with no items at all. + self.__list_canvas_item.drops_between_items = True scroll_area_canvas_item = CanvasItem.ScrollAreaCanvasItem(self.__list_canvas_item) scroll_area_canvas_item.auto_resize_contents = True scroll_group_canvas_item = CanvasItem.CanvasItemComposition() @@ -928,6 +954,9 @@ def close(self) -> None: self.on_escape_pressed = None self.on_return_pressed = None self.on_item_handle_context_menu = None + self.on_item_drag_started = None + self.on_can_drop_mime_data = None + self.on_drop_mime_data = None super().close() def __index_for_item(self, item: typing.Any) -> typing.Optional[int]: @@ -962,6 +991,57 @@ def _handle_escape_pressed(self) -> bool: return bool(self.on_escape_pressed()) return False + def _handle_drag_started(self, item: typing.Any, p: Geometry.IntPoint, modifiers: UserInterface.KeyboardModifiers) -> bool: + # the user has begun dragging the item out of the list; the callback puts it on a drag, typically by calling + # drag on this widget with mime data describing the item. + index = self.__index_for_item(item) + if index is not None and callable(self.on_item_drag_started): + return bool(self.on_item_drag_started(index, p.x, p.y, modifiers)) + return False + + def _handle_can_drop_mime_data(self, mime_data: UserInterface.MimeData, action: str, drop_index: typing.Optional[int]) -> bool: + # the drop index is where the drop would be inserted: zero before the first item, the number of items after + # the last one. + if callable(self.on_can_drop_mime_data): + return bool(self.on_can_drop_mime_data(mime_data, action, drop_index)) + return False + + def _handle_drop_mime_data(self, mime_data: UserInterface.MimeData, action: str, drop_index: typing.Optional[int]) -> str: + if callable(self.on_drop_mime_data): + return str(self.on_drop_mime_data(mime_data, action, drop_index)) + return "ignore" + + def render_item(self, index: int) -> typing.Optional[Bitmap.Bitmap]: + """Return an image of the item at the given index, as the list draws it. + + A drag started on an item carries an image of what is being dragged, and this is what the item looks like: the + whole row, rather than any one part of it. The item has to have been displayed to be drawn, so a list which + has not been laid out yet has no image of its items to give. + """ + item_canvas_items = self.__list_canvas_item._grid_flow_item_canvas_items + if not 0 <= index < len(item_canvas_items): + return None + canvas_size = self.__list_canvas_item._rect_for_index(index).size + if not canvas_size.width or not canvas_size.height: + return None + drawing_context = DrawingContext.DrawingContext() + item_canvas_items[index].repaint_immediate(drawing_context, canvas_size) + return Bitmap.promote_bitmap(self.__ui.create_rgba_image(drawing_context, canvas_size.width, canvas_size.height)) + + def item_rect(self, index: int) -> Geometry.IntRect: + """Return where the item at the given index is drawn within the list, which is empty if it is not drawn.""" + if not 0 <= index < len(self.__list_model.items): + return Geometry.IntRect.empty_rect() + return self.__list_canvas_item._rect_for_index(index) + + @property + def wants_drag_events(self) -> bool: + return self.__list_canvas_item.wants_drag_events + + @wants_drag_events.setter + def wants_drag_events(self, value: bool) -> None: + self.__list_canvas_item.wants_drag_events = value + def _get_item_tool_tip(self, item: typing.Any) -> typing.Optional[str]: # items may carry their own tool tip; subclasses can override to get it from somewhere else. return typing.cast(typing.Optional[str], getattr(item, "tool_tip", None)) @@ -1379,14 +1459,18 @@ def __init__(self, ui: UserInterface.UserInterface, column_widget = ui.create_column_widget(properties=properties) super().__init__(CompositeWidgetBehavior(column_widget)) self.ui = ui - self.on_clicked = None + self.__on_clicked: typing.Optional[typing.Callable[[], None]] = None def button_clicked() -> None: - if callable(self.on_clicked): - self.on_clicked() + if callable(self.__on_clicked): + self.__on_clicked() self.__bitmap_canvas_item = CanvasItem.BitmapButtonCanvasItem(bitmap) self.__bitmap_canvas_item.on_button_clicked = button_clicked + # an image with nothing to do when it is clicked is only something to look at, so it leaves the mouse to + # whatever is displaying it. an image within a row of a list would otherwise take the mouse from the list, + # leaving the part of the row it covers unable to select the row or to drag it. + self.__bitmap_canvas_item.wants_mouse_events = False bitmap_canvas_widget = self.ui.create_canvas_widget() bitmap_canvas_widget.canvas_item.add_canvas_item(self.__bitmap_canvas_item) column_widget.add(bitmap_canvas_widget) @@ -1402,8 +1486,19 @@ def set_image(value: typing.Optional[Bitmap.BitmapOrArray]) -> None: def close(self) -> None: self.__image_binding_helper.close() self.__image_binding_helper = typing.cast(typing.Any, None) + self.on_clicked = None super().close() + @property + def on_clicked(self) -> typing.Optional[typing.Callable[[], None]]: + return self.__on_clicked + + @on_clicked.setter + def on_clicked(self, on_clicked: typing.Optional[typing.Callable[[], None]]) -> None: + self.__on_clicked = on_clicked + # the image takes the mouse only while there is something for a click to do. + self.__bitmap_canvas_item.wants_mouse_events = callable(on_clicked) + @property def image(self) -> typing.Optional[Bitmap.Bitmap]: return Bitmap.promote_bitmap(self.__image_binding_helper.value) diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index 70eaa4b..8b521db 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -955,6 +955,21 @@ def test_closing_a_child_window_runs_its_close_handling(self) -> None: self.assertEqual([False], results) self.assertEqual(0, len(window._dialogs)) + def test_closing_a_window_closes_what_it_was_displaying(self) -> None: + # the content of a canvas window is drawn by canvas items within a single widget of the host, so closing the + # window has to close them as well as the widgets they draw. they draw on a thread, and closing them is what + # stops it; left open they go on drawing into a window which is no longer there. + ui = CanvasUserInterface.CanvasUserInterface(TestUI.UserInterface()) + window = Window.Window(ui) + column = ui.create_column_widget() + column.add(ui.create_line_edit_widget()) + window.attach_widget(column) + canvas_item = CanvasUserInterface.extract_canvas_item(column) + assert canvas_item + self.assertIsNotNone(canvas_item.container) + window.close() + self.assertIsNone(canvas_item.container) + def test_widget_reports_where_it_is_on_the_screen(self) -> None: # a menu popped up beside a widget has to know where the widget is. the content of a canvas window is drawn # within a single widget of the host, so a widget maps its position through the canvas item displaying it. diff --git a/nion/ui/test/Declarative_test.py b/nion/ui/test/Declarative_test.py index fd6ab8b..df664cd 100644 --- a/nion/ui/test/Declarative_test.py +++ b/nion/ui/test/Declarative_test.py @@ -5,6 +5,9 @@ import typing import unittest +# third party libraries +import numpy + # third party libraries # None @@ -12,6 +15,7 @@ from nion.ui import CanvasItem from nion.ui import CanvasUserInterface from nion.ui import Declarative +from nion.ui import DrawingContext from nion.ui import GridFlowCanvasItem from nion.ui import ListCanvasItem from nion.ui import TestUI @@ -144,6 +148,92 @@ def item_context_menu(self, widget: Declarative.UIWidget, index: typing.Optional return True +class ListViewDragHandler(ItemsHandler): + """A handler with two list views, where an item dragged out of one is dropped into the other. + + The item is described as mime data naming the list it came from and its index; dropping it moves it from that + list into the list it was dropped on, at the item it was dropped on.""" + + MIME_TYPE = "text/vnd.nion.test-item" + + def __init__(self, ui: UserInterface.UserInterface, left_items: typing.Sequence[str], + right_items: typing.Sequence[str]) -> None: + super().__init__() + u = Declarative.DeclarativeUI() + self.__ui = ui + self.left_model = ListModel.ListModel[str]("items", items=list(left_items)) + self.right_model = ListModel.ListModel[str]("items", items=list(right_items)) + self.drag_indexes: typing.List[int] = list() + self.drop_indexes: typing.List[typing.Optional[int]] = list() + self.left_list_view: typing.Optional[Widgets.ListViewWidget] = None + self.right_list_view: typing.Optional[Widgets.ListViewWidget] = None + # the left list is only dragged from and the right one only dropped on, so that a test can tell which list + # took part in a drag and which in a drop. + left_list_view = u.create_list_view(items="left_model.items", item_component_id="item", item_height=20, + name="left_list_view", on_item_drag_started="item_drag_started") + right_list_view = u.create_list_view(items="right_model.items", item_component_id="item", item_height=20, + name="right_list_view", on_can_drop_mime_data="can_drop_mime_data", + on_drop_mime_data="drop_mime_data") + self.ui_view = u.create_row(left_list_view, right_list_view) + + def item_drag_started(self, widget: Declarative.UIWidget, index: int, x: int, y: int, + modifiers: UserInterface.KeyboardModifiers) -> bool: + self.drag_indexes.append(index) + mime_data = self.__ui.create_mime_data() + mime_data.set_data_as_string(ListViewDragHandler.MIME_TYPE, self.left_model.items[index]) + typing.cast(UserInterface.Widget, widget).drag(mime_data) + return True + + def can_drop_mime_data(self, widget: Declarative.UIWidget, mime_data: UserInterface.MimeData, action: str, + drop_index: typing.Optional[int]) -> bool: + return mime_data.has_format(ListViewDragHandler.MIME_TYPE) + + def drop_mime_data(self, widget: Declarative.UIWidget, mime_data: UserInterface.MimeData, action: str, + drop_index: typing.Optional[int]) -> str: + if not mime_data.has_format(ListViewDragHandler.MIME_TYPE): + return "ignore" + self.drop_indexes.append(drop_index) + item = mime_data.data_as_string(ListViewDragHandler.MIME_TYPE) + self.left_model.remove_item(self.left_model.items.index(item)) + self.right_model.insert_item(drop_index if drop_index is not None else len(self.right_model.items), item) + return "move" + + +class ImageRowItemHandler(Declarative.Handler): + """Display one item as an image followed by its text, the way a row of a list often looks.""" + + def __init__(self, item: str) -> None: + super().__init__() + u = Declarative.DeclarativeUI() + self.item = item + self.icon = numpy.zeros((16, 16), numpy.uint32) + self.is_selected_model = Model.PropertyModel(False) + self.ui_view = u.create_row(u.create_image(image="@binding(icon)", width=16, height=16), + u.create_label(text=item), u.create_stretch(), spacing=8) + + +class ImageRowListViewHandler(Declarative.Handler): + """A handler whose list view displays each item as an image followed by its text, and drags its items.""" + + def __init__(self, items: typing.Sequence[str]) -> None: + super().__init__() + u = Declarative.DeclarativeUI() + self.list_model = ListModel.ListModel[str]("items", items=list(items)) + self.drag_indexes: typing.List[int] = list() + self.list_view: typing.Optional[Widgets.ListViewWidget] = None + self.ui_view = u.create_list_view(items="list_model.items", item_component_id="item", item_height=20, + name="list_view", on_item_drag_started="item_drag_started") + + def create_handler(self, component_id: str, item: typing.Any = None, container: typing.Any = None, + **kwargs: typing.Any) -> typing.Optional[ImageRowItemHandler]: + return ImageRowItemHandler(item) if component_id == "item" else None + + def item_drag_started(self, widget: Declarative.UIWidget, index: int, x: int, y: int, + modifiers: UserInterface.KeyboardModifiers) -> bool: + self.drag_indexes.append(index) + return True + + class StackItemsHandler(ItemsHandler): """A handler with a stack whose children are built from an observable list of items.""" @@ -352,18 +442,22 @@ def test_list_view_closes_item_handlers_when_closed(self) -> None: widget.close() self.assertEqual(["a", "b", "c"], sorted(item_handler.item for item_handler in handler.item_handlers if item_handler.closed)) - def test_list_view_lays_out_one_row_per_item(self) -> None: - # tests that the constructed list actually lays out its items, one row of item_height for each item. + def test_list_view_lays_out_one_row_per_item_and_takes_the_room_it_is_given(self) -> None: + # tests that the constructed list lays out one row of item_height for each item, keeping that extent when it + # is given less room, which is what lets it scroll, and taking the extra when it is given more, so that the + # room past its last item belongs to the list rather than to whatever displays it. with event_loop_context() as event_loop: handler = ListViewHandler(["a", "b", "c"]) widget = Declarative.construct_widget(TestUI.UserInterface(), event_loop, handler) with contextlib.closing(widget): list_view = typing.cast(Widgets.ListViewWidget, handler.list_view) list_canvas_item = list_view._list_canvas_item - list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=40)) self.assertEqual(Geometry.IntSize(width=200, height=60), list_canvas_item.canvas_size) - handler.list_model.remove_item(0) list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + self.assertEqual(Geometry.IntSize(width=200, height=100), list_canvas_item.canvas_size) + handler.list_model.remove_item(0) + list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=20)) self.assertEqual(Geometry.IntSize(width=200, height=40), list_canvas_item.canvas_size) def test_list_view_current_index_binding_follows_the_selection(self) -> None: @@ -396,6 +490,182 @@ def test_list_view_reports_an_item_chosen_by_double_click_or_return(self) -> Non list_canvas_item.key_pressed(ui.create_key_by_id("return")) self.assertEqual([1, 1], handler.selected_indexes) + def test_list_view_reports_the_item_a_drag_starts_on(self) -> None: + # tests that dragging an item out of a list reports that item, so that the handler can describe it as mime + # data. the drag has to move far enough from where it started to be a drag rather than a click. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewDragHandler(ui, ["a", "b", "c"], ["x"]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.left_list_view) + list_canvas_item = list_view._list_canvas_item + list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + list_canvas_item.simulate_drag((30, 10), (30, 100)) + self.assertEqual([1], handler.drag_indexes) + + def test_list_view_drop_reports_where_the_item_would_be_inserted(self) -> None: + # tests that a drop reports the gap it would go into rather than an item it would land on, so that it can + # land before the first item and after the last one as well as between any two of them. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewDragHandler(ui, ["a", "b", "c"], ["x", "y"]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.right_list_view) + list_canvas_item = list_view._list_canvas_item + list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + # the top half of an item takes the gap before it, the bottom half the gap after it. + self.assertEqual(0, self._drop(list_canvas_item, ui, "a", 4)) + self.assertEqual(["a", "x", "y"], list(handler.right_model.items)) + self.assertEqual(3, self._drop(list_canvas_item, ui, "b", 90)) + self.assertEqual(["a", "x", "y", "b"], list(handler.right_model.items)) + self.assertEqual(2, self._drop(list_canvas_item, ui, "c", 45)) + self.assertEqual(["a", "x", "c", "y", "b"], list(handler.right_model.items)) + self.assertEqual([], list(handler.left_model.items)) + + def test_list_view_takes_a_drop_when_it_has_no_items_left(self) -> None: + # tests that a list which has been emptied can still be dropped on: it keeps the room it was given, so the + # drop lands on the list rather than on whatever the list is displayed in, and goes in as the first item. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewDragHandler(ui, ["a"], list()) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.right_list_view) + list_canvas_item = list_view._list_canvas_item + list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + self.assertEqual(Geometry.IntSize(width=200, height=100), list_canvas_item.canvas_size) + mime_data = ui.create_mime_data() + mime_data.set_data_as_string(ListViewDragHandler.MIME_TYPE, "a") + self.assertTrue(list_canvas_item.wants_drag_event(mime_data, 10, 50)) + self.assertEqual(0, self._drop(list_canvas_item, ui, "a", 50)) + self.assertEqual(["a"], list(handler.right_model.items)) + + def test_list_view_drop_lands_under_the_mouse_when_the_list_is_scrolled(self) -> None: + # tests that a drop on a list which is scrolled lands at the gap under the mouse rather than at the gap the + # same distance down the room the list is shown in. the drop arrives in the coordinates of that room, so the + # two differ by however far the list has been scrolled. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewDragHandler(ui, ["a"], [f"x{index}" for index in range(10)]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.right_list_view) + canvas_widget = list_view._canvas_widget + canvas_size = Geometry.IntSize(width=200, height=100) + canvas_widget.canvas_item.update_layout(Geometry.IntPoint(), canvas_size) + # ten rows of twenty in a hundred of room, scrolled down by three rows. + scroll_area = typing.cast(CanvasItem.ScrollAreaCanvasItem, list_view._list_canvas_item.container) + scroll_area.update_content_origin(Geometry.IntPoint(y=-60, x=0)) + canvas_widget.canvas_item.update_layout(Geometry.IntPoint(), canvas_size) + on_drag_enter = canvas_widget.on_drag_enter + on_drag_move = canvas_widget.on_drag_move + on_drop = canvas_widget.on_drop + assert callable(on_drag_enter) and callable(on_drag_move) and callable(on_drop) + mime_data = ui.create_mime_data() + mime_data.set_data_as_string(ListViewDragHandler.MIME_TYPE, "a") + on_drag_enter(mime_data) + on_drag_move(mime_data, 10, 5) + on_drop(mime_data, 10, 5) + # the top of the room shows the fourth row, so the drop goes in before it rather than at the top. + self.assertEqual([3], handler.drop_indexes) + self.assertEqual("a", handler.right_model.items[3]) + + def test_list_view_shows_where_a_drop_would_land_while_the_drag_moves(self) -> None: + # tests that the gap the drop would go into follows the drag, and that it is gone once the drag leaves, so + # that what is shown while dragging is where the item actually goes. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewDragHandler(ui, ["a"], ["x", "y", "z"]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.right_list_view) + list_canvas_item = list_view._list_canvas_item + list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + mime_data = ui.create_mime_data() + mime_data.set_data_as_string(ListViewDragHandler.MIME_TYPE, "a") + list_canvas_item.drag_enter(mime_data) + for y, insert_index in ((4, 0), (16, 1), (30, 2), (90, 3)): + list_canvas_item.drag_move(mime_data, 10, y) + self.assertEqual(insert_index, list_canvas_item._drop_index) + list_canvas_item.drag_leave() + self.assertIsNone(list_canvas_item._drop_index) + + def _drop(self, list_canvas_item: ListCanvasItem.ListCanvasItem2, ui: UserInterface.UserInterface, item: str, + y: int) -> typing.Optional[int]: + # drag the named item over the list at the given height and drop it there; report where it went in. + mime_data = ui.create_mime_data() + mime_data.set_data_as_string(ListViewDragHandler.MIME_TYPE, item) + list_canvas_item.drag_enter(mime_data) + list_canvas_item.drag_move(mime_data, 10, y) + insert_index = list_canvas_item._drop_index + list_canvas_item.drop(mime_data, 10, y) + return insert_index + + def test_list_view_drags_a_row_from_any_part_of_it(self) -> None: + # tests that a drag started anywhere in a row drags that row, the image at the start of it included. an image + # is only something to look at unless it is given something to do when it is clicked, so it leaves the mouse + # to the list displaying it rather than covering part of the row. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ImageRowListViewHandler(["a", "b", "c"]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.list_view) + canvas_widget = list_view._canvas_widget + canvas_size = Geometry.IntSize(width=200, height=100) + canvas_widget.canvas_item.update_layout(Geometry.IntPoint(), canvas_size) + # the canvas items within a row are placed as the list is drawn, so the row has to be drawn once + # before a point within it can be resolved to the canvas item displaying that part of it. + canvas_widget.canvas_item.repaint_immediate(DrawingContext.DrawingContext(), canvas_size) + modifiers = CanvasItem.KeyboardModifiers() + for x in (8, 60): # over the image at the start of the row, and over the text beside it + handler.drag_indexes = list() + on_mouse_pressed = canvas_widget.on_mouse_pressed + on_mouse_position_changed = canvas_widget.on_mouse_position_changed + on_mouse_released = canvas_widget.on_mouse_released + assert callable(on_mouse_pressed) and callable(on_mouse_position_changed) and callable(on_mouse_released) + on_mouse_pressed(x, 10, modifiers) + on_mouse_position_changed(x, 10, modifiers) + on_mouse_position_changed(x + 60, 10, modifiers) + on_mouse_released(x + 60, 10, modifiers) + self.assertEqual([0], handler.drag_indexes, f"no drag started at x={x}") + + def test_list_view_gives_an_image_of_a_whole_item(self) -> None: + # tests that the image a drag carries is the whole row, the size the list draws it, rather than any one part + # of it, and that a list with nothing drawn yet has no image of its items to give. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ImageRowListViewHandler(["a", "b", "c"]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + list_view = typing.cast(Widgets.ListViewWidget, handler.list_view) + self.assertIsNone(list_view.render_item(0)) + list_view._list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) + bitmap = list_view.render_item(1) + assert bitmap is not None + # the row spans the width of the list and is one item high, which is where it is drawn. + self.assertEqual(Geometry.IntRect(Geometry.IntPoint(y=20, x=0), Geometry.IntSize(width=200, height=20)), + list_view.item_rect(1)) + self.assertEqual(Geometry.IntSize(width=200, height=20), bitmap.computed_shape) + self.assertIsNone(list_view.render_item(3)) + self.assertIsNone(list_view.render_item(-1)) + + def test_list_view_takes_part_in_a_drop_only_when_it_says_how_to_handle_one(self) -> None: + # tests that a list with no way to handle a drop leaves the drag alone, so that it reaches whatever is drawn + # behind the list instead of stopping there. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewDragHandler(ui, ["a", "b", "c"], ["x"]) + widget = Declarative.construct_widget(ui, event_loop, handler) + with contextlib.closing(widget): + mime_data = ui.create_mime_data() + left_list_canvas_item = typing.cast(Widgets.ListViewWidget, handler.left_list_view)._list_canvas_item + right_list_canvas_item = typing.cast(Widgets.ListViewWidget, handler.right_list_view)._list_canvas_item + self.assertFalse(left_list_canvas_item.wants_drag_event(mime_data, 10, 30)) + self.assertTrue(right_list_canvas_item.wants_drag_event(mime_data, 10, 30)) + def test_list_view_reports_focus_changes(self) -> None: # tests that a list view reports gaining and losing the keyboard focus. the focus is taken by the list canvas # item inside the widget, so the column the widget wraps has no focus change of its own to report. @@ -515,11 +785,9 @@ def create_handler(self, component_id: str, **kwargs: typing.Any) -> typing.Opti with contextlib.closing(widget): list_view = typing.cast(Widgets.ListViewWidget, handler.list_view) list_canvas_item = list_view._list_canvas_item - list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) - self.assertEqual(Geometry.IntSize(width=200, height=40), list_canvas_item.canvas_size) + self.assertEqual(2, len(list_canvas_item._grid_flow_item_canvas_items)) handler.list_model.remove_item(0) - list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) - self.assertEqual(Geometry.IntSize(width=200, height=20), list_canvas_item.canvas_size) + self.assertEqual(1, len(list_canvas_item._grid_flow_item_canvas_items)) def test_list_view_allows_a_different_component_per_item(self) -> None: # tests that the item handler chooses the ui view, so items of different kinds can be displayed differently. @@ -552,8 +820,7 @@ def create_handler(self, component_id: str, item: typing.Any = None, with contextlib.closing(widget): self.assertEqual(["label-a", "button-b"], [row_handler.item for row_handler in handler.row_handlers]) list_view = typing.cast(Widgets.ListViewWidget, handler.list_view) - list_view._list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=100)) - self.assertEqual(Geometry.IntSize(width=200, height=40), list_view._list_canvas_item.canvas_size) + self.assertEqual(2, len(list_view._list_canvas_item._grid_flow_item_canvas_items)) def test_list_view_selection_style_allows_multiple_selection(self) -> None: # tests that the selection style reaches the selection; the default style allows only one item at a time. @@ -596,8 +863,6 @@ def __init__(self) -> None: list_box = typing.cast(Widgets.StringListViewWidget, handler.list_box) self.assertEqual(["Alpha", "Beta", "Gamma"], list(list_box.items)) list_canvas_item = list_box._list_canvas_item - list_canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=200, height=200)) - self.assertEqual(Geometry.IntSize(width=200, height=60), list_canvas_item.canvas_size) row_canvas_items = [typing.cast(CanvasItem.TextCanvasItem, row._canvas_item) for row in list_canvas_item._grid_flow_item_canvas_items] self.assertEqual(["Alpha", "Beta", "Gamma"], [row_canvas_item.text for row_canvas_item in row_canvas_items]) diff --git a/nionui_app/nionui_examples/ui_demo/DragAndDrop.py b/nionui_app/nionui_examples/ui_demo/DragAndDrop.py new file mode 100644 index 0000000..eb73ecc --- /dev/null +++ b/nionui_app/nionui_examples/ui_demo/DragAndDrop.py @@ -0,0 +1,174 @@ +import typing + +import numpy +import numpy.typing + +from nion.ui import Declarative +from nion.ui import UserInterface +from nion.ui import Widgets +from nion.ui import Window +from nion.utils import ListModel +from nion.utils import Model + +IconType = numpy.typing.NDArray[typing.Any] + +ColorType = typing.Tuple[int, int, int] + +# the mime type the demo describes its own items with. a drag carrying it comes from one of the lists here; one +# without it comes from somewhere else, and is taken as text. +ITEM_MIME_TYPE = "text/vnd.nion.ui-demo-item" + +TEXT_MIME_TYPE = "text/plain" + +_colors: typing.Sequence[ColorType] = ((90, 160, 230), (230, 150, 90), (140, 200, 120), (200, 120, 200)) + + +def make_icon(w: int, h: int, color: ColorType) -> IconType: + # make bitmap data for the item icon, a solid block of the given color + bitmap: IconType = numpy.zeros((h, w, 4), numpy.uint8) + bitmap[..., 0] = color[2] # blue + bitmap[..., 1] = color[1] # green + bitmap[..., 2] = color[0] # red + bitmap[..., 3] = 255 + return bitmap.view(numpy.uint32).reshape(bitmap.shape[:-1]) + + +class ListItem: + """An item of one of the lists, with a title and the color of its icon.""" + + def __init__(self, title: str, color: ColorType) -> None: + self.title = title + self.color = color + self.icon = make_icon(16, 16, color) + + +class ItemHandler(Declarative.Handler): + """Display one item of a list: its icon and its title.""" + + def __init__(self, item: ListItem) -> None: + super().__init__() + u = Declarative.DeclarativeUI() + self.title = item.title + self.icon = item.icon + self.is_selected_model = Model.PropertyModel(False) + self.ui_view = u.create_row( + u.create_image(image="@binding(icon)", width=16, height=16), + u.create_label(text="@binding(title)"), + u.create_stretch(), + spacing=8, margin=4) + + +class Handler(Declarative.Handler): + """Move items between two lists by dragging them. + + Dragging an item describes it as mime data and starts a drag; dropping puts it into the gap the drag was over, + in whichever list took the drop, so an item can be dropped before the first item, after the last one, or into a + list which has been emptied. The same drag also carries the item title as plain text, so an item can be dropped + on something outside the demo, and text dragged in from outside becomes a new item. + """ + + def __init__(self) -> None: + super().__init__() + # the page is displayed within a window; the container handler supplies it for reaching the user interface. + self.container_handler: typing.Any = None + # the left list holds more items than it has room for, so that it scrolls: a drop on a scrolled list lands at + # the gap under the mouse rather than at the gap the same distance down the room the list is shown in. + left_titles = ("Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta", "Theta") + right_titles = ("Iota", "Kappa") + self.left_model = ListModel.ListModel[ListItem]("items", items=[ListItem(title, _colors[index % len(_colors)]) + for index, title in enumerate(left_titles)]) + self.right_model = ListModel.ListModel[ListItem]("items", items=[ListItem(title, _colors[index % len(_colors)]) + for index, title in enumerate(right_titles)]) + self.left_list_view: typing.Optional[UserInterface.Widget] = None + self.right_list_view: typing.Optional[UserInterface.Widget] = None + self.status_model = Model.PropertyModel("Drag an item from one list to the other.") + + @property + def __ui(self) -> UserInterface.UserInterface: + return typing.cast(Window.Window, getattr(self.container_handler, "window")).ui + + def create_handler(self, component_id: str, item: typing.Any = None, container: typing.Any = None, + **kwargs: typing.Any) -> typing.Optional[ItemHandler]: + return ItemHandler(item) if component_id == "item" else None + + def __model_for_widget(self, widget: Declarative.UIWidget) -> ListModel.ListModel[ListItem]: + return self.left_model if widget == self.left_list_view else self.right_model + + def __find_item(self, title: str) -> typing.Optional[typing.Tuple[ListModel.ListModel[ListItem], int]]: + for model in (self.left_model, self.right_model): + for index, item in enumerate(model.items): + if item.title == title: + return model, index + return None + + def item_drag_started(self, widget: Declarative.UIWidget, index: int, x: int, y: int, + modifiers: UserInterface.KeyboardModifiers) -> bool: + item = self.__model_for_widget(widget).items[index] + mime_data = self.__ui.create_mime_data() + mime_data.set_data_as_string(ITEM_MIME_TYPE, item.title) + mime_data.set_data_as_string(TEXT_MIME_TYPE, item.title) + + def drag_finished(action: str) -> None: + self.status_model.value = f"Dragged {item.title}: {action}" + + # the whole row goes along as the thumbnail, icon and title together, so what follows the cursor looks like + # the item being dragged. the row is grabbed where it was clicked, so the point is taken relative to the row + # rather than to the list. + list_view = typing.cast(Widgets.ListViewWidget, widget) + item_rect = list_view.item_rect(index) + list_view.drag(mime_data, list_view.render_item(index), hot_spot_x=x - item_rect.left, + hot_spot_y=y - item_rect.top, drag_finished_fn=drag_finished) + self.status_model.value = f"Dragging {item.title}" + return True + + def can_drop_mime_data(self, widget: Declarative.UIWidget, mime_data: UserInterface.MimeData, action: str, + drop_index: typing.Optional[int]) -> bool: + # asked as the drag moves over the list, which is how the list knows to show the gap the drop would go into. + return mime_data.has_format(ITEM_MIME_TYPE) or mime_data.has_format(TEXT_MIME_TYPE) + + def drop_mime_data(self, widget: Declarative.UIWidget, mime_data: UserInterface.MimeData, action: str, + drop_index: typing.Optional[int]) -> str: + model = self.__model_for_widget(widget) + # the drop index is the gap the item goes into: zero before the first item, the number of items after the + # last one, and zero for a list with no items left in it at all. + index = drop_index if drop_index is not None else len(model.items) + title = mime_data.data_as_string(ITEM_MIME_TYPE if mime_data.has_format(ITEM_MIME_TYPE) else TEXT_MIME_TYPE) + if not title: + return "ignore" + source = self.__find_item(title) + if source is None: + # nothing here is named that, so the drag came from outside; make an item of the text it carried. + model.insert_item(index, ListItem(title, _colors[len(model.items) % len(_colors)])) + self.status_model.value = f"Added {title} at position {index + 1}" + return "copy" + source_model, source_index = source + item = source_model.items[source_index] + # taking the item out of the list it came from shifts the gaps after it up by one. + if source_model == model and source_index < index: + index -= 1 + source_model.remove_item(source_index) + model.insert_item(index, item) + self.status_model.value = f"Moved {title} to position {index + 1}" + return "move" + + +def construct_ui(u: Declarative.DeclarativeUI) -> Declarative.UIDescription: + # both lists drag their items out and take a drop, so an item can be moved either way between them, and moved + # within a list to reorder it. + left_list_view = u.create_list_view(items="left_model.items", item_component_id="item", item_height=24, + name="left_list_view", width=160, height=160, + on_item_drag_started="item_drag_started", + on_can_drop_mime_data="can_drop_mime_data", + on_drop_mime_data="drop_mime_data") + + right_list_view = u.create_list_view(items="right_model.items", item_component_id="item", item_height=24, + name="right_list_view", width=160, height=160, + on_item_drag_started="item_drag_started", + on_can_drop_mime_data="can_drop_mime_data", + on_drop_mime_data="drop_mime_data") + + list_row = u.create_row(left_list_view, right_list_view, u.create_stretch(), spacing=12) + + status_label = u.create_label(text="@binding(status_model.value)") + + return u.create_column(list_row, status_label, spacing=8) diff --git a/nionui_app/nionui_examples/ui_demo/ToolTips.py b/nionui_app/nionui_examples/ui_demo/ToolTips.py new file mode 100644 index 0000000..7aac351 --- /dev/null +++ b/nionui_app/nionui_examples/ui_demo/ToolTips.py @@ -0,0 +1,85 @@ +import typing + +from nion.ui import Declarative +from nion.ui import UserInterface +from nion.utils import ListModel +from nion.utils import Model + + +class NamedItem: + """An item displayed by its name, carrying the tool tip shown while the mouse rests on it. + + A list displays an item by its string conversion and takes its tool tip from its `tool_tip` attribute, so any + object with the two will do. + """ + + def __init__(self, name: str, tool_tip: str) -> None: + self.name = name + self.tool_tip = tool_tip + + def __str__(self) -> str: + return self.name + + +class ItemHandler(Declarative.Handler): + """Display one item of the list view: its name, and its tool tip alongside so both are visible at once.""" + + def __init__(self, item: NamedItem) -> None: + super().__init__() + u = Declarative.DeclarativeUI() + self.name = item.name + self.tool_tip_text = item.tool_tip + self.is_selected_model = Model.PropertyModel(False) + self.ui_view = u.create_row( + u.create_label(text="@binding(name)", width=80), + u.create_label(text="@binding(tool_tip_text)", color="#888"), + u.create_stretch(), + spacing=8, margin=4) + + +class Handler(Declarative.Handler): + """Show the tool tip of the item under the mouse, which each row of a list carries for itself. + + A widget tool tip belongs to the whole widget, so it says the same thing wherever the mouse rests on it. An item + tool tip belongs to one row, so it changes as the mouse moves from row to row without leaving the list. + """ + + def __init__(self) -> None: + super().__init__() + items = [NamedItem("Alpha", "the first letter"), + NamedItem("Beta", "the second letter"), + NamedItem("Gamma", "the third letter"), + NamedItem("Delta", "the fourth letter")] + # the list box takes its items as a sequence; the list view follows an observable list. + self.list_box_items_model = Model.PropertyModel[typing.List[typing.Any]](list(items)) + self.list_view_items_model = ListModel.ListModel[NamedItem]("items", items=list(items)) + self.current_index_model = Model.PropertyModel(0) + self.list_view_current_index_model = Model.PropertyModel(0) + + def create_handler(self, component_id: str, item: typing.Any = None, container: typing.Any = None, + **kwargs: typing.Any) -> typing.Optional[ItemHandler]: + return ItemHandler(item) if component_id == "item" else None + + +def construct_ui(u: Declarative.DeclarativeUI) -> Declarative.UIDescription: + # rest the mouse on a row of either list: the tool tip which appears is the one that row carries, and it changes + # as the mouse moves to the next row. + list_box = u.create_list_box(items_ref="@binding(list_box_items_model.value)", + current_index="@binding(current_index_model.value)", + width=200, height=120) + + list_view = u.create_list_view(items="list_view_items_model.items", item_component_id="item", item_height=24, + current_index="@binding(list_view_current_index_model.value)", + width=260, height=120) + + lists_row = u.create_row(u.create_column(u.create_label(text="List box"), list_box, spacing=4), + u.create_column(u.create_label(text="List view"), list_view, spacing=4), + u.create_stretch(), spacing=16) + + # a widget tool tip, for contrast: it belongs to the button rather than to anything within it, so it says the + # same thing wherever the mouse rests on the button. + widget_tool_tip_row = u.create_row(u.create_push_button(text="Widget tool tip", tool_tip="the whole button"), + u.create_check_box(text="Also a widget tool tip", tool_tip="the whole check box"), + u.create_stretch(), spacing=8) + + return u.create_column(lists_row, widget_tool_tip_row, spacing=12) diff --git a/nionui_app/nionui_examples/ui_demo/main.py b/nionui_app/nionui_examples/ui_demo/main.py index d2d85a8..eaaad0b 100644 --- a/nionui_app/nionui_examples/ui_demo/main.py +++ b/nionui_app/nionui_examples/ui_demo/main.py @@ -24,6 +24,7 @@ from . import ContextMenus from . import Converters from . import Dialogs +from . import DragAndDrop from . import FocusKeyboard from . import Groups from . import Layout @@ -41,6 +42,7 @@ from . import Stacks from . import StatusBar from . import Tabs +from . import ToolTips from . import TextAreas _ = gettext.gettext @@ -82,6 +84,7 @@ def main(args: typing.Sequence[typing.Any], bootstrap_args: typing.Mapping[str, (ContextMenus, "context_menus", _("Context Menus")), (Converters, "converters", _("Converters")), (Dialogs, "dialogs", _("Dialogs")), + (DragAndDrop, "drag_and_drop", _("Drag and Drop")), (FocusKeyboard, "focus_keyboard", _("Focus and Keyboard")), (Groups, "groups", _("Groups")), (LineEdits, "line_edits", _("Line Edits")), @@ -98,6 +101,7 @@ def main(args: typing.Sequence[typing.Any], bootstrap_args: typing.Mapping[str, (Stacks, "stacks", _("Stacks")), (StatusBar, "status_bar", _("Status Bar")), (Tabs, "tabs", _("Tabs")), + (ToolTips, "tool_tips", _("Tool Tips")), (TextAreas, "text_areas", _("Text Areas")), (ComponentLayout, "component_layout", _("Component Layout")), (ComponentStack, "component_stack", _("Component Stack")),