Skip to content
Snippets Groups Projects

Refactor Cart into checkerboard table

Merged Mateusz Kudela requested to merge mkudela/issue-173 into develop
2 files
+ 138
140
Compare changes
  • Side-by-side
  • Inline
Files
2
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING
from textual import on
from textual.binding import Binding
from textual.containers import Container
from textual.css.query import NoMatches
from textual.containers import Horizontal
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Static
from clive.__private.core.formatters.humanize import humanize_operation_details, humanize_operation_name
@@ -15,30 +14,22 @@ from clive.__private.ui.get_css import get_relative_css_path
from clive.__private.ui.shared.base_screen import BaseScreen
from clive.__private.ui.transaction_summary import TransactionSummaryFromCart
from clive.__private.ui.widgets.clive_button import CliveButton
from clive.__private.ui.widgets.clive_checkerboard_table import (
EVEN_CLASS_NAME,
ODD_CLASS_NAME,
CliveCheckerboardTable,
CliveCheckerBoardTableCell,
CliveCheckerboardTableRow,
)
from clive.__private.ui.widgets.clive_widget import CliveWidget
from clive.__private.ui.widgets.dynamic_label import DynamicLabel
from clive.__private.ui.widgets.scrolling import ScrollablePart
if TYPE_CHECKING:
from textual.app import ComposeResult
from typing_extensions import Self
from clive.__private.core.profile_data import ProfileData
from clive.models import Operation
class DynamicColumn(DynamicLabel):
"""Column with dynamic content."""
class StaticColumn(Static):
"""Column with static content."""
class ColumnLayout(Static):
"""Holds column order."""
class ButtonMoveUp(CliveButton):
"""Button used for moving the operation up in the cart."""
@@ -57,14 +48,12 @@ class ButtonDelete(CliveButton):
"""Button used for removing the operation from cart."""
def __init__(self) -> None:
super().__init__("Remove", id_="delete-button")
class StaticPart(Container):
"""Container for the static part of the screen - title, global buttons and table header."""
super().__init__("Remove", id_="delete-button", variant="error")
class CartItem(ColumnLayout, CliveWidget):
class CartItem(CliveCheckerboardTableRow, CliveWidget, can_focus=True):
reactive_idx: reactive[int] = reactive(0)
"""Row of CartTable."""
BINDINGS = [
Binding("ctrl+up", "select_previous", "Prev"),
Binding("ctrl+down", "select_next", "Next"),
]
@@ -90,1+79,1 @@
def __init__(self, operation_idx: int) -> None:
self.__idx = operation_idx
assert self.is_valid(), "During construction, index has to be valid"
super().__init__()
assert self.is_valid(self.__idx), "During construction, index has to be valid"
super().__init__(
CliveCheckerBoardTableCell(self.get_operation_index(), classes="index"),
CliveCheckerBoardTableCell(self.get_operation_name(), classes="operation-type"),
CliveCheckerBoardTableCell(self.get_operation_details(), classes="operation-details"),
CliveCheckerBoardTableCell(
Horizontal(
ButtonMoveUp(disabled=self.__is_first), ButtonMoveDown(disabled=self.__is_last), ButtonDelete()
),
classes="actions",
),
)
def __repr__(self) -> str:
return f"{self.__class__.__name__}(idx={self.__idx})"
def on_mount(self) -> None:
self.reactive_idx = self.__idx
if self.__is_first:
self.unbind("ctrl+up")
elif self.__is_last:
self.unbind("ctrl+down")
def is_valid(self) -> bool:
return self.__idx < self.__operations_count
def compose(self) -> ComposeResult:
def get_operation_index(_: ProfileData) -> str:
return f"{self.__idx + 1}." if self.is_valid() else "?"
def watch_reactive_idx(self, idx: int) -> None:
assert self.is_valid(idx)
self.__idx = idx
self.query_one(".index").update_content(self.get_operation_index()) # type: ignore[attr-defined]
def get_operation_name(_: ProfileData) -> str:
return humanize_operation_name(self.operation) if self.is_valid() else "?"
def is_valid(self, idx: int) -> bool:
return idx < self.__operations_count
def get_operation_details(_: ProfileData) -> str:
return humanize_operation_details(self.operation) if self.is_valid() else "?"
def get_operation_index(self) -> str:
return f"{self.__idx + 1}." if self.is_valid(self.idx) else "?"
yield DynamicColumn(
self.app.world,
"profile_data",
get_operation_index,
classes="cell cell-middle",
)
yield DynamicColumn(
self.app.world,
"profile_data",
get_operation_name,
shrink=True,
classes="cell cell-variant cell-middle",
)
yield DynamicColumn(
self.app.world,
"profile_data",
get_operation_details,
shrink=True,
classes="cell",
)
yield ButtonMoveUp(disabled=self.__is_first)
yield ButtonMoveDown(disabled=self.__is_last)
yield ButtonDelete()
def focus(self, _: bool = True) -> Self: # noqa: FBT001, FBT002
if focused := self.app.focused: # Focus the corresponding button as it was before
assert focused.id, "Previously focused widget has no id!"
with contextlib.suppress(NoMatches):
previous = self.get_child_by_id(focused.id)
if previous.focusable:
previous.focus()
return self
for child in reversed(self.children): # Focus first focusable
if child.focusable:
child.focus()
return self
def get_operation_name(self) -> str:
return humanize_operation_name(self.operation) if self.is_valid(self.idx) else "?"
def action_select_previous(self) -> None:
self.post_message(self.Focus(target_idx=self.__idx - 1))
@@ -160,13 +123,16 @@ class CartItem(ColumnLayout, CliveWidget):
def action_select_next(self) -> None:
self.post_message(self.Focus(target_idx=self.__idx + 1))
def get_operation_details(self) -> str:
return humanize_operation_details(self.operation) if self.is_valid(self.idx) else "?"
@property
def idx(self) -> int:
return self.__idx
@property
def operation(self) -> Operation:
assert self.is_valid(), "cannot get operation, position is invalid"
assert self.is_valid(self.idx), "cannot get operation, position is invalid"
return self.app.world.profile_data.cart[self.__idx]
@property
@@ -200,12 +166,29 @@ class CartItem(ColumnLayout, CliveWidget):
self.app.trigger_profile_data_watchers()
class CartHeader(ColumnLayout):
class CartHeader(Horizontal):
def compose(self) -> ComposeResult:
yield StaticColumn("No.", classes="cell cell-middle")
yield StaticColumn("Operation type", classes="cell cell-variant cell-middle")
yield StaticColumn("Operation details", classes="cell cell-middle")
yield StaticColumn("Actions", id="actions", classes="cell cell-variant cell-middle")
yield Static("No.", classes=f"{ODD_CLASS_NAME} index")
yield Static("Operation type", classes=f"{EVEN_CLASS_NAME} operation-type")
yield Static("Operation details", classes=f"{ODD_CLASS_NAME} operation-details")
yield Static("Actions", classes=f"{EVEN_CLASS_NAME} actions")
class CartTable(CliveCheckerboardTable):
"""Table with CartItems."""
def __init__(self) -> None:
super().__init__(header=CartHeader(), title=Static(""))
def create_static_rows(self, start_index: int = 0, end_index: int | None = None) -> list[CartItem]:
if end_index:
assert (
end_index <= len(self.app.world.profile_data.cart) - 1
), "End index is greater than cart's last item index"
return [
CartItem(idx)
for idx in range(start_index, len(self.app.world.profile_data.cart) if end_index is None else end_index + 1)
]
class Cart(BaseScreen):
@@ -221,43 +204,62 @@ class Cart(BaseScreen):
def __init__(self) -> None:
super().__init__()
self.__scrollable_part = ScrollablePart()
self._cart_table = CartTable()
def create_main_panel(self) -> ComposeResult:
with StaticPart():
yield CartHeader()
with self.__scrollable_part:
yield from self.__rebuild_items()
yield self._cart_table
def __rebuild_items(self) -> ComposeResult:
for idx in range(len(self.app.world.profile_data.cart)):
yield CartItem(idx)
async def __rebuild_items(self, from_index: int = 0, to_index: int | None = None) -> None:
await self._cart_table.rebuild(starting_from_element=from_index, ending_with_element=to_index)
def _devalue_indexes_and_readjust_styles_after_remove(self, triggering_widget: CartItem) -> None:
start_index = triggering_widget.idx
end_index = len(self.app.world.profile_data.cart)
rows = self.query(CartItem)[start_index:end_index]
for row in rows:
row.reactive_idx = row.idx - 1
self._cart_table.set_evenness_styles(rows, starting_index=start_index)
@on(CartItem.Delete)
def remove_item(self, event: CartItem.Delete) -> None:
async def remove_item(self, event: CartItem.Delete) -> None:
self.app.world.profile_data.cart.remove(event.widget.operation)
self.app.trigger_profile_data_watchers()
self.__scrollable_part.query(CartItem).remove()
self.__scrollable_part.mount(*self.__rebuild_items())
await self.query(CliveCheckerboardTableRow)[event.widget.idx].remove()
self._devalue_indexes_and_readjust_styles_after_remove(triggering_widget=event.widget)
if len(self.app.world.profile_data.cart) > 0 and event.widget.idx == 0:
# disable first ButtomMoveUp if first element was removed
self.query(ButtonMoveUp)[0].disabled = True
if 0 < len(self.app.world.profile_data.cart) == event.widget.idx:
# disable last ButtonMoveDown if only last element was removed
self.query(ButtonMoveDown)[-1].disabled = True
@on(CartItem.Move)
def move_item(self, event: CartItem.Move) -> None:
async def move_item(self, event: CartItem.Move) -> None:
assert event.to_idx >= 0
assert event.to_idx < len(self.app.world.profile_data.cart)
self.app.world.profile_data.cart.swap(event.from_idx, event.to_idx)
self.app.trigger_profile_data_watchers()
start, end = (event.from_idx, event.to_idx) if event.from_idx < event.to_idx else (event.to_idx, event.from_idx)
await self.__rebuild_items(from_index=start, to_index=end)
# focus item that was moved
for cart_item in self.query(CartItem):
if event.to_idx == cart_item.idx:
self.app.set_focus(cart_item)
@on(CartItem.Focus)
def focus_item(self, event: CartItem.Focus) -> None:
for cart_item in self.query(CartItem):
if event.target_idx == cart_item.idx:
cart_item.focus()
self.app.set_focus(cart_item)
def action_summary(self) -> None:
self.app.push_screen(TransactionSummaryFromCart())
def action_clear_all(self) -> None:
async def action_clear_all(self) -> None:
self.app.world.profile_data.cart.clear()
self.app.trigger_profile_data_watchers()
self.__scrollable_part.add_class("-hidden")
await self.__rebuild_items()
Loading