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/CanvasItem.py b/nion/ui/CanvasItem.py index 4f4b403..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 @@ -989,6 +990,30 @@ 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.""" + focus_chain = self._focus_chain() + return focus_chain[0] if focus_chain else None + @property def canvas_size(self) -> typing.Optional[Geometry.IntSize]: """ Returns size of canvas_rect (external coordinates). """ @@ -1098,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. """ @@ -3452,6 +3494,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 @@ -3473,9 +3534,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) @@ -3492,10 +3555,17 @@ 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): - """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 @@ -3526,7 +3596,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) @@ -3563,6 +3633,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. 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 + 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)) @@ -3817,6 +3898,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: @@ -4248,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 @@ -4292,10 +4400,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 _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 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.""" 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) @@ -4306,7 +4423,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: @@ -4447,6 +4576,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 @@ -4617,7 +4750,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) @@ -4711,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 @@ -4834,7 +4973,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: @@ -5971,13 +6123,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 @@ -6034,9 +6187,17 @@ 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): + """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__() @@ -6164,6 +6325,14 @@ 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. 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 + return super().key_pressed(key) + def _toggle_checked(self) -> None: if self.enabled: if self.check_state == "checked": @@ -6194,7 +6363,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 196f147..749318e 100644 --- a/nion/ui/CanvasUserInterface.py +++ b/nion/ui/CanvasUserInterface.py @@ -70,6 +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. 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: @@ -137,13 +141,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 +202,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 +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. 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 @@ -302,6 +313,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 +377,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) @@ -466,6 +485,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 @@ -474,7 +506,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 @@ -487,8 +519,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 @@ -533,6 +566,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: @@ -587,6 +622,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 @@ -603,6 +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 + # 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 @@ -716,9 +757,30 @@ 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 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: 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): + self.on_focus_changed(self.__focus_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 @@ -779,20 +841,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: @@ -2241,6 +2304,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.__text: typing.Optional[str] = None self.__icon: typing.Optional[Bitmap.Bitmap] = None @@ -2298,6 +2363,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 @@ -2348,6 +2415,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 @@ -2400,6 +2469,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 @@ -2452,6 +2523,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 @@ -2510,17 +2583,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 @@ -2538,6 +2633,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 @@ -2560,10 +2657,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 @@ -2603,6 +2701,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 @@ -2663,6 +2780,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/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/Widgets.py b/nion/ui/Widgets.py index 430d748..f848925 100644 --- a/nion/ui/Widgets.py +++ b/nion/ui/Widgets.py @@ -236,6 +236,67 @@ 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 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. + + 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 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: + 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 + + 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 = 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 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() + 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 +317,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 = 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. 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 +354,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 +415,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 +427,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): @@ -749,7 +815,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]: @@ -809,6 +875,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 +905,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() @@ -865,9 +945,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 @@ -916,13 +1001,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/CanvasItem_test.py b/nion/ui/test/CanvasItem_test.py index 9c97ddd..1b31e47 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: @@ -672,6 +698,267 @@ 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_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_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. + 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() diff --git a/nion/ui/test/CanvasUserInterface_test.py b/nion/ui/test/CanvasUserInterface_test.py index fc6f870..70eaa4b 100644 --- a/nion/ui/test/CanvasUserInterface_test.py +++ b/nion/ui/test/CanvasUserInterface_test.py @@ -11,8 +11,11 @@ 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 Binding from nion.utils import Geometry +from nion.utils import Model class TestComboBoxCanvasSizing(unittest.TestCase): @@ -430,6 +433,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() @@ -988,5 +999,282 @@ 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 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() + 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] + # 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) + shift = key_name == "backtab" + 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() + 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_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_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_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_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() + 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() diff --git a/nion/ui/test/Declarative_test.py b/nion/ui/test/Declarative_test.py index b6fc123..fd6ab8b 100644 --- a/nion/ui/test/Declarative_test.py +++ b/nion/ui/test/Declarative_test.py @@ -107,6 +107,8 @@ 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, name="list_view", @@ -114,8 +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) @@ -201,6 +212,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: @@ -357,6 +396,73 @@ 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_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. + 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: 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 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")),