# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.
"""Classes to control the view of the MultibodyTUI."""
from typing import TypeVar, Generic, Protocol, Self
from collections.abc import Iterable
[docs]
class HasId(Protocol):
"""Structural type to require an id getter method."""
[docs]
def id(self) -> int:
"""Id of the object.
Returns
-------
int
The ID of the object.
"""
...
# Generic type satisfying the HasId protocol
Value = TypeVar("Value", bound=HasId)
[docs]
class View(Generic[Value]):
"""Container for exploring subsets of items of a given type.
At any time, the view has a current list of items (the 'selection').
And, if the selection is nonempty, a single specific item from the
selection (the 'current item').
Key features:
- Change to a different selection, maintaining the current item if
it is a member of the new selection.
- Mark the current item as a 'favorite' and list all favorites.
- Take any number of steps forward or backward in the selection.
"""
def __init__(self, name: str, init_values: Iterable[Value]):
"""Create a View instance.
Parameters
----------
name: str
Name of the view
init_values: Iterable[Value]
The initial selection
"""
self._name = name
self._values = list(init_values)
self._index = 0
self._favorites = {}
[docs]
def copy(self) -> Self:
"""Create a copy of this view.
Returns
-------
typing.Self
A copy of this view
"""
copy = View(self._name, self._values)
copy._index = self._index
copy._favorites = self._favorites
return copy
@property
def current(self) -> Value | None:
"""The current item or None if the selection is empty."""
if not self._values:
return None
return self._values[self._index]
@property
def selection(self) -> list[Value]:
"""The selection."""
return self._values[:]
@property
def index(self) -> int:
"""The index of the current item in the selection list."""
return self._index
@property
def name(self) -> str:
"""The name of the view."""
return self._name
[docs]
def select(self, values: Iterable[Value], current: Value | None = None):
"""Change to a new selection.
Parameters
----------
values: Iterable[Value]
The new selection
current: Value | None = None
The preferred current item in the new selection, using the
current item from the prior selection if None.
"""
values = list(values)
if not values:
return
if current is None:
current = self.current
if current is None:
index = 0
else:
for index, candidate in enumerate(values):
if candidate.id() == current.id():
break
else:
index = 0
self._values = values
self._index = index
[docs]
def isFavorite(self, value: Value) -> bool:
"""Whether the given item is a favorite.
Parameters
----------
value: Value
The item to check
Returns
-------
bool
Whether the given item is a favorite
"""
return value.id() in self._favorites
@property
def on_favorite(self) -> bool:
"""Whether the current item is a favorite."""
value = self.current
if value is None:
return False
return self.isFavorite(value)
@property
def favorites(self) -> list[Value]:
"""The list of all favorite items."""
return list(self._favorites.values())
[docs]
def toggleFavorite(self, value: Value | None = None):
"""Toggle the favorite status of an item.
Parameters
----------
value: Value | None = None
The item to toggle, or the current item if None
"""
if value is None:
value = self.current
if value is None:
return
# Copy the favorites if we're going to modify it. Without
# doing this copy, toggling a favorite in a copy of this
# View would also affect the favorites of this View.
# Instead, View.copy could copy the favorites dictionary,
# but that would result in more overall favorites copies
# getting created, so we do the copy here instead.
favorites = self._favorites.copy()
if value.id() in favorites:
del favorites[value.id()]
else:
favorites[value.id()] = value
self._favorites = favorites
[docs]
def next(self, step: int = 1):
"""Increment the current item index.
When the step size is greater than one, this has 'sticky'
behavior around the endpoints of the selection, stopping early
at both the end and beginning of the selection. This is more
complex than the more obvious solution of using modulo, but will
hopefully result in more intuitive behavior to the user.
Parameters
----------
step: int = 1
The amount to increment the current item index
"""
nval = len(self._values)
if nval < 2:
return
i = self._index + step
if i >= nval:
# Make the point where we wrap around "sticky"
if self._index < nval - 1:
i = nval - 1
else:
i = 0
self._index = i
[docs]
def prev(self, step: int = 1):
"""Decrement the current item index.
When the step size is greater than one, this has 'sticky'
behavior around the endpoints of the selection, stopping early
at both the beginning and end of the selection. This is more
complex than the more obvious solution of using modulo, but will
hopefully result in more intuitive behavior to the user.
Parameters
----------
step: int = 1
The amount to decrement the current item index
"""
nval = len(self._values)
if nval < 2:
return
i = self._index - step
if i < 0:
# Make the point where we wrap around "sticky"
if self._index > 0:
i = 0
else:
i = nval - 1
self._index = i
[docs]
def __bool__(self) -> bool:
"""Whether the view's selection is nonempty."""
return bool(self._values)
[docs]
def __eq__(self, other: "View") -> bool:
"""Whether the views have identical state."""
if self._index != other._index:
return False
if self._values != other._values:
return False
if self._favorites != other._favorites:
return False
return True
[docs]
def __len__(self) -> int:
"""Get the number of items in the view's selection."""
return len(self._values)