Skip to main content

Focus

A view with one list handles keys itself. A view with a sidebar, a table and a filter has to decide which of them a key belongs to, and that decision is the same every time: Tab moves on, everything else goes to whichever pane has the cursor. FocusRing is that decision, written once.

The ring

public sealed class PackagesView : IArlecchinoView
{
private readonly FocusRing _focus;
private readonly Tree<Project> _tree;
private readonly Table<Package> _table;

public PackagesView(ArlecchinoKeymap keymap)
{
_tree = new Tree<Project>(keymap);
_table = new Table<Package>(keymap);

_focus = new FocusRing(keymap);
_focus.Add(_tree);
_focus.Add(_table);
}

public ViewRoute Handle(KeyPress key) => _focus.Handle(key).Route;

public ViewRoute HandleMouse(MouseEvent mouse) => _focus.HandleMouse(mouse).Route;
}
MemberMeaning
Add(element)Adds an element; the first one added starts focused
CurrentThe focused element, or null while the ring is empty
IndexPosition of the focused element
ItemsThe elements, in the order they were added
Focus(element)Moves the focus to a particular element of this ring
FocusNext() / FocusPrevious()Moves on, wrapping around
Handle(key)Moves the focus on NextField / PreviousField, and otherwise hands the key to the focused element
HandleMouse(mouse)Offers the event to every element and moves the focus to whichever claims it

The ring takes the keymap, so the keys that move the focus are the application's NextField and PreviousFieldTab and Shift+Tab unless they were rebound.

A ring inside a ring

FocusRing is an IArlecchinoFocusable itself, so one goes inside another and the view says nothing about it:

var fields = new FocusRing(keymap);
fields.Add(_name);
fields.Add(_version);

_focus.Add(fields);
_focus.Add(_table);

Tab walks into the inner ring, through what it holds, and out the far side into the next element of the outer one — a nested ring does not wrap, it hands the step back at either end. It remembers where it was left, so coming back from either direction lands where the cursor was rather than at the top.

That is the whole of what a widget made of parts needs: build a ring for the parts, add the ring.

IArlecchinoFocusable

public interface IArlecchinoFocusable
{
bool IsFocused { get; set; }
FocusResult Handle(KeyPress key);
FocusResult HandleMouse(MouseEvent mouse) => FocusResult.Ignored;
bool MoveFocus(FocusDirection direction) => false;
(string Key, string Description)[] Hints() => [];
}

IsFocused is set by the ring. Draw the element differently while it is false — that is what the Selection and ActiveSelection roles are for: the cursor row of the pane with the focus and the cursor row of the pane without it.

Only IsFocused and Handle have to be written. The other three have defaults that say nothing to add: an element that ignores the mouse, holds no focusable parts of its own, and has no keys worth listing implements two members and is done.

Hints is what the box at the bottom of the screen is built from. An element states the keys it reacts to while it has the focus, the screen collects them down the chain of focus, and the box follows the cursor instead of listing the same keys wherever it stands — the focused element's keys first, the screen's after them, minus any the element already claimed.

MoveFocus is for a widget made of several fields: it takes a step inside itself and answers true, or leaves the step alone and answers false, and the ring around it moves on as it always has.

Every interactive widget already implements it — ListBox, Table, Tree, Tabs, TextView, ScrollPane and Form — so a ring of widgets needs no adapter.

FocusResult

An element says what it did with an event:

ValueMeaning
FocusResult.IgnoredNot mine; whoever asked should keep looking
FocusResult.HandledMine, and nothing else should see it
FocusResult.Navigate(route)Mine, and the screen should go somewhere

WasHandled and Route are the two things a caller reads. Returning Ignored from the focused element is what lets a key fall through to the view — a screen-wide r for reload keeps working while a list has the cursor.

Panes that are not objects

A view that keeps its logic in methods rather than in widgets wraps them:

private readonly FocusRing _focus;
private readonly FocusablePane _places;

_places = new FocusablePane(HandlePlacesKey, HandlePlacesClick);
_focus.Add(_places);

That is how the file picker holds its list and its places sidebar.

Focus and the mouse

HandleMouse on the ring asks every element, not just the focused one, and moves the focus to the one that claims the event. So a click on an unfocused pane selects it and acts inside it in one go, which is what a click is expected to do. An element that does not care where the pointer was should return Ignored for events outside its own region.