Skip to content
Snippets Groups Projects

Refactor Cart into checkerboard table

Merged Mateusz Kudela requested to merge mkudela/issue-173 into develop
2 files
+ 164
144
Compare changes
  • Side-by-side
  • Inline
Files
2
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Sequence
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,23 @@ 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 textual.widget import Widget
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 +49,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+80,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 +124,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 +167,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 +205,83 @@ 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()
def __rebuild_items(self) -> ComposeResult:
for idx in range(len(self.app.world.profile_data.cart)):
yield CartItem(idx)
yield self._cart_table
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 _handle_remove_event(self, triggering_widget: CartItem) -> None:
def devalue_indexes(start_index: int) -> Sequence[CliveCheckerboardTableRow]:
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
return rows
start_index = triggering_widget.idx
rows = devalue_indexes(start_index)
self._cart_table.set_evenness_styles(rows, starting_index=start_index)
def _update_values_of_swapped_rows(self, from_index: int, to_index: int) -> None:
def get_cells_with_values(row_index: int) -> tuple[list[CliveCheckerBoardTableCell], list[str | Widget]]:
row = self.query(CartItem)[row_index]
start_index = 1 # exclude value of first cell - index
end_index = -1 # exclude value of last cell - horizontal with buttons
cells = row.query(CliveCheckerBoardTableCell)[start_index:end_index]
data = [cell.content for cell in cells]
return cells, data
from_cells, from_data = get_cells_with_values(from_index)
to_cells, to_data = get_cells_with_values(to_index)
for cells, data in [(to_cells, from_data), (from_cells, to_data)]:
for cell, value in zip(cells, data):
cell.update_content(value) # type: ignore[arg-type]
@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._handle_remove_event(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:
assert event.to_idx >= 0
assert event.to_idx < len(self.app.world.profile_data.cart)
async def move_item(self, event: CartItem.Move) -> None:
assert event.to_idx >= 0, "Item cannot be moved to id lower than 0."
assert event.to_idx < len(
self.app.world.profile_data.cart
), "Item cannot be moved to id greater than cart length."
self.app.world.profile_data.cart.swap(event.from_idx, event.to_idx)
self.app.trigger_profile_data_watchers()
self._update_values_of_swapped_rows(from_index=event.from_idx, to_index=event.to_idx)
# 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