Keyboard and Mouse¶
Warning
This page is not fully done, and the way functui handles keyboard and mouse navigation will be changed soon!
Important
This page assumes that you have already a way to get a
InputEvent. If that’s not the case, check out
I/O Overview.
Functui has the functui.nav module which provides the NavState class and multiple nodes to allow interactivity.
NavState¶
The NavState class is an immutable representation of all state related to
keyboard navigation and mouse interactivity. Usually an app needs only one
NavState instance. A NavState object has an
update() method that needs to be call every time
user has submitted some input. (For example a key press or a mouse position
change). The update() method returns a new NavState object that
represents the updated state based on input.
The whole signature of the update method is as follows:
def update(
self,
res: Result,
action: NavAction | None = None,
nav_data: list[InteractibleID] = field(default_factory=list),
mouse_position: Coordinate | None = None,
):
As you may see the update method has the obvious mouse_positon argument,
but the purpose of the others can be unclear. Below follows an explanation of
how to provide those other arguments and why they are needed.
action¶
This is essentially just the user input, but converted to a
NavAction value. You can convert user input stored in an
InputEvent to an NavAction by using the
DEFAULT_NAV_BINDINGS dictionary.
# which can be done like this
from functui import InputEvent
from functui.nav import DEFAULT_NAV_BINDINGS, NavState
nav = NavState()
# usually we get the input event form some io function,
# but here we just create one for the example's sake
event = InputEvent()
nav = nav.update(action=DEFAULT_NAV_BINDINGS.get(event.key_event, None))
Just to get an idea how an Action enum can look like, all possible NavActions are listed below.
- class functui.nav.NavAction(*values)[source]
An action that is meant to be sent to
NavData.update.- SELECT_VIA_KEYBOARD
- SELECT_VIA_MOUSE_START
For example, if user presses down left click.
- SELECT_VIA_MOUSE_END
For example, if user releases left click.
- PAGE_DOWN
- PAGE_UP
- SCROLL_UP
- SCROLL_DOWN
- NAV_UP
- NAV_RIGHT
- NAV_DOWN
- NAV_LEFT
Tip
This pattern where an update method takes in an action is not unique to NavState. This pattern allows decoupeling how user input is represented from the update method. In other words, with this pattern, the update method does not need to parse user input, this responsibility is deligated to other parts of the program which follows the single responsibility principle.
res¶
The result produced from rendering a Layout.
NavState needs the result in order to know where on the screen certain
nodes were rendered. This data is used to check if the mouse position is inside
any (for example) buttons or scrollable areas. More on this topic will follow,
but in short you use the interaction_area() wrapper node to
mark nodes as interactible.
See also
nav_data¶
The keyboard navigation tree. This is discussed in the next section.
Keyboard Navigation¶
To facilitate keyboard navigation, you need to create a tree of InteractibleID s. Note that this tree is created separatly from the renderable ui tree. To create a simple container that can be vertically navigated through (by pressing up and down) use an InteractibleID as a container and create children with the child() method.
To detect if an interactible is active you can use the is_active() method after the update method was called.
To detect if an interactible is selected (was active and then enter was pressed) you can use is_selected() method.
from functui.nav import InteractibleID, NavState, NavAction, ROOT_VERTICAL
selectable_1 = ROOT_VERTICAL.child(0)
selectable_2 = ROOT_VERTICAL.child(1)
selectable_3 = ROOT_VERTICAL.child(2)
nav_tree = [selectable_1, selectable_2, selectable_3]
nav = NavState()
# at first nothing is active, so navigation in any direction
# will activate the first item in tree.
nav = nav.update(nav_tree=nav_tree, action=NavAction.NAV_UP)
# use .is_active method to test if an interactible is active
assert nav.is_active(selectable_1)
# use .is_selected method to test if an interactible is selected()
nav = nav.update(nav_tree=nav_tree, action=NavAction.SELECT_VIE_KEYBOARD)
assert nav.is_active(selectable_2)
nav = nav.update(nav_tree=nav_tree, action=NavAction.NAV_DOWN)
assert nav.is_active(selectable_2)
You can also nest containers and specify their navigation direction.
from functui.nav import InteractibleID, NavState, NavAction, ROOT_VERTICAL, Direction
root = ROOT_VERTICAL
# directiot=Direction.HORIZONTAL means navigating by
# NavAction.NAV_LEFT and NavAction.NAV_RIGHT
inner_container = root.child(0, direction=Direction.HORIZONTAL)
inner_child_1 = inner_container.child(0)
inner_child_2 = inner_container.child(1)
outer_child_1 = root.child(1)
# the above create a tree that looks something like this
# x-------------------------------x
# |x-----------------------------x|
# || inner_child_1 inner_child_2 ||
# |x-----------------------------x|
# | outer_child_1 |
# x-------------------------------x
nav_tree = [inner_child_1, inner_child_2, outer_child_1]
nav = NavState()
# just activate keyboard navigation.
nav = nav.update(nav_tree=nav_tree, action=NavAction.NAV_DOWN)
assert nav.is_active(inner_child_1)
nav = nav.update(nav_tree=nav_tree, action=NavAction.NAV_RIGHT)
assert nav.is_active(inner_child_2)
nav = nav.update(nav_tree=nav_tree, action=NavAction.NAV_DOWN)
assert nav.is_active(outer_child_1)
Mouse Detection¶
Mouse detection can be performed with an interaction_area()
wrapper node.
To detect if mouse is hovering over an intractable use the
is_hover() method.
Unlike keyboard navigation, selection is split into two stages.
is_held_down() method returns True while left
click is held down. When left click is relased
is_selected() returns true. This behaviour is
useful for implementing buttons that get highlighted when you hold left click
and have ability to be canceled if you move your mouse away.
NavData and The Elm Architecture¶
To allow for keyboard and mouse interactivity with the elm architecture set
NavAction as an attribute in your model.
@dataclass
class Model:
nav: NavData
...
Then in the update function update NavData before doing any other logic.
This is so that you can use methods like
is_selected() and not be a frame behind.
def update(input: InputEvent, res: Result, m: Model):
action = None
if input.key_event in default_nav_bindings:
action = default_nav_bindings[input.key_event]
m.nav = m.nav.update(
res=res,
action=action,
nav_tree=[...],
mouse_position=input.mouse_position_event
)
# Put your update code here
# for example, if a button is pressed do something
if m.nav.is_selected(...):
...
Below follows a template you can copy for elm applications that allows for interactivity.
from functui.classes import *
from functui.common import *
from functui.nav import ROOT_HORIZONTAL, ROOT_VERTICAL, InteractibleID, NavState, default_nav_bindings, interaction_area
from functui.io.curses import get_input_event, draw_result, wrapper
import curses
from dataclasses import dataclass
@dataclass
class Model():
nav: NavState
# Add more attributes to contain all of your persistent state.
def update(input: InputEvent, res: Result, m: Model):
# update NavState for keyboard and mouse interactivity.
action = None
if input.key_event in default_nav_bindings:
action = default_nav_bindings[input.key_event]
m.nav = m.nav.update(
res=res,
action=action,
nav_tree=[],
mouse_position=input.mouse_position_event
)
# Put your update code here.
# Create your keyboard navigation tree here.
def view(m: Model):
# Layout rendering code here.
layout = vbox([
text("Hello World!") | border,
text("Press ctrl+c to exit.") | fg(Color4.CYAN) | border,
])
return layout
m = Model(
nav = NavState(),
)
def main(stdscr: curses.window):
while True:
y, x = stdscr.getmaxyx()
res = layout_to_result(view(m), Rect(x, y))
draw_result(res, stdscr)
key: InputEvent = get_input_event(stdscr)
if key.key_event == 'ctrl+c':
break # exit program
update(key, res, m)
if __name__ == "__main__":
wrapper(main)