pen-tracker/build/lib/pen_tracker/tui.py
Don Harper 51a1697c83 feat: Implement CLI and TUI for Fountain Pen Tracker
- Added CLI functionality for adding, editing, viewing, and deleting fountain pens.
- Introduced TUI using Textual for a more interactive experience.
- Created Pen and PenTracker classes to manage pen data and CSV storage.
- Implemented input validation for date fields.
- Added export functionality to JSON format.
- Updated project version to 0.2.0.
- Added unit tests for PenTracker functionality.
2026-04-26 23:00:52 -05:00

159 lines
5.3 KiB
Python

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Label, Input, Button, Header, Footer, DataTable
from textual.containers import Vertical, Horizontal
from textual.binding import Binding
from .engine import PenTracker, Pen
# --- TUI SCREENS ---
class PenFormScreen(Screen):
"""A screen for adding or editing a pen."""
def __init__(self, tracker, existing_pen=None):
super().__init__()
self.tracker = tracker
self.existing_pen = existing_pen
def compose(self) -> ComposeResult:
with Vertical(id="form-container"):
yield Label("📝 NEW PEN" if not self.existing_pen else "✏️ EDIT PEN")
for header in self.tracker.headers:
field = self.tracker.key_map.get(header, header)
val = getattr(self.existing_pen, field) if self.existing_pen else ""
yield Input(value=val, placeholder=header, id=header)
with Horizontal():
yield Button("Save", variant="success", id="save_cmd")
yield Button("Cancel", variant="error", id="cancel_cmd")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel_cmd":
self.dismiss()
return
new_data = {}
for header in self.tracker.headers:
try:
input_widget = self.query_one(f"#{header}", Input)
new_data[header] = input_widget.value if input_widget.value.strip() else "N/A"
except Exception:
new_data[header] = "N/A"
# Map to Pen fields
mapped_data = {self.tracker.key_map.get(k, k): v for k, v in new_data.items()}
new_pen = Pen(**mapped_data)
if self.existing_pen is not None and self.existing_pen in self.tracker.pens:
idx = self.tracker.pens.index(self.existing_pen)
self.tracker.pens[idx] = new_pen
else:
self.tracker.pens.append(new_pen)
# save_data() handles the sorting internally
self.tracker.save_data()
self.dismiss(new_pen)
class PenTrackerApp(App):
"""The Main TUI Application."""
CSS = """
#form-container {
width: 60%;
height: auto;
border: heavy white;
padding: 1 2;
margin: 5 10;
background: $panel;
}
Label {
width: 100%;
text-align: center;
text-style: bold;
margin-bottom: 1;
}
Input {
margin-bottom: 1;
}
Horizontal {
align: center middle;
height: auto;
}
Button {
margin: 0 1;
}
DataTable {
height: 1fr;
}
"""
BINDINGS = [
Binding("d", "delete_selected", "Delete Selected"),
Binding("a", "add_new", "Add New Pen"),
Binding("q", "quit", "Quit"),
]
def compose(self) -> ComposeResult:
yield Header()
yield DataTable(id="pen_table")
yield Footer()
def on_mount(self) -> None:
self.tracker = PenTracker('Pens.csv')
self._refresh_table()
def _refresh_table(self):
table = self.query_one("#pen_table", DataTable)
table.clear(columns=True)
display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes']
table.add_columns(*display_cols)
for idx, pen in enumerate(self.tracker.pens):
row_values = [getattr(pen, c.replace('-','_')) for c in display_cols]
# We use the index as the row key to track items accurately
table.add_row(*row_values, key=str(idx))
def action_add_new(self) -> None:
form = PenFormScreen(self.tracker)
self.push_screen(form, self.handle_form_result)
def action_edit_selected(self, index_str: str):
try:
idx = int(index_str)
existing_pen = self.tracker.pens[idx]
form = PenFormScreen(self.tracker, existing_pen=existing_pen)
self.push_screen(form, self.handle_form_result)
except (ValueError, IndexError):
pass
def handle_form_result(self, updated_pen_data) -> None:
if updated_pen_data:
self._refresh_table()
self.notify("Collection Updated & Sorted!", title="Success")
def action_delete_selected(self) -> None:
table = self.query_one("#pen_table", DataTable)
try:
# Accessing via the table's current cursor
table = self.query_one("#pen_table", DataTable)
row_node = table.get_row_at_cursor()
idx = int(row_node.key.value)
removed = self.tracker.pens.pop(idx)
self.tracker.save_data()
self._refresh_table()
self.notify(f"Deleted {removed.Make}", title="Removed")
except Exception:
self.notify("No pen selected to delete", title="Error", severity="error")
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
try:
idx = int(event.cell_key.row_key.value)
self.action_edit_selected(str(idx))
except (AttributeError, ValueError):
self.notify("Error selecting row", title="Error", severity="error")
def main():
# This is the entry point defined in pyproject.toml
app = PenTrackerApp()
app.run()