[ Concepts ]
Focus Management, Scopes, and Modal Trapping
The idea in plain words
Section titled “The idea in plain words”In a multi-widget terminal interface, only one interactive control should receive typed characters at a time, and users expect Tab, Shift+Tab, directional arrows, or mouse clicks to move focus predictably. koto::FocusManager centralizes focus state, syncs the .focused boolean on your state struct instances, and isolates focus inside modal dialogs when opened.
How koto implements it
Section titled “How koto implements it”- Type-erased
focus ring(std::vector<FocusTarget>): Callingfm.add(state)constrainsstatewith the C++20FocusableStateconcept (s.focusedands.handle_event(k)) and usesif constexprto detectHasMouseHandler(s.handle_mouse(m)) andHasPasteHandler(s.insert(str)), storing thin lambda closures inside aFocusTargetstruct (include/koto/focus.hpp:146-187). The first target added to an emptyFocusManagerreceives focus automatically (m_active_index = 0) (src/focus.cpp:117-124). - 1D and 2D navigation policies (
FocusNavPolicy):Tab(Key::Tab) andShift+Tab(Key::BackTab) always callfm.next()andfm.prev(), cycling across targets inm_targetsthat matchactive_modal_scope()and satisfyis_focusable(has_geometry())(src/focus.cpp:257-313, 550-558).- In
FocusNavPolicy::TabOnly(default), arrow keys are passed only to the focused widget and never move focus between widgets. - In
FocusNavPolicy::SpatialArrows, arrow keys not consumed by the focused widget invokefm.move(Direction), which compares harvestedboundscenters (center_row(),center_col()) and edge distances inactive_modal_scope()to pick the target minimizingprimary_dist + 2 * secondary_dist(src/focus.cpp:408-498, 580-586). - In
FocusNavPolicy::AltArrows,Alt+ arrow keys invokefm.move(Direction)before the focused widget sees the key (src/focus.cpp:561-567).
- Normal
scopevs.modal scope:- Pushing a normal
scope(fm.scope(Id("panel"))orfm.push_scope(id, ScopeMode::Normal)) combinesscope_idintofm.scoped_id(base_id)so reusable components can register local IDs without colliding (src/focus.cpp:779-791). - Pushing a
modal scope(fm.modal_scope(Id("dialog"))orfm.push_scope(id, ScopeMode::Modal)) saves the current focusedIdontom_saved_focus_stack, tags all subsequently registered targets withmodal_scope = scope_id, immediately focuses the first target registered in that modal, restrictsnext(),prev(),focus(), andmove()to targets withmodal_scope == scope_id, and absorbs mouse clicks outside the modal (src/focus.cpp:89-115, 636-700, 722-742). - Popping a
modal scope(fm.pop_scope()or~IdScope()) erases all targets witht.modal_scope == popped_idfromm_targetsand restores focus to the savedId(src/focus.cpp:744-777).
- Pushing a normal
TextInputState first("a");TextInputState second("b");fm.add(first).add(second);fm.next();Consequences for your code
Section titled “Consequences for your code”- Register persistent background widgets once before
app.run(): Becauseregister_targetupdates existing targets in place byId(src/focus.cpp:55-99), you can register backgroundstate structinstances once during setup and only callfm.push_scope(..., ScopeMode::Modal)+fm.add(...)when opening a modal dialog (andfm.pop_scope()when closing it). - Hidden widgets on inactive tabs are skipped automatically: Once
sync_geometry(frame)has harvested at least one valid bounding box (fm.has_geometry() == true), any registered widget that was not drawn during the most recenton_renderhasbounds == Area{0, 0, 0, 0}and is skipped bynext()andprev()(src/focus.cpp:265-277, 382-406).