[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
develop
Myk 2022-06-01 17:42:13 -07:00 committed by GitHub
parent bc0def4342
commit ad2d9cad03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 308 additions and 6 deletions

@ -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:

@ -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

@ -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

@ -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