Source code for cadrumo.entrypoints.tui.ledger.entries

"""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"]