diff --git a/build/lib/pen_tracker/cli.py b/build/lib/pen_tracker/cli.py index 6052306..a73c052 100644 --- a/build/lib/pen_tracker/cli.py +++ b/build/lib/pen_tracker/cli.py @@ -3,7 +3,7 @@ import csv import argparse import json from datetime import datetime -from .engine import PenTracker, Pen +from .engine import PenTracker, Pen, InkTracker, Ink def validate_date(date_str: str) -> bool: """Validate date in YYYY-MM-DD format.""" @@ -17,6 +17,10 @@ def validate_date(date_str: str) -> bool: class CLITracker(PenTracker): + def __init__(self, storage_file=None): + super().__init__(storage_file) + self.ink_tracker = InkTracker() + def add_pen(self): print("\n--- Add New Fountain Pen ---") new_pen_data = {} @@ -29,6 +33,9 @@ class CLITracker(PenTracker): for label, key in fields: while True: + if key == 'Current-Ink': + value = self.select_ink() + break value = input(f"Enter {label}: ").strip() if not value: value = "N/A" @@ -74,6 +81,15 @@ class CLITracker(PenTracker): field = self.key_map.get(header, header) current_val = getattr(pen, field) while True: + if header == 'Current-Ink': + print(f"Current Ink: {current_val}") + change_ink = input("Change ink? (y/n): ").strip().lower() + if change_ink == 'y': + new_val = self.select_ink() + break + else: + new_val = "" + break new_val = input(f"{header} [{current_val}]: ").strip() if not new_val: break @@ -149,6 +165,69 @@ class CLITracker(PenTracker): except (ValueError, IndexError): print("[!] Invalid ID.") + def add_ink(self): + print("\n--- Add New Ink ---") + new_ink_data = {} + fields = [ + ("Vendor", "Vendor"), ("Name", "Name"), ("Color", "Color"), + ("Purchased (YYYY-MM-DD)", "Purchased"), ("Size", "Size"), ("Notes", "Notes") + ] + + for label, key in fields: + while True: + value = input(f"Enter {label}: ").strip() + if not value: + value = "N/A" + break + if key == 'Purchased': + if validate_date(value): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + new_ink_data[key] = value + + new_ink = Ink(**new_ink_data) + self.ink_tracker.inks.append(new_ink) + self.ink_tracker.save_data() + print("\n[✔] Ink added successfully to inks.csv!") + + def view_all_inks(self): + if not self.ink_tracker.inks: + print("\n[!] Your ink collection is currently empty.") + return + + print("\n" + "="*80) + print(f"{'ID':<4} | {'VENDOR':<12} | {'NAME':<20} | {'COLOR':<15} | {'SIZE':<10}") + print("-" * 80) + + for idx, ink in enumerate(self.ink_tracker.inks): + vendor = ink.Vendor[:12] + name = ink.Name[:20] + color = ink.Color[:15] + size = ink.Size[:10] + print(f"{idx:<4} | {vendor:<12} | {name:<20} | {color:<15} | {size:<10}") + + print("="*80) + + def select_ink(self): + """Helper method to select an ink from the list.""" + if not self.ink_tracker.inks: + print("[!] No inks available. Add some inks first.") + return "N/A" + + self.view_all_inks() + try: + idx = int(input("\nEnter the ID of the ink to select (or -1 for N/A): ")) + if idx == -1: + return "N/A" + ink = self.ink_tracker.inks[idx] + return ink.Name + except (ValueError, IndexError): + print("[!] Invalid ID.") + return "N/A" + def clear_screen(): os.system('cls' if os.name == 'nt' else 'clear') @@ -180,6 +259,17 @@ def main(): export_parser = subparsers.add_parser('export', help='Export to JSON') export_parser.add_argument('--output', default='pens.json', help='Output file') + # Ink commands + add_ink_parser = subparsers.add_parser('add-ink', help='Add a new ink') + add_ink_parser.add_argument('--vendor', required=True) + add_ink_parser.add_argument('--name', required=True) + add_ink_parser.add_argument('--color') + add_ink_parser.add_argument('--purchased') + add_ink_parser.add_argument('--size') + add_ink_parser.add_argument('--notes') + + list_inks_parser = subparsers.add_parser('list-inks', help='List all inks') + args = parser.parse_args() tracker = CLITracker(args.csv) @@ -217,6 +307,24 @@ def main(): with open(args.output, 'w') as f: json.dump([asdict(p) for p in tracker.pens], f, indent=2) print(f"Exported to {args.output}") + elif args.command == 'add-ink': + if args.purchased and not validate_date(args.purchased): + print("Invalid purchased date format.") + return + ink_data = { + 'Vendor': args.vendor, + 'Name': args.name, + 'Color': args.color or 'N/A', + 'Purchased': args.purchased or 'N/A', + 'Size': args.size or 'N/A', + 'Notes': args.notes or 'N/A' + } + new_ink = Ink(**ink_data) + tracker.ink_tracker.inks.append(new_ink) + tracker.ink_tracker.save_data() + print("Ink added successfully.") + elif args.command == 'list-inks': + tracker.view_all_inks() else: # Interactive mode while True: @@ -225,7 +333,9 @@ def main(): print("2. Add New Pen") print("3. Edit a Pen") print("4. Delete a Pen") - print("5. Exit") + print("5. View Ink Collection") + print("6. Add New Ink") + print("7. Exit") choice = input("\nSelect an option: ") @@ -242,6 +352,12 @@ def main(): clear_screen() tracker.delete_pen() elif choice == '5': + clear_screen() + tracker.view_all_inks() + elif choice == '6': + clear_screen() + tracker.add_ink() + elif choice == '7': print("Goodbye! Happy writing! ✒️") break else: diff --git a/build/lib/pen_tracker/engine.py b/build/lib/pen_tracker/engine.py index 8444a3f..cfc6601 100644 --- a/build/lib/pen_tracker/engine.py +++ b/build/lib/pen_tracker/engine.py @@ -6,6 +6,15 @@ from typing import List logger = logging.getLogger(__name__) +@dataclass +class Ink: + Vendor: str = "N/A" + Name: str = "N/A" + Color: str = "N/A" + Purchased: str = "N/A" + Size: str = "N/A" + Notes: str = "N/A" + @dataclass class Pen: Make: str = "N/A" @@ -21,6 +30,65 @@ class Pen: Inked_date: str = "N/A" Notes: str = "N/A" +class InkTracker: + def __init__(self, storage_file: str = None): + if storage_file is None: + storage_file = os.path.abspath('inks.csv') + self.storage_file = os.path.abspath(storage_file) + self.headers = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes'] + self.inks: List[Ink] = self.load_data() + + def _sort_inks(self): + """Sorts the inks list by Vendor, then by Name alphabetically.""" + self.inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower())) + + def load_data(self) -> List[Ink]: + """Loads data from the CSV file.""" + if not os.path.exists(self.storage_file): + self._create_empty_csv() + return [] + + inks = [] + try: + with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: + reader = csv.DictReader(f) + if reader.fieldnames: + reader.fieldnames = [h.strip() for h in reader.fieldnames] + for row in reader: + if None in row: + extras = row.pop(None) + if extras: + extra_text = ",".join(extras).strip() + if extra_text: + row['Notes'] = (row.get('Notes') or '') + if row['Notes']: + row['Notes'] += ", " + row['Notes'] += extra_text + clean_row = { + k.strip(): (v.strip() if v else "N/A") + for k, v in row.items() if k is not None + } + inks.append(Ink(**clean_row)) + inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower())) + except Exception as e: + logger.error(f"Error loading inks CSV: {e}") + return inks + + def _create_empty_csv(self): + """Creates the CSV file with headers if it is missing.""" + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + + def save_data(self): + """Sorts the data before writing to ensure persistent alphabetical order.""" + self._sort_inks() + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + for ink in self.inks: + writer.writerow(asdict(ink)) + class PenTracker: def __init__(self, storage_file: str = None): if storage_file is None: diff --git a/build/lib/pen_tracker/tui.py b/build/lib/pen_tracker/tui.py index ced7649..ed32fb1 100644 --- a/build/lib/pen_tracker/tui.py +++ b/build/lib/pen_tracker/tui.py @@ -3,7 +3,7 @@ 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 +from .engine import PenTracker, Pen, InkTracker, Ink # --- TUI SCREENS --- class PenFormScreen(Screen): @@ -54,6 +54,50 @@ class PenFormScreen(Screen): self.tracker.save_data() self.dismiss(new_pen) +class InkFormScreen(Screen): + """A screen for adding or editing an ink.""" + + def __init__(self, ink_tracker, existing_ink=None): + super().__init__() + self.ink_tracker = ink_tracker + self.existing_ink = existing_ink + + def compose(self) -> ComposeResult: + with Vertical(id="form-container"): + yield Label("🖋️ NEW INK" if not self.existing_ink else "✏️ EDIT INK") + + for header in self.ink_tracker.headers: + val = getattr(self.existing_ink, header) if self.existing_ink 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.ink_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" + + new_ink = Ink(**new_data) + + if self.existing_ink is not None and self.existing_ink in self.ink_tracker.inks: + idx = self.ink_tracker.inks.index(self.existing_ink) + self.ink_tracker.inks[idx] = new_ink + else: + self.ink_tracker.inks.append(new_ink) + + self.ink_tracker.save_data() + self.dismiss(new_ink) + class PenTrackerApp(App): """The Main TUI Application.""" @@ -89,7 +133,8 @@ class PenTrackerApp(App): BINDINGS = [ Binding("d", "delete_selected", "Delete Selected"), - Binding("a", "add_new", "Add New Pen"), + Binding("a", "add_new", "Add New"), + Binding("i", "toggle_mode", "Toggle Pens/Inks"), Binding("q", "quit", "Quit"), ] @@ -100,29 +145,46 @@ class PenTrackerApp(App): def on_mount(self) -> None: self.tracker = PenTracker() + self.ink_tracker = InkTracker() + self.mode = "pens" # "pens" or "inks" 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) + if self.mode == "pens": + display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes'] + table.add_columns(*display_cols) + items = self.tracker.pens + else: # inks + display_cols = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes'] + table.add_columns(*display_cols) + items = self.ink_tracker.inks - 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 + for idx, item in enumerate(items): + if self.mode == "pens": + row_values = [getattr(item, c.replace('-','_')) for c in display_cols] + else: + row_values = [getattr(item, c) for c in display_cols] table.add_row(*row_values, key=str(idx)) def action_add_new(self) -> None: - form = PenFormScreen(self.tracker) + if self.mode == "pens": + form = PenFormScreen(self.tracker) + else: + form = InkFormScreen(self.ink_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) + if self.mode == "pens": + existing_item = self.tracker.pens[idx] + form = PenFormScreen(self.tracker, existing_item) + else: + existing_item = self.ink_tracker.inks[idx] + form = InkFormScreen(self.ink_tracker, existing_item) self.push_screen(form, self.handle_form_result) except (ValueError, IndexError): pass @@ -135,16 +197,26 @@ class PenTrackerApp(App): 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() + if self.mode == "pens": + removed = self.tracker.pens.pop(idx) + self.tracker.save_data() + self.notify(f"Deleted {removed.Make}", title="Removed") + else: + removed = self.ink_tracker.inks.pop(idx) + self.ink_tracker.save_data() + self.notify(f"Deleted {removed.Name}", title="Removed") self._refresh_table() - self.notify(f"Deleted {removed.Make}", title="Removed") except Exception: - self.notify("No pen selected to delete", title="Error", severity="error") + self.notify("No item selected to delete", title="Error", severity="error") + + def action_toggle_mode(self) -> None: + self.mode = "inks" if self.mode == "pens" else "pens" + self._refresh_table() + mode_name = "Pens" if self.mode == "pens" else "Inks" + self.notify(f"Switched to {mode_name} mode", title="Mode Changed") def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: try: diff --git a/pyproject.toml b/pyproject.toml index e5ccc2e..6c8f6e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pen-tracker" -version = "0.3.0" +version = "0.4.0" authors = [ { name="Don Harper", email="don@donharper.org" }, ] diff --git a/src/pen_tracker.egg-info/PKG-INFO b/src/pen_tracker.egg-info/PKG-INFO index 83ea7ac..78eb99e 100644 --- a/src/pen_tracker.egg-info/PKG-INFO +++ b/src/pen_tracker.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: pen-tracker -Version: 0.3.0 +Version: 0.4.0 Summary: A fountain pen collection tracker. Author-email: Don Harper Requires-Python: >=3.8 diff --git a/src/pen_tracker/__pycache__/cli.cpython-313.pyc b/src/pen_tracker/__pycache__/cli.cpython-313.pyc index b92abeb..3860351 100644 Binary files a/src/pen_tracker/__pycache__/cli.cpython-313.pyc and b/src/pen_tracker/__pycache__/cli.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/engine.cpython-313.pyc b/src/pen_tracker/__pycache__/engine.cpython-313.pyc index 4e87b3d..507df11 100644 Binary files a/src/pen_tracker/__pycache__/engine.cpython-313.pyc and b/src/pen_tracker/__pycache__/engine.cpython-313.pyc differ diff --git a/src/pen_tracker/__pycache__/tui.cpython-313.pyc b/src/pen_tracker/__pycache__/tui.cpython-313.pyc index 3d3c8aa..4d88dd4 100644 Binary files a/src/pen_tracker/__pycache__/tui.cpython-313.pyc and b/src/pen_tracker/__pycache__/tui.cpython-313.pyc differ diff --git a/src/pen_tracker/cli.py b/src/pen_tracker/cli.py index 6052306..a73c052 100644 --- a/src/pen_tracker/cli.py +++ b/src/pen_tracker/cli.py @@ -3,7 +3,7 @@ import csv import argparse import json from datetime import datetime -from .engine import PenTracker, Pen +from .engine import PenTracker, Pen, InkTracker, Ink def validate_date(date_str: str) -> bool: """Validate date in YYYY-MM-DD format.""" @@ -17,6 +17,10 @@ def validate_date(date_str: str) -> bool: class CLITracker(PenTracker): + def __init__(self, storage_file=None): + super().__init__(storage_file) + self.ink_tracker = InkTracker() + def add_pen(self): print("\n--- Add New Fountain Pen ---") new_pen_data = {} @@ -29,6 +33,9 @@ class CLITracker(PenTracker): for label, key in fields: while True: + if key == 'Current-Ink': + value = self.select_ink() + break value = input(f"Enter {label}: ").strip() if not value: value = "N/A" @@ -74,6 +81,15 @@ class CLITracker(PenTracker): field = self.key_map.get(header, header) current_val = getattr(pen, field) while True: + if header == 'Current-Ink': + print(f"Current Ink: {current_val}") + change_ink = input("Change ink? (y/n): ").strip().lower() + if change_ink == 'y': + new_val = self.select_ink() + break + else: + new_val = "" + break new_val = input(f"{header} [{current_val}]: ").strip() if not new_val: break @@ -149,6 +165,69 @@ class CLITracker(PenTracker): except (ValueError, IndexError): print("[!] Invalid ID.") + def add_ink(self): + print("\n--- Add New Ink ---") + new_ink_data = {} + fields = [ + ("Vendor", "Vendor"), ("Name", "Name"), ("Color", "Color"), + ("Purchased (YYYY-MM-DD)", "Purchased"), ("Size", "Size"), ("Notes", "Notes") + ] + + for label, key in fields: + while True: + value = input(f"Enter {label}: ").strip() + if not value: + value = "N/A" + break + if key == 'Purchased': + if validate_date(value): + break + else: + print("Invalid date format. Use YYYY-MM-DD.") + else: + break + new_ink_data[key] = value + + new_ink = Ink(**new_ink_data) + self.ink_tracker.inks.append(new_ink) + self.ink_tracker.save_data() + print("\n[✔] Ink added successfully to inks.csv!") + + def view_all_inks(self): + if not self.ink_tracker.inks: + print("\n[!] Your ink collection is currently empty.") + return + + print("\n" + "="*80) + print(f"{'ID':<4} | {'VENDOR':<12} | {'NAME':<20} | {'COLOR':<15} | {'SIZE':<10}") + print("-" * 80) + + for idx, ink in enumerate(self.ink_tracker.inks): + vendor = ink.Vendor[:12] + name = ink.Name[:20] + color = ink.Color[:15] + size = ink.Size[:10] + print(f"{idx:<4} | {vendor:<12} | {name:<20} | {color:<15} | {size:<10}") + + print("="*80) + + def select_ink(self): + """Helper method to select an ink from the list.""" + if not self.ink_tracker.inks: + print("[!] No inks available. Add some inks first.") + return "N/A" + + self.view_all_inks() + try: + idx = int(input("\nEnter the ID of the ink to select (or -1 for N/A): ")) + if idx == -1: + return "N/A" + ink = self.ink_tracker.inks[idx] + return ink.Name + except (ValueError, IndexError): + print("[!] Invalid ID.") + return "N/A" + def clear_screen(): os.system('cls' if os.name == 'nt' else 'clear') @@ -180,6 +259,17 @@ def main(): export_parser = subparsers.add_parser('export', help='Export to JSON') export_parser.add_argument('--output', default='pens.json', help='Output file') + # Ink commands + add_ink_parser = subparsers.add_parser('add-ink', help='Add a new ink') + add_ink_parser.add_argument('--vendor', required=True) + add_ink_parser.add_argument('--name', required=True) + add_ink_parser.add_argument('--color') + add_ink_parser.add_argument('--purchased') + add_ink_parser.add_argument('--size') + add_ink_parser.add_argument('--notes') + + list_inks_parser = subparsers.add_parser('list-inks', help='List all inks') + args = parser.parse_args() tracker = CLITracker(args.csv) @@ -217,6 +307,24 @@ def main(): with open(args.output, 'w') as f: json.dump([asdict(p) for p in tracker.pens], f, indent=2) print(f"Exported to {args.output}") + elif args.command == 'add-ink': + if args.purchased and not validate_date(args.purchased): + print("Invalid purchased date format.") + return + ink_data = { + 'Vendor': args.vendor, + 'Name': args.name, + 'Color': args.color or 'N/A', + 'Purchased': args.purchased or 'N/A', + 'Size': args.size or 'N/A', + 'Notes': args.notes or 'N/A' + } + new_ink = Ink(**ink_data) + tracker.ink_tracker.inks.append(new_ink) + tracker.ink_tracker.save_data() + print("Ink added successfully.") + elif args.command == 'list-inks': + tracker.view_all_inks() else: # Interactive mode while True: @@ -225,7 +333,9 @@ def main(): print("2. Add New Pen") print("3. Edit a Pen") print("4. Delete a Pen") - print("5. Exit") + print("5. View Ink Collection") + print("6. Add New Ink") + print("7. Exit") choice = input("\nSelect an option: ") @@ -242,6 +352,12 @@ def main(): clear_screen() tracker.delete_pen() elif choice == '5': + clear_screen() + tracker.view_all_inks() + elif choice == '6': + clear_screen() + tracker.add_ink() + elif choice == '7': print("Goodbye! Happy writing! ✒️") break else: diff --git a/src/pen_tracker/engine.py b/src/pen_tracker/engine.py index 8444a3f..cfc6601 100644 --- a/src/pen_tracker/engine.py +++ b/src/pen_tracker/engine.py @@ -6,6 +6,15 @@ from typing import List logger = logging.getLogger(__name__) +@dataclass +class Ink: + Vendor: str = "N/A" + Name: str = "N/A" + Color: str = "N/A" + Purchased: str = "N/A" + Size: str = "N/A" + Notes: str = "N/A" + @dataclass class Pen: Make: str = "N/A" @@ -21,6 +30,65 @@ class Pen: Inked_date: str = "N/A" Notes: str = "N/A" +class InkTracker: + def __init__(self, storage_file: str = None): + if storage_file is None: + storage_file = os.path.abspath('inks.csv') + self.storage_file = os.path.abspath(storage_file) + self.headers = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes'] + self.inks: List[Ink] = self.load_data() + + def _sort_inks(self): + """Sorts the inks list by Vendor, then by Name alphabetically.""" + self.inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower())) + + def load_data(self) -> List[Ink]: + """Loads data from the CSV file.""" + if not os.path.exists(self.storage_file): + self._create_empty_csv() + return [] + + inks = [] + try: + with open(self.storage_file, mode='r', encoding='utf-8-sig') as f: + reader = csv.DictReader(f) + if reader.fieldnames: + reader.fieldnames = [h.strip() for h in reader.fieldnames] + for row in reader: + if None in row: + extras = row.pop(None) + if extras: + extra_text = ",".join(extras).strip() + if extra_text: + row['Notes'] = (row.get('Notes') or '') + if row['Notes']: + row['Notes'] += ", " + row['Notes'] += extra_text + clean_row = { + k.strip(): (v.strip() if v else "N/A") + for k, v in row.items() if k is not None + } + inks.append(Ink(**clean_row)) + inks.sort(key=lambda x: (x.Vendor.lower(), x.Name.lower())) + except Exception as e: + logger.error(f"Error loading inks CSV: {e}") + return inks + + def _create_empty_csv(self): + """Creates the CSV file with headers if it is missing.""" + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + + def save_data(self): + """Sorts the data before writing to ensure persistent alphabetical order.""" + self._sort_inks() + with open(self.storage_file, mode='w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=self.headers) + writer.writeheader() + for ink in self.inks: + writer.writerow(asdict(ink)) + class PenTracker: def __init__(self, storage_file: str = None): if storage_file is None: diff --git a/src/pen_tracker/tui.py b/src/pen_tracker/tui.py index ced7649..ed32fb1 100644 --- a/src/pen_tracker/tui.py +++ b/src/pen_tracker/tui.py @@ -3,7 +3,7 @@ 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 +from .engine import PenTracker, Pen, InkTracker, Ink # --- TUI SCREENS --- class PenFormScreen(Screen): @@ -54,6 +54,50 @@ class PenFormScreen(Screen): self.tracker.save_data() self.dismiss(new_pen) +class InkFormScreen(Screen): + """A screen for adding or editing an ink.""" + + def __init__(self, ink_tracker, existing_ink=None): + super().__init__() + self.ink_tracker = ink_tracker + self.existing_ink = existing_ink + + def compose(self) -> ComposeResult: + with Vertical(id="form-container"): + yield Label("🖋️ NEW INK" if not self.existing_ink else "✏️ EDIT INK") + + for header in self.ink_tracker.headers: + val = getattr(self.existing_ink, header) if self.existing_ink 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.ink_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" + + new_ink = Ink(**new_data) + + if self.existing_ink is not None and self.existing_ink in self.ink_tracker.inks: + idx = self.ink_tracker.inks.index(self.existing_ink) + self.ink_tracker.inks[idx] = new_ink + else: + self.ink_tracker.inks.append(new_ink) + + self.ink_tracker.save_data() + self.dismiss(new_ink) + class PenTrackerApp(App): """The Main TUI Application.""" @@ -89,7 +133,8 @@ class PenTrackerApp(App): BINDINGS = [ Binding("d", "delete_selected", "Delete Selected"), - Binding("a", "add_new", "Add New Pen"), + Binding("a", "add_new", "Add New"), + Binding("i", "toggle_mode", "Toggle Pens/Inks"), Binding("q", "quit", "Quit"), ] @@ -100,29 +145,46 @@ class PenTrackerApp(App): def on_mount(self) -> None: self.tracker = PenTracker() + self.ink_tracker = InkTracker() + self.mode = "pens" # "pens" or "inks" 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) + if self.mode == "pens": + display_cols = ['Make', 'Model', 'Nib', 'Nib-Material', 'Body','Cap', 'Current-Ink', 'Inked-date', 'Notes'] + table.add_columns(*display_cols) + items = self.tracker.pens + else: # inks + display_cols = ['Vendor', 'Name', 'Color', 'Purchased', 'Size', 'Notes'] + table.add_columns(*display_cols) + items = self.ink_tracker.inks - 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 + for idx, item in enumerate(items): + if self.mode == "pens": + row_values = [getattr(item, c.replace('-','_')) for c in display_cols] + else: + row_values = [getattr(item, c) for c in display_cols] table.add_row(*row_values, key=str(idx)) def action_add_new(self) -> None: - form = PenFormScreen(self.tracker) + if self.mode == "pens": + form = PenFormScreen(self.tracker) + else: + form = InkFormScreen(self.ink_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) + if self.mode == "pens": + existing_item = self.tracker.pens[idx] + form = PenFormScreen(self.tracker, existing_item) + else: + existing_item = self.ink_tracker.inks[idx] + form = InkFormScreen(self.ink_tracker, existing_item) self.push_screen(form, self.handle_form_result) except (ValueError, IndexError): pass @@ -135,16 +197,26 @@ class PenTrackerApp(App): 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() + if self.mode == "pens": + removed = self.tracker.pens.pop(idx) + self.tracker.save_data() + self.notify(f"Deleted {removed.Make}", title="Removed") + else: + removed = self.ink_tracker.inks.pop(idx) + self.ink_tracker.save_data() + self.notify(f"Deleted {removed.Name}", title="Removed") self._refresh_table() - self.notify(f"Deleted {removed.Make}", title="Removed") except Exception: - self.notify("No pen selected to delete", title="Error", severity="error") + self.notify("No item selected to delete", title="Error", severity="error") + + def action_toggle_mode(self) -> None: + self.mode = "inks" if self.mode == "pens" else "pens" + self._refresh_table() + mode_name = "Pens" if self.mode == "pens" else "Inks" + self.notify(f"Switched to {mode_name} mode", title="Mode Changed") def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: try: diff --git a/tests/__pycache__/test_engine.cpython-313.pyc b/tests/__pycache__/test_engine.cpython-313.pyc index 7c794c1..69504fe 100644 Binary files a/tests/__pycache__/test_engine.cpython-313.pyc and b/tests/__pycache__/test_engine.cpython-313.pyc differ diff --git a/tests/test_engine.py b/tests/test_engine.py index 503c79d..dfdce76 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,7 +1,7 @@ import unittest import tempfile import os -from pen_tracker.engine import PenTracker, Pen +from pen_tracker.engine import PenTracker, Pen, InkTracker, Ink class TestPenTracker(unittest.TestCase): def setUp(self): @@ -33,5 +33,51 @@ class TestPenTracker(unittest.TestCase): self.assertEqual(new_tracker.pens[0].Make, "A") self.assertEqual(new_tracker.pens[1].Make, "Z") + + +class TestInkTracker(unittest.TestCase): + def setUp(self): + self.temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv') + self.temp_file.close() + self.tracker = InkTracker(self.temp_file.name) + + def tearDown(self): + os.unlink(self.temp_file.name) + + def test_add_and_load_ink(self): + ink = Ink(Vendor="Diamine", Name="Pumpkin", Color="Orange") + self.tracker.inks.append(ink) + self.tracker.save_data() + + new_tracker = InkTracker(self.temp_file.name) + self.assertEqual(len(new_tracker.inks), 1) + self.assertEqual(new_tracker.inks[0].Vendor, "Diamine") + self.assertEqual(new_tracker.inks[0].Name, "Pumpkin") + + def test_sorting(self): + self.tracker.inks = [ + Ink(Vendor="Z", Name="A"), + Ink(Vendor="A", Name="B") + ] + self.tracker.save_data() + new_tracker = InkTracker(self.temp_file.name) + self.assertEqual(new_tracker.inks[0].Vendor, "A") + self.assertEqual(new_tracker.inks[1].Vendor, "Z") + + def test_loads_row_with_extra_trailing_field(self): + csv_content = ( + "Vendor,Name,Color,Purchased,Size,Notes\n" + "Waterman,Intense Black,Black,,\"Cartridge,International Short\",\n" + "Pilot,Black Cartridge,Black,2024-01-01,Cartridge,Good ink\n" + ) + with open(self.temp_file.name, 'w', encoding='utf-8') as f: + f.write(csv_content) + + new_tracker = InkTracker(self.temp_file.name) + self.assertEqual(len(new_tracker.inks), 2) + self.assertEqual(new_tracker.inks[0].Vendor, "Pilot") + self.assertEqual(new_tracker.inks[1].Vendor, "Waterman") + self.assertEqual(new_tracker.inks[1].Notes, "N/A") + if __name__ == '__main__': unittest.main() \ No newline at end of file