Skip to content

Commit

Permalink
Directional Navigation (#201)
Browse files Browse the repository at this point in the history
* Work on directional navigation

* Tab navigation is working now

* Clean up interface, write a lil documentation
  • Loading branch information
LPGhatguy authored Jan 25, 2025
1 parent 4d44508 commit 018ec80
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 24 deletions.
23 changes: 21 additions & 2 deletions crates/yakui-core/src/input/input_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::dom::{Dom, DomNode};
use crate::event::{Event, EventInterest, EventResponse, WidgetEvent};
use crate::id::WidgetId;
use crate::layout::LayoutDom;
use crate::navigation::{navigate, NavDirection};
use crate::widget::EventContext;

use super::mouse::MouseButton;
Expand All @@ -32,6 +33,9 @@ pub struct InputState {
/// The widget that was selected last frame.
last_selection: Cell<Option<WidgetId>>,

/// If there's a pending navigation event, it's stored here!
pending_navigation: Cell<Option<NavDirection>>,

/// If set, text input should be active.
text_input_enabled: Cell<bool>,
}
Expand Down Expand Up @@ -112,8 +116,9 @@ impl InputState {
mouse_entered_and_sunk: Vec::new(),
mouse_down_in: HashMap::new(),
}),
last_selection: Cell::new(None),
selection: Cell::new(None),
last_selection: Cell::new(None),
pending_navigation: Cell::new(None),
text_input_enabled: Cell::new(false),
}
}
Expand All @@ -125,8 +130,17 @@ impl InputState {
}

/// Finish applying input events for this frame.
pub fn finish(&self) {
pub fn finish(&self, dom: &Dom, layout: &LayoutDom) {
self.settle_buttons();
self.handle_navigation(dom, layout);
}

fn handle_navigation(&self, dom: &Dom, layout: &LayoutDom) {
if let Some(dir) = self.pending_navigation.take() {
if let Some(new_focus) = navigate(dom, layout, self, dir) {
dom.request_focus(new_focus);
}
}
}

/// Enables text input. Should be called every update from a widget that
Expand All @@ -151,6 +165,11 @@ impl InputState {
self.selection.set(id);
}

/// Attempt to navigate in a direction within the UI.
pub fn navigate(&self, dir: NavDirection) {
self.pending_navigation.set(Some(dir));
}

pub(crate) fn handle_event(
&self,
dom: &Dom,
Expand Down
2 changes: 0 additions & 2 deletions crates/yakui-core/src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@
mod input_state;
mod mouse;
mod mouse_interest;
mod navigation;

pub(crate) use self::mouse_interest::*;

pub use self::input_state::*;
pub use self::mouse::*;
pub use self::navigation::*;

pub use keyboard_types::{Code as KeyCode, Modifiers};
16 changes: 0 additions & 16 deletions crates/yakui-core/src/input/navigation.rs

This file was deleted.

1 change: 1 addition & 0 deletions crates/yakui-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod event;
pub mod geometry;
pub mod input;
pub mod layout;
pub mod navigation;
pub mod paint;
pub mod widget;

Expand Down
47 changes: 47 additions & 0 deletions crates/yakui-core/src/navigation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! Types and utilities for handling UI navigation with mice, keyboards, and
//! gamepads.
use crate::dom::Dom;
use crate::input::InputState;
use crate::layout::LayoutDom;
use crate::widget::NavigateContext;
use crate::WidgetId;

/// Possible directions that a user can navigate in when using a gamepad or
/// keyboard in a UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(missing_docs)]
pub enum NavDirection {
/// The next widget in the layout, used when the user presses tab.
Next,

/// The previous widget in the layout, used if the user presses shift+tab.
Previous,

Down,
Up,
Left,
Right,
}

pub(crate) fn navigate(
dom: &Dom,
layout: &LayoutDom,
input: &InputState,
dir: NavDirection,
) -> Option<WidgetId> {
let mut current = input.selection();

while let Some(id) = current {
let node = dom.get(id).unwrap();
let ctx = NavigateContext { dom, layout, input };

if let Some(new_id) = ctx.try_navigate(id, dir) {
return Some(new_id);
}

current = node.parent;
}

None
}
2 changes: 1 addition & 1 deletion crates/yakui-core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ impl Yakui {
self.layout.sync_removals(&self.dom.removed_nodes());
self.layout
.calculate_all(&self.dom, &self.input, &self.paint);
self.input.finish();
self.input.finish(&self.dom, &self.layout);
}

/// Calculates the geometry needed to render the current state and gives
Expand Down
134 changes: 132 additions & 2 deletions crates/yakui-core/src/widget.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Defines traits for building widgets.
use std::any::{type_name, Any, TypeId};
use std::collections::VecDeque;
use std::fmt;

use glam::Vec2;
Expand All @@ -9,8 +10,9 @@ use crate::dom::Dom;
use crate::event::EventResponse;
use crate::event::{EventInterest, WidgetEvent};
use crate::geometry::{Constraints, FlexFit};
use crate::input::{InputState, NavDirection};
use crate::input::InputState;
use crate::layout::LayoutDom;
use crate::navigation::NavDirection;
use crate::paint::PaintDom;
use crate::{Flow, WidgetId};

Expand Down Expand Up @@ -65,12 +67,54 @@ pub struct EventContext<'dom> {

/// Information available to a widget when it is being queried for navigation.
#[allow(missing_docs)]
#[derive(Clone, Copy)]
pub struct NavigateContext<'dom> {
pub dom: &'dom Dom,
pub layout: &'dom LayoutDom,
pub input: &'dom InputState,
}

impl<'dom> NavigateContext<'dom> {
/// Query for navigation to the given widget or one of its descendents.
pub fn try_navigate(&self, widget: WidgetId, dir: NavDirection) -> Option<WidgetId> {
self.dom.enter(widget);
let node = self.dom.get(widget).unwrap();

println!(
"Enter Navigate {dir:?} on {widget:?} ({})",
node.widget.type_name()
);

let res = node.widget.navigate(*self, dir);

println!(
"Result of Navigate {dir:?} on {widget:?} ({}): {res:?}",
node.widget.type_name()
);

self.dom.exit(widget);

res
}

/// Tells whether `descendent` is a descendent of `parent`.
pub fn contains(&self, parent: WidgetId, descendent: WidgetId) -> bool {
let mut queue = VecDeque::new();
queue.push_back(parent);

while let Some(current) = queue.pop_front() {
if current == descendent {
return true;
}

let node = self.dom.get(current).unwrap();
queue.extend(node.children.iter().copied());
}

false
}
}

/// A yakui widget. Implement this trait to create a custom widget if composing
/// existing widgets does not solve your use case.
pub trait Widget: 'static + fmt::Debug {
Expand Down Expand Up @@ -168,7 +212,86 @@ pub trait Widget: 'static + fmt::Debug {
/// given direction.
#[allow(unused)]
fn navigate(&self, ctx: NavigateContext<'_>, dir: NavDirection) -> Option<WidgetId> {
None
let node_id = ctx.dom.current();
let node = ctx.dom.get_current();

let selection = ctx.input.selection()?;
let mut current_index = None;

for (index, &child) in node.children.iter().enumerate() {
if ctx.contains(child, selection) {
current_index = Some(index);
break;
}
}

if let Some(index) = current_index {
// The navigation is originating from inside this widget. This
// widget should find the next focusable child, or return None.

match dir {
NavDirection::Next => {
for &child in node.children.iter().skip(index + 1) {
if let Some(id) = ctx.try_navigate(child, NavDirection::Next) {
return Some(id);
}
}
}

NavDirection::Previous => {
if let Some(prev_index) = index.checked_sub(1) {
let skip = node.children.len() - prev_index - 1;
for &child in node.children.iter().rev().skip(skip) {
if let Some(id) = ctx.try_navigate(child, NavDirection::Previous) {
return Some(id);
}
}
}
}

_ => {
log::debug!("NavDirection::{dir:?} not implemented in Widget::navigate.");
}
}

None
} else {
// The navigation is originating from outside this widget. This code
// should pick the widget that's nearest to the given navigation
// direction that's focusable.

if selection != node_id && self.event_interest().contains(EventInterest::FOCUS) {
// This widget is directly focusable, so focus it!
return Some(node_id);
}

match dir {
NavDirection::Next => {
for &child in &node.children {
if let Some(id) = ctx.try_navigate(child, NavDirection::Next) {
return Some(id);
}
}

None
}

NavDirection::Previous => {
for &child in node.children.iter().rev() {
if let Some(id) = ctx.try_navigate(child, NavDirection::Previous) {
return Some(id);
}
}

None
}

_ => {
log::debug!("NavDirection::{dir:?} not implemented in Widget::navigate.");
None
}
}
}
}
}

Expand All @@ -194,6 +317,9 @@ pub trait ErasedWidget: Any + fmt::Debug {

/// Returns the type name of the widget, usable only for debugging.
fn type_name(&self) -> &'static str;

/// See [`Widget::navigate`].
fn navigate(&self, ctx: NavigateContext<'_>, dir: NavDirection) -> Option<WidgetId>;
}

impl<T> ErasedWidget for T
Expand Down Expand Up @@ -229,6 +355,10 @@ where
fn type_name(&self) -> &'static str {
type_name::<T>()
}

fn navigate(&self, ctx: NavigateContext<'_>, dir: NavDirection) -> Option<WidgetId> {
<T as Widget>::navigate(self, ctx, dir)
}
}

mopmopafy!(ErasedWidget);
18 changes: 17 additions & 1 deletion crates/yakui-widgets/src/widgets/textbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use cosmic_text::{Edit, Selection};
use yakui_core::event::{EventInterest, EventResponse, WidgetEvent};
use yakui_core::geometry::{Color, Constraints, Rect, Vec2};
use yakui_core::input::{KeyCode, Modifiers, MouseButton};
use yakui_core::navigation::NavDirection;
use yakui_core::paint::PaintRect;
use yakui_core::widget::{EventContext, LayoutContext, PaintContext, Widget};
use yakui_core::Response;
Expand Down Expand Up @@ -359,7 +360,10 @@ impl Widget for TextBoxWidget {
}

fn event_interest(&self) -> EventInterest {
EventInterest::MOUSE_INSIDE | EventInterest::FOCUSED_KEYBOARD | EventInterest::MOUSE_MOVE
EventInterest::MOUSE_INSIDE
| EventInterest::FOCUS
| EventInterest::FOCUSED_KEYBOARD
| EventInterest::MOUSE_MOVE
}

fn event(&mut self, ctx: EventContext<'_>, event: &WidgetEvent) -> EventResponse {
Expand Down Expand Up @@ -486,6 +490,18 @@ impl Widget for TextBoxWidget {
let res;

match key {
KeyCode::Tab => {
if *down {
if modifiers.shift() {
ctx.input.navigate(NavDirection::Previous);
} else {
ctx.input.navigate(NavDirection::Next);
}
}

res = EventResponse::Sink;
}

KeyCode::ArrowLeft => {
if *down {
if modifiers.shift() {
Expand Down
Loading

0 comments on commit 018ec80

Please sign in to comment.