From ad2d9cad037a94c677e94df6fff9d303677d4f86 Mon Sep 17 00:00:00 2001 From: Myk Date: Wed, 1 Jun 2022 17:42:13 -0700 Subject: [PATCH] [lua] implement keyboard focus subsystem (#2160) * implement keyboard focus subsystem * Fix error in focus group combining * documentation for the inputToSubviews decision * modify unit tests to catch that last bug --- docs/Lua API.rst | 39 ++++++++-- docs/changelog.txt | 1 + library/lua/gui.lua | 98 +++++++++++++++++++++++- test/library/gui.lua | 176 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 6 deletions(-) diff --git a/docs/Lua API.rst b/docs/Lua API.rst index 04257b49d..2edb1b015 100644 --- a/docs/Lua API.rst +++ b/docs/Lua API.rst @@ -3516,14 +3516,24 @@ The class defines the following attributes: :visible: Specifies that the view should be painted. :active: Specifies that the view should receive events, if also visible. :view_id: Specifies an identifier to easily identify the view among subviews. - This is reserved for implementation of top-level views, and should - not be used by widgets for their internal subviews. + This is reserved for use by script writers and should not be set by + library widgets for their internal subviews. +:on_focus: Called when the view gains keyboard focus; see ``setFocus()`` below. +:on_unfocus: Called when the view loses keyboard focus. It also always has the following fields: :subviews: Contains a table of all subviews. The sequence part of the table is used for iteration. In addition, subviews are also - indexed under their *view_id*, if any; see ``addviews()`` below. + indexed under their ``view_id``, if any; see ``addviews()`` below. +:parent_view: A reference to the parent view. This field is ``nil`` until the + view is added as a subview to another view with ``addviews()``. +:focus_group: The list of widgets in a hierarchy. This table is unique and empty + when a view is initialized, but is replaced by a shared table when + the view is added to a parent via ``addviews()``. If a view in the + focus group has keyboard focus, that widget can be accessed via + ``focus_group.cur``. +:focus: A boolean indicating whether the view currently has keyboard focus. These fields are computed by the layout process: @@ -3617,8 +3627,27 @@ The class has the following methods: Calls ``onInput`` on all visible active subviews, iterating the ``subviews`` sequence in *reverse order*, so that topmost subviews get events first. - Returns *true* if any of the subviews handled the event. - + Returns ``true`` if any of the subviews handled the event. If a subview within + the view's ``focus_group`` has focus and it and all of its ancestors are + active and visible, that subview is offered the chance to handle the input + before any other subviews. + +* ``view:getPreferredFocusState()`` + + Returns ``false`` by default, but should be overridden by subclasses that may + want to take keyboard focus (if it is unclaimed) when they are added to a + parent view with ``addviews()``. + +* ``view:setFocus(focus)`` + + Sets the keyboard focus to the view if ``focus`` is ``true``, or relinquishes + keyboard focus if ``focus`` is ``false``. Views that newly acquire keyboard + focus will trigger the ``on_focus`` callback, and views that lose keyboard + focus will trigger the ``on_unfocus`` callback. While a view has focus, all + keyboard input is sent to that view before any of its siblings or parents. + Keyboard input is propagated as normal (see ``inputToSubviews()`` above) if + there is no view with focus or if the view with focus returns ``false`` from + its ``onInput()`` function. .. _lua-gui-screen: diff --git a/docs/changelog.txt b/docs/changelog.txt index 3b75390dd..0a4bcd68a 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -50,6 +50,7 @@ changelog.txt uses a syntax similar to RST, with a few special sequences: - ``word_wrap``: argument ``bool collapse_whitespace`` converted to enum ``word_wrap_whitespace_mode mode``, with valid modes ``WSMODE_KEEP_ALL``, ``WSMODE_COLLAPSE_ALL``, and ``WSMODE_TRIM_LEADING``. ## Lua +- ``gui.View``: all ``View`` subclasses (including all ``Widgets``) can now acquire keyboard focus with the new ``View:setFocus()`` function. See docs for details. - ``widgets.HotkeyLabel``: the ``key_sep`` string is now configurable - ``widgets.EditField``: the ``key_sep`` string is now configurable - ``widgets.EditField``: can now display an optional string label in addition to the activation key diff --git a/library/lua/gui.lua b/library/lua/gui.lua index 8521e1dfe..8d546b21a 100644 --- a/library/lua/gui.lua +++ b/library/lua/gui.lua @@ -376,10 +376,21 @@ View.ATTRS { active = true, visible = true, view_id = DEFAULT_NIL, + on_focus = DEFAULT_NIL, + on_unfocus = DEFAULT_NIL, } function View:init(args) self.subviews = {} + self.focus_group = {self} + self.focus = false +end + +local function inherit_focus_group(view, focus_group) + for _,child in ipairs(view.subviews) do + inherit_focus_group(child, focus_group) + end + view.focus_group = focus_group end function View:addviews(list) @@ -388,6 +399,31 @@ function View:addviews(list) local sv = self.subviews for _,obj in ipairs(list) do + -- absorb the focus groups of new children + for _,focus_obj in ipairs(obj.focus_group) do + table.insert(self.focus_group, focus_obj) + end + -- if the child's focus group has a focus owner, absorb it if we don't + -- already have one. otherwise keep the focus owner we have and clear + -- the focus of the child. + if obj.focus_group.cur then + if not self.focus_group.cur then + self.focus_group.cur = obj.focus_group.cur + else + obj.focus_group.cur:setFocus(false) + end + end + -- overwrite the child's focus_group hierarchy with ours + inherit_focus_group(obj, self.focus_group) + -- if we don't have a focus owner, give it to the new child if they want + if not self.focus_group.cur and obj:getPreferredFocusState() then + obj:setFocus(true) + end + + -- set ourselves as the parent view of the new child + obj.parent_view = self + + -- add the subview to our child list table.insert(sv, obj) local id = obj.view_id @@ -405,6 +441,33 @@ function View:addviews(list) end end +-- should be overridden by widgets that care about capturing keyboard focus +-- (e.g. widgets.EditField) +function View:getPreferredFocusState() + return false +end + +function View:setFocus(focus) + if focus then + if self.focus then return end -- nothing to do if we already have focus + if self.focus_group.cur then + -- steal focus from current owner + self.focus_group.cur:setFocus(false) + end + self.focus_group.cur = self + self.focus = true + if self.on_focus then + self.on_focus() + end + elseif self.focus then + self.focus = false + self.focus_group.cur = nil + if self.on_unfocus then + self.on_unfocus() + end + end +end + function View:getWindowSize() local rect = self.frame_body return rect.width, rect.height @@ -476,12 +539,45 @@ end function View:onRenderBody(dc) end +-- Returns whether we should invoke the focus owner's onInput() function from +-- the given view's inputToSubviews() function. That is, returns true if: +-- - the view is not itself the focus owner since that would be an infinite loop +-- - the view is not a descendent of the focus owner (same as above) +-- - the focus owner and all of its ancestors are visible and active, since if +-- the focus owner is not (directly or transitively) visible or active, then +-- it shouldn't be getting input. +local function should_send_input_to_focus_owner(view, focus_owner) + local iter = view + while iter do + if iter == focus_owner then + return false + end + iter = iter.parent_view + end + iter = focus_owner + while iter do + if not iter.visible or not iter.active then + return false + end + iter = iter.parent_view + end + return true +end + function View:inputToSubviews(keys) local children = self.subviews + -- give focus owner first dibs on the input + local focus_owner = self.focus_group.cur + if focus_owner and should_send_input_to_focus_owner(self, focus_owner) and + focus_owner:onInput(keys) then + return true + end + for i=#children,1,-1 do local child = children[i] - if child.visible and child.active and child:onInput(keys) then + if child.visible and child.active and child ~= focus_owner and + child:onInput(keys) then return true end end diff --git a/test/library/gui.lua b/test/library/gui.lua index 4cfad07b1..fe04614d6 100644 --- a/test/library/gui.lua +++ b/test/library/gui.lua @@ -17,3 +17,179 @@ function test.clear_pen() tile_color = false, }) end + +WantsFocusView = defclass(WantsFocusView, gui.View) +function WantsFocusView:getPreferredFocusState() + return true +end + +function test.view_wants_focus() + local parent = gui.View() + expect.false_(parent.focus) + + -- expect first (regular) child to not get focus + local regular_child = gui.View() + expect.false_(regular_child.focus) + expect.ne(parent.focus_group, regular_child.focus_group) + parent:addviews{regular_child} + expect.false_(regular_child.focus) + expect.eq(parent.focus_group, regular_child.focus_group) + + -- the first child who wants focus gets it + local focus_child = WantsFocusView() + expect.false_(focus_child.focus) + parent:addviews{focus_child} + expect.true_(focus_child.focus) + expect.eq(parent.focus_group.cur, focus_child) + + -- the second child who wants focus doesn't + local focus_child2 = WantsFocusView() + parent:addviews{focus_child2} + expect.false_(focus_child2.focus) + expect.eq(parent.focus_group.cur, focus_child) +end + +function test.inherit_focus_from_subview() + local parent = gui.View() + local regular_child = gui.View() + local focus_child = WantsFocusView() + regular_child:addviews{focus_child} + expect.true_(focus_child.focus) + parent:addviews{regular_child} + expect.eq(parent.focus_group.cur, focus_child) +end + +function test.subviews_negotiate_focus() + local parent = gui.View() + local regular_child = gui.View() + local regular_child2 = gui.View() + local focus_child = WantsFocusView() + local focus_child2 = WantsFocusView() + regular_child:addviews{focus_child} + regular_child2:addviews{focus_child2} + expect.true_(focus_child.focus) + expect.true_(focus_child2.focus) + expect.ne(regular_child.focus_group, regular_child2.focus_group) + parent:addviews{regular_child} + expect.eq(parent.focus_group.cur, focus_child) + expect.true_(focus_child.focus) + expect.true_(focus_child2.focus) + parent:addviews{regular_child2} + expect.eq(parent.focus_group.cur, focus_child) + expect.eq(regular_child.focus_group, regular_child2.focus_group) + expect.true_(focus_child.focus) + expect.false_(focus_child2.focus) +end + +MockInputView = defclass(MockInputView, gui.View) +function MockInputView:onInput(keys) + self.mock(keys) + MockInputView.super.onInput(self, keys) + return true +end + +local function reset_child_mocks(parent) + for _,child in ipairs(parent.subviews) do + child.mock = mock.func() + reset_child_mocks(child) + end +end + +-- verify that input got routed as expected +local function test_children(expected, parent) + local children = parent.subviews + for i,val in ipairs(expected) do + expect.eq(val, children[i].mock.call_count, 'child '..i) + end +end + +function test.keyboard_follows_focus() + local parent = gui.View() + local regular_child = MockInputView{} + local regular_child2 = MockInputView{} + local last_child = MockInputView{} + parent:addviews{regular_child, regular_child2, last_child} + + reset_child_mocks(parent) + parent:onInput({'a'}) + test_children({0,0,1}, parent) + + regular_child:setFocus(true) + reset_child_mocks(parent) + parent:onInput({'a'}) + test_children({1,0,0}, parent) + + regular_child2:setFocus(true) + reset_child_mocks(parent) + parent:onInput({'a'}) + test_children({0,1,0}, parent) + + regular_child2:setFocus(false) + reset_child_mocks(parent) + parent:onInput({'a'}) + test_children({0,0,1}, parent) +end + +function test.one_callback_on_double_focus() + local on_focus = mock.func() + local view = gui.View{on_focus=on_focus} + expect.eq(0, on_focus.call_count) + view:setFocus(true) + expect.eq(1, on_focus.call_count) + view:setFocus(true) + expect.eq(1, on_focus.call_count) +end + +function test.one_callback_on_double_unfocus() + local on_unfocus = mock.func() + local view = gui.View{on_unfocus=on_unfocus} + expect.eq(0, on_unfocus.call_count) + view:setFocus(false) + expect.eq(0, on_unfocus.call_count) + view:setFocus(true) + expect.eq(0, on_unfocus.call_count) + view:setFocus(false) + expect.eq(1, on_unfocus.call_count) + view:setFocus(false) + expect.eq(1, on_unfocus.call_count) +end + +function test.no_input_when_focus_owner_is_hidden() + local parent = gui.View() + local child1 = MockInputView() + local child2 = MockInputView() + parent:addviews{child1, child2} + child1:setFocus(true) + child1.visible = false + reset_child_mocks(parent) + parent:onInput({'a'}) + test_children({0,1}, parent) +end + +function test.no_input_when_ancestor_is_hidden() + local grandparent = gui.View() + local parent = MockInputView() + local child1 = MockInputView() + local child2 = MockInputView() + grandparent:addviews{parent} + parent:addviews{child1, child2} + child1:setFocus(true) + parent.visible = false + reset_child_mocks(grandparent) + grandparent:onInput({'a'}) + test_children({0}, grandparent) + test_children({0,0}, parent) +end + +function test.no_input_loop_in_children_of_focus_owner() + local grandparent = gui.View() + local parent = MockInputView() + local child = MockInputView() + grandparent:addviews{parent} + parent:addviews{child} + parent:setFocus(true) + reset_child_mocks(grandparent) + child:onInput({'a'}) + test_children({0}, grandparent) + test_children({1}, parent) +end