"""The operator's own Ledger entries, shown in full."""
from __future__ import annotations
from typing import ClassVar, cast, override
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Static
from ....core.identity.transaction_ids import TransactionId
from ..components.widgets import ContentDataTable
from .controller import (
LedgerEntrySelected,
LedgerWorkspaceController,
LedgerWorkspaceScreen,
ledger_copy,
review_status_label,
)
from .models import LedgerEntryRowV1
from .workspace_presentation import ledger_workspace_page, restore_transaction_focus
def _description_fill_column(columns: tuple[tuple[str, str, int], ...]) -> int:
"""Choose the description column as the table's only surplus-width target."""
names = [name for name, _, _ in columns]
return names.index("description") if "description" in names else -1
def _selected_row_key(table: DataTable[str]) -> str | None:
"""Capture the current semantic row key before a table rebuild."""
if not table.is_valid_row_index(table.cursor_row):
return None
return table.ordered_rows[table.cursor_row].key.value
def _add_entry_columns(table: ContentDataTable[str], columns: tuple[tuple[str, str, int], ...]) -> None:
"""Recreate the visible columns with the same translated header floors."""
for name, key, size in columns:
header = ledger_copy(key)
table.add_column(header, key=name, width=max(size, len(header)))
def _entry_cells(row: LedgerEntryRowV1) -> dict[str, str]:
"""Project one backend-owned row into the table's display cells."""
entry = row.source
return {
"date": entry.date,
"description": entry.description,
"amount": f"{entry.amount} {entry.currency}",
"review_status": review_status_label(row.review_status),
"counterparty": entry.counterparty,
"classification": entry.business_classification,
"direction": entry.direction,
}
def _add_entry_rows(
table: ContentDataTable[str],
columns: tuple[tuple[str, str, int], ...],
rows: tuple[LedgerEntryRowV1, ...],
) -> None:
"""Append rows in controller order, retaining each transaction key."""
for row in rows:
cells = _entry_cells(row)
table.add_row(*(cells[name] for name, _, _ in columns), key=row.transaction_id)
def _restore_entry_selection(table: DataTable[str], selected: str | None) -> None:
"""Restore the cursor by transaction key rather than by prior row index."""
if selected is None:
return
for index, ordered in enumerate(table.ordered_rows):
if ordered.key.value == selected:
table.move_cursor(row=index)
break
[docs]
class LedgerEntriesScreen(LedgerWorkspaceScreen):
"""Show each entry's own facts: date, amount, counterparty, classification.
The column SET is responsive, the data is not. Seven columns do not fit the
80-column floor, so narrow terminals drop the lowest-priority columns
rather than overflowing the right edge, where content is unreachable. The
order in `_COLUMNS` is that priority: date and description first because
they identify the entry, review status last-but-kept because it is what the
operator is acting on.
Nothing is withheld -- every column returns as the terminal widens, and the
projection behind it always carries the full record.
"""
_COLUMNS: ClassVar[tuple[tuple[str, str, int], ...]] = (
("date", "tui.ledger.column.date", 10),
("description", "tui.ledger.column.description", 24),
("amount", "tui.ledger.column.amount", 14),
("review_status", "tui.ledger.column.review_status", 10),
("counterparty", "tui.ledger.column.counterparty", 18),
("classification", "tui.ledger.column.classification", 12),
("direction", "tui.ledger.column.direction", 10),
)
def __init__(self, controller: LedgerWorkspaceController) -> None:
"""Retain the injected read-only workspace controller."""
super().__init__(controller, id="ledger-entries-screen")
self.selected_transaction_id: TransactionId | None = None
[docs]
@override
def compose(self) -> ComposeResult:
yield Static(ledger_copy("tui.ledger.entries.title"), classes="cadrumo-banner")
with ledger_workspace_page() as navigation:
yield navigation
yield ContentDataTable[str](id="ledger-entries", cursor_type="row", zebra_stripes=True)
yield Static(id="ledger-empty", classes="ledger-empty", markup=False)
yield Static(id="ledger-refusal", classes="ledger-refusal", markup=False)
[docs]
def on_mount(self) -> None:
"""Populate the safe entry index using semantic row identities."""
self.populate_navigation()
# Unsubscripted on purpose: Textual `isinstance`-checks this argument,
# and a subscripted generic raises `TypeError: Subscripted generics
# cannot be used with class and instance checks` at mount. The cast
# carries the element type for the reader and the type checker.
table = cast("ContentDataTable[str]", self.query_one("#ledger-entries", ContentDataTable))
self._fill_table(table, cast("App[None]", self.app).size.width)
if not table.row_count:
# An empty ledger is a state, not a refusal: it gets the muted line,
# and the warning line stays free for a navigation refusal.
self.query_one("#ledger-empty", Static).update(ledger_copy("tui.ledger.entries.empty"))
navigation = cast("DataTable[str]", self.query_one("#ledger-navigation", DataTable))
restore_transaction_focus(
navigation=navigation,
table=table,
transaction_id=self.controller.restored_transaction_id(),
)
def _visible_columns(self, width: int) -> tuple[tuple[str, str, int], ...]:
"""Take columns in priority order while they still fit the terminal."""
taken: list[tuple[str, str, int]] = []
used = 0
for column in self._COLUMNS:
# The header is a floor on the rendered column, not merely a label:
# a column narrower than its own heading is widened to fit it, so
# budgeting the authored width alone under-counts every column
# whose translated heading is longer and overflows the last one.
# Two cells of padding per column, plus one for the scrollbar.
cost = max(column[2], len(ledger_copy(column[1]))) + 2
if used + cost > width - 1:
break
taken.append(column)
used += cost
return tuple(taken)
def _fill_table(self, table: ContentDataTable[str], width: int) -> None:
"""Rebuild the table for this width, preserving row identity.
A rebuild replaces every row object, so the cursor -- which addresses a
row by position -- would silently land on whichever entry now occupies
that index. The operator would be looking at one entry and acting on
another. The remembered key is re-resolved after the rebuild so the
selection follows the ENTRY across a resize, not the row number.
"""
# The description is what the spare width is FOR: an identifier, a date
# or a state word gains nothing from being wider than its content,
# while a truncated description is the one cell the operator cannot
# reconstruct from the others.
table.fill_column = _description_fill_column(self._visible_columns(width))
selected = _selected_row_key(table)
table.clear(columns=True)
columns = self._visible_columns(width)
_add_entry_columns(table, columns)
_add_entry_rows(table, columns, self.controller.entry_rows())
table.absorb_surplus_width()
_restore_entry_selection(table, selected)
[docs]
def on_resize(self) -> None:
"""Re-take the column set for the new width."""
# Unsubscripted on purpose: Textual `isinstance`-checks this argument,
# and a subscripted generic raises `TypeError: Subscripted generics
# cannot be used with class and instance checks` at mount. The cast
# carries the element type for the reader and the type checker.
table = cast("ContentDataTable[str]", self.query_one("#ledger-entries", ContentDataTable))
self._fill_table(table, cast("App[None]", self.app).size.width)
[docs]
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Route navigation or retain a safe semantic entry selection."""
if self.handle_navigation_selection(event):
return
event_table = cast("DataTable[str]", event.data_table)
transaction_id = event.row_key.value
if event_table.id != "ledger-entries" or transaction_id is None:
return
self.selected_transaction_id = transaction_id
self.post_message(LedgerEntrySelected(transaction_id))
__all__ = ["LedgerEntriesScreen"]