From 448b4e45e6e1324fad960fd06caa3286aa7f01b4 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:38:57 -0700 Subject: [PATCH 01/13] Report focus changes from a canvas widget to its focus changed callback. --- nion/ui/CanvasUserInterface.py | 8 ++++++++ nion/ui/test/CanvasUserInterface_test.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 196f147..1e71282 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -716,9 +716,17 @@ def __init__(self, canvas_item: CanvasItem.AbstractCanvasItem, does_retain_focus self.__does_retain_focus = does_retain_focus self._no_focus = "no_focus" self.__window: typing.Optional[UserInterface.Window] = None + # the canvas item announces when it gains or loses focus; pass that along as the widget's own focus + # changed callback, which is what a widget reports in the Qt backend too. + self.__focus_changed_listener = canvas_item.focus_changed_event.listen(ReferenceCounting.weak_partial(WidgetBehavior.__handle_focus_changed, self)) + + def __handle_focus_changed(self) -> None: + if callable(self.on_focus_changed): + self.on_focus_changed(self.canvas_item.focused) def close(self) -> None: # close the canvas item? + self.__focus_changed_listener = typing.cast(typing.Any, None) self.on_ui_activity = None self.on_context_menu_event = None self.on_focus_changed = None diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index fc6f870..cfd79f6 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -430,6 +430,14 @@ def test_focus_lost_fires_editing_finished(self) -> None: canvas_item._set_focused(False) self.assertEqual(finished_values, ["x"]) + def test_widget_reports_focus_changes(self) -> None: + widget, canvas_item = self._make_line_edit() + focus_states: typing.List[bool] = list() + widget.on_focus_changed = focus_states.append + canvas_item._set_focused(True) + canvas_item._set_focused(False) + self.assertEqual(focus_states, [True, False]) + def test_return_pressed_fires_editing_finished_and_return_callback(self) -> None: widget, canvas_item = self._make_line_edit() finished_values: typing.List[str] = list() From b702374e89882f267f0d142f38fbf39dec58b5a4 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:38:57 -0700 Subject: [PATCH 02/13] Add a focus changed callback to the declarative user interface. --- docs/index.rst | 3 +++ nion/ui/Declarative.py | 8 ++++++-- nion/ui/test/Declarative_test.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index b6cf2e9..147f0f1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -716,6 +716,9 @@ Many widgets and layouts share common properties. These common properties are de A string value indicating the font for the widget. Bindable. ``border_color`` A string value indicating the border color for the widget. Used for debugging. Bindable. +``on_focus_changed`` + The name of a handler method called when the widget gains or loses the keyboard focus. The widget and a boolean + saying whether it is now focused are passed to the method. Resources ^^^^^^^^^ diff --git a/nion/ui/Declarative.py b/nion/ui/Declarative.py index 3e05417..8fba1ae 100755 --- a/nion/ui/Declarative.py +++ b/nion/ui/Declarative.py @@ -84,7 +84,7 @@ class DeclarativeUI: # TODO: thumbnails # TODO: display panels # TODO: periodic - # TODO: focus handler + # ----: focus handler # ----: bindings # TODO: commands # TODO: standard dialog boxes, open, save, print, confirm @@ -119,7 +119,8 @@ def __process_common_properties(self, d: typing.MutableMapping[str, typing.Any], "background_color", "border_color", "widget_id", - "style" + "style", + "on_focus_changed", ) for k in common_properties: if k in kwargs and kwargs[k] is not None: @@ -1323,6 +1324,9 @@ def connect_attributes(widget: UserInterface.Widget, d: UIDescription, handler: connect_reference_value(widget, d, handler, "border_color", finishes, value_type=str) connect_reference_value(widget, d, handler, "color", finishes, value_type=str) connect_reference_value(widget, d, handler, "font", finishes, value_type=str) + # every widget reports when it gains or loses the keyboard focus, so the callback is connected here rather + # than by each widget in turn. + connect_event(widget, widget, d, handler, "on_focus_changed", ["focused"]) widget.widget_id = d.get("widget_id", widget.widget_id) diff --git a/nion/ui/test/Declarative_test.py b/nion/ui/test/Declarative_test.py index b6fc123..df52a93 100644 --- a/nion/ui/test/Declarative_test.py +++ b/nion/ui/test/Declarative_test.py @@ -201,6 +201,34 @@ def __init__(self) -> None: with contextlib.closing(widget): self.assertIsInstance(widget, UserInterface.SplitterWidget) + def test_focus_changed_reports_the_widget_gaining_and_losing_focus(self) -> None: + # tests that on_focus_changed, available on every widget, reaches the handler method it names. + u = Declarative.DeclarativeUI() + + class Handler(Declarative.Handler): + def __init__(self) -> None: + super().__init__() + self.line_edit: typing.Optional[UserInterface.LineEditWidget] = None + self.button: typing.Optional[UserInterface.PushButtonWidget] = None + self.focus_reports: typing.List[typing.Tuple[str, bool]] = list() + self.ui_view = u.create_row(u.create_line_edit(name="line_edit", on_focus_changed="focus_changed"), + u.create_push_button(text="Button", name="button", + on_focus_changed="focus_changed")) + + def focus_changed(self, widget: UserInterface.Widget, focused: bool) -> None: + self.focus_reports.append(("line_edit" if widget == self.line_edit else "button", focused)) + + with event_loop_context() as event_loop: + handler = Handler() + widget = Declarative.construct_widget(TestUI.UserInterface(), event_loop, handler) + with contextlib.closing(widget): + assert handler.line_edit and handler.button + handler.line_edit.focused = True + handler.button.focused = True + handler.button.focused = False + self.assertEqual([("line_edit", True), ("line_edit", False), ("button", True), ("button", False)], + handler.focus_reports) + def test_update_binding_from_thread(self) -> None: # tests that setting the source model on a thread updates the ui model properly using bindable property. with event_loop_context() as event_loop: From 254ca478a775fd771b4ee34689cac1047ede12d9 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:39:26 -0700 Subject: [PATCH 03/13] Report, take, and give up the focus of a list view widget. --- nion/ui/Widgets.py | 28 +++++++++++++++-- nion/ui/test/Declarative_test.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index 430d748..8e90dea 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -809,6 +809,10 @@ def __init__(self, ui: UserInterface.UserInterface, list_model: ListModel.ListMo if v_scroll_enabled: scroll_group_canvas_item.add_canvas_item(CanvasItem.ScrollBarCanvasItem(scroll_area_canvas_item)) canvas_widget = ui.create_canvas_widget(properties=properties) + # the list canvas item takes the keyboard focus, but it can only be reached through the widget drawing it, + # so that widget has to be able to take the focus too. without this the list is skipped by the tab order + # and can be focused only by clicking it. + canvas_widget.focusable = True canvas_widget.canvas_item.add_canvas_item(scroll_group_canvas_item) column_widget.add(canvas_widget) self.__canvas_widget = canvas_widget @@ -835,9 +839,19 @@ def selection_changed() -> None: self.__selection_changed_event_listener = self.__selection.changed_event.listen(selection_changed) + # the list canvas item is what actually takes the keyboard focus within the canvas widget, so it is what + # reports the focus changing. the composite behavior wrapping the column has no focus of its own to report. + def focus_changed() -> None: + if callable(self.on_focus_changed): + self.on_focus_changed(self.__list_canvas_item.focused) + + self.__focus_changed_event_listener = self.__list_canvas_item.focus_changed_event.listen(focus_changed) + self.current_index = self.__selection.current_index def close(self) -> None: + self.__focus_changed_event_listener.close() + self.__focus_changed_event_listener = typing.cast(typing.Any, None) self.__selection_changed_event_listener.close() self.__selection_changed_event_listener = typing.cast(typing.Any, None) self.__current_index_binding_helper.close() @@ -916,13 +930,23 @@ def unbind_current_index(self) -> None: def _list_canvas_item(self) -> ListCanvasItem.ListCanvasItem2: return self.__list_canvas_item + @property + def _canvas_widget(self) -> UserInterface.CanvasWidget: + return self.__canvas_widget + @property def focused(self) -> bool: - return self.__canvas_widget.focused and self.__list_canvas_item.focused + # the list canvas item is what holds the focus; it is cleared when the widget drawing it loses the focus. + return self.__list_canvas_item.focused @focused.setter def focused(self, focused: bool) -> None: - self.__list_canvas_item.request_focus() + if focused: + self.__list_canvas_item.request_focus() + else: + # the widget drawing the list holds the keyboard focus on the list's behalf, so giving up the focus + # means giving up both: dropping the widget focus takes the focus off the list canvas item with it. + self.__canvas_widget.focused = False class StringListViewWidget(ListViewWidget): diff --git a/nion/ui/test/Declarative_test.py b/nion/ui/test/Declarative_test.py index df52a93..24ba875 100644 --- a/nion/ui/test/Declarative_test.py +++ b/nion/ui/test/Declarative_test.py @@ -107,6 +107,7 @@ def __init__(self, items: typing.Sequence[str]) -> None: self.selected_indexes: typing.List[int] = list() self.context_menu_indexes: typing.List[typing.Optional[int]] = list() self.escape_count = 0 + self.focus_reports: typing.List[bool] = 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", @@ -114,8 +115,12 @@ def __init__(self, items: typing.Sequence[str]) -> None: on_item_changed="item_changed", on_item_selected="item_selected", on_escape_pressed="escape_pressed", + on_focus_changed="focus_changed", on_item_handle_context_menu="item_context_menu") + def focus_changed(self, widget: Declarative.UIWidget, focused: bool) -> None: + self.focus_reports.append(focused) + def item_changed(self, widget: Declarative.UIWidget, current_index: typing.Optional[int]) -> None: self.changed_indexes.append(current_index) @@ -385,6 +390,55 @@ 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_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. + with event_loop_context() as event_loop: + handler = ListViewEventsHandler(["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_view._list_canvas_item._set_focused(True) + list_view._list_canvas_item._set_focused(False) + self.assertEqual([True, False], handler.focus_reports) + + def test_list_view_can_take_the_keyboard_focus(self) -> None: + # tests that the widget drawing the list can take the keyboard focus, so that the tab order reaches the list + # rather than skipping it, and that the widget then answers that it is focused. + with event_loop_context() as event_loop: + handler = ListViewEventsHandler(["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) + self.assertTrue(list_view._canvas_widget.focusable) + self.assertFalse(list_view.focused) + list_view._list_canvas_item._set_focused(True) + self.assertTrue(list_view.focused) + + def test_list_view_gives_up_the_focus(self) -> None: + # tests that clearing the focus of a list view actually clears it, rather than focusing it, and that the + # keys stop reaching it once it is cleared. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewEventsHandler(["a", "b", "c"]) + widget = Declarative.construct_widget(ui, 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_view.focused = True + self.assertTrue(list_view.focused) + list_view.focused = False + self.assertFalse(list_view.focused) + self.assertFalse(list_view._canvas_widget.focused) + # the keys go to the focused canvas item, and there is no longer one. the widget dispatches them, + # which is where a key arriving from the window enters the canvas item hierarchy. + handler.current_index_model.value = 0 + on_key_pressed = list_view._canvas_widget.on_key_pressed + assert on_key_pressed + self.assertFalse(on_key_pressed(ui.create_key_by_id("down"))) + self.assertEqual(0, handler.current_index_model.value) + def test_list_view_reports_escape_and_context_menu(self) -> None: # tests the remaining callbacks: escape is passed to the handler, and the context menu reports the item index. with event_loop_context() as event_loop: From fd0b76e44b307894be0d6a3b41a454289fec2605 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:39:58 -0700 Subject: [PATCH 04/13] Report a list view return press only for the return key, not for a double click. --- nion/ui/Widgets.py | 11 ++++++++--- nion/ui/test/Declarative_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index 8e90dea..f3f3daa 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -749,7 +749,7 @@ def key_pressed_event(self, key_event: GridFlowCanvasItem.GridFlowCanvasItemKeyP if key_event.key.is_escape: return list_view_widget._handle_escape_pressed() if key_event.key.is_enter_or_return: - return list_view_widget._handle_item_selected(key_event.item) + return list_view_widget._handle_return_pressed(key_event.item) return False def item_tool_tip(self, item: typing.Any) -> typing.Optional[str]: @@ -879,9 +879,14 @@ def __index_for_item(self, item: typing.Any) -> typing.Optional[int]: def _handle_item_selected(self, item: typing.Any) -> bool: # the item was chosen, by double click or by pressing return. index = self.__index_for_item(item) - handled = False if index is not None and callable(self.on_item_selected): - handled = bool(self.on_item_selected(index)) + return bool(self.on_item_selected(index)) + return False + + def _handle_return_pressed(self, item: typing.Any) -> bool: + # return chooses the item under the selection and is reported as the return key too. a double click chooses + # the item the same way, but is not a key press, so it is not reported as one. + handled = self._handle_item_selected(item) if callable(self.on_return_pressed): handled = bool(self.on_return_pressed()) or handled return handled diff --git a/nion/ui/test/Declarative_test.py b/nion/ui/test/Declarative_test.py index 24ba875..fd6ab8b 100644 --- a/nion/ui/test/Declarative_test.py +++ b/nion/ui/test/Declarative_test.py @@ -107,6 +107,7 @@ def __init__(self, items: typing.Sequence[str]) -> None: self.selected_indexes: typing.List[int] = list() self.context_menu_indexes: typing.List[typing.Optional[int]] = list() self.escape_count = 0 + self.return_count = 0 self.focus_reports: typing.List[bool] = 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, @@ -115,12 +116,17 @@ def __init__(self, items: typing.Sequence[str]) -> None: on_item_changed="item_changed", on_item_selected="item_selected", on_escape_pressed="escape_pressed", + on_return_pressed="return_pressed", on_focus_changed="focus_changed", on_item_handle_context_menu="item_context_menu") def focus_changed(self, widget: Declarative.UIWidget, focused: bool) -> None: self.focus_reports.append(focused) + def return_pressed(self, widget: Declarative.UIWidget) -> bool: + self.return_count += 1 + return True + def item_changed(self, widget: Declarative.UIWidget, current_index: typing.Optional[int]) -> None: self.changed_indexes.append(current_index) @@ -415,6 +421,24 @@ def test_list_view_can_take_the_keyboard_focus(self) -> None: list_view._list_canvas_item._set_focused(True) self.assertTrue(list_view.focused) + def test_list_view_reports_return_only_for_the_return_key(self) -> None: + # tests that a double click chooses an item without being reported as a return key press. both choose the + # item, but only one of them is a key. + with event_loop_context() as event_loop: + ui = TestUI.UserInterface() + handler = ListViewEventsHandler(["a", "b", "c"]) + widget = Declarative.construct_widget(ui, 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.mouse_double_clicked(10, 30, CanvasItem.KeyboardModifiers()) + self.assertEqual([1], handler.selected_indexes) + self.assertEqual(0, handler.return_count) + list_canvas_item.key_pressed(ui.create_key_by_id("return")) + self.assertEqual([1, 1], handler.selected_indexes) + self.assertEqual(1, handler.return_count) + def test_list_view_gives_up_the_focus(self) -> None: # tests that clearing the focus of a list view actually clears it, rather than focusing it, and that the # keys stop reaching it once it is cleared. From 95c7475ff5a25aa5706046fb7c40b4b9fda8be23 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:40:11 -0700 Subject: [PATCH 05/13] Focus the first focusable canvas item when a canvas widget is tabbed into. --- nion/ui/CanvasItem.py | 29 +++++++++++- nion/ui/test/CanvasItem_test.py | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/nion/ui/CanvasItem.py b/nion/ui/CanvasItem.py index 4f4b403..1be4849 100644 --- a/nion/ui/CanvasItem.py +++ b/nion/ui/CanvasItem.py @@ -989,6 +989,19 @@ def canvas_items(self) -> typing.Sequence[AbstractCanvasItem]: """ Returns a list of all canvas items in the hierarchy. """ return list() + def _first_focusable_item(self) -> typing.Optional[AbstractCanvasItem]: + """Return the first canvas item below this one which can take the focus, in display order. + + This is the item which should take the focus when the widget is given the focus without saying which item + is to have it, as happens when the user tabs into it. This item itself is not a candidate.""" + for canvas_item in self.canvas_items: + if canvas_item.focusable: + return canvas_item + focusable_item = canvas_item._first_focusable_item() + if focusable_item: + return focusable_item + return None + @property def canvas_size(self) -> typing.Optional[Geometry.IntSize]: """ Returns size of canvas_rect (external coordinates). """ @@ -4292,10 +4305,19 @@ def _set_focused_item(self, focused_item: AbstractCanvasItem | None, p: Geometry elif focused_item: focused_item.adjust_secondary_focus(p or Geometry.IntPoint(), modifiers) + def _first_focusable_item(self) -> typing.Optional[AbstractCanvasItem]: + # this item is a focus scope boundary: it takes the focus on behalf of its content and then passes it on to + # the item within, so it, and not that item, is what the container outside is to focus. the content is held + # in the wrapper rather than as a child of this item, so the search has to be forwarded to it. + return self if self.__wrapper_canvas_item._first_focusable_item() else None + def _set_focused(self, focused: bool) -> None: """Called when focus changes.""" if focused and not self.focused_item: - self._set_focused_item(self.__last_focused_item) + # as in the root canvas item, the focus can arrive without saying which item is to have it, by tabbing + # into the widget. the item focused last time is the one to return to, and the first item which can take + # the focus is the one to start with. + self._set_focused_item(self.__last_focused_item or self.__wrapper_canvas_item._first_focusable_item()) elif not focused and self.focused_item: self._set_focused_item(None) super()._set_focused(focused) @@ -4617,7 +4639,10 @@ def _set_focused_item(self, focused_item: typing.Optional[AbstractCanvasItem], p def __focus_changed(self, focused: bool) -> None: """ Called when widget focus changes. """ if focused and not self.focused_item: - self._set_focused_item(self.__last_focused_item) + # the widget can be given the focus without saying which item is to have it, by tabbing into it. the + # item focused last time is the one to return to, but a widget which has not been focused before has + # none, and leaving the focus on the widget alone would send the keys nowhere and report nothing. + self._set_focused_item(self.__last_focused_item or self._first_focusable_item()) elif not focused and self.focused_item: self._set_focused_item(None) diff --git a/nion/ui/test/CanvasItem_test.py b/nion/ui/test/CanvasItem_test.py index 9c97ddd..45038af 100644 --- a/nion/ui/test/CanvasItem_test.py +++ b/nion/ui/test/CanvasItem_test.py @@ -672,6 +672,88 @@ def test_grid_layout_within_column_layout(self) -> None: self.assertEqual(grid_canvas.canvas_items[3].canvas_origin, Geometry.IntPoint(x=160, y=240)) self.assertEqual(grid_canvas.canvas_items[3].canvas_size, Geometry.IntSize(width=160, height=240)) + def test_focusing_the_widget_focuses_the_first_focusable_item(self) -> None: + # a widget tabbed into is given the focus without being told which item is to have it. the first item which + # can take the focus should get it, so that the keys reach the content rather than stopping at the widget. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + container = CanvasItem.CanvasItemComposition() + unfocusable_item = _TestCanvasItem() + focusable_item = _TestCanvasItem() + focusable_item.focusable = True + later_focusable_item = _TestCanvasItem() + later_focusable_item.focusable = True + container.add_canvas_item(unfocusable_item) + container.add_canvas_item(focusable_item) + canvas_item.add_canvas_item(container) + canvas_item.add_canvas_item(later_focusable_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + self.assertIsNone(canvas_item.focused_item) + # give the widget the focus, the way tabbing into it does. + canvas_widget.focused = True + self.assertEqual(focusable_item, canvas_item.focused_item) + self.assertTrue(focusable_item.focused) + + def test_focusing_the_widget_returns_to_the_item_focused_last(self) -> None: + # once an item inside has held the focus, coming back to the widget returns the focus to that item rather + # than to the first one. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + first_item = _TestCanvasItem() + second_item = _TestCanvasItem() + first_item.focusable = True + second_item.focusable = True + canvas_item.add_canvas_item(first_item) + canvas_item.add_canvas_item(second_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + second_item.request_focus() + self.assertEqual(second_item, canvas_item.focused_item) + canvas_widget.focused = False + self.assertIsNone(canvas_item.focused_item) + canvas_widget.focused = True + self.assertEqual(second_item, canvas_item.focused_item) + + def test_focusing_the_widget_focuses_through_a_threaded_canvas_item(self) -> None: + # a threaded canvas item is a focus scope boundary: it holds the focus on behalf of its content and passes + # it on to the item within. tabbing into the widget should reach that item rather than stopping short. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + content_item = _TestCanvasItem() + content_item.focusable = True + threaded_canvas_item = CanvasItem.ThreadedCanvasItem(content_item) + canvas_item.add_canvas_item(threaded_canvas_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + canvas_widget.focused = True + # the boundary is what the container outside focuses; the item within is what ends up focused. + self.assertEqual(threaded_canvas_item, canvas_item.focused_item) + self.assertEqual(content_item, threaded_canvas_item.focused_item) + self.assertTrue(content_item.focused) + + def test_a_threaded_canvas_item_with_nothing_focusable_does_not_take_the_focus(self) -> None: + # a scope with nothing inside it which can take the focus should be passed over, not given a focus it has + # nowhere to put. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + threaded_canvas_item = CanvasItem.ThreadedCanvasItem(_TestCanvasItem()) + focusable_item = _TestCanvasItem() + focusable_item.focusable = True + canvas_item.add_canvas_item(threaded_canvas_item) + canvas_item.add_canvas_item(focusable_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + canvas_widget.focused = True + self.assertEqual(focusable_item, canvas_item.focused_item) + def test_focus_changed_messages_sent_when_focus_changes(self) -> None: # setup canvas ui = TestUI.UserInterface() From 90d9be20cc77476955377ed90a16dc741007e313 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:40:16 -0700 Subject: [PATCH 06/13] Add a focus and keyboard demo page to the ui demo application. --- .../nionui_examples/ui_demo/FocusKeyboard.py | 237 ++++++++++++++++++ nionui_app/nionui_examples/ui_demo/main.py | 2 + 2 files changed, 239 insertions(+) create mode 100644 nionui_app/nionui_examples/ui_demo/FocusKeyboard.py diff --git a/nionui_app/nionui_examples/ui_demo/FocusKeyboard.py b/nionui_app/nionui_examples/ui_demo/FocusKeyboard.py new file mode 100644 index 0000000..ead8092 --- /dev/null +++ b/nionui_app/nionui_examples/ui_demo/FocusKeyboard.py @@ -0,0 +1,237 @@ +"""A page exercising the keyboard focus: which widget has it, how it moves, and where the keys go. + +The goals of this page are: + +1. Report which widget has the keyboard focus, as announced by the widget itself. Every focusable widget on the + page reports gaining and losing the focus, and the "Focused" label names whichever one currently claims it. +2. Report which widget answers that it is focused when it is asked, rather than waiting to be told. The "Poll + Focus" button asks each widget in turn and the "Polled" label lists the answers. The two labels are meant to + agree; where they disagree, a widget is announcing one thing and answering another. +3. Move the focus from outside the widget receiving it. The "Focus Name" and "Focus List" buttons set the focus + the way a command or a validation failure would, rather than the user clicking the widget. +4. Give up the focus. "Clear Focus" unfocuses whatever holds the focus, leaving nothing focused. +5. Show the tab order. Tab should visit the two fields, the two buttons, and the list, in the order they appear + on the page, and shift-tab should walk back. +6. Show where the keys go. Each key reaches only the widget which has the focus, and the "Last key" label names + the key just pressed and the widget it arrived at. Only a key press appears there: choosing an item of the list + by double clicking it is reported as a choice, on the status line, because it is not a key. The keys are passed + on afterwards, so typing still fills in the field it was typed into. + + A field reports every key it receives. The list reports only the keys it does not act on itself, which is why + return appears on the "Last key" label but the arrow keys do not: the list consumes those to move its selection, + which the status line reports instead. + +The widgets are chosen to cover the three ways a widget takes the focus: a line edit, which takes it and keeps it +while editing; a push button, which takes it to be pressed by the keyboard; and a list view, which takes it on +behalf of the canvas items displaying its rows. +""" +from __future__ import annotations + +import typing + +from nion.ui import Declarative +from nion.ui import UserInterface +from nion.ui import Widgets +from nion.utils import ListModel +from nion.utils import Model + + +class Item: + """An item of the list, displayed as one row of the list view.""" + + def __init__(self, title: str) -> None: + self.title = title + + def __str__(self) -> str: + return self.title + + +class RowHandler(Declarative.Handler): + """Display one item of the list.""" + + def __init__(self, item: Item) -> None: + super().__init__() + u = Declarative.DeclarativeUI() + self.title = item.title + self.ui_view = u.create_row(u.create_label(text="@binding(title)"), u.create_stretch(), margin=4) + + +class Handler(Declarative.Handler): + + def __init__(self) -> None: + super().__init__() + # the page is displayed within a window; the container handler supplies it. + self.container_handler: typing.Any = None + self.name_line_edit: typing.Optional[UserInterface.LineEditWidget] = None + self.color_line_edit: typing.Optional[UserInterface.LineEditWidget] = None + self.first_button: typing.Optional[UserInterface.PushButtonWidget] = None + self.second_button: typing.Optional[UserInterface.PushButtonWidget] = None + self.list_view: typing.Optional[Widgets.ListViewWidget] = None + self.items_model = ListModel.ListModel[Item]("items", items=[Item("Alpha"), Item("Beta"), Item("Gamma")]) + self.current_index_model = Model.PropertyModel(0) + self.focus_model = Model.PropertyModel("Focused: nothing") + self.polled_model = Model.PropertyModel("Polled: (not yet polled)") + self.last_key_model = Model.PropertyModel("Last key: (none yet)") + self.status_model = Model.PropertyModel("") + self.__focused_title: typing.Optional[str] = None + + def create_handler(self, component_id: str, item: typing.Any = None, container: typing.Any = None, + **kwargs: typing.Any) -> typing.Optional[RowHandler]: + return RowHandler(item) if component_id == "row" else None + + @property + def __focusable_widgets(self) -> typing.Sequence[typing.Tuple[str, typing.Optional[UserInterface.Widget]]]: + # the widgets which can take the focus, in the order they appear on the page, which is the order tab is + # expected to visit them in. + return (("Name", self.name_line_edit), ("Color", self.color_line_edit), ("First", self.first_button), + ("Second", self.second_button), ("List", self.list_view)) + + def __title_of(self, widget: UserInterface.Widget) -> str: + for title, focusable_widget in self.__focusable_widgets: + if focusable_widget is widget: + return title + return "an unnamed widget" + + def __widget_titled(self, title: str) -> typing.Optional[UserInterface.Widget]: + for focusable_title, focusable_widget in self.__focusable_widgets: + if focusable_title == title: + return focusable_widget + return None + + def __report_key(self, title: str, key_name: str) -> None: + # the last key only: a running log of keys says nothing the last one does not, and reads as noise next to + # the focus labels it is meant to be compared against. + self.last_key_model.value = f"Last key: {key_name} → {title}" + + # goals 1 and 6: the widget announces the focus arriving and leaving. + + def focus_changed(self, widget: UserInterface.Widget, focused: bool) -> None: + # a widget losing the focus reports separately from the next one gaining it, and in no guaranteed order, so + # only the widget which claimed the focus may report giving it up. + title = self.__title_of(widget) + if focused: + self.__focused_title = title + elif self.__focused_title == title: + self.__focused_title = None + self.focus_model.value = f"Focused: {self.__focused_title or 'nothing'}" + + # goal 2: the widget answers whether it is focused when asked. + + def poll_focus(self, widget: UserInterface.PushButtonWidget) -> None: + titles = [title for title, w in self.__focusable_widgets if w and w.focused] + self.polled_model.value = "Polled: " + (", ".join(titles) if titles else "nothing") + + # goals 3 and 4: the focus is moved and cleared from outside the widget holding it. + + def focus_name(self, widget: UserInterface.PushButtonWidget) -> None: + self.__request_focus("Name") + + def focus_list(self, widget: UserInterface.PushButtonWidget) -> None: + self.__request_focus("List") + + def clear_focus(self, widget: UserInterface.PushButtonWidget) -> None: + for title, focusable_widget in self.__focusable_widgets: + if focusable_widget and focusable_widget.focused: + focusable_widget.focused = False + self.status_model.value = "Cleared the focus" + + def __request_focus(self, title: str) -> None: + widget = self.__widget_titled(title) + if widget: + widget.focused = True + self.status_model.value = f"Asked {title} for the focus" + + # goal 5: the keys arrive at the widget holding the focus. + + def key_pressed(self, widget: UserInterface.Widget, key: UserInterface.Key) -> bool: + # returning False passes the key on to the widget itself, so a character still ends up in the field it was + # typed into and tab still moves the focus. + self.__report_key(self.__title_of(widget), describe_key(key)) + return False + + def return_pressed(self, widget: UserInterface.Widget) -> bool: + # the list view reports return on its own rather than through a key handler. + self.__report_key(self.__title_of(widget), "Return") + return False + + def item_selected(self, widget: UserInterface.Widget, current_index: int) -> bool: + # an item chosen by double clicking it, which is not a key press and so is not reported as one. + items = self.items_model.items + title = items[current_index].title if 0 <= current_index < len(items) else None + self.status_model.value = f"Chose {title}" + return False + + def item_changed(self, widget: UserInterface.Widget, current_index: typing.Optional[int]) -> None: + # the arrow keys reaching the focused list show up as its selection changing. + items = self.items_model.items + title = items[current_index].title if current_index is not None and 0 <= current_index < len(items) else None + self.status_model.value = f"List selection: {title}" + + def button_clicked(self, widget: UserInterface.PushButtonWidget) -> None: + self.status_model.value = f"Clicked {self.__title_of(widget)}" + + +def describe_key(key: UserInterface.Key) -> str: + """Name a key the way a menu would, so that the key log reads as what was typed.""" + named_keys = (("Tab", key.is_tab), ("Backtab", key.is_backtab), ("Return", key.is_enter_or_return), + ("Escape", key.is_escape), ("Backspace", key.is_backspace), ("Delete", key.is_delete), + ("Up", key.is_up_arrow), ("Down", key.is_down_arrow), ("Left", key.is_left_arrow), + ("Right", key.is_right_arrow)) + name = next((named_key for named_key, is_key in named_keys if is_key), None) + if name is None: + if key.text == " ": + name = "Space" + elif key.text and key.text.isprintable(): + name = key.text + else: + name = f"#{key.key}" + modifiers = key.modifiers + prefixes = [prefix for prefix, is_down in (("shift", modifiers.shift), ("control", modifiers.control), + ("alt", modifiers.alt), ("meta", modifiers.meta)) if is_down] + return "+".join(prefixes + [name]) + + +def construct_ui(u: Declarative.DeclarativeUI) -> Declarative.UIDescription: + # the focusable widgets appear in the order tab should visit them in. + + # a line edit takes the focus and keeps it while the text is edited. + name_line_edit = u.create_line_edit(placeholder_text="Name", name="name_line_edit", width=120, + on_focus_changed="focus_changed", on_key_pressed="key_pressed") + color_line_edit = u.create_line_edit(placeholder_text="Color", name="color_line_edit", width=120, + on_focus_changed="focus_changed", on_key_pressed="key_pressed") + field_row = u.create_row(name_line_edit, color_line_edit, u.create_stretch(), spacing=8) + + # a push button takes the focus so that it can be pressed by the keyboard. + first_button = u.create_push_button(text="First", name="first_button", on_clicked="button_clicked", + on_focus_changed="focus_changed") + second_button = u.create_push_button(text="Second", name="second_button", on_clicked="button_clicked", + on_focus_changed="focus_changed") + button_row = u.create_row(first_button, second_button, u.create_stretch(), spacing=8) + + # a list view takes the focus on behalf of the canvas items displaying its rows; the arrow keys then move its + # selection and return chooses an item. + list_view = u.create_list_view(items="items_model.items", item_component_id="row", item_height=24, + name="list_view", current_index="@binding(current_index_model.value)", + on_item_changed="item_changed", on_item_selected="item_selected", + on_return_pressed="return_pressed", + on_focus_changed="focus_changed", width=200, height=90) + + # the buttons which move the focus are deliberately not focusable targets themselves in the list above: they + # stand in for a command moving the focus somewhere the user is not currently working. + focus_row = u.create_row(u.create_push_button(text="Focus Name", on_clicked="focus_name"), + u.create_push_button(text="Focus List", on_clicked="focus_list"), + u.create_push_button(text="Clear Focus", on_clicked="clear_focus"), + u.create_push_button(text="Poll Focus", on_clicked="poll_focus"), + u.create_stretch(), spacing=8) + + return u.create_column( + u.create_label(text="Press tab to move the focus; type to see where the keys go."), + field_row, + button_row, + list_view, + focus_row, + u.create_label(text="@binding(focus_model.value)"), + u.create_label(text="@binding(polled_model.value)"), + u.create_label(text="@binding(last_key_model.value)"), + u.create_label(text="@binding(status_model.value)"), + spacing=8) diff --git a/nionui_app/nionui_examples/ui_demo/main.py b/nionui_app/nionui_examples/ui_demo/main.py index 56d5726..d2d85a8 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 FocusKeyboard from . import Groups from . import Layout from . import LineEdits @@ -81,6 +82,7 @@ def main(args: typing.Sequence[typing.Any], bootstrap_args: typing.Mapping[str, (ContextMenus, "context_menus", _("Context Menus")), (Converters, "converters", _("Converters")), (Dialogs, "dialogs", _("Dialogs")), + (FocusKeyboard, "focus_keyboard", _("Focus and Keyboard")), (Groups, "groups", _("Groups")), (LineEdits, "line_edits", _("Line Edits")), (ListBoxes, "list_boxes", _("List Boxes")), From d3816eb090d20cfed22196b5120ae1b23c8e1258 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:57:32 -0700 Subject: [PATCH 07/13] Make the tab key move the focus through a canvas item hierarchy. --- nion/ui/CanvasItem.py | 90 +++++++++++++++--- nion/ui/test/CanvasItem_test.py | 156 ++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 13 deletions(-) diff --git a/nion/ui/CanvasItem.py b/nion/ui/CanvasItem.py index 1be4849..ad274ed 100644 --- a/nion/ui/CanvasItem.py +++ b/nion/ui/CanvasItem.py @@ -989,18 +989,29 @@ def canvas_items(self) -> typing.Sequence[AbstractCanvasItem]: """ Returns a list of all canvas items in the hierarchy. """ return list() + def _focus_chain(self) -> typing.Sequence[AbstractCanvasItem]: + """Return the canvas items below this one which can take the focus, in display order. + + This is the order the focus walks in when the user moves it from one item to the next with the tab key. + This item itself is not a candidate. + + An item which can take the focus stands for everything below it: the focus goes to the item itself and the + walk does not descend into it.""" + focus_chain: typing.List[AbstractCanvasItem] = list() + for canvas_item in self.canvas_items: + if canvas_item.focusable: + focus_chain.append(canvas_item) + else: + focus_chain.extend(canvas_item._focus_chain()) + return focus_chain + def _first_focusable_item(self) -> typing.Optional[AbstractCanvasItem]: """Return the first canvas item below this one which can take the focus, in display order. This is the item which should take the focus when the widget is given the focus without saying which item is to have it, as happens when the user tabs into it. This item itself is not a candidate.""" - for canvas_item in self.canvas_items: - if canvas_item.focusable: - return canvas_item - focusable_item = canvas_item._first_focusable_item() - if focusable_item: - return focusable_item - return None + focus_chain = self._focus_chain() + return focus_chain[0] if focus_chain else None @property def canvas_size(self) -> typing.Optional[Geometry.IntSize]: @@ -3830,6 +3841,30 @@ def mouse_position_changed(self, x: int, y: int, modifiers: UserInterface.Keyboa return super().mouse_position_changed(x, y, modifiers) +def next_focus_chain_item(focus_chain: typing.Sequence[AbstractCanvasItem], + focused_item: typing.Optional[AbstractCanvasItem], + backwards: bool, wraps: bool) -> typing.Optional[AbstractCanvasItem]: + """Return the item of the focus chain which follows the focused item, or precedes it when walking backwards. + + When nothing in the chain is focused, the focus starts at the first item of the chain, or at the last one when + walking backwards. + + Returns None when the focus is already at the end of the chain being walked towards and the chain does not come + back around to its other end, which means the focus has nowhere to go within this chain and whatever contains it + should be given the chance to move it instead. + """ + if not focus_chain: + return None + if focused_item not in focus_chain: + return focus_chain[-1] if backwards else focus_chain[0] + index = focus_chain.index(focused_item) + (-1 if backwards else 1) + if wraps: + index = index % len(focus_chain) + elif not 0 <= index < len(focus_chain): + return None + return focus_chain[index] + + class CanvasWidgetSection: def draw(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: Geometry.IntRect) -> None: @@ -4305,11 +4340,11 @@ def _set_focused_item(self, focused_item: AbstractCanvasItem | None, p: Geometry elif focused_item: focused_item.adjust_secondary_focus(p or Geometry.IntPoint(), modifiers) - def _first_focusable_item(self) -> typing.Optional[AbstractCanvasItem]: + def _focus_chain(self) -> typing.Sequence[AbstractCanvasItem]: # this item is a focus scope boundary: it takes the focus on behalf of its content and then passes it on to - # the item within, so it, and not that item, is what the container outside is to focus. the content is held - # in the wrapper rather than as a child of this item, so the search has to be forwarded to it. - return self if self.__wrapper_canvas_item._first_focusable_item() else None + # the item within, so it, and not that item, is the single stop the chain outside sees. walking its content + # is its own business, and the content is held in the wrapper rather than as a child of this item. + return [self] if self.__wrapper_canvas_item._focus_chain() else list() def _set_focused(self, focused: bool) -> None: """Called when focus changes.""" @@ -4328,7 +4363,19 @@ def __key_pressed(self, key: UserInterface.Key) -> bool: # save the key pressed item and key so that we can release the key if focus changes while the key is down self.__key_pressed_item = focused_item self.__key_pressed_key = key - return self.__key_pressed_item.key_pressed(key) + if self.__key_pressed_item.key_pressed(key): + return True + # the content of this item is a focus chain of its own; the focus leaves it by leaving the key unhandled, + # which lets the chain containing this item move the focus past it. + return self.__move_focus(key) + + def __move_focus(self, key: UserInterface.Key) -> bool: + if key.is_tab or key.is_backtab: + next_focused_item = next_focus_chain_item(self.__wrapper_canvas_item._focus_chain(), self.focused_item, + key.is_backtab, False) + if next_focused_item: + self._set_focused_item(next_focused_item) + return True return False def __key_released(self, key: UserInterface.Key) -> bool: @@ -4469,6 +4516,10 @@ def __init__(self, canvas_widget: UserInterface.CanvasWidget, **kwargs: typing.A setattr(self.__canvas_widget, "_root_canvas_item", weakref.ref(self)) # for debugging self.__drawing_context_updated = False self.__interaction_count = 0 + # whether the focus comes back around to the first item of this hierarchy when it moves past the last one. + # a canvas widget drawing only part of a window hands the focus on to whatever is drawn beside it, by + # leaving the key unhandled; one drawing an entire window has nothing to hand it to and so comes around. + self.focus_chain_wraps = False self.__focused_item: typing.Optional[AbstractCanvasItem] = None self.__last_focused_item: typing.Optional[AbstractCanvasItem] = None self.__key_pressed_item: typing.Optional[AbstractCanvasItem] = None @@ -4859,7 +4910,20 @@ def __key_pressed(self, key: UserInterface.Key) -> bool: # save the key pressed item and key so that we can release the key if focus changes while the key is down self.__key_pressed_item = focused_item self.__key_pressed_key = key - return self.__key_pressed_item.key_pressed(key) + if self.__key_pressed_item.key_pressed(key): + return True + return self.__move_focus(key) + + def __move_focus(self, key: UserInterface.Key) -> bool: + # the tab key moves the focus to the next item of the focus chain and backtab to the previous one. only a + # key the focused item did not use itself moves the focus, so an item which uses tab for its own purposes + # keeps it. + if key.is_tab or key.is_backtab: + next_focused_item = next_focus_chain_item(self._focus_chain(), self.focused_item, key.is_backtab, + self.focus_chain_wraps) + if next_focused_item: + self._set_focused_item(next_focused_item) + return True return False def __key_released(self, key: UserInterface.Key) -> bool: diff --git a/nion/ui/test/CanvasItem_test.py b/nion/ui/test/CanvasItem_test.py index 45038af..22037db 100644 --- a/nion/ui/test/CanvasItem_test.py +++ b/nion/ui/test/CanvasItem_test.py @@ -132,6 +132,32 @@ def _get_composer(self, composer_cache: CanvasItem.ComposerCache) -> typing.Opti return _TestCanvasItemComposer(self, self.layout_sizing, composer_cache) +class _FocusableCanvasItem(_TestCanvasItem): + """A canvas item which can take the focus but does not act on the keys it receives.""" + + def __init__(self) -> None: + super().__init__() + self.focusable = True + + def key_pressed(self, key: UserInterface.Key) -> bool: + self.key = key + return False + + +def _tab_key() -> UserInterface.Key: + return TestUI.Key(str(), "tab", CanvasItem.KeyboardModifiers()) + + +def _backtab_key() -> UserInterface.Key: + return TestUI.Key(str(), "backtab", CanvasItem.KeyboardModifiers(shift=True)) + + +def _send_key(canvas_widget: UserInterface.CanvasWidget, key: UserInterface.Key) -> bool: + on_key_pressed = canvas_widget.on_key_pressed + assert callable(on_key_pressed) + return on_key_pressed(key) + + class TestCanvasItemClass(unittest.TestCase): def setUp(self) -> None: @@ -754,6 +780,136 @@ def test_a_threaded_canvas_item_with_nothing_focusable_does_not_take_the_focus(s canvas_widget.focused = True self.assertEqual(focusable_item, canvas_item.focused_item) + def test_tab_moves_the_focus_to_the_next_item_and_backtab_to_the_previous_one(self) -> None: + # the tab key walks the focus through the items which can take it, in the order they appear, and backtab + # walks back. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + first_item = _FocusableCanvasItem() + # an item nested in a plain composition is still part of the walk; the composition is not. + container = CanvasItem.CanvasItemComposition() + second_item = _FocusableCanvasItem() + container.add_canvas_item(_TestCanvasItem()) + container.add_canvas_item(second_item) + third_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(first_item) + canvas_item.add_canvas_item(container) + canvas_item.add_canvas_item(third_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + canvas_widget.focused = True + self.assertEqual(first_item, canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(second_item, canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(third_item, canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _backtab_key())) + self.assertEqual(second_item, canvas_item.focused_item) + + def test_tab_past_the_last_item_leaves_the_key_unhandled_and_the_focus_alone(self) -> None: + # the content of a canvas widget is only part of a window, so the focus moving past its last item is for + # whatever is drawn beside it to handle; the key is left unhandled to say so. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + first_item = _FocusableCanvasItem() + last_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(first_item) + canvas_item.add_canvas_item(last_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + last_item.request_focus() + self.assertFalse(_send_key(canvas_widget, _tab_key())) + self.assertEqual(last_item, canvas_item.focused_item) + first_item.request_focus() + self.assertFalse(_send_key(canvas_widget, _backtab_key())) + self.assertEqual(first_item, canvas_item.focused_item) + + def test_tab_comes_back_around_when_the_hierarchy_holds_everything_which_can_be_focused(self) -> None: + # a canvas widget drawing an entire window has nowhere to hand the focus on to, so it comes back around to + # its first item instead of stopping at its last one. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + typing.cast(CanvasItem.RootCanvasItem, canvas_item).focus_chain_wraps = True + first_item = _FocusableCanvasItem() + last_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(first_item) + canvas_item.add_canvas_item(last_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + last_item.request_focus() + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(first_item, canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _backtab_key())) + self.assertEqual(last_item, canvas_item.focused_item) + + def test_tab_resumes_at_the_first_item_when_nothing_is_focused(self) -> None: + # after the focus has been given up, tab starts the walk over rather than having nowhere to start from. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + first_item = _FocusableCanvasItem() + last_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(first_item) + canvas_item.add_canvas_item(last_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + last_item.request_focus() + last_item.clear_focus() + self.assertIsNone(canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(first_item, canvas_item.focused_item) + + def test_an_item_which_uses_tab_itself_keeps_the_focus(self) -> None: + # only a key the focused item did not use moves the focus, so an item which acts on tab is not walked past. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + # a _TestCanvasItem acts on every key it receives, tab included. + tab_handling_item = _TestCanvasItem() + tab_handling_item.focusable = True + other_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(tab_handling_item) + canvas_item.add_canvas_item(other_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + canvas_widget.focused = True + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(tab_handling_item, canvas_item.focused_item) + + def test_tab_walks_the_content_of_a_threaded_canvas_item_before_leaving_it(self) -> None: + # a threaded canvas item is a focus scope of its own: tab walks the items within it, and only once past the + # last of them does the focus leave it for the item beside it. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + content = CanvasItem.CanvasItemComposition() + first_content_item = _FocusableCanvasItem() + second_content_item = _FocusableCanvasItem() + content.add_canvas_item(first_content_item) + content.add_canvas_item(second_content_item) + threaded_canvas_item = CanvasItem.ThreadedCanvasItem(content) + outside_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(threaded_canvas_item) + canvas_item.add_canvas_item(outside_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + canvas_widget.focused = True + self.assertEqual(first_content_item, threaded_canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(second_content_item, threaded_canvas_item.focused_item) + self.assertEqual(threaded_canvas_item, canvas_item.focused_item) + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(outside_item, canvas_item.focused_item) + def test_focus_changed_messages_sent_when_focus_changes(self) -> None: # setup canvas ui = TestUI.UserInterface() From 12ea8d7a8ecb3f48644aed4bd069f10b2bf90e83 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:59:33 -0700 Subject: [PATCH 08/13] Let a canvas widget hold the focus on behalf of the canvas items drawn in it. --- nion/ui/CanvasUserInterface.py | 55 +++++++++- nion/ui/test/CanvasUserInterface_test.py | 123 +++++++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 1e71282..3b50c36 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -2518,17 +2518,39 @@ def pressed(self) -> bool: class CanvasWidgetCanvasItem(CanvasItem.CanvasWidgetCanvasItem): + """The canvas item drawing the content of a canvas widget. + + The content is drawn as part of the canvas item hierarchy of the window containing the widget rather than in a + hierarchy of its own, so this item is a boundary within that hierarchy rather than the root of a new one: the + focus reaches the items drawn here only through the widget, which holds it on their behalf. + """ + + def __init__(self, canvas_widget: UserInterface.CanvasWidget) -> None: + super().__init__() + self.__canvas_widget = canvas_widget @property def canvas_widget(self) -> UserInterface.CanvasWidget: - # TODO - raise NotImplementedError() + return self.__canvas_widget @property def focused_item(self) -> typing.Optional[CanvasItem.AbstractCanvasItem]: - # TODO + # the widget has no focus of its own; whichever item drawn here holds the focus holds it for the widget. + base_container = typing.cast(typing.Any, self._base_container) + focused_item = typing.cast(typing.Optional[CanvasItem.AbstractCanvasItem], + base_container.focused_item if base_container else None) + canvas_item = focused_item + while canvas_item: + if canvas_item is self: + return focused_item + canvas_item = canvas_item.container return None + def _focus_chain(self) -> typing.Sequence[CanvasItem.AbstractCanvasItem]: + # the items drawn here can be reached only through the widget drawing them, so they take part in the focus + # chain only when that widget is able to take the focus itself. + return super()._focus_chain() if self.__canvas_widget.focusable else list() + def size_changed(self, width: int, height: int) -> None: pass # TODO @@ -2546,6 +2568,8 @@ def __init__(self, properties: typing.Optional[typing.Mapping[str, typing.Any]], super().__init__(self.__canvas_item, False, properties) self.__get_font_metrics_fn = get_font_metrics_fn self.__focusable = False + # the canvas item drawing the content of this widget; set when the widget creates and attaches it. + self.__content_canvas_item = typing.cast(CanvasWidgetCanvasItem, None) self.on_mouse_entered: typing.Optional[typing.Callable[[], None]] = None self.on_mouse_exited: typing.Optional[typing.Callable[[], None]] = None self.on_mouse_clicked: typing.Optional[typing.Callable[[int, int, UserInterface.KeyboardModifiers], bool]] = None @@ -2568,10 +2592,11 @@ def __init__(self, properties: typing.Optional[typing.Mapping[str, typing.Any]], def _set_canvas_item(self, canvas_item: CanvasItem.AbstractCanvasItem) -> None: self.__canvas_item.remove_all_canvas_items() self.__canvas_item.add_canvas_item(canvas_item) + self.__content_canvas_item = typing.cast(CanvasWidgetCanvasItem, canvas_item) # TODO: how does sizing work? def _create_composition_canvas_item(self, canvas_widget: UserInterface.CanvasWidget, layout_render: typing.Optional[str]) -> CanvasItem.CanvasWidgetCanvasItem: - return CanvasWidgetCanvasItem() + return CanvasWidgetCanvasItem(canvas_widget) def draw(self, drawing_context: DrawingContext.DrawingContext) -> None: pass @@ -2611,6 +2636,25 @@ def focusable(self) -> bool: def focusable(self, focusable: bool) -> None: self.__focusable = focusable + @property + def focused(self) -> bool: + # the widget has no focus of its own; it is focused when one of the canvas items drawn in it is. + return self.__content_canvas_item.focused_item is not None + + @focused.setter + def focused(self, focused: bool) -> None: + # the widget takes the focus on behalf of the canvas items drawn in it, so it passes the focus on to the + # first of them which can take it, and gives up the focus by taking it off whichever one holds it. a widget + # which cannot take the focus has none to give to its content and none to give up. + if focused: + first_focusable_item = self.__content_canvas_item._first_focusable_item() + if first_focusable_item: + first_focusable_item.request_focus() + else: + focused_item = self.__content_canvas_item.focused_item + if focused_item: + focused_item.clear_focus() + class ProgressBarWidgetBehavior(CanvasWidgetBehavior, UserInterface.ProgressBarWidgetBehavior): pass @@ -2671,6 +2715,9 @@ def _attach_root_widget(self, root_widget: typing.Optional[UserInterface.Widget] # new root canvas item, the events will be passed into the root widget # hierarchy. root_canvas_item = CanvasItem.RootCanvasItem(self.__canvas_widget) + # everything in the window which can take the focus is drawn in this one widget, so there is nothing beside + # it to hand the focus on to: the focus comes back around to the first item instead of stopping at the last. + root_canvas_item.focus_chain_wraps = True assert root_widget canvas_item = extract_canvas_item(root_widget) assert canvas_item diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index cfd79f6..959128d 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -11,6 +11,7 @@ from nion.ui import DrawingContext from nion.ui import TestUI from nion.ui import UserInterface +from nion.ui import Widgets from nion.ui import Window from nion.utils import Geometry @@ -996,5 +997,127 @@ def test_child_window_is_created_under_the_window_of_the_host(self) -> None: self.assertEqual([None, parent_document_window._root_window], host_ui.window_parents) +class TestCanvasWidgetFocus(unittest.TestCase): + """The canvas backend draws a whole window in one widget of the host, so a canvas widget within it is a + boundary in the window's canvas item hierarchy rather than a hierarchy of its own. These tests cover the focus + crossing that boundary: which items the focus walks through, and which it is kept out of.""" + + def setUp(self) -> None: + self.event_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.event_loop) + self.host_ui = TestUI.UserInterface() + self.ui = CanvasUserInterface.CanvasUserInterface(self.host_ui) + + def tearDown(self) -> None: + self.event_loop.stop() + self.event_loop.run_forever() + self.event_loop.close() + + def _make_window(self, content: UserInterface.Widget) -> typing.Tuple[CanvasUserInterface.CanvasWindow, UserInterface.CanvasWidget]: + # the canvas window wraps a window of the host user interface, so it is built on the host ui; rooting it in + # a canvas widget of the canvas ui would nest the content in a second hierarchy which nothing drives. + window = CanvasUserInterface.CanvasWindow(self.host_ui, "test") + window._attach_root_widget(content) + window.show() + host_canvas_widget = typing.cast(UserInterface.CanvasWidget, window._CanvasWindow__canvas_widget) # type: ignore[attr-defined] + host_canvas_widget.focused = True + return window, host_canvas_widget + + def _send_key(self, host_canvas_widget: UserInterface.CanvasWidget, key_name: str) -> bool: + on_key_pressed = host_canvas_widget.on_key_pressed + assert callable(on_key_pressed) + shift = key_name == "backtab" + return on_key_pressed(TestUI.Key(str(), key_name, CanvasItem.KeyboardModifiers(shift=shift))) + + def _make_canvas_widget(self, focusable: bool) -> typing.Tuple[UserInterface.CanvasWidget, CanvasItem.AbstractCanvasItem]: + canvas_widget = self.ui.create_canvas_widget() + canvas_widget.focusable = focusable + content_item = CanvasItem.CanvasItemComposition() + content_item.focusable = True + canvas_widget.canvas_item.add_canvas_item(content_item) + return canvas_widget, content_item + + def test_tab_walks_the_widgets_of_a_window_in_order_and_comes_back_around(self) -> None: + # the whole window is drawn in one widget of the host, so the focus has nothing outside to move on to when + # it reaches the last widget: it returns to the first one. + column = self.ui.create_column_widget() + first_line_edit = self.ui.create_line_edit_widget() + second_line_edit = self.ui.create_line_edit_widget() + column.add(first_line_edit) + column.add(second_line_edit) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(first_line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertTrue(second_line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertTrue(first_line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "backtab")) + self.assertTrue(second_line_edit.focused) + + def test_tab_stops_at_a_canvas_widget_which_can_take_the_focus(self) -> None: + # the canvas items drawn in a canvas widget are reached through that widget, so a widget which can take the + # focus puts its content into the walk. + column = self.ui.create_column_widget() + line_edit = self.ui.create_line_edit_widget() + canvas_widget, content_item = self._make_canvas_widget(focusable=True) + column.add(line_edit) + column.add(canvas_widget) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertTrue(content_item.focused) + self.assertTrue(canvas_widget.focused) + + def test_tab_skips_the_content_of_a_canvas_widget_which_cannot_take_the_focus(self) -> None: + # a widget which cannot take the focus cannot hold it on behalf of its content either, so the walk passes + # over everything drawn in it rather than focusing an item the user could never tab to. + column = self.ui.create_column_widget() + line_edit = self.ui.create_line_edit_widget() + canvas_widget, content_item = self._make_canvas_widget(focusable=False) + column.add(line_edit) + column.add(canvas_widget) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertFalse(content_item.focused) + self.assertTrue(line_edit.focused) + # and it cannot be given the focus by asking for it either. + canvas_widget.focused = True + self.assertFalse(content_item.focused) + + def test_a_canvas_widget_gives_up_the_focus_of_the_item_holding_it(self) -> None: + # the widget holds the focus on behalf of the item drawn in it, so giving up the widget's focus has to take + # the focus off that item; leaving it focused would send the keys to an item nothing has the focus for. + column = self.ui.create_column_widget() + canvas_widget, content_item = self._make_canvas_widget(focusable=True) + column.add(canvas_widget) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(content_item.focused) + self.assertTrue(canvas_widget.focused) + canvas_widget.focused = False + self.assertFalse(content_item.focused) + self.assertFalse(canvas_widget.focused) + + def test_the_list_view_takes_its_place_in_the_walk_and_gives_up_the_focus(self) -> None: + # the list view draws its rows in a canvas widget of its own, which is the case the boundary exists for. + column = self.ui.create_column_widget() + line_edit = self.ui.create_line_edit_widget() + list_widget = Widgets.StringListViewWidget(self.ui, items=["a", "b"], item_height=20) + column.add(line_edit) + column.add(list_widget) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertTrue(list_widget.focused) + self.assertFalse(line_edit.focused) + list_widget.focused = False + self.assertFalse(list_widget.focused) + + if __name__ == '__main__': unittest.main() From b1d0c0b55e00d0844dd17dde2d848d9822ebe6b9 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:04:55 -0700 Subject: [PATCH 09/13] Let a push button take the focus and the keyboard in the canvas backend. --- nion/ui/CanvasUserInterface.py | 25 +++++--- nion/ui/Widgets.py | 72 ++++++++++++++++++++++-- nion/ui/test/CanvasUserInterface_test.py | 50 ++++++++++++++-- nion/ui/test/Widgets_test.py | 22 ++++++++ 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 3b50c36..2046f97 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -716,13 +716,21 @@ def __init__(self, canvas_item: CanvasItem.AbstractCanvasItem, does_retain_focus self.__does_retain_focus = does_retain_focus self._no_focus = "no_focus" self.__window: typing.Optional[UserInterface.Window] = None + # the canvas item which takes the focus for the widget as a whole. a widget drawn by a single canvas item + # is that item; one drawn by several of them names the one which takes the focus for all of them. + self.__focus_canvas_item = canvas_item # the canvas item announces when it gains or loses focus; pass that along as the widget's own focus - # changed callback, which is what a widget reports in the Qt backend too. + # changed callback, which is what a widget reports whichever way it is drawn. + self.__focus_changed_listener = canvas_item.focus_changed_event.listen(ReferenceCounting.weak_partial(WidgetBehavior.__handle_focus_changed, self)) + + def _set_focus_canvas_item(self, canvas_item: CanvasItem.AbstractCanvasItem) -> None: + """Say which of the canvas items drawing this widget takes the focus for the widget as a whole.""" + self.__focus_canvas_item = canvas_item self.__focus_changed_listener = canvas_item.focus_changed_event.listen(ReferenceCounting.weak_partial(WidgetBehavior.__handle_focus_changed, self)) def __handle_focus_changed(self) -> None: if callable(self.on_focus_changed): - self.on_focus_changed(self.canvas_item.focused) + self.on_focus_changed(self.__focus_canvas_item.focused) def close(self) -> None: # close the canvas item? @@ -787,20 +795,21 @@ def _register_ui_activity(self) -> None: @property def focused(self) -> bool: - return self.canvas_item.focused + return self.__focus_canvas_item.focused @focused.setter def focused(self, focused: bool) -> None: # go through the container which tracks the focused canvas item, in both directions. setting the flag on the # canvas item alone leaves the container still thinking the item is focused, so focusing it again does # nothing, and leaves the keyboard focus of the host elsewhere, so key strokes go nowhere. - base_container = typing.cast(typing.Any, self.canvas_item._base_container) + focus_canvas_item = self.__focus_canvas_item + base_container = typing.cast(typing.Any, focus_canvas_item._base_container) if focused: - self.canvas_item.request_focus() - elif base_container and base_container.focused_item is self.canvas_item: + focus_canvas_item.request_focus() + elif base_container and base_container.focused_item is focus_canvas_item: base_container._set_focused_item(None) else: - self.canvas_item._set_focused(False) + focus_canvas_item._set_focused(False) @property def does_retain_focus(self) -> bool: @@ -2249,6 +2258,8 @@ def __init__(self, ui: CanvasUserInterface, properties: typing.Optional[typing.M self.__canvas_item_controller = widget_canvas_item_factory.create_push_button_widget_canvas_item_controller() self.__canvas_item.add_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) + # the button itself takes the focus and handles the keys; the composition around it only carries the sizing. + self._set_focus_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) self.__text: typing.Optional[str] = None self.__icon: typing.Optional[Bitmap.Bitmap] = None diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index f3f3daa..e28fea5 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -236,6 +236,65 @@ def apply_sizing_properties(canvas_item: CanvasItem.AbstractCanvasItem, properti canvas_item.update_sizing(canvas_item_sizing.with_unconstrained_height().with_preferred_height(min(max_height, preferred_height)).with_maximum_height(max_height)) +class PushButtonCanvasItem(CanvasItem.CanvasItemComposition): + """The canvas item drawing a push button. + + A button is drawn by several cells -- an icon and a text -- but it takes the focus and is pressed as a single + unit, so the focus and the keys are handled here rather than by any one of the cells. + + A button has nothing selected to show the focus on, so it shows it as a stronger border around itself. The + border it shows when it is not focused is its base border, which is what the widget drawing the button sets. + """ + + focused_border_color = "#3875D6" + focused_border_width = 1.5 + + def __init__(self) -> None: + super().__init__() + self.focusable = True + self.on_clicked: typing.Optional[typing.Callable[[], None]] = None + self.__base_border_color: typing.Optional[str] = None + self.__base_border_width: typing.Optional[float] = None + + def close(self) -> None: + self.on_clicked = None + super().close() + + @property + def base_border_color(self) -> typing.Optional[str]: + return self.__base_border_color + + @base_border_color.setter + def base_border_color(self, base_border_color: typing.Optional[str]) -> None: + self.__base_border_color = base_border_color + self.__update_border() + + @property + def base_border_width(self) -> typing.Optional[float]: + return self.__base_border_width + + @base_border_width.setter + def base_border_width(self, base_border_width: typing.Optional[float]) -> None: + self.__base_border_width = base_border_width + self.__update_border() + + def _set_focused(self, focused: bool) -> None: + super()._set_focused(focused) + self.__update_border() + + def __update_border(self) -> None: + self.border_color = self.focused_border_color if self.focused else self.__base_border_color + self.border_width = self.focused_border_width if self.focused else self.__base_border_width + + def key_pressed(self, key: UserInterface.Key) -> bool: + # the space bar and return press the button, the way clicking it does. + if self.enabled and (key.text == " " or key.is_enter_or_return): + if callable(self.on_clicked): + self.on_clicked() + return True + return super().key_pressed(key) + + class BasicPushButtonWidgetCanvasItemController(PushButtonWidgetCanvasItemController): # matches a typical native push button's default minimum width so that short-text buttons @@ -256,14 +315,15 @@ def __init__(self, ui: UserInterface.UserInterface, properties: typing.Optional[ # margins instead, applied once around the combo rather than doubled up per item. self.__text_button_canvas_item = CanvasItem.TextButtonCanvasItem(padding=Geometry.IntSize(height=4, width=0), group_controller=self.__group_controller) self.__icon_button_canvas_item = CanvasItem.BitmapButtonCanvasItem(padding=Geometry.IntSize(height=4, width=0), group_controller=self.__group_controller) - self.__stack = CanvasItem.CanvasItemComposition() + self.__stack = PushButtonCanvasItem() self.__stack.layout = CanvasItem.CanvasItemRowLayout(margins=Geometry.Margins(top=0, left=8, bottom=0, right=8)) # the "base" background is the button's normal, non-hovered appearance; it is what # set_background_color changes, and what the hover/press tint is computed relative to. self.__base_background_color: typing.Optional[typing.Union[str, DrawingContext.LinearGradient]] = "white" self.__stack.background_color = self.__base_background_color - self.__stack.border_color = "#c0c0c0" - self.__stack.border_width = 0.5 + # the base border is the button's normal, unfocused appearance; the focus draws over it. + self.__stack.base_border_color = "#c0c0c0" + self.__stack.base_border_width = 0.5 self.__stack.corner_radius = 3 # icon (if any) is shown to the left of the text (if any); both can be visible at once. self.__stack.add_canvas_item(self.__icon_button_canvas_item) @@ -292,6 +352,8 @@ def handle_style_changed(hover: bool, pressed: bool) -> None: self.__group_controller.on_clicked = handle_clicked self.__group_controller.on_style_changed = handle_style_changed + # the button can also be pressed by the keyboard, which the cells within it never see. + self.__stack.on_clicked = handle_clicked @property def widget_source(self) -> WidgetSource: @@ -351,6 +413,8 @@ def set_icon(self, bitmap: typing.Optional[Bitmap.BitmapOrArray]) -> None: def set_enabled(self, enabled: bool) -> None: self.__text_button_canvas_item.enabled = enabled self.__icon_button_canvas_item.enabled = enabled + # the button as a whole is what the keyboard presses, so it has to know whether it can be pressed. + self.__stack.enabled = enabled def set_tool_tip(self, tool_tip: typing.Optional[str]) -> None: self.__text_button_canvas_item.tool_tip = tool_tip @@ -361,7 +425,7 @@ def set_background_color(self, background_color: typing.Optional[typing.Union[st self.__stack.background_color = background_color def set_border_color(self, border_color: typing.Optional[str]) -> None: - self.__stack.border_color = border_color + self.__stack.base_border_color = border_color class TabWidgetCanvasItemController(BaseWidgetCanvasItemController): diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index 959128d..2138593 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -998,9 +998,9 @@ def test_child_window_is_created_under_the_window_of_the_host(self) -> None: class TestCanvasWidgetFocus(unittest.TestCase): - """The canvas backend draws a whole window in one widget of the host, so a canvas widget within it is a - boundary in the window's canvas item hierarchy rather than a hierarchy of its own. These tests cover the focus - crossing that boundary: which items the focus walks through, and which it is kept out of.""" + """The canvas backend draws a whole window in one hierarchy of canvas items, so a widget in it is one or more + canvas items rather than something with a focus of its own. These tests cover which of them takes the focus: + the boundary a canvas widget draws around its content, and the item a widget of several of them is focused by.""" def setUp(self) -> None: self.event_loop = asyncio.new_event_loop() @@ -1023,11 +1023,11 @@ def _make_window(self, content: UserInterface.Widget) -> typing.Tuple[CanvasUser host_canvas_widget.focused = True return window, host_canvas_widget - def _send_key(self, host_canvas_widget: UserInterface.CanvasWidget, key_name: str) -> bool: + def _send_key(self, host_canvas_widget: UserInterface.CanvasWidget, key_name: str, *, text: str = str()) -> bool: on_key_pressed = host_canvas_widget.on_key_pressed assert callable(on_key_pressed) shift = key_name == "backtab" - return on_key_pressed(TestUI.Key(str(), key_name, CanvasItem.KeyboardModifiers(shift=shift))) + return on_key_pressed(TestUI.Key(text, key_name, CanvasItem.KeyboardModifiers(shift=shift))) def _make_canvas_widget(self, focusable: bool) -> typing.Tuple[UserInterface.CanvasWidget, CanvasItem.AbstractCanvasItem]: canvas_widget = self.ui.create_canvas_widget() @@ -1102,6 +1102,46 @@ def test_a_canvas_widget_gives_up_the_focus_of_the_item_holding_it(self) -> None self.assertFalse(content_item.focused) self.assertFalse(canvas_widget.focused) + def test_a_push_button_takes_the_focus_and_reports_it(self) -> None: + # a push button is drawn by several canvas items -- an icon and a text -- but takes the focus as one, so + # that it can be reached by the keyboard and say so. + column = self.ui.create_column_widget() + line_edit = self.ui.create_line_edit_widget() + push_button = self.ui.create_push_button_widget("Press") + focus_changes: typing.List[bool] = list() + push_button.on_focus_changed = focus_changes.append + column.add(line_edit) + column.add(push_button) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(line_edit.focused) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertTrue(push_button.focused) + self.assertEqual([True], focus_changes) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertFalse(push_button.focused) + self.assertEqual([True, False], focus_changes) + + def test_the_space_bar_and_return_press_the_focused_push_button(self) -> None: + # a button which has the focus is pressed by the keyboard, the way clicking it presses it. + column = self.ui.create_column_widget() + push_button = self.ui.create_push_button_widget("Press") + clicked_count = 0 + + def handle_clicked() -> None: + nonlocal clicked_count + clicked_count += 1 + + push_button.on_clicked = handle_clicked + column.add(push_button) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(push_button.focused) + self.assertTrue(self._send_key(host_canvas_widget, "space", text=" ")) + self.assertEqual(1, clicked_count) + self.assertTrue(self._send_key(host_canvas_widget, "return")) + self.assertEqual(2, clicked_count) + def test_the_list_view_takes_its_place_in_the_walk_and_gives_up_the_focus(self) -> None: # the list view draws its rows in a canvas widget of its own, which is the case the boundary exists for. column = self.ui.create_column_widget() diff --git a/nion/ui/test/Widgets_test.py b/nion/ui/test/Widgets_test.py index b7c8d12..206ae6a 100644 --- a/nion/ui/test/Widgets_test.py +++ b/nion/ui/test/Widgets_test.py @@ -50,6 +50,28 @@ def test_add_item_to_string_list_widget_causes_container_to_relayout(self) -> No self.assertEqual(scroll_canvas_rect.height, 200) self.assertEqual(scroll_content_rect.height, 20) + def test_a_disabled_push_button_is_not_pressed_by_the_keyboard(self) -> None: + # the button reaches the keyboard through the focus, which a disabled button can still hold if it was + # disabled while focused; being disabled is what has to stop it from being pressed. + from nion.ui import Widgets + ui = TestUI.UserInterface() + controller = Widgets.BasicPushButtonWidgetCanvasItemController(ui) + controller.set_text("Press") + clicked_count = 0 + + def handle_clicked() -> None: + nonlocal clicked_count + clicked_count += 1 + + controller.on_clicked = handle_clicked + button_canvas_item = controller.widget_source.canvas_item + space_key = TestUI.Key(" ", "space", CanvasItem.KeyboardModifiers()) + self.assertTrue(button_canvas_item.key_pressed(space_key)) + self.assertEqual(1, clicked_count) + controller.set_enabled(False) + self.assertFalse(button_canvas_item.key_pressed(space_key)) + self.assertEqual(1, clicked_count) + def test_push_button_shows_both_text_and_icon_when_both_are_set(self) -> None: from nion.ui import Bitmap from nion.ui import Widgets From 83da5941c68b1deefd602b01f4c21efd8ab8c999 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:08:44 -0700 Subject: [PATCH 10/13] Let a check box and radio button take the focus and the keyboard in the canvas backend. --- nion/ui/CanvasItem.py | 35 +++++++++++++++++++-- nion/ui/CanvasUserInterface.py | 36 ++++++++++++++++----- nion/ui/Widgets.py | 12 +++---- nion/ui/test/CanvasUserInterface_test.py | 40 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/nion/ui/CanvasItem.py b/nion/ui/CanvasItem.py index ad274ed..9edf848 100644 --- a/nion/ui/CanvasItem.py +++ b/nion/ui/CanvasItem.py @@ -3476,6 +3476,25 @@ def current_index(self, index: int | None) -> None: self.update() +# the appearance of the ring which shows that a canvas item has the keyboard focus. a control with nothing +# selected to show the focus on -- a button, a check box, a slider -- shows it as a ring around itself instead. +FOCUS_RING_COLOR = "#3875D6" +FOCUS_RING_WIDTH = 1.5 + + +def draw_focus_ring(drawing_context: DrawingContext.DrawingContext, canvas_rect: Geometry.IntRect) -> None: + """Draw the ring showing that the canvas item occupying canvas_rect has the keyboard focus.""" + with drawing_context.saver(): + drawing_context.begin_path() + # inset by half the width so the ring is drawn inside the item rather than half outside it. + inset = FOCUS_RING_WIDTH / 2 + drawing_context.round_rect(canvas_rect.left + inset, canvas_rect.top + inset, + canvas_rect.width - FOCUS_RING_WIDTH, canvas_rect.height - FOCUS_RING_WIDTH, 3.0) + drawing_context.stroke_style = FOCUS_RING_COLOR + drawing_context.line_width = FOCUS_RING_WIDTH + drawing_context.stroke() + + SLIDER_THUMB_WIDTH = 8 SLIDER_THUMB_HEIGHT = 16 SLIDER_BAR_OFFSET = 1 @@ -6060,13 +6079,14 @@ def font(self, value: typing.Optional[str]) -> None: class CheckBoxCanvasItemComposer(BaseComposer): def __init__(self, canvas_item: AbstractCanvasItem, layout_sizing: Sizing, cache: ComposerCache, - check_state: str, enabled: bool, mouse_inside: bool, mouse_pressed: bool, + check_state: str, enabled: bool, mouse_inside: bool, mouse_pressed: bool, focused: bool, text: str, text_color: str, text_disabled_color: str, font: str) -> None: super().__init__(canvas_item, layout_sizing, cache) self.__check_state = check_state self.__enabled = enabled self.__mouse_inside = mouse_inside self.__mouse_pressed = mouse_pressed + self.__focused = focused self.__text = text self.__text_color = text_color self.__text_disabled_color = text_disabled_color @@ -6123,6 +6143,8 @@ def _repaint(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: drawing_context.text_baseline = 'middle' drawing_context.fill_style = text_color if enabled else text_disabled_color drawing_context.fill_text(text, tx, cy + 1) + if self.__focused: + draw_focus_ring(drawing_context, canvas_rect) class CheckBoxCanvasItem(AbstractCanvasItem): @@ -6130,6 +6152,8 @@ class CheckBoxCanvasItem(AbstractCanvasItem): def __init__(self, text: typing.Optional[str] = None) -> None: super().__init__() self.wants_mouse_events = True + # the check box is toggled by the keyboard as well as by the mouse, so it takes the keyboard focus. + self.focusable = True self.__enabled = True self.__mouse_inside = False self.__mouse_pressed = False @@ -6253,6 +6277,13 @@ def mouse_clicked(self, x: int, y: int, modifiers: UserInterface.KeyboardModifie self._toggle_checked() return True + def key_pressed(self, key: UserInterface.Key) -> bool: + # the space bar toggles the check box, the way clicking it does. + if self.enabled and key.text == " ": + self._toggle_checked() + return True + return super().key_pressed(key) + def _toggle_checked(self) -> None: if self.enabled: if self.check_state == "checked": @@ -6283,7 +6314,7 @@ def size_to_content(self, get_font_metrics_fn: typing.Callable[[str, str], UserI self.intrinsic_size = Geometry.IntSize(new_height, new_width) def _get_composer(self, composer_cache: ComposerCache) -> typing.Optional[BaseComposer]: - return CheckBoxCanvasItemComposer(self, self.layout_sizing, composer_cache, self.check_state, self.enabled, self.__mouse_inside, self.__mouse_pressed, self.__text, self.__text_color, self.__text_disabled_color, self.__font) + return CheckBoxCanvasItemComposer(self, self.layout_sizing, composer_cache, self.check_state, self.enabled, self.__mouse_inside, self.__mouse_pressed, self.focused, self.__text, self.__text_color, self.__text_disabled_color, self.__font) class EmptyCanvasItemComposer(BaseComposer): diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 2046f97..bc2ed82 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -137,13 +137,14 @@ def checked(self, value: bool) -> None: ... class RadioButtonCanvasItemComposer(CanvasItem.BaseComposer): def __init__(self, canvas_item: CanvasItem.AbstractCanvasItem, layout_sizing: CanvasItem.Sizing, cache: CanvasItem.ComposerCache, - checked: bool, enabled: bool, mouse_inside: bool, mouse_pressed: bool, + checked: bool, enabled: bool, mouse_inside: bool, mouse_pressed: bool, focused: bool, text: str, text_color: str, text_disabled_color: str, font: str) -> None: super().__init__(canvas_item, layout_sizing, cache) self.__checked = checked self.__enabled = enabled self.__mouse_inside = mouse_inside self.__mouse_pressed = mouse_pressed + self.__focused = focused self.__text = text self.__text_color = text_color self.__text_disabled_color = text_disabled_color @@ -197,6 +198,8 @@ def _repaint(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: drawing_context.text_baseline = 'middle' drawing_context.fill_style = text_color if enabled else text_disabled_color drawing_context.fill_text(text, tx, cy + 1) + if self.__focused: + CanvasItem.draw_focus_ring(drawing_context, canvas_rect) class RadioButtonCanvasItem(CanvasItem.AbstractCanvasItem): @@ -204,6 +207,8 @@ class RadioButtonCanvasItem(CanvasItem.AbstractCanvasItem): def __init__(self, text: typing.Optional[str] = None) -> None: super().__init__() self.wants_mouse_events = True + # the radio button is chosen by the keyboard as well as by the mouse, so it takes the keyboard focus. + self.focusable = True self.__enabled = True self.__mouse_inside = False self.__mouse_pressed = False @@ -302,6 +307,14 @@ def mouse_clicked(self, x: int, y: int, modifiers: UserInterface.KeyboardModifie self.on_clicked() return True + def key_pressed(self, key: UserInterface.Key) -> bool: + # the space bar chooses the radio button, the way clicking it does. + if self.enabled and key.text == " ": + if callable(self.on_clicked): + self.on_clicked() + return True + return super().key_pressed(key) + @property def _mouse_inside(self) -> bool: return self.__mouse_inside @@ -358,7 +371,7 @@ def _repaint(self, drawing_context: DrawingContext.DrawingContext) -> None: drawing_context.fill() def _get_composer(self, composer_cache: CanvasItem.ComposerCache) -> typing.Optional[CanvasItem.BaseComposer]: return RadioButtonCanvasItemComposer(self, self.layout_sizing, composer_cache, self.checked, self.enabled, - self.__mouse_inside, self.__mouse_pressed, self.__text, + self.__mouse_inside, self.__mouse_pressed, self.focused, self.__text, self.__text_color, self.__text_disabled_color, self.__font) @@ -723,10 +736,15 @@ def __init__(self, canvas_item: CanvasItem.AbstractCanvasItem, does_retain_focus # changed callback, which is what a widget reports whichever way it is drawn. self.__focus_changed_listener = canvas_item.focus_changed_event.listen(ReferenceCounting.weak_partial(WidgetBehavior.__handle_focus_changed, self)) - def _set_focus_canvas_item(self, canvas_item: CanvasItem.AbstractCanvasItem) -> None: - """Say which of the canvas items drawing this widget takes the focus for the widget as a whole.""" - self.__focus_canvas_item = canvas_item - self.__focus_changed_listener = canvas_item.focus_changed_event.listen(ReferenceCounting.weak_partial(WidgetBehavior.__handle_focus_changed, self)) + def _set_focus_canvas_item(self, canvas_item: typing.Optional[CanvasItem.AbstractCanvasItem] = None) -> None: + """Say which of the canvas items drawing this widget takes the focus for the widget as a whole. + + Without an item, it is the first one below the widget's own canvas item which can take the focus, which is + what a control wrapped in a composition carrying its sizing amounts to.""" + canvas_item = canvas_item or self.canvas_item._first_focusable_item() + if canvas_item: + self.__focus_canvas_item = canvas_item + self.__focus_changed_listener = canvas_item.focus_changed_event.listen(ReferenceCounting.weak_partial(WidgetBehavior.__handle_focus_changed, self)) def __handle_focus_changed(self) -> None: if callable(self.on_focus_changed): @@ -2259,7 +2277,7 @@ def __init__(self, ui: CanvasUserInterface, properties: typing.Optional[typing.M self.__canvas_item.add_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) # the button itself takes the focus and handles the keys; the composition around it only carries the sizing. - self._set_focus_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) + self._set_focus_canvas_item() self.__text: typing.Optional[str] = None self.__icon: typing.Optional[Bitmap.Bitmap] = None @@ -2317,6 +2335,8 @@ def __init__(self, ui: CanvasUserInterface, properties: typing.Optional[typing.M self.__canvas_item_controller = widget_canvas_item_factory.create_check_box_widget_canvas_item_controller() self.__canvas_item.add_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) + # the check box itself takes the focus and handles the keys; the composition around it only carries the sizing. + self._set_focus_canvas_item() self.on_check_state_changed: typing.Optional[typing.Callable[[str], None]] = None @@ -2367,6 +2387,8 @@ def __init__(self, ui: CanvasUserInterface, properties: typing.Optional[typing.M self.__canvas_item_controller = widget_canvas_item_factory.create_radio_button_widget_canvas_item_controller() self.__canvas_item.add_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) + # the radio button itself takes the focus and handles the keys; the composition around it only carries the sizing. + self._set_focus_canvas_item() self.on_clicked: typing.Optional[typing.Callable[[], None]] = None diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index e28fea5..6852b73 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -242,13 +242,11 @@ class PushButtonCanvasItem(CanvasItem.CanvasItemComposition): A button is drawn by several cells -- an icon and a text -- but it takes the focus and is pressed as a single unit, so the focus and the keys are handled here rather than by any one of the cells. - A button has nothing selected to show the focus on, so it shows it as a stronger border around itself. The - border it shows when it is not focused is its base border, which is what the widget drawing the button sets. + A button has nothing selected to show the focus on, so it shows it as a stronger border around itself, drawn + in the same color as the focus ring of the controls which draw one. The border it shows when it is not focused + is its base border, which is what the widget drawing the button sets. """ - focused_border_color = "#3875D6" - focused_border_width = 1.5 - def __init__(self) -> None: super().__init__() self.focusable = True @@ -283,8 +281,8 @@ def _set_focused(self, focused: bool) -> None: self.__update_border() def __update_border(self) -> None: - self.border_color = self.focused_border_color if self.focused else self.__base_border_color - self.border_width = self.focused_border_width if self.focused else self.__base_border_width + self.border_color = CanvasItem.FOCUS_RING_COLOR if self.focused else self.__base_border_color + self.border_width = CanvasItem.FOCUS_RING_WIDTH if self.focused else self.__base_border_width def key_pressed(self, key: UserInterface.Key) -> bool: # the space bar and return press the button, the way clicking it does. diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index 2138593..8019a7d 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -13,7 +13,9 @@ from nion.ui import UserInterface from nion.ui import Widgets from nion.ui import Window +from nion.utils import Binding from nion.utils import Geometry +from nion.utils import Model class TestComboBoxCanvasSizing(unittest.TestCase): @@ -1142,6 +1144,44 @@ def handle_clicked() -> None: self.assertTrue(self._send_key(host_canvas_widget, "return")) self.assertEqual(2, clicked_count) + def test_the_space_bar_toggles_the_focused_check_box(self) -> None: + # a check box which has the focus is toggled by the keyboard, the way clicking it toggles it. + column = self.ui.create_column_widget() + check_box = self.ui.create_check_box_widget("Check") + check_states: typing.List[str] = list() + check_box.on_check_state_changed = check_states.append + column.add(check_box) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(check_box.focused) + self.assertTrue(self._send_key(host_canvas_widget, "space", text=" ")) + self.assertEqual("checked", check_box.check_state) + self.assertTrue(self._send_key(host_canvas_widget, "space", text=" ")) + self.assertEqual("unchecked", check_box.check_state) + self.assertEqual(["checked", "unchecked"], check_states) + + def test_the_space_bar_chooses_the_focused_radio_button(self) -> None: + # a radio button which has the focus is chosen by the keyboard, and choosing one drops the other. + column = self.ui.create_column_widget() + first_radio_button = self.ui.create_radio_button_widget("First") + second_radio_button = self.ui.create_radio_button_widget("Second") + first_radio_button.value = 1 + second_radio_button.value = 2 + binding_model = Model.PropertyModel(1) + first_radio_button.bind_group_value(Binding.PropertyBinding(binding_model, "value")) + second_radio_button.bind_group_value(Binding.PropertyBinding(binding_model, "value")) + column.add(first_radio_button) + column.add(second_radio_button) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(first_radio_button.checked) + self.assertTrue(self._send_key(host_canvas_widget, "tab")) + self.assertTrue(second_radio_button.focused) + self.assertTrue(self._send_key(host_canvas_widget, "space", text=" ")) + self.assertEqual(2, binding_model.value) + self.assertTrue(second_radio_button.checked) + self.assertFalse(first_radio_button.checked) + def test_the_list_view_takes_its_place_in_the_walk_and_gives_up_the_focus(self) -> None: # the list view draws its rows in a canvas widget of its own, which is the case the boundary exists for. column = self.ui.create_column_widget() From a11c799b74429f8592fd10cd551bf121ce059e66 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:11:03 -0700 Subject: [PATCH 11/13] Let a combo box and slider take the focus and the keyboard in the canvas backend. --- nion/ui/CanvasItem.py | 21 ++++++++++++++-- nion/ui/CanvasUserInterface.py | 28 ++++++++++++++++++--- nion/ui/Widgets.py | 19 ++++++++------- nion/ui/test/CanvasUserInterface_test.py | 31 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/nion/ui/CanvasItem.py b/nion/ui/CanvasItem.py index 9edf848..cfcd50b 100644 --- a/nion/ui/CanvasItem.py +++ b/nion/ui/CanvasItem.py @@ -3516,9 +3516,11 @@ def get_slider_thumb_rect(canvas_size: typing.Optional[Geometry.IntSize], value: class SliderCanvasItemComposer(BaseComposer): - def __init__(self, canvas_item: AbstractCanvasItem, layout_sizing: Sizing, cache: ComposerCache, value: float) -> None: + def __init__(self, canvas_item: AbstractCanvasItem, layout_sizing: Sizing, cache: ComposerCache, value: float, + focused: bool) -> None: super().__init__(canvas_item, layout_sizing, cache) self.__value = value + self.__focused = focused def _repaint(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: Geometry.IntRect, composer_cache: ComposerCache) -> None: thumb_rect = get_slider_thumb_rect(canvas_rect.size, self.__value) @@ -3535,6 +3537,8 @@ def _repaint(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: drawing_context.rect(thumb_rect.left, thumb_rect.top, thumb_rect.width, thumb_rect.height) drawing_context.fill_style = "#007AD8" drawing_context.fill() + if self.__focused: + draw_focus_ring(drawing_context, canvas_rect) class SliderCanvasItem(AbstractCanvasItem, Observable.Observable): @@ -3542,6 +3546,8 @@ class SliderCanvasItem(AbstractCanvasItem, Observable.Observable): def __init__(self) -> None: super().__init__() self.wants_mouse_events = True + # the thumb is moved by the arrow keys as well as by the mouse, so the slider takes the keyboard focus. + self.focusable = True self.__tracking = False self.__tracking_start = Geometry.IntPoint() self.__tracking_value = 0.0 @@ -3569,7 +3575,7 @@ def value(self, value: float) -> None: def _get_composer(self, composer_cache: ComposerCache) -> typing.Optional[BaseComposer]: value = self.value if not self.__tracking else self.__tracking_value - return SliderCanvasItemComposer(self, self.layout_sizing, composer_cache, value) + return SliderCanvasItemComposer(self, self.layout_sizing, composer_cache, value, self.focused) def mouse_pressed(self, x: int, y: int, modifiers: UserInterface.KeyboardModifiers) -> bool: thumb_rect = get_slider_thumb_rect(self.canvas_size, self.value) @@ -3606,6 +3612,17 @@ def mouse_position_changed(self, x: int, y: int, modifiers: UserInterface.Keyboa self.value = value return super().mouse_position_changed(x, y, modifiers) + def key_pressed(self, key: UserInterface.Key) -> bool: + # the arrow keys move the thumb along the bar, a step at a time; which arrows depends on nothing but which + # way along the bar the thumb is to go. + if key.is_left_arrow or key.is_down_arrow: + self.__adjust_thumb(-1.0) + return True + if key.is_right_arrow or key.is_up_arrow: + self.__adjust_thumb(1.0) + return True + return super().key_pressed(key) + def __adjust_thumb(self, amount: float) -> None: self.value_change_stream.begin() self.value = max(0.0, min(1.0, self.value + amount * 0.1)) diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index bc2ed82..4157a26 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -479,6 +479,19 @@ def set_tool_tip(self, tool_tip: typing.Optional[str]) -> None: ... def set_background_color(self, background_color: typing.Optional[typing.Union[str, DrawingContext.LinearGradient]]) -> None: ... +class ComboBoxCanvasItem(Widgets.ControlCanvasItem): + """The canvas item drawing a combo box: its text and its triangle, which behave as one control.""" + + def key_pressed(self, key: UserInterface.Key) -> bool: + # the arrow keys open the list of items, as the space bar and return do: choosing an item is what the combo + # box is for, and the list is where the items are. + if self.enabled and (key.is_up_arrow or key.is_down_arrow): + if callable(self.on_clicked): + self.on_clicked() + return True + return super().key_pressed(key) + + class BasicComboBoxWidgetCanvasItemController(ComboBoxWidgetCanvasItemController): # extra horizontal space (on each side) reserved around the text so it does not draw flush @@ -487,7 +500,7 @@ class BasicComboBoxWidgetCanvasItemController(ComboBoxWidgetCanvasItemController def __init__(self, ui: UserInterface.UserInterface) -> None: super().__init__(ui) - self.__row = CanvasItem.CanvasItemComposition() + self.__row = ComboBoxCanvasItem() self.__row.layout = CanvasItem.CanvasItemRowLayout() # a shared group controller keeps the text and triangle cells' hover/pressed state (and # hence their mouse-over highlight) in sync, so the combo box highlights as a single unit @@ -500,8 +513,9 @@ def __init__(self, ui: UserInterface.UserInterface) -> None: # set_background_color changes, and what the hover/press tint is computed relative to. self.__base_background_color: typing.Optional[typing.Union[str, DrawingContext.LinearGradient]] = "white" self.__row.background_color = self.__base_background_color - self.__row.border_color = "#c0c0c0" - self.__row.border_width = 0.5 + # the base border is the combo box's normal, unfocused appearance; the focus draws over it. + self.__row.base_border_color = "#c0c0c0" + self.__row.base_border_width = 0.5 self.__triangle = CanvasItem.StaticTextCanvasItem("\N{BLACK DOWN-POINTING TRIANGLE}", group_controller=self.__group_controller) self.__triangle.wants_mouse_events = True # a thin vertical line between the text and the down-arrow gives a visual indication that @@ -546,6 +560,8 @@ def handle_style_changed(hover: bool, pressed: bool) -> None: self.__group_controller.on_clicked = handle_clicked self.__group_controller.on_style_changed = handle_style_changed + # the combo box can also be opened by the keyboard, which the cells within it never see. + self.__row.on_clicked = handle_clicked @property def widget_source(self) -> Widgets.WidgetSource: @@ -600,6 +616,8 @@ def set_item_strings(self, strings: typing.Sequence[str]) -> None: def set_enabled(self, enabled: bool) -> None: self.__text_button_canvas_item.enabled = enabled + # the combo box as a whole is what the keyboard opens, so it has to know whether it can be opened. + self.__row.enabled = enabled def set_tool_tip(self, tool_tip: typing.Optional[str]) -> None: self.__text_button_canvas_item.tool_tip = tool_tip @@ -2441,6 +2459,8 @@ def __init__(self, ui: CanvasUserInterface, properties: typing.Optional[typing.M self.__canvas_item_controller = widget_canvas_item_factory.create_combo_box_widget_canvas_item_controller() self.__canvas_item.add_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) + # the combo box itself takes the focus and handles the keys; the composition around it only carries the sizing. + self._set_focus_canvas_item() self.on_current_text_changed: typing.Optional[typing.Callable[[str], None]] = None @@ -2493,6 +2513,8 @@ def __init__(self, ui: CanvasUserInterface, properties: typing.Optional[typing.M self.__canvas_item_controller = widget_canvas_item_factory.create_slider_widget_canvas_item_controller() self.__canvas_item.add_canvas_item(self.__canvas_item_controller.widget_source.canvas_item) + # the slider itself takes the focus and handles the keys; the composition around it only carries the sizing. + self._set_focus_canvas_item() self.on_value_changed: typing.Optional[typing.Callable[[int], None]] = None self.on_slider_pressed: typing.Optional[typing.Callable[[], None]] = None diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index 6852b73..9f00410 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -236,15 +236,16 @@ def apply_sizing_properties(canvas_item: CanvasItem.AbstractCanvasItem, properti canvas_item.update_sizing(canvas_item_sizing.with_unconstrained_height().with_preferred_height(min(max_height, preferred_height)).with_maximum_height(max_height)) -class PushButtonCanvasItem(CanvasItem.CanvasItemComposition): - """The canvas item drawing a push button. +class ControlCanvasItem(CanvasItem.CanvasItemComposition): + """The canvas item drawing a control made of several cells: a push button of an icon and a text, say, or a + combo box of a text and a triangle. - A button is drawn by several cells -- an icon and a text -- but it takes the focus and is pressed as a single - unit, so the focus and the keys are handled here rather than by any one of the cells. + The cells are drawn separately but the control takes the focus and is activated as a single unit, so the focus + and the keys are handled here rather than by any one of the cells. - A button has nothing selected to show the focus on, so it shows it as a stronger border around itself, drawn - in the same color as the focus ring of the controls which draw one. The border it shows when it is not focused - is its base border, which is what the widget drawing the button sets. + A control like this has nothing selected to show the focus on, so it shows it as a stronger border around + itself, drawn in the same color as the focus ring of the controls which draw one. The border it shows when it + is not focused is its base border, which is what the widget drawing the control sets. """ def __init__(self) -> None: @@ -285,7 +286,7 @@ def __update_border(self) -> None: self.border_width = CanvasItem.FOCUS_RING_WIDTH if self.focused else self.__base_border_width def key_pressed(self, key: UserInterface.Key) -> bool: - # the space bar and return press the button, the way clicking it does. + # the space bar and return activate the control, the way clicking it does. if self.enabled and (key.text == " " or key.is_enter_or_return): if callable(self.on_clicked): self.on_clicked() @@ -313,7 +314,7 @@ def __init__(self, ui: UserInterface.UserInterface, properties: typing.Optional[ # margins instead, applied once around the combo rather than doubled up per item. self.__text_button_canvas_item = CanvasItem.TextButtonCanvasItem(padding=Geometry.IntSize(height=4, width=0), group_controller=self.__group_controller) self.__icon_button_canvas_item = CanvasItem.BitmapButtonCanvasItem(padding=Geometry.IntSize(height=4, width=0), group_controller=self.__group_controller) - self.__stack = PushButtonCanvasItem() + self.__stack = ControlCanvasItem() self.__stack.layout = CanvasItem.CanvasItemRowLayout(margins=Geometry.Margins(top=0, left=8, bottom=0, right=8)) # the "base" background is the button's normal, non-hovered appearance; it is what # set_background_color changes, and what the hover/press tint is computed relative to. diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index 8019a7d..e885d89 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -1182,6 +1182,37 @@ def test_the_space_bar_chooses_the_focused_radio_button(self) -> None: self.assertTrue(second_radio_button.checked) self.assertFalse(first_radio_button.checked) + def test_the_arrow_keys_move_the_thumb_of_the_focused_slider(self) -> None: + # a slider which has the focus is moved by the keyboard, the way dragging its thumb moves it. + column = self.ui.create_column_widget() + slider = self.ui.create_slider_widget() + slider.minimum = 0 + slider.maximum = 100 + slider.value = 50 + column.add(slider) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(slider.focused) + self.assertTrue(self._send_key(host_canvas_widget, "right")) + self.assertGreater(slider.value, 50) + moved_value = slider.value + self.assertTrue(self._send_key(host_canvas_widget, "left")) + self.assertLess(slider.value, moved_value) + + def test_a_combo_box_takes_the_focus_and_opens_its_list_from_the_keyboard(self) -> None: + # a combo box is drawn by its text and its triangle but takes the focus as one, and the keys which open its + # list of items are the ones it acts on. + column = self.ui.create_column_widget() + combo_box = self.ui.create_combo_box_widget(["Alpha", "Beta"]) + column.add(combo_box) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(combo_box.focused) + self.assertTrue(self._send_key(host_canvas_widget, "space", text=" ")) + self.assertTrue(self._send_key(host_canvas_widget, "down")) + # a key it has no use for is left for the focus to move on. + self.assertFalse(self._send_key(host_canvas_widget, "escape")) + def test_the_list_view_takes_its_place_in_the_walk_and_gives_up_the_focus(self) -> None: # the list view draws its rows in a canvas widget of its own, which is the case the boundary exists for. column = self.ui.create_column_widget() From 3023b7fe694b2f55db455a6839c88b1f6e393c49 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:14:01 -0700 Subject: [PATCH 12/13] Let a check box or slider canvas item take the focus only when it is a control of its own. --- nion/ui/CanvasItem.py | 24 ++++++++++++++++-------- nion/ui/CanvasUserInterface.py | 4 ++++ nion/ui/test/CanvasItem_test.py | 24 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/nion/ui/CanvasItem.py b/nion/ui/CanvasItem.py index cfcd50b..2dd4173 100644 --- a/nion/ui/CanvasItem.py +++ b/nion/ui/CanvasItem.py @@ -3542,12 +3542,15 @@ def _repaint(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: class SliderCanvasItem(AbstractCanvasItem, Observable.Observable): - """Slider.""" + """A slider whose thumb can be dragged by the mouse, and moved by the arrow keys once it is focusable. + + It is not focusable to begin with, for the same reason a check box is not: a slider drawn over other content + takes the focus, and the arrow keys, away from that content when it is clicked, which is right for a slider + standing on its own as a control and wrong for one drawn over something else. Whatever draws it says which it is. + """ def __init__(self) -> None: super().__init__() self.wants_mouse_events = True - # the thumb is moved by the arrow keys as well as by the mouse, so the slider takes the keyboard focus. - self.focusable = True self.__tracking = False self.__tracking_start = Geometry.IntPoint() self.__tracking_value = 0.0 @@ -3613,8 +3616,8 @@ def mouse_position_changed(self, x: int, y: int, modifiers: UserInterface.Keyboa return super().mouse_position_changed(x, y, modifiers) def key_pressed(self, key: UserInterface.Key) -> bool: - # the arrow keys move the thumb along the bar, a step at a time; which arrows depends on nothing but which - # way along the bar the thumb is to go. + # the arrow keys move the thumb along the bar, a step at a time. this only happens if the slider has been + # made focusable, which is for whatever is drawing it to decide -- see the class comment. if key.is_left_arrow or key.is_down_arrow: self.__adjust_thumb(-1.0) return True @@ -6165,12 +6168,16 @@ def _repaint(self, drawing_context: DrawingContext.DrawingContext, canvas_rect: class CheckBoxCanvasItem(AbstractCanvasItem): + """A check box which can be toggled by the mouse, and by the space bar once it is focusable. + + It is not focusable to begin with: a check box drawn among other content takes the focus away from that content + when it is clicked, which is right for a check box standing on its own as a control and wrong for one drawn as + part of something else. Whatever draws it says which it is. + """ def __init__(self, text: typing.Optional[str] = None) -> None: super().__init__() self.wants_mouse_events = True - # the check box is toggled by the keyboard as well as by the mouse, so it takes the keyboard focus. - self.focusable = True self.__enabled = True self.__mouse_inside = False self.__mouse_pressed = False @@ -6295,7 +6302,8 @@ def mouse_clicked(self, x: int, y: int, modifiers: UserInterface.KeyboardModifie return True def key_pressed(self, key: UserInterface.Key) -> bool: - # the space bar toggles the check box, the way clicking it does. + # the space bar toggles the check box, the way clicking it does. this only happens if the check box has + # been made focusable, which is for whatever is drawing it to decide -- see the class comment. if self.enabled and key.text == " ": self._toggle_checked() return True diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 4157a26..630da05 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -70,6 +70,8 @@ def __init__(self, ui: UserInterface.UserInterface) -> None: self.__row = CanvasItem.CanvasItemComposition() self.__row.layout = CanvasItem.CanvasItemRowLayout() self.__check_box_canvas_item = CanvasItem.CheckBoxCanvasItem() + # the check box is a control of its own here, so it takes the focus and can be toggled by the keyboard. + self.__check_box_canvas_item.focusable = True self.__row.add_canvas_item(self.__check_box_canvas_item) def handle_check_state_changed(check_state: str) -> None: @@ -634,6 +636,8 @@ def __init__(self, ui: UserInterface.UserInterface) -> None: self.__row = CanvasItem.CanvasItemComposition() self.__row.layout = CanvasItem.CanvasItemRowLayout() self.__slider_canvas_item = CanvasItem.SliderCanvasItem() + # the slider is a control of its own here, so it takes the focus and can be moved by the arrow keys. + self.__slider_canvas_item.focusable = True self.__row.add_canvas_item(self.__slider_canvas_item) self.__minimum = 0 diff --git a/nion/ui/test/CanvasItem_test.py b/nion/ui/test/CanvasItem_test.py index 22037db..103757a 100644 --- a/nion/ui/test/CanvasItem_test.py +++ b/nion/ui/test/CanvasItem_test.py @@ -780,6 +780,30 @@ def test_a_threaded_canvas_item_with_nothing_focusable_does_not_take_the_focus(s canvas_widget.focused = True self.assertEqual(focusable_item, canvas_item.focused_item) + def test_a_check_box_or_slider_drawn_among_other_content_does_not_take_the_focus_from_it(self) -> None: + # these items are drawn both as controls of their own and as part of other content; only whatever draws + # them knows which, so they are not focusable until they are told to be, and until then the focus, and the + # keys, stay with the content around them. + check_box_canvas_item = CanvasItem.CheckBoxCanvasItem() + slider_canvas_item = CanvasItem.SliderCanvasItem() + self.assertFalse(check_box_canvas_item.focusable) + self.assertFalse(slider_canvas_item.focusable) + + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + content_item = _FocusableCanvasItem() + canvas_item.add_canvas_item(content_item) + canvas_item.add_canvas_item(check_box_canvas_item) + canvas_item.add_canvas_item(slider_canvas_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + canvas_widget.focused = True + self.assertEqual(content_item, canvas_item.focused_item) + self.assertFalse(_send_key(canvas_widget, _tab_key())) + self.assertEqual(content_item, canvas_item.focused_item) + def test_tab_moves_the_focus_to_the_next_item_and_backtab_to_the_previous_one(self) -> None: # the tab key walks the focus through the items which can take it, in the order they appear, and backtab # walks back. From 31d8241b705ca7923883779e7c237252dfb4c5f1 Mon Sep 17 00:00:00 2001 From: Chris Meyer <34664+cmeyer@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:23:39 -0700 Subject: [PATCH 13/13] Leave the focus where it is when a control is clicked. --- nion/ui/CanvasItem.py | 28 +++++++++++++-- nion/ui/CanvasUserInterface.py | 12 +++++-- nion/ui/Widgets.py | 3 ++ nion/ui/test/CanvasItem_test.py | 25 +++++++++++++ nion/ui/test/CanvasUserInterface_test.py | 46 ++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/nion/ui/CanvasItem.py b/nion/ui/CanvasItem.py index 2dd4173..ded4100 100644 --- a/nion/ui/CanvasItem.py +++ b/nion/ui/CanvasItem.py @@ -927,6 +927,7 @@ def __init__(self, cache: typing.Optional[ComposerCache] = None) -> None: self.__layout_count = 0 self.__focused = False self.__focusable = False + self.__takes_focus_on_click = True self.wants_mouse_events = False self.wants_drag_events = False self.on_focus_changed: typing.Optional[typing.Callable[[bool], None]] = None @@ -1122,6 +1123,23 @@ def focusable(self, focusable: bool) -> None: """ self.__focusable = focusable + @property + def takes_focus_on_click(self) -> bool: + """Return whether clicking this canvas item gives it the focus. + + An item which can take the focus usually takes it when it is clicked: a field clicked into is where the + typing should go from then on. + + A control operated by the click itself -- a button being pressed, a check box being toggled -- has no use + for the focus at that moment, and taking it would move the focus away from wherever the user was working. + Such a control takes the focus only when the focus is walked to it. + """ + return self.__takes_focus_on_click + + @takes_focus_on_click.setter + def takes_focus_on_click(self, takes_focus_on_click: bool) -> None: + self.__takes_focus_on_click = takes_focus_on_click + @property def focused(self) -> bool: """ Return whether the canvas item is focused. """ @@ -4335,7 +4353,10 @@ def __request_focus(self, canvas_item: AbstractCanvasItem, p: Geometry.IntPoint, canvas_item_: AbstractCanvasItem | None = canvas_item while canvas_item_: if canvas_item_.focusable: - canvas_item_._request_focus(p, modifiers) + # an item which does not take the focus when it is clicked leaves the focus where it is: the click + # operates the control, and moving the focus would take it away from wherever the user was working. + if canvas_item_.takes_focus_on_click: + canvas_item_._request_focus(p, modifiers) break canvas_item_ = canvas_item_.container @@ -4826,7 +4847,10 @@ def __request_focus(self, canvas_item: AbstractCanvasItem, p: Geometry.IntPoint, canvas_item_: typing.Optional[AbstractCanvasItem] = canvas_item while canvas_item_: if canvas_item_.focusable: - canvas_item_._request_focus(p, modifiers) + # an item which does not take the focus when it is clicked leaves the focus where it is: the click + # operates the control, and moving the focus would take it away from wherever the user was working. + if canvas_item_.takes_focus_on_click: + canvas_item_._request_focus(p, modifiers) break canvas_item_ = canvas_item_.container diff --git a/nion/ui/CanvasUserInterface.py b/nion/ui/CanvasUserInterface.py index 630da05..749318e 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -70,8 +70,10 @@ def __init__(self, ui: UserInterface.UserInterface) -> None: self.__row = CanvasItem.CanvasItemComposition() self.__row.layout = CanvasItem.CanvasItemRowLayout() self.__check_box_canvas_item = CanvasItem.CheckBoxCanvasItem() - # the check box is a control of its own here, so it takes the focus and can be toggled by the keyboard. + # the check box is a control of its own here, so it takes the focus and can be toggled by the keyboard. a + # click toggles it rather than settling into it, so it takes the focus only by being walked to. self.__check_box_canvas_item.focusable = True + self.__check_box_canvas_item.takes_focus_on_click = False self.__row.add_canvas_item(self.__check_box_canvas_item) def handle_check_state_changed(check_state: str) -> None: @@ -209,8 +211,10 @@ class RadioButtonCanvasItem(CanvasItem.AbstractCanvasItem): def __init__(self, text: typing.Optional[str] = None) -> None: super().__init__() self.wants_mouse_events = True - # the radio button is chosen by the keyboard as well as by the mouse, so it takes the keyboard focus. + # the radio button is chosen by the keyboard as well as by the mouse, so it takes the keyboard focus. a + # click chooses it rather than settling into it, so it takes the focus only by being walked to. self.focusable = True + self.takes_focus_on_click = False self.__enabled = True self.__mouse_inside = False self.__mouse_pressed = False @@ -636,8 +640,10 @@ def __init__(self, ui: UserInterface.UserInterface) -> None: self.__row = CanvasItem.CanvasItemComposition() self.__row.layout = CanvasItem.CanvasItemRowLayout() self.__slider_canvas_item = CanvasItem.SliderCanvasItem() - # the slider is a control of its own here, so it takes the focus and can be moved by the arrow keys. + # the slider is a control of its own here, so it takes the focus and can be moved by the arrow keys. the + # thumb is dragged rather than settled into, so it takes the focus only by being walked to. self.__slider_canvas_item.focusable = True + self.__slider_canvas_item.takes_focus_on_click = False self.__row.add_canvas_item(self.__slider_canvas_item) self.__minimum = 0 diff --git a/nion/ui/Widgets.py b/nion/ui/Widgets.py index 9f00410..f848925 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -251,6 +251,9 @@ class ControlCanvasItem(CanvasItem.CanvasItemComposition): def __init__(self) -> None: super().__init__() self.focusable = True + # a click presses the control rather than settling into it, so it is reached by the keyboard only by + # walking the focus to it; clicking it leaves the focus wherever the user was working. + self.takes_focus_on_click = False self.on_clicked: typing.Optional[typing.Callable[[], None]] = None self.__base_border_color: typing.Optional[str] = None self.__base_border_width: typing.Optional[float] = None diff --git a/nion/ui/test/CanvasItem_test.py b/nion/ui/test/CanvasItem_test.py index 103757a..1b31e47 100644 --- a/nion/ui/test/CanvasItem_test.py +++ b/nion/ui/test/CanvasItem_test.py @@ -804,6 +804,31 @@ def test_a_check_box_or_slider_drawn_among_other_content_does_not_take_the_focus self.assertFalse(_send_key(canvas_widget, _tab_key())) self.assertEqual(content_item, canvas_item.focused_item) + def test_an_item_which_does_not_take_the_focus_on_a_click_is_still_reached_by_tab(self) -> None: + # a control operated by the click itself has no use for the focus at that moment, and taking it would move + # the focus away from wherever the user was working. walking the focus to it is how it is reached instead. + ui = TestUI.UserInterface() + canvas_widget = ui.create_canvas_widget() + with contextlib.closing(canvas_widget): + canvas_item = canvas_widget.canvas_item + canvas_item.layout = CanvasItem.CanvasItemRowLayout() + settled_into_item = _FocusableCanvasItem() + clicked_item = _FocusableCanvasItem() + clicked_item.takes_focus_on_click = False + canvas_item.add_canvas_item(settled_into_item) + canvas_item.add_canvas_item(clicked_item) + canvas_item.update_layout(Geometry.IntPoint(x=0, y=0), Geometry.IntSize(width=640, height=480)) + modifiers = CanvasItem.KeyboardModifiers() + # clicking the item which is settled into moves the focus to it, as usual. + canvas_widget.simulate_mouse_click(160, 240, modifiers) + self.assertEqual(settled_into_item, canvas_item.focused_item) + # clicking the other one operates it without taking the focus away from where it was. + canvas_widget.simulate_mouse_click(160 + 320, 240, modifiers) + self.assertEqual(settled_into_item, canvas_item.focused_item) + # but tab still walks the focus onto it. + self.assertTrue(_send_key(canvas_widget, _tab_key())) + self.assertEqual(clicked_item, canvas_item.focused_item) + def test_tab_moves_the_focus_to_the_next_item_and_backtab_to_the_previous_one(self) -> None: # the tab key walks the focus through the items which can take it, in the order they appear, and backtab # walks back. diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index e885d89..70eaa4b 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -1022,9 +1022,18 @@ def _make_window(self, content: UserInterface.Widget) -> typing.Tuple[CanvasUser window._attach_root_widget(content) window.show() host_canvas_widget = typing.cast(UserInterface.CanvasWidget, window._CanvasWindow__canvas_widget) # type: ignore[attr-defined] + # lay the content out so that the canvas items have the positions a click is resolved against. + host_canvas_widget.canvas_item.update_layout(Geometry.IntPoint(), Geometry.IntSize(width=640, height=480)) host_canvas_widget.focused = True return window, host_canvas_widget + def _click(self, host_canvas_widget: UserInterface.CanvasWidget, widget: UserInterface.Widget) -> None: + # click the middle of the widget, which is where whatever it is drawn by actually is. + canvas_item = typing.cast(CanvasItem.AbstractCanvasItem, widget._behavior.canvas_item) # type: ignore[attr-defined] + canvas_size = canvas_item.canvas_size or Geometry.IntSize() + point = canvas_item.map_to_base_container(Geometry.IntPoint(y=canvas_size.height // 2, x=canvas_size.width // 2)) + host_canvas_widget.simulate_mouse_click(point.x, point.y, CanvasItem.KeyboardModifiers()) + def _send_key(self, host_canvas_widget: UserInterface.CanvasWidget, key_name: str, *, text: str = str()) -> bool: on_key_pressed = host_canvas_widget.on_key_pressed assert callable(on_key_pressed) @@ -1213,6 +1222,43 @@ def test_a_combo_box_takes_the_focus_and_opens_its_list_from_the_keyboard(self) # a key it has no use for is left for the focus to move on. self.assertFalse(self._send_key(host_canvas_widget, "escape")) + def test_clicking_a_push_button_presses_it_without_taking_the_focus(self) -> None: + # a button is pressed by the click rather than settled into, so clicking it leaves the focus wherever the + # user was working -- which is what lets a command button be pressed without disturbing the field in use. + column = self.ui.create_column_widget() + line_edit = self.ui.create_line_edit_widget() + push_button = self.ui.create_push_button_widget("Press") + clicked_count = 0 + + def handle_clicked() -> None: + nonlocal clicked_count + clicked_count += 1 + + push_button.on_clicked = handle_clicked + column.add(line_edit) + column.add(push_button) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(line_edit.focused) + self._click(host_canvas_widget, push_button) + self.assertEqual(1, clicked_count) + self.assertFalse(push_button.focused) + self.assertTrue(line_edit.focused) + + def test_clicking_a_line_edit_moves_the_focus_into_it(self) -> None: + # a field is settled into rather than operated, so clicking it is how the typing gets there. + column = self.ui.create_column_widget() + first_line_edit = self.ui.create_line_edit_widget() + second_line_edit = self.ui.create_line_edit_widget() + column.add(first_line_edit) + column.add(second_line_edit) + window, host_canvas_widget = self._make_window(column) + with contextlib.closing(window): + self.assertTrue(first_line_edit.focused) + self._click(host_canvas_widget, second_line_edit) + self.assertTrue(second_line_edit.focused) + self.assertFalse(first_line_edit.focused) + def test_the_list_view_takes_its_place_in_the_walk_and_gives_up_the_focus(self) -> None: # the list view draws its rows in a canvas widget of its own, which is the case the boundary exists for. column = self.ui.create_column_widget()