183 lines
5.6 KiB
Python
183 lines
5.6 KiB
Python
|
|
"""
|
||
|
|
Utilities to move index-based selections backward/forward.
|
||
|
|
|
||
|
|
These utilities concern themselves with selections where not all options are available,
|
||
|
|
otherwise it would be enough to increment/decrement the index and use the operator `%`
|
||
|
|
to implement wrapping.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from itertools import count
|
||
|
|
from typing import Literal, Protocol, Sequence
|
||
|
|
|
||
|
|
from typing_extensions import TypeAlias
|
||
|
|
|
||
|
|
from textual._loop import loop_from_index
|
||
|
|
|
||
|
|
|
||
|
|
class Disableable(Protocol):
|
||
|
|
"""Non-widgets that have an enabled/disabled status."""
|
||
|
|
|
||
|
|
disabled: bool
|
||
|
|
|
||
|
|
|
||
|
|
Direction: TypeAlias = Literal[-1, 1]
|
||
|
|
"""Valid values to determine navigation direction.
|
||
|
|
|
||
|
|
In a vertical setting, 1 points down and -1 points up.
|
||
|
|
In a horizontal setting, 1 points right and -1 points left.
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def get_directed_distance(
|
||
|
|
index: int, start: int, direction: Direction, wrap_at: int
|
||
|
|
) -> int:
|
||
|
|
"""Computes the distance going from `start` to `index` in the given direction.
|
||
|
|
|
||
|
|
Starting at `start`, this is the number of steps you need to take in the given
|
||
|
|
`direction` to reach `index`, assuming there is wrapping at 0 and `wrap_at`.
|
||
|
|
This is also the smallest non-negative integer solution `d` to
|
||
|
|
`(start + d * direction) % wrap_at == index`.
|
||
|
|
|
||
|
|
The diagram below illustrates the computation of `d1 = distance(2, 8, 1, 10)` and
|
||
|
|
`d2 = distance(2, 8, -1, 10)`:
|
||
|
|
|
||
|
|
```
|
||
|
|
start ────────────────────┐
|
||
|
|
index ────────┐ │
|
||
|
|
indices 0 1 2 3 4 5 6 7 8 9
|
||
|
|
d1 2 3 4 0 1
|
||
|
|
> > > > > (direction == 1)
|
||
|
|
d2 6 5 4 3 2 1 0
|
||
|
|
< < < < < < < (direction == -1)
|
||
|
|
```
|
||
|
|
|
||
|
|
Args:
|
||
|
|
index: The index that we want to reach.
|
||
|
|
start: The starting point to consider when computing the distance.
|
||
|
|
direction: The direction in which we want to compute the distance.
|
||
|
|
wrap_at: Controls at what point wrapping around takes place.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The computed distance.
|
||
|
|
"""
|
||
|
|
return direction * (index - start) % wrap_at
|
||
|
|
|
||
|
|
|
||
|
|
def find_first_enabled(
|
||
|
|
candidates: Sequence[Disableable],
|
||
|
|
) -> int | None:
|
||
|
|
"""Find the first enabled candidate in a sequence of possibly-disabled objects.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
candidates: The sequence of candidates to consider.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The first enabled candidate or `None` if none were available.
|
||
|
|
"""
|
||
|
|
return next(
|
||
|
|
(index for index, candidate in enumerate(candidates) if not candidate.disabled),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def find_last_enabled(candidates: Sequence[Disableable]) -> int | None:
|
||
|
|
"""Find the last enabled candidate in a sequence of possibly-disabled objects.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
candidates: The sequence of candidates to consider.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The last enabled candidate or `None` if none were available.
|
||
|
|
"""
|
||
|
|
total_candidates = len(candidates)
|
||
|
|
return next(
|
||
|
|
(
|
||
|
|
total_candidates - offset_from_end
|
||
|
|
for offset_from_end, candidate in enumerate(reversed(candidates), start=1)
|
||
|
|
if not candidate.disabled
|
||
|
|
),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def find_next_enabled(
|
||
|
|
candidates: Sequence[Disableable],
|
||
|
|
anchor: int | None,
|
||
|
|
direction: Direction,
|
||
|
|
) -> int | None:
|
||
|
|
"""Find the next enabled object if we're currently at the given anchor.
|
||
|
|
|
||
|
|
The definition of "next" depends on the given direction and this function will wrap
|
||
|
|
around the ends of the sequence of object candidates.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
candidates: The sequence of object candidates to consider.
|
||
|
|
anchor: The point of the sequence from which we'll start looking for the next
|
||
|
|
enabled object.
|
||
|
|
direction: The direction in which to traverse the candidates when looking for
|
||
|
|
the next enabled candidate.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The next enabled object. If none are available, return the anchor.
|
||
|
|
"""
|
||
|
|
|
||
|
|
if anchor is None:
|
||
|
|
if candidates:
|
||
|
|
return (
|
||
|
|
find_first_enabled(candidates)
|
||
|
|
if direction == 1
|
||
|
|
else find_last_enabled(candidates)
|
||
|
|
)
|
||
|
|
return None
|
||
|
|
|
||
|
|
for index, candidate in loop_from_index(candidates, anchor, direction, wrap=True):
|
||
|
|
if not candidate.disabled:
|
||
|
|
return index
|
||
|
|
return anchor
|
||
|
|
|
||
|
|
|
||
|
|
def find_next_enabled_no_wrap(
|
||
|
|
candidates: Sequence[Disableable],
|
||
|
|
anchor: int | None,
|
||
|
|
direction: Direction,
|
||
|
|
with_anchor: bool = False,
|
||
|
|
) -> int | None:
|
||
|
|
"""Find the next enabled object starting from the given anchor (without wrapping).
|
||
|
|
|
||
|
|
The meaning of "next" and "past" depend on the direction specified.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
candidates: The sequence of object candidates to consider.
|
||
|
|
anchor: The point of the sequence from which we'll start looking for the next
|
||
|
|
enabled object.
|
||
|
|
direction: The direction in which to traverse the candidates when looking for
|
||
|
|
the next enabled candidate.
|
||
|
|
with_anchor: Whether to consider the anchor or not.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The next enabled object. If none are available, return None.
|
||
|
|
"""
|
||
|
|
|
||
|
|
if anchor is None:
|
||
|
|
if candidates:
|
||
|
|
return (
|
||
|
|
find_first_enabled(candidates)
|
||
|
|
if direction == 1
|
||
|
|
else find_last_enabled(candidates)
|
||
|
|
)
|
||
|
|
return None
|
||
|
|
|
||
|
|
start = anchor if with_anchor else anchor + direction
|
||
|
|
counter = count(start, direction)
|
||
|
|
valid_candidates = (
|
||
|
|
candidates[start:] if direction == 1 else reversed(candidates[: start + 1])
|
||
|
|
)
|
||
|
|
|
||
|
|
for idx, candidate in zip(counter, valid_candidates):
|
||
|
|
if candidate.disabled:
|
||
|
|
continue
|
||
|
|
return idx
|
||
|
|
return None
|