Source code for functui.nav

"""Tools to make layouts responsive to keyboard and mouse input"""
from enum import Enum, auto
from typing import Self, Literal, Iterable, Any, NamedTuple
from dataclasses import dataclass, field
from types import MappingProxyType
from functools import partial
from .classes import Coordinate, Result, ResultData, Layout, Frame, Box, Rect, clamp, min_size_horizontal
from .common import vbox, offset, vbar

__all__ = [
    "NavAction",
    "KeyboardNavAction",
    "ScrollAction",

    "Direction",
    "InteractibleID",
    "InteractibleIDPart",
    "ROOT_VERTICAL",
    "ROOT_HORIZONTAL",
    "EMPTY_INTERACTIBLE",

    "NavState",
    "DEFAULT_NAV_BINDINGS",

    "interaction_area",
    "v_scroll",
]




type KeyboardNavAction = Literal[NavAction.NAV_DOWN, NavAction.NAV_UP, NavAction.NAV_LEFT, NavAction.NAV_RIGHT]
KEYBOARD_NAV_ACTION = [NavAction.NAV_DOWN, NavAction.NAV_UP, NavAction.NAV_LEFT, NavAction.NAV_RIGHT]

type ScrollAction = Literal[NavAction.PAGE_DOWN, NavAction.PAGE_UP, NavAction.SCROLL_DOWN, NavAction.SCROLL_UP]
SCROLL_ACTION = [NavAction.PAGE_DOWN, NavAction.PAGE_UP, NavAction.SCROLL_DOWN, NavAction.SCROLL_UP]

[docs] class Direction(Enum): """Used to specify navigation direction for a container defined by a :obj:`InteractibleID`. Attributes: VERTICAL: HORIZONTAL:""" VERTICAL = auto() HORIZONTAL = auto()
[docs] @dataclass(frozen=True, eq=True) class InteractibleIDPart: direction: Direction local_id: int persistent: bool first_child_default: bool
[docs] @dataclass(frozen=True, eq=True) class InteractibleID: """Used to create keyboard navigable tree. May be used either as a container or an item. If an id is created with the :obj:`InteractibleID.child` method (which is the preffered way of creating new InteractibleID's), the parent will be saved in the child as an :obj:`InteractibleIDPart` part.""" data: tuple[InteractibleIDPart, ...] """Every intercatible id stores its own part at the end of the data tuple, and its ancestors parts before it."""
[docs] def child(self, local_id: int, direction: None | Direction = None, persistent: bool = False) -> Self: """Create a new InteractibleID with specified attributes. The newly created child will remeber this ID as its parent. (as well as all of this ID's ancestors if there are any) Args: local_id: Child's local id relative to its parent (the InteractibleID on which this method is being called on). Used to distinguish this child from other children of same parent. direction: If child is used as a container, navigate it's children by specified direction. If no direction is provided, it will be inherited from child's parent. (the InteractibleID on which this method is being called on). persistent: If child is used as a container, remember which child was active and make it active insted of just the first elemnt if this container becomes active again. """ if len(self.data): return self.__class__((*self.data, InteractibleIDPart( direction=self.data[-1].direction if direction is None else direction, local_id=local_id, persistent=persistent, first_child_default=False ))) return self.__class__((*self.data, InteractibleIDPart( direction=direction if direction is not None else Direction.VERTICAL, local_id=local_id, persistent=persistent, first_child_default=False )))
@property def direction(self): return self.data[-1].direction @property def local_id(self): return self.data[-1].local_id @property def persistent(self): return self.data[-1].persistent @property def first_child_default(self): return self.data[-1].first_child_default @property def depth(self): return len(self.data) @property def parent(self): """may error""" # may error? return InteractibleID(self.data[:-1])
[docs] def mutual_ancestor(self, b: Self) -> Self: """Get the closest ID to which both self, and a are descendants.""" # enumerate to retain order and not earase duplicates # BUG this will error if a is longer than b AND the last part of the shorter one matches the longer one # technicaly this should not happend but it can i guess out = [] for i, part in enumerate(self.data): if part == b.data[i]: out.append(part) else: break return self.__class__(tuple(out))
def ancestors(self) -> list[Self]: out = [] for part in self.data: if len(out): out.append(InteractibleID((*out[-1].data, part,))) else: out.append(InteractibleID((part,))) return out def __bool__(self): return bool(len(self.data))
# def with_attributes(self, direction: Direction | None = None, persistent: bool | None = None, first_child_default: bool | None = None): # return self.__class__( # (*self.data[:-1], InteractibleIDPart( # direction=direction if direction is not None else self.data[-1].direction, # local_id=self.data[-1].local_id, # persistent=persistent if persistent is not None else self.data[-1].persistent, # first_child_default=first_child_default if first_child_default is not None else self.data[-1].first_child_default) # ) # ) ROOT_VERTICAL = InteractibleID((InteractibleIDPart(direction=Direction.VERTICAL, local_id=0, persistent=False, first_child_default=False),)) """A Root for a keyboard navigation tree who's children are navigated vertically.""" ROOT_HORIZONTAL = InteractibleID((InteractibleIDPart(direction=Direction.HORIZONTAL, local_id=0, persistent=False, first_child_default=False),)) """A Root for a keyboard navigation tree who's children are navigated horizontaly.""" EMPTY_INTERACTIBLE = InteractibleID(()) # EMPTY_INTERACTIBLE = InteractibleID((InteractibleIDPart(direction=Direction.VERTICAL, local_id=-1, persistent=False, first_child_default=False),)) @dataclass(frozen=True, eq=True) class SetState(ResultData): new_state: tuple[tuple[InteractibleID, Any], ...] def merge_children(self, child_data): return SetState((*self.new_state, *child_data.new_state)) def set_state(*new_state: tuple[InteractibleID, Any]): return SetState(new_state) # @dataclass(frozen=True, eq=True) # class NextInteractible(ResultData): # next_id: InteractibleID # def merge_children(self, child_data): # return child_data class BoxData(NamedTuple): visible_box: Box actual_box: Box dragable: bool class _HoveredData(NamedTuple): id: InteractibleID is_dragable: bool @dataclass(frozen=True, eq=True) class InteractionAreas(ResultData): areas: dict[InteractibleID, BoxData] def merge_children(self, child_data): self.areas.update(child_data.areas) return self
[docs] def interaction_area(interactible_id: InteractibleID, dragable=False): """A wrapper node that marks its child layout as interactive. Meant to be used along with :obj:`NavState`. This wrapper node also retrieves at which size and position child layout was rendered at. This allows mouse hover detection, and in a scrollable container, automatically scrolling to a child that became active through keyboard navigation. """ def _out(child: Layout): return Layout( func=interaction_area, min_size=child.min_size, render=partial(_render_interaction_area, interactible_id, child, dragable) ) return _out
def _render_interaction_area( interactible_id: InteractibleID, child: Layout, dragable: bool, frame: Frame, box: Box ) -> Result: res = Result() availabe_box = frame.view_box.intersect(box) res.set_data(InteractionAreas({interactible_id: BoxData(availabe_box, box, dragable)})) res.add_children_after([child.render(frame, box)]) return res def _try_find_nearest(nav_data: tuple[InteractibleID, ...], current_index: int, direction: Direction, backwards: bool) -> int | None: next_index = current_index advance = lambda n: n + (-1 if backwards else 1) next_index = advance(next_index) original_depth = len(nav_data[current_index].data) original_id = nav_data[current_index] while True: # if next index is out of bounds if next_index >= len(nav_data) or next_index < 0: return None # if next index parent is a different direction then inputed, # in this case just keep advancing index until either end of nav_data or direction matches and nav_depth is same or less than original if nav_data[next_index].mutual_ancestor(original_id).direction != direction: next_index = advance(next_index) continue # if skipped_ids and nav_data[next_index].depth > original_depth: # if depth exceeds original depth then continue # # in a strcuture similar to the following: # # # # vbox # # - item 1 # # - item 2 [start point] # # hbox # # - item 1 (with vbox submenu) # # - item 1 # # - item 2 # # vbox2 # # - item 1 [desired end point on navigating down] # # # # in order to get to desired point you have to skip the items in the vbox submenu # # it is this if statement that hinders you from selecting them. # next_index = advance(next_index) # continue # at this point we found an appropritae index return next_index class _ApplyRulesResult(NamedTuple): next_index: int depth: int done: bool def _apply_rules( persistent_selected_ids: MappingProxyType[InteractibleID, InteractibleID], nav_data: tuple[InteractibleID, ...], current_index: int, depth: int, backwards: bool ) -> _ApplyRulesResult: curr_id = nav_data[current_index] # find the depth at which the part is either persistent or first_child_default while True: if depth >= curr_id.depth: return _ApplyRulesResult(current_index, depth, True) part = curr_id.data[depth-1] if part.persistent or part.first_child_default: break depth += 1 parent = InteractibleID(curr_id.data[:depth]) if parent.persistent: remembered_id = persistent_selected_ids.get(parent, None) if remembered_id is not None and remembered_id in nav_data: next_id = remembered_id current_index = nav_data.index(next_id) return _ApplyRulesResult(current_index, depth, False) if backwards: # go to first index while True: if current_index <= 0: return _ApplyRulesResult(0, depth, True) curr_id = nav_data[current_index] if len(curr_id.data) > depth: if curr_id.data[depth].local_id == 0: return _ApplyRulesResult(current_index, depth, False) else: return _ApplyRulesResult(current_index, depth, False) current_index -= 1 return _ApplyRulesResult(current_index, depth, False) class _NavigationResult(NamedTuple): next_id: InteractibleID shared_parent: InteractibleID def _navigate_by_keyboard( persistent_selected_ids: MappingProxyType[InteractibleID, InteractibleID], current_index: int, nav_data: tuple[InteractibleID, ...], action: KeyboardNavAction ) -> _NavigationResult | None: direction = Direction.HORIZONTAL if action in (NavAction.NAV_RIGHT, NavAction.NAV_LEFT) else Direction.VERTICAL backwards = False if direction == Direction.HORIZONTAL: backwards = True if action == NavAction.NAV_LEFT else False elif direction == Direction.VERTICAL: backwards = True if action == NavAction.NAV_UP else False next_index = _try_find_nearest(nav_data, current_index, direction, backwards) if next_index is not None: next_id = nav_data[next_index] current_id = nav_data[current_index] shared_parent = next_id.mutual_ancestor(current_id) next_parent = next_id.parent current_parent = current_id.parent if next_parent == current_parent: # parent is the same, no need to look up persistent data return _NavigationResult(next_id, shared_parent) done = False depth = shared_parent.depth while not done: next_index, depth, done = _apply_rules(persistent_selected_ids, nav_data, next_index, depth, backwards) depth += 1 next_id = nav_data[next_index] return _NavigationResult(next_id, shared_parent) def debug_interactible_str(id: InteractibleID): return "|".join(f"{"1" if i.first_child_default else " "}{"p" if i.persistent else " "}{i.local_id}{"V" if i.direction == Direction.VERTICAL else "H"}" for i in id.data) def debug_nav_data_str(state: NavState, nav_data: Iterable[InteractibleID], persistent: bool = True): out = ["==| first_child_default | persistent | local_id | direction |=="] for id in nav_data: interactible_str = debug_interactible_str(id) out.append((">" if state.is_active(id) else " ") + interactible_str) if persistent and state._persistent_selected_id: out.append("== Persistent ==") for id in state._persistent_selected_id.values(): interactible_str = debug_interactible_str(id) out.append((">" if state.is_active(id) else " ") + interactible_str) return "\n".join(out) DEFAULT_NAV_BINDINGS = { "h": NavAction.NAV_LEFT, "left": NavAction.NAV_LEFT, "j": NavAction.NAV_DOWN, "down": NavAction.NAV_DOWN, "k": NavAction.NAV_UP, "up": NavAction.NAV_UP, "l": NavAction.NAV_RIGHT, "right": NavAction.NAV_RIGHT, "enter": NavAction.SELECT_VIA_KEYBOARD, " ": NavAction.SELECT_VIA_KEYBOARD, "left mouse": NavAction.SELECT_VIA_MOUSE_START, "left mouse released": NavAction.SELECT_VIA_MOUSE_END, "page up": NavAction.PAGE_UP, "ctrl+u": NavAction.PAGE_UP, "page down": NavAction.PAGE_DOWN, "ctrl+d": NavAction.PAGE_DOWN, "mouse wheel down": NavAction.SCROLL_DOWN, "mouse wheel up": NavAction.SCROLL_UP } """A dictinary that maps the string representation of keycodes to a :obj:`NavAction`""" class _NewActiveBox(NamedTuple): box: Box reverse: bool = False
[docs] def v_scroll(container_id: InteractibleID, nav: NavState): """Allow vertical scrolling if child does not fit into available space.""" def _v_scroll(child: Layout): at_y: int | None = nav.try_state(container_id, int) if at_y is None: at_y = 0 # find active box active_box = None if (_active_box := nav.areas.get(nav.active_id, None)) is not None\ and nav.action in KEYBOARD_NAV_ACTION\ and nav.active_id.data[:len(container_id.data)] == container_id.data: # ^^^^^^^^ if active_id is a child of container_id active_box = _NewActiveBox(_active_box.actual_box, nav.action == NavAction.NAV_UP) at_y += nav.get_scrolling_difference() return Layout( func=v_scroll, min_size=child.min_size, render=partial( _v_scroll_render, at_y, active_box, container_id, child, ) ) return _v_scroll
def _v_scroll_render( scroll_dy: int, active_box: _NewActiveBox | None, container_id: InteractibleID, child: Layout, frame: Frame, box: Box ): # move to selected if selected out of bounds a = [] if active_box is not None: selected_at_y = active_box.box.position.y - box.position.y # to local space start = 0 # including end = box.height # excluding # a.append(text(str(start))) # a.append(text(str(end))) # a.append(text("scroll_dy:" + str(scroll_dy))) # a.append(text("selected_at_local:" + str(selected_at_y))) # a.append(text("selected_at_global:" + str(active_box))) if active_box.reverse: # aproach form below if not (start <= selected_at_y < end): scroll_dy += (selected_at_y) else: # aproach from above if not (start <= (selected_at_y + active_box.box.height) < end): scroll_dy += (selected_at_y - box.height + active_box.box.height) scroll_dy = clamp(scroll_dy, 0, child.min_size(frame.measure_text, Rect(box.width, 9999)).height - box.height ) res = Result() res.set_data(set_state((container_id, scroll_dy))) # a.append(text("final:" + str(scroll_dy))) modified_child = vbox([child,], at_y=-scroll_dy) res.add_children_after([modified_child.render(frame, box)]) return res