From 37f02c0822c28fe2f4c58135a6ea85ea775bc932 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 24 Aug 2025 05:53:50 -0700 Subject: [PATCH 01/11] Show unknown (not break) if genre not set --- src/libro/actions/show.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libro/actions/show.py b/src/libro/actions/show.py index 7d04b64..d672c8d 100644 --- a/src/libro/actions/show.py +++ b/src/libro/actions/show.py @@ -48,9 +48,10 @@ def show_books(db, args={}): if current_genre is not None: # Don't add separator before first genre table.add_row("", "", "", "", "", style="dim") current_genre = book["genre"] + genre_display = current_genre.title() if current_genre else "Unknown" table.add_row( "", - f"[bold]{current_genre.title()} ({count[current_genre]})[/bold]", + f"[bold]{genre_display} ({count[current_genre]})[/bold]", "", "", "", From 18c7e209329966615d2fdb566f18e21676a34ff2 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 24 Aug 2025 06:26:36 -0700 Subject: [PATCH 02/11] Init commit for TUI --- pyproject.toml | 1 + src/libro/actions/tui.py | 578 +++++++++++++++++++++++++++++++++++++++ src/libro/config.py | 3 + src/libro/main.py | 3 + 4 files changed, 585 insertions(+) create mode 100644 src/libro/actions/tui.py diff --git a/pyproject.toml b/pyproject.toml index 9aad6ba..05816ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "appdirs>=1.4", "rich>=13.3", "prompt_toolkit", + "textual>=5.3.0", ] [project.urls] diff --git a/src/libro/actions/tui.py b/src/libro/actions/tui.py new file mode 100644 index 0000000..9a53c3f --- /dev/null +++ b/src/libro/actions/tui.py @@ -0,0 +1,578 @@ +"""TUI interface for Libro using Textual""" + +import sqlite3 +from datetime import datetime +from typing import Dict, List, Optional + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import ( + DataTable, + Footer, + Header, + Label, + Button, + Input, + TextArea, + Select, +) +from textual.screen import ModalScreen +from textual.binding import Binding + +from libro.actions.show import get_reviews +from libro.models import ReadingListBook, Book, Review + + +class BookDetailScreen(ModalScreen): + """Modal screen to display book and review details""" + + CSS = """ + BookDetailScreen { + align: center middle; + } + + .detail-container { + width: 80; + height: auto; + background: $surface; + border: thick $primary; + padding: 1; + } + + .detail-table { + height: auto; + margin-bottom: 1; + } + + .close-button { + width: 100%; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("escape", "close", "Close"), + ] + + def __init__(self, db_path: str, review_id: int): + super().__init__() + self.db_path = db_path + self.review_id = review_id + + def compose(self) -> ComposeResult: + """Create the book detail view""" + with Container(classes="detail-container"): + yield Label(f"Review Details - ID: {self.review_id}", classes="title") + yield DataTable(id="detail_table", classes="detail-table") + yield Button("Close", id="close_button", classes="close-button") + + def on_mount(self) -> None: + """Load book details when screen opens""" + self.load_book_details() + + def load_book_details(self) -> None: + """Load and display book and review details""" + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Get book and review details (same query as CLI show command) + cursor = db.cursor() + cursor.execute( + """SELECT b.id, b.title, b.author, b.pub_year, b.pages, b.genre, + r.id, r.rating, r.date_read, r.review + FROM books b + LEFT JOIN reviews r ON b.id = r.book_id + WHERE r.id = ?""", + (self.review_id,), + ) + book_data = cursor.fetchone() + + if not book_data: + self.notify(f"No review found with ID {self.review_id}") + self.app.pop_screen() + return + + # Set up the detail table + table = self.query_one("#detail_table", DataTable) + table.add_column("Field", width=20) + table.add_column("Value", width=50) + + # Field mappings + display_data = [ + ("Book ID", book_data[0]), + ("Title", book_data[1]), + ("Author", book_data[2]), + ("Publication Year", book_data[3]), + ("Pages", book_data[4]), + ("Genre", book_data[5]), + ("Review ID", book_data[6]), + ("Rating", book_data[7]), + ("Date Read", book_data[8]), + ("My Review", book_data[9]), + ] + + for field, value in display_data: + display_value = str(value) if value is not None else "Not set" + table.add_row(field, display_value) + + # Show reading lists that contain this book + book_id = book_data[0] + reading_lists = ReadingListBook.get_lists_for_book(db, book_id) + + if reading_lists: + table.add_row("Reading Lists", ", ".join(reading_lists)) + + except sqlite3.Error as e: + self.notify(f"Database error: {e}") + finally: + if "db" in locals(): + db.close() + + def on_button_pressed(self, event) -> None: + """Handle button presses""" + if event.button.id == "close_button": + self.action_close() + + def action_close(self) -> None: + """Close the detail screen""" + self.app.pop_screen() + + +class AddBookScreen(ModalScreen): + """Modal screen for adding a new book and review""" + + CSS = """ + AddBookScreen { + align: center middle; + } + + .form-container { + width: 80; + height: auto; + background: $surface; + border: thick $primary; + padding: 1; + } + + .form-section { + margin-bottom: 1; + } + + .form-field { + margin-bottom: 1; + } + + .form-buttons { + width: 100%; + margin-top: 1; + } + + .button-row { + width: 100%; + height: 3; + } + + Input, Select, TextArea { + width: 100%; + } + + TextArea { + height: 4; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+s", "save", "Save"), + ] + + def __init__(self, db_path: str): + super().__init__() + self.db_path = db_path + + def compose(self) -> ComposeResult: + """Create the add book form""" + with Container(classes="form-container"): + yield Label("Add New Book & Review", classes="title") + + # Book Information Section + with Container(classes="form-section"): + yield Label("[bold cyan]Book Information[/bold cyan]") + with Container(classes="form-field"): + yield Label("Title *") + yield Input(placeholder="Enter book title", id="title_input") + + with Container(classes="form-field"): + yield Label("Author *") + yield Input(placeholder="Enter author name", id="author_input") + + with Container(classes="form-field"): + yield Label("Publication Year") + yield Input(placeholder="YYYY", id="year_input") + + with Container(classes="form-field"): + yield Label("Pages") + yield Input(placeholder="Number of pages", id="pages_input") + + with Container(classes="form-field"): + yield Label("Genre") + yield Select( + [ + ("", ""), + ("fiction", "Fiction"), + ("non-fiction", "Non-Fiction"), + ("mystery", "Mystery"), + ("science-fiction", "Science Fiction"), + ("fantasy", "Fantasy"), + ("romance", "Romance"), + ("thriller", "Thriller"), + ("biography", "Biography"), + ("history", "History"), + ("philosophy", "Philosophy"), + ("other", "Other"), + ], + id="genre_select", + ) + + # Review Information Section + with Container(classes="form-section"): + yield Label("[bold cyan]Review Information[/bold cyan]") + with Container(classes="form-field"): + yield Label("Date Read") + yield Input(placeholder="YYYY-MM-DD", id="date_input") + + with Container(classes="form-field"): + yield Label("Rating (1-5)") + yield Input(placeholder="1-5", id="rating_input") + + with Container(classes="form-field"): + yield Label("Your Review") + yield TextArea(id="review_textarea") + + # Buttons + with Horizontal(classes="form-buttons"): + yield Button("Save", id="save_button", variant="primary") + yield Button("Cancel", id="cancel_button") + + def on_button_pressed(self, event) -> None: + """Handle button presses""" + if event.button.id == "save_button": + self.action_save() + elif event.button.id == "cancel_button": + self.action_cancel() + + def action_save(self) -> None: + """Save the new book and review""" + # Get form values + title = self.query_one("#title_input", Input).value.strip() + author = self.query_one("#author_input", Input).value.strip() + year_str = self.query_one("#year_input", Input).value.strip() + pages_str = self.query_one("#pages_input", Input).value.strip() + genre_value = self.query_one("#genre_select", Select).value + genre = str(genre_value) if genre_value else None + date_str = self.query_one("#date_input", Input).value.strip() + rating_str = self.query_one("#rating_input", Input).value.strip() + review_text = self.query_one("#review_textarea", TextArea).text.strip() + + # Validate required fields + if not title: + self.notify("Title is required") + return + if not author: + self.notify("Author is required") + return + + # Validate and convert numeric fields + pub_year = None + if year_str: + try: + pub_year = int(year_str) + if pub_year < 0 or pub_year > datetime.now().year + 10: + self.notify("Invalid publication year") + return + except ValueError: + self.notify("Publication year must be a number") + return + + pages = None + if pages_str: + try: + pages = int(pages_str) + if pages < 0: + self.notify("Pages must be positive") + return + except ValueError: + self.notify("Pages must be a number") + return + + rating = None + if rating_str: + try: + rating = int(rating_str) + if rating < 1 or rating > 5: + self.notify("Rating must be between 1 and 5") + return + except ValueError: + self.notify("Rating must be a number") + return + + # Validate date format + date_read = None + if date_str: + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + date_read = date_obj.date() + except ValueError: + self.notify("Date must be in YYYY-MM-DD format") + return + + # Save to database + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Create and insert book + book = Book( + title=title, + author=author, + pub_year=pub_year, + pages=pages, + genre=genre, + ) + book_id = book.insert(db) + + # Create and insert review + review = Review( + book_id=book_id, + date_read=date_read, + rating=rating, + review=review_text if review_text else None, + ) + review.insert(db) + + self.notify(f"Successfully added '{title}'!") + + # Refresh the main screen before closing + main_screen = self.app.screen_stack[0] # Main screen is at the bottom + if hasattr(main_screen, "load_books_data"): + main_screen.load_books_data() + + self.app.pop_screen() + + except sqlite3.Error as e: + self.notify(f"Database error: {e}") + except Exception as e: + self.notify(f"Error: {e}") + finally: + if "db" in locals(): + db.close() + + def action_cancel(self) -> None: + """Cancel adding book""" + self.app.pop_screen() + + +class LibroTUI(App): + """Main TUI application for Libro""" + + CSS = """ + Screen { + background: $background; + } + + .header { + dock: top; + height: 3; + background: $primary; + content-align: center middle; + color: $text; + } + + .main-container { + height: 1fr; + padding: 1; + } + + .footer-menu { + dock: bottom; + height: 3; + background: $surface; + color: $text; + content-align: center middle; + } + + DataTable { + height: 1fr; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("r", "refresh", "Refresh"), + Binding("a", "add_book", "Add Book"), + Binding("b", "books_view", "Books"), + Binding("l", "lists_view", "Lists"), + Binding("enter", "view_details", "View Details"), + Binding("question_mark", "help", "Help"), + ] + + def __init__(self, db_path: str): + super().__init__() + self.db_path = db_path + self.current_year = datetime.now().year + + def compose(self) -> ComposeResult: + """Create the UI layout""" + yield Header() + + with Container(classes="header"): + yield Label(f"Libro - Books Read in {self.current_year}", classes="title") + + with Container(classes="main-container"): + yield DataTable(id="books_table") + + with Container(classes="footer-menu"): + yield Label( + "q: Quit | r: Refresh | a: Add Book | b: Books | l: Lists | ?: Help" + ) + + def on_mount(self) -> None: + """Initialize the table when the app starts""" + self.theme = "nord" + self.load_books_data() + + def load_books_data(self) -> None: + """Load and display books read in current year""" + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Get books for current year (same logic as CLI report command) + books = get_reviews(db, year=self.current_year) + + table = self.query_one("#books_table", DataTable) + table.clear(columns=True) + + # Add columns + table.add_column("Review ID", width=10) + table.add_column("Title", width=30) + table.add_column("Author", width=25) + table.add_column("Genre", width=15) + table.add_column("Rating", width=8) + table.add_column("Date Read", width=12) + + if not books: + table.add_row("No books found for current year", "", "", "", "", "") + return + + # Group by genre and add rows + current_genre = None + genre_counts: dict[str, int] = {} + for book in books: + genre_key = book["genre"] or "Unknown" + genre_counts[genre_key] = genre_counts.get(genre_key, 0) + 1 + + for book in books: + # Add genre separator if genre changes + if book["genre"] != current_genre: + if current_genre is not None: + table.add_row("", "", "", "", "", "") # Empty separator row + + current_genre = book["genre"] + genre_display = ( + current_genre.title() if current_genre else "Unknown" + ) + genre_key = current_genre or "Unknown" + genre_header = f"{genre_display} ({genre_counts[genre_key]})" + table.add_row("", genre_header, "", "", "", "") + + # Format date + date_str = book["date_read"] + if date_str: + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + formatted_date = date_obj.strftime("%b %d") + except ValueError: + formatted_date = date_str + else: + formatted_date = "" + + table.add_row( + str(book["review_id"]), + book["title"], + book["author"], + book["genre"] or "", + str(book["rating"]) if book["rating"] else "", + formatted_date, + ) + + except sqlite3.Error as e: + table = self.query_one("#books_table", DataTable) + table.clear(columns=True) + table.add_column("Error", width=50) + table.add_row(f"Database error: {e}") + finally: + if "db" in locals(): + db.close() + + async def action_quit(self) -> None: + """Exit the application""" + self.exit() + + def action_refresh(self) -> None: + """Refresh the current view""" + self.load_books_data() + + def action_view_details(self) -> None: + """View details of the selected book""" + table = self.query_one("#books_table", DataTable) + + if table.cursor_row is None: + self.notify("No row selected") + return + + # Get the selected row data + row_data = table.get_row_at(table.cursor_row) + + if not row_data or len(row_data) == 0: + self.notify("Invalid selection") + return + + # The first column should be the Review ID + review_id_str = str(row_data[0]) + + # Skip empty rows and genre headers + if not review_id_str or review_id_str == "": + self.notify("Select a book row to view details") + return + + try: + review_id = int(review_id_str) + # Open the book detail screen + self.push_screen(BookDetailScreen(self.db_path, review_id)) + except ValueError: + self.notify("Select a book row to view details") + return + + def action_add_book(self) -> None: + """Add a new book and review""" + self.push_screen(AddBookScreen(self.db_path)) + + def action_books_view(self) -> None: + """Switch to books-only view (placeholder for now)""" + self.notify("Books view coming soon!") + + def action_lists_view(self) -> None: + """Switch to reading lists view (placeholder for now)""" + self.notify("Lists view coming soon!") + + def action_help(self) -> None: + """Show help dialog (placeholder for now)""" + self.notify("Help: Use arrow keys to navigate, Enter to select, q to quit") + + +def launch_tui(db_path: str) -> None: + """Launch the TUI application""" + app = LibroTUI(db_path) + app.run() diff --git a/src/libro/config.py b/src/libro/config.py index f6ee9ed..008d5f3 100644 --- a/src/libro/config.py +++ b/src/libro/config.py @@ -154,6 +154,9 @@ def init_args() -> Dict: "--description", type=str, help="Description for new reading list" ) + # TUI command + subparsers.add_parser("tui", help="Launch interactive TUI interface") + args = vars(parser.parse_args()) if args["version"]: diff --git a/src/libro/main.py b/src/libro/main.py index b17849e..48807ba 100644 --- a/src/libro/main.py +++ b/src/libro/main.py @@ -15,6 +15,7 @@ from libro.actions.db import init_db, migrate_db from libro.actions.importer import import_books from libro.actions.lists import manage_lists +from libro.actions.tui import launch_tui def main(): @@ -116,6 +117,8 @@ def main(): except ValueError: print(f"Unknown review action or invalid ID: {action_or_id}") print("Valid actions: add, edit, or a review ID number") + case "tui": + launch_tui(str(dbfile)) case _: print("Not yet implemented") From 4f20d04c16a610319d3331ec2438258230de4bf0 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 24 Aug 2025 07:13:11 -0700 Subject: [PATCH 03/11] TUI: checkpoint with add form working well --- src/libro/actions/tui.py | 578 --------------------------- src/libro/main.py | 2 +- src/libro/tui/__init__.py | 13 + src/libro/tui/app.py | 195 +++++++++ src/libro/tui/screens/__init__.py | 6 + src/libro/tui/screens/add_book.py | 289 ++++++++++++++ src/libro/tui/screens/book_detail.py | 126 ++++++ src/libro/tui/utils.py | 4 + 8 files changed, 634 insertions(+), 579 deletions(-) delete mode 100644 src/libro/actions/tui.py create mode 100644 src/libro/tui/__init__.py create mode 100644 src/libro/tui/app.py create mode 100644 src/libro/tui/screens/__init__.py create mode 100644 src/libro/tui/screens/add_book.py create mode 100644 src/libro/tui/screens/book_detail.py create mode 100644 src/libro/tui/utils.py diff --git a/src/libro/actions/tui.py b/src/libro/actions/tui.py deleted file mode 100644 index 9a53c3f..0000000 --- a/src/libro/actions/tui.py +++ /dev/null @@ -1,578 +0,0 @@ -"""TUI interface for Libro using Textual""" - -import sqlite3 -from datetime import datetime -from typing import Dict, List, Optional - -from textual.app import App, ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import ( - DataTable, - Footer, - Header, - Label, - Button, - Input, - TextArea, - Select, -) -from textual.screen import ModalScreen -from textual.binding import Binding - -from libro.actions.show import get_reviews -from libro.models import ReadingListBook, Book, Review - - -class BookDetailScreen(ModalScreen): - """Modal screen to display book and review details""" - - CSS = """ - BookDetailScreen { - align: center middle; - } - - .detail-container { - width: 80; - height: auto; - background: $surface; - border: thick $primary; - padding: 1; - } - - .detail-table { - height: auto; - margin-bottom: 1; - } - - .close-button { - width: 100%; - margin-top: 1; - } - """ - - BINDINGS = [ - Binding("escape", "close", "Close"), - ] - - def __init__(self, db_path: str, review_id: int): - super().__init__() - self.db_path = db_path - self.review_id = review_id - - def compose(self) -> ComposeResult: - """Create the book detail view""" - with Container(classes="detail-container"): - yield Label(f"Review Details - ID: {self.review_id}", classes="title") - yield DataTable(id="detail_table", classes="detail-table") - yield Button("Close", id="close_button", classes="close-button") - - def on_mount(self) -> None: - """Load book details when screen opens""" - self.load_book_details() - - def load_book_details(self) -> None: - """Load and display book and review details""" - try: - db = sqlite3.connect(self.db_path) - db.row_factory = sqlite3.Row - - # Get book and review details (same query as CLI show command) - cursor = db.cursor() - cursor.execute( - """SELECT b.id, b.title, b.author, b.pub_year, b.pages, b.genre, - r.id, r.rating, r.date_read, r.review - FROM books b - LEFT JOIN reviews r ON b.id = r.book_id - WHERE r.id = ?""", - (self.review_id,), - ) - book_data = cursor.fetchone() - - if not book_data: - self.notify(f"No review found with ID {self.review_id}") - self.app.pop_screen() - return - - # Set up the detail table - table = self.query_one("#detail_table", DataTable) - table.add_column("Field", width=20) - table.add_column("Value", width=50) - - # Field mappings - display_data = [ - ("Book ID", book_data[0]), - ("Title", book_data[1]), - ("Author", book_data[2]), - ("Publication Year", book_data[3]), - ("Pages", book_data[4]), - ("Genre", book_data[5]), - ("Review ID", book_data[6]), - ("Rating", book_data[7]), - ("Date Read", book_data[8]), - ("My Review", book_data[9]), - ] - - for field, value in display_data: - display_value = str(value) if value is not None else "Not set" - table.add_row(field, display_value) - - # Show reading lists that contain this book - book_id = book_data[0] - reading_lists = ReadingListBook.get_lists_for_book(db, book_id) - - if reading_lists: - table.add_row("Reading Lists", ", ".join(reading_lists)) - - except sqlite3.Error as e: - self.notify(f"Database error: {e}") - finally: - if "db" in locals(): - db.close() - - def on_button_pressed(self, event) -> None: - """Handle button presses""" - if event.button.id == "close_button": - self.action_close() - - def action_close(self) -> None: - """Close the detail screen""" - self.app.pop_screen() - - -class AddBookScreen(ModalScreen): - """Modal screen for adding a new book and review""" - - CSS = """ - AddBookScreen { - align: center middle; - } - - .form-container { - width: 80; - height: auto; - background: $surface; - border: thick $primary; - padding: 1; - } - - .form-section { - margin-bottom: 1; - } - - .form-field { - margin-bottom: 1; - } - - .form-buttons { - width: 100%; - margin-top: 1; - } - - .button-row { - width: 100%; - height: 3; - } - - Input, Select, TextArea { - width: 100%; - } - - TextArea { - height: 4; - } - """ - - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("ctrl+s", "save", "Save"), - ] - - def __init__(self, db_path: str): - super().__init__() - self.db_path = db_path - - def compose(self) -> ComposeResult: - """Create the add book form""" - with Container(classes="form-container"): - yield Label("Add New Book & Review", classes="title") - - # Book Information Section - with Container(classes="form-section"): - yield Label("[bold cyan]Book Information[/bold cyan]") - with Container(classes="form-field"): - yield Label("Title *") - yield Input(placeholder="Enter book title", id="title_input") - - with Container(classes="form-field"): - yield Label("Author *") - yield Input(placeholder="Enter author name", id="author_input") - - with Container(classes="form-field"): - yield Label("Publication Year") - yield Input(placeholder="YYYY", id="year_input") - - with Container(classes="form-field"): - yield Label("Pages") - yield Input(placeholder="Number of pages", id="pages_input") - - with Container(classes="form-field"): - yield Label("Genre") - yield Select( - [ - ("", ""), - ("fiction", "Fiction"), - ("non-fiction", "Non-Fiction"), - ("mystery", "Mystery"), - ("science-fiction", "Science Fiction"), - ("fantasy", "Fantasy"), - ("romance", "Romance"), - ("thriller", "Thriller"), - ("biography", "Biography"), - ("history", "History"), - ("philosophy", "Philosophy"), - ("other", "Other"), - ], - id="genre_select", - ) - - # Review Information Section - with Container(classes="form-section"): - yield Label("[bold cyan]Review Information[/bold cyan]") - with Container(classes="form-field"): - yield Label("Date Read") - yield Input(placeholder="YYYY-MM-DD", id="date_input") - - with Container(classes="form-field"): - yield Label("Rating (1-5)") - yield Input(placeholder="1-5", id="rating_input") - - with Container(classes="form-field"): - yield Label("Your Review") - yield TextArea(id="review_textarea") - - # Buttons - with Horizontal(classes="form-buttons"): - yield Button("Save", id="save_button", variant="primary") - yield Button("Cancel", id="cancel_button") - - def on_button_pressed(self, event) -> None: - """Handle button presses""" - if event.button.id == "save_button": - self.action_save() - elif event.button.id == "cancel_button": - self.action_cancel() - - def action_save(self) -> None: - """Save the new book and review""" - # Get form values - title = self.query_one("#title_input", Input).value.strip() - author = self.query_one("#author_input", Input).value.strip() - year_str = self.query_one("#year_input", Input).value.strip() - pages_str = self.query_one("#pages_input", Input).value.strip() - genre_value = self.query_one("#genre_select", Select).value - genre = str(genre_value) if genre_value else None - date_str = self.query_one("#date_input", Input).value.strip() - rating_str = self.query_one("#rating_input", Input).value.strip() - review_text = self.query_one("#review_textarea", TextArea).text.strip() - - # Validate required fields - if not title: - self.notify("Title is required") - return - if not author: - self.notify("Author is required") - return - - # Validate and convert numeric fields - pub_year = None - if year_str: - try: - pub_year = int(year_str) - if pub_year < 0 or pub_year > datetime.now().year + 10: - self.notify("Invalid publication year") - return - except ValueError: - self.notify("Publication year must be a number") - return - - pages = None - if pages_str: - try: - pages = int(pages_str) - if pages < 0: - self.notify("Pages must be positive") - return - except ValueError: - self.notify("Pages must be a number") - return - - rating = None - if rating_str: - try: - rating = int(rating_str) - if rating < 1 or rating > 5: - self.notify("Rating must be between 1 and 5") - return - except ValueError: - self.notify("Rating must be a number") - return - - # Validate date format - date_read = None - if date_str: - try: - date_obj = datetime.strptime(date_str, "%Y-%m-%d") - date_read = date_obj.date() - except ValueError: - self.notify("Date must be in YYYY-MM-DD format") - return - - # Save to database - try: - db = sqlite3.connect(self.db_path) - db.row_factory = sqlite3.Row - - # Create and insert book - book = Book( - title=title, - author=author, - pub_year=pub_year, - pages=pages, - genre=genre, - ) - book_id = book.insert(db) - - # Create and insert review - review = Review( - book_id=book_id, - date_read=date_read, - rating=rating, - review=review_text if review_text else None, - ) - review.insert(db) - - self.notify(f"Successfully added '{title}'!") - - # Refresh the main screen before closing - main_screen = self.app.screen_stack[0] # Main screen is at the bottom - if hasattr(main_screen, "load_books_data"): - main_screen.load_books_data() - - self.app.pop_screen() - - except sqlite3.Error as e: - self.notify(f"Database error: {e}") - except Exception as e: - self.notify(f"Error: {e}") - finally: - if "db" in locals(): - db.close() - - def action_cancel(self) -> None: - """Cancel adding book""" - self.app.pop_screen() - - -class LibroTUI(App): - """Main TUI application for Libro""" - - CSS = """ - Screen { - background: $background; - } - - .header { - dock: top; - height: 3; - background: $primary; - content-align: center middle; - color: $text; - } - - .main-container { - height: 1fr; - padding: 1; - } - - .footer-menu { - dock: bottom; - height: 3; - background: $surface; - color: $text; - content-align: center middle; - } - - DataTable { - height: 1fr; - } - """ - - BINDINGS = [ - Binding("q", "quit", "Quit"), - Binding("r", "refresh", "Refresh"), - Binding("a", "add_book", "Add Book"), - Binding("b", "books_view", "Books"), - Binding("l", "lists_view", "Lists"), - Binding("enter", "view_details", "View Details"), - Binding("question_mark", "help", "Help"), - ] - - def __init__(self, db_path: str): - super().__init__() - self.db_path = db_path - self.current_year = datetime.now().year - - def compose(self) -> ComposeResult: - """Create the UI layout""" - yield Header() - - with Container(classes="header"): - yield Label(f"Libro - Books Read in {self.current_year}", classes="title") - - with Container(classes="main-container"): - yield DataTable(id="books_table") - - with Container(classes="footer-menu"): - yield Label( - "q: Quit | r: Refresh | a: Add Book | b: Books | l: Lists | ?: Help" - ) - - def on_mount(self) -> None: - """Initialize the table when the app starts""" - self.theme = "nord" - self.load_books_data() - - def load_books_data(self) -> None: - """Load and display books read in current year""" - try: - db = sqlite3.connect(self.db_path) - db.row_factory = sqlite3.Row - - # Get books for current year (same logic as CLI report command) - books = get_reviews(db, year=self.current_year) - - table = self.query_one("#books_table", DataTable) - table.clear(columns=True) - - # Add columns - table.add_column("Review ID", width=10) - table.add_column("Title", width=30) - table.add_column("Author", width=25) - table.add_column("Genre", width=15) - table.add_column("Rating", width=8) - table.add_column("Date Read", width=12) - - if not books: - table.add_row("No books found for current year", "", "", "", "", "") - return - - # Group by genre and add rows - current_genre = None - genre_counts: dict[str, int] = {} - for book in books: - genre_key = book["genre"] or "Unknown" - genre_counts[genre_key] = genre_counts.get(genre_key, 0) + 1 - - for book in books: - # Add genre separator if genre changes - if book["genre"] != current_genre: - if current_genre is not None: - table.add_row("", "", "", "", "", "") # Empty separator row - - current_genre = book["genre"] - genre_display = ( - current_genre.title() if current_genre else "Unknown" - ) - genre_key = current_genre or "Unknown" - genre_header = f"{genre_display} ({genre_counts[genre_key]})" - table.add_row("", genre_header, "", "", "", "") - - # Format date - date_str = book["date_read"] - if date_str: - try: - date_obj = datetime.strptime(date_str, "%Y-%m-%d") - formatted_date = date_obj.strftime("%b %d") - except ValueError: - formatted_date = date_str - else: - formatted_date = "" - - table.add_row( - str(book["review_id"]), - book["title"], - book["author"], - book["genre"] or "", - str(book["rating"]) if book["rating"] else "", - formatted_date, - ) - - except sqlite3.Error as e: - table = self.query_one("#books_table", DataTable) - table.clear(columns=True) - table.add_column("Error", width=50) - table.add_row(f"Database error: {e}") - finally: - if "db" in locals(): - db.close() - - async def action_quit(self) -> None: - """Exit the application""" - self.exit() - - def action_refresh(self) -> None: - """Refresh the current view""" - self.load_books_data() - - def action_view_details(self) -> None: - """View details of the selected book""" - table = self.query_one("#books_table", DataTable) - - if table.cursor_row is None: - self.notify("No row selected") - return - - # Get the selected row data - row_data = table.get_row_at(table.cursor_row) - - if not row_data or len(row_data) == 0: - self.notify("Invalid selection") - return - - # The first column should be the Review ID - review_id_str = str(row_data[0]) - - # Skip empty rows and genre headers - if not review_id_str or review_id_str == "": - self.notify("Select a book row to view details") - return - - try: - review_id = int(review_id_str) - # Open the book detail screen - self.push_screen(BookDetailScreen(self.db_path, review_id)) - except ValueError: - self.notify("Select a book row to view details") - return - - def action_add_book(self) -> None: - """Add a new book and review""" - self.push_screen(AddBookScreen(self.db_path)) - - def action_books_view(self) -> None: - """Switch to books-only view (placeholder for now)""" - self.notify("Books view coming soon!") - - def action_lists_view(self) -> None: - """Switch to reading lists view (placeholder for now)""" - self.notify("Lists view coming soon!") - - def action_help(self) -> None: - """Show help dialog (placeholder for now)""" - self.notify("Help: Use arrow keys to navigate, Enter to select, q to quit") - - -def launch_tui(db_path: str) -> None: - """Launch the TUI application""" - app = LibroTUI(db_path) - app.run() diff --git a/src/libro/main.py b/src/libro/main.py index 48807ba..ed9a49f 100644 --- a/src/libro/main.py +++ b/src/libro/main.py @@ -15,7 +15,7 @@ from libro.actions.db import init_db, migrate_db from libro.actions.importer import import_books from libro.actions.lists import manage_lists -from libro.actions.tui import launch_tui +from libro.tui import launch_tui def main(): diff --git a/src/libro/tui/__init__.py b/src/libro/tui/__init__.py new file mode 100644 index 0000000..d956781 --- /dev/null +++ b/src/libro/tui/__init__.py @@ -0,0 +1,13 @@ +"""TUI interface for Libro using Textual""" + +from .app import LibroTUI +from .screens import BookDetailScreen, AddBookScreen + + +def launch_tui(db_path: str) -> None: + """Launch the TUI application""" + app = LibroTUI(db_path) + app.run() + + +__all__ = ["launch_tui", "LibroTUI", "BookDetailScreen", "AddBookScreen"] diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py new file mode 100644 index 0000000..15b981a --- /dev/null +++ b/src/libro/tui/app.py @@ -0,0 +1,195 @@ +"""Main TUI application for Libro""" + +import sqlite3 +from datetime import datetime +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widgets import DataTable, Header, Label +from textual.binding import Binding + +from libro.actions.show import get_reviews +from .screens.book_detail import BookDetailScreen +from .screens.add_book import AddBookScreen + + +class LibroTUI(App): + """Main TUI application for Libro""" + + TITLE = "Libro" + + CSS = """ + .footer-menu { + dock: bottom; + height: 1; + background: $surface; + color: $text; + content-align: center middle; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("r", "refresh", "Refresh"), + Binding("a", "add_book", "Add Book"), + Binding("b", "books_view", "Books"), + Binding("l", "lists_view", "Lists"), + Binding("enter", "view_details", "View Details"), + Binding("question_mark", "help", "Help"), + ] + + def __init__(self, db_path: str): + super().__init__() + self.db_path = db_path + self.current_year = datetime.now().year + + def compose(self) -> ComposeResult: + """Create the UI layout""" + yield Header() + yield DataTable(id="books_table", cursor_type="row") + yield Container( + Label("q: Quit | r: Refresh | a: Add Book | Enter: View Details | ?: Help"), + classes="footer-menu", + ) + + def on_mount(self) -> None: + """Initialize the table when the app starts""" + self.theme = "gruvbox" + self.sub_title = f"Books Read in {self.current_year}" + self.load_books_data() + + def load_books_data(self) -> None: + """Load and display books read in current year""" + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Get books for current year (same logic as CLI report command) + books = get_reviews(db, year=self.current_year) + + table = self.query_one("#books_table", DataTable) + table.clear(columns=True) + + # Add columns + table.add_column("Review ID", width=10) + table.add_column("Title", width=30) + table.add_column("Author", width=25) + table.add_column("Genre", width=15) + table.add_column("Rating", width=8) + table.add_column("Date Read", width=12) + + if not books: + table.add_row("No books found for current year", "", "", "", "", "") + return + + # Group by genre and add rows + current_genre = None + genre_counts: dict[str, int] = {} + for book in books: + genre_key = book["genre"] or "Unknown" + genre_counts[genre_key] = genre_counts.get(genre_key, 0) + 1 + + for book in books: + # Add genre separator if genre changes + if book["genre"] != current_genre: + if current_genre is not None: + table.add_row("", "", "", "", "", "") # Empty separator row + + current_genre = book["genre"] + genre_display = ( + current_genre.title() if current_genre else "Unknown" + ) + genre_key = current_genre or "Unknown" + genre_header = f"{genre_display} ({genre_counts[genre_key]})" + table.add_row("", genre_header, "", "", "", "") + + # Format date + date_str = book["date_read"] + if date_str: + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + formatted_date = date_obj.strftime("%b %d") + except ValueError: + formatted_date = date_str + else: + formatted_date = "" + + table.add_row( + str(book["review_id"]), + book["title"], + book["author"], + book["genre"] or "", + str(book["rating"]) if book["rating"] else "", + formatted_date, + ) + + except sqlite3.Error as e: + table = self.query_one("#books_table", DataTable) + table.clear(columns=True) + table.add_column("Error", width=50) + table.add_row(f"Database error: {e}") + finally: + if "db" in locals(): + db.close() + + async def action_quit(self) -> None: + """Exit the application""" + self.exit() + + def action_refresh(self) -> None: + """Refresh the current view""" + self.load_books_data() + + def action_view_details(self) -> None: + """View details of the selected book""" + self._view_selected_book() + + def on_data_table_row_selected(self, event) -> None: + """Handle row selection in the data table""" + pass # Just for navigation, Enter key will trigger view details + + def _view_selected_book(self) -> None: + """View details of the currently selected book""" + table = self.query_one("#books_table", DataTable) + + if table.cursor_row is None: + self.notify("No row selected") + return + + # Get the selected row data + row_data = table.get_row_at(table.cursor_row) + + if not row_data or len(row_data) == 0: + self.notify("Invalid selection") + return + + # The first column should be the Review ID + review_id_str = str(row_data[0]) + + # Skip empty rows and genre headers + if not review_id_str or review_id_str == "": + self.notify("Select a book row to view details") + return + + try: + review_id = int(review_id_str) + # Open the book detail screen + self.push_screen(BookDetailScreen(self.db_path, review_id)) + except ValueError: + self.notify("Select a book row to view details") + return + + def action_add_book(self) -> None: + """Add a new book and review""" + self.push_screen(AddBookScreen(self.db_path)) + + def action_books_view(self) -> None: + """Switch to books-only view (placeholder for now)""" + self.notify("Books view coming soon!") + + def action_lists_view(self) -> None: + """Switch to reading lists view (placeholder for now)""" + self.notify("Lists view coming soon!") + + def action_help(self) -> None: + """Show help dialog (placeholder for now)""" + self.notify("Help: Use arrow keys to navigate, Enter to select, q to quit") diff --git a/src/libro/tui/screens/__init__.py b/src/libro/tui/screens/__init__.py new file mode 100644 index 0000000..f5fb5ec --- /dev/null +++ b/src/libro/tui/screens/__init__.py @@ -0,0 +1,6 @@ +"""TUI screens for Libro application""" + +from .book_detail import BookDetailScreen +from .add_book import AddBookScreen + +__all__ = ["BookDetailScreen", "AddBookScreen"] diff --git a/src/libro/tui/screens/add_book.py b/src/libro/tui/screens/add_book.py new file mode 100644 index 0000000..d07bee1 --- /dev/null +++ b/src/libro/tui/screens/add_book.py @@ -0,0 +1,289 @@ +"""Add book screen with form for creating new books and reviews""" + +import sqlite3 +from datetime import datetime +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.widgets import Button, Input, Label, Select, TextArea +from textual.screen import ModalScreen +from textual.binding import Binding +from textual.suggester import Suggester + +from libro.models import Book, Review + + +class AuthorSuggester(Suggester): + """Suggester for author names based on existing books in database""" + + def __init__(self, db_path: str): + super().__init__(use_cache=True, case_sensitive=False) + self.db_path = db_path + self._authors = None + + def _get_authors(self): + """Get all unique authors from the database""" + if self._authors is None: + try: + db = sqlite3.connect(self.db_path) + cursor = db.cursor() + cursor.execute(""" + SELECT DISTINCT author + FROM books + WHERE author IS NOT NULL AND author != '' + ORDER BY author + """) + self._authors = [row[0] for row in cursor.fetchall()] + except sqlite3.Error: + self._authors = [] + finally: + if "db" in locals(): + db.close() + return self._authors + + async def get_suggestion(self, value: str) -> str | None: + """Get author suggestion based on partial input""" + if not value: + return None + + authors = self._get_authors() + value_lower = value.lower() + + # Find the first author that starts with the input value + for author in authors: + if author.lower().startswith(value_lower): + return author + + return None + + +class AddBookScreen(ModalScreen): + """Modal screen for adding a new book and review""" + + CSS = """ + AddBookScreen { + align: center middle; + } + + .form-container { + width: 80; + background: $surface; + border: thick $primary; + padding: 1; + } + + .section-card { + border: round $accent; + padding: 0 1; + margin: 1 0; + height: auto; + } + + .mt-1 { + margin-top: 1; + } + + .mb-1 { + margin-bottom: 1; + } + + TextArea { + height: 8; + } + + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+s", "save", "Save"), + ] + + def __init__(self, db_path: str): + super().__init__() + self.db_path = db_path + self.genre_options = self._get_genre_options() + self.author_suggester = AuthorSuggester(db_path) + + def _get_genre_options(self): + """Get genre options from existing books in database""" + try: + db = sqlite3.connect(self.db_path) + cursor = db.cursor() + cursor.execute( + """ + SELECT DISTINCT genre + FROM books + WHERE genre IS NOT NULL AND genre != '' + ORDER BY genre + """ + ) + genres = [("", "")] # Empty option first + for row in cursor.fetchall(): + genre = row[0] + genres.append((genre, genre.title())) + return genres + except sqlite3.Error: + # Fallback to basic genres if DB query fails + return [("", ""), ("fiction", "fiction"), ("nonfiction", "nonfiction")] + finally: + if "db" in locals(): + db.close() + + def compose(self) -> ComposeResult: + """Create the add book form""" + with Container(classes="form-container"): + yield Label("Add New Book & Review") + + # Book Information Card + with Container(classes="section-card"): + yield Label("[bold cyan]Book Information[/bold cyan]") + yield Label("Title *", classes="mt-1") + yield Input(placeholder="Enter book title", id="title_input") + + yield Label("Author *", classes="mt-1") + yield Input( + placeholder="Enter author name", + id="author_input", + suggester=self.author_suggester, + ) + + yield Label("Publication Year", classes="mt-1") + yield Input(placeholder="YYYY", id="year_input") + + yield Label("Pages", classes="mt-1") + yield Input(placeholder="Number of pages", id="pages_input") + + yield Label("Genre", classes="mt-1") + yield Select(self.genre_options, id="genre_select") + + # Review Information Card + with Container(classes="section-card"): + yield Label("[bold cyan]Review Information[/bold cyan]") + yield Label("Date Read", classes="mt-1") + yield Input(placeholder="YYYY-MM-DD", id="date_input") + + yield Label("Rating (1-5)", classes="mt-1") + yield Input(placeholder="1-5", id="rating_input") + + yield Label("Your Review", classes="mt-1") + yield TextArea(id="review_textarea") + + with Horizontal(): + yield Button("Save", id="save_button", variant="primary") + yield Button("Cancel", id="cancel_button") + + def on_button_pressed(self, event) -> None: + """Handle button presses""" + if event.button.id == "save_button": + self.action_save() + elif event.button.id == "cancel_button": + self.action_cancel() + + def action_save(self) -> None: + """Save the new book and review""" + # Get form values + title = self.query_one("#title_input", Input).value.strip() + author = self.query_one("#author_input", Input).value.strip() + year_str = self.query_one("#year_input", Input).value.strip() + pages_str = self.query_one("#pages_input", Input).value.strip() + genre_value = self.query_one("#genre_select", Select).value + genre = str(genre_value) if genre_value else None + date_str = self.query_one("#date_input", Input).value.strip() + rating_str = self.query_one("#rating_input", Input).value.strip() + review_text = self.query_one("#review_textarea", TextArea).text.strip() + + # Validate required fields + if not title: + self.notify("Title is required") + return + if not author: + self.notify("Author is required") + return + + # Validate and convert numeric fields + pub_year = None + if year_str: + try: + pub_year = int(year_str) + if pub_year < 0 or pub_year > datetime.now().year + 10: + self.notify("Invalid publication year") + return + except ValueError: + self.notify("Publication year must be a number") + return + + pages = None + if pages_str: + try: + pages = int(pages_str) + if pages < 0: + self.notify("Pages must be positive") + return + except ValueError: + self.notify("Pages must be a number") + return + + rating = None + if rating_str: + try: + rating = int(rating_str) + if rating < 1 or rating > 5: + self.notify("Rating must be between 1 and 5") + return + except ValueError: + self.notify("Rating must be a number") + return + + # Validate date format + date_read = None + if date_str: + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + date_read = date_obj.date() + except ValueError: + self.notify("Date must be in YYYY-MM-DD format") + return + + # Save to database + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Create and insert book + book = Book( + title=title, + author=author, + pub_year=pub_year, + pages=pages, + genre=genre, + ) + book_id = book.insert(db) + + # Create and insert review + review = Review( + book_id=book_id, + date_read=date_read, + rating=rating, + review=review_text if review_text else None, + ) + review.insert(db) + + self.notify(f"Successfully added '{title}'!") + + # Refresh the main screen before closing + main_screen = self.app.screen_stack[0] # Main screen is at the bottom + if hasattr(main_screen, "load_books_data"): + main_screen.load_books_data() + + self.app.pop_screen() + + except sqlite3.Error as e: + self.notify(f"Database error: {e}") + except Exception as e: + self.notify(f"Error: {e}") + finally: + if "db" in locals(): + db.close() + + def action_cancel(self) -> None: + """Cancel adding book""" + self.app.pop_screen() diff --git a/src/libro/tui/screens/book_detail.py b/src/libro/tui/screens/book_detail.py new file mode 100644 index 0000000..208a1a2 --- /dev/null +++ b/src/libro/tui/screens/book_detail.py @@ -0,0 +1,126 @@ +"""Book detail screen for displaying book and review information""" + +import sqlite3 +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Button, DataTable, Label +from textual.screen import ModalScreen +from textual.binding import Binding + +from libro.models import ReadingListBook + + +class BookDetailScreen(ModalScreen): + """Modal screen to display book and review details""" + + CSS = """ + BookDetailScreen { + align: center middle; + } + + .detail-container { + width: 80; + height: auto; + background: $surface; + border: thick $primary; + padding: 1; + } + + .detail-table { + height: auto; + margin-bottom: 1; + } + + .close-button { + width: 100%; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("escape", "close", "Close"), + ] + + def __init__(self, db_path: str, review_id: int): + super().__init__() + self.db_path = db_path + self.review_id = review_id + + def compose(self) -> ComposeResult: + """Create the book detail view""" + with Container(classes="detail-container"): + yield Label(f"Review Details - ID: {self.review_id}", classes="title") + yield DataTable(id="detail_table", classes="detail-table") + yield Button("Close", id="close_button", classes="close-button") + + def on_mount(self) -> None: + """Load book details when screen opens""" + self.load_book_details() + + def load_book_details(self) -> None: + """Load and display book and review details""" + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Get book and review details (same query as CLI show command) + cursor = db.cursor() + cursor.execute( + """SELECT b.id, b.title, b.author, b.pub_year, b.pages, b.genre, + r.id, r.rating, r.date_read, r.review + FROM books b + LEFT JOIN reviews r ON b.id = r.book_id + WHERE r.id = ?""", + (self.review_id,), + ) + book_data = cursor.fetchone() + + if not book_data: + self.notify(f"No review found with ID {self.review_id}") + self.app.pop_screen() + return + + # Set up the detail table + table = self.query_one("#detail_table", DataTable) + table.add_column("Field", width=20) + table.add_column("Value", width=50) + + # Field mappings + display_data = [ + ("Book ID", book_data[0]), + ("Title", book_data[1]), + ("Author", book_data[2]), + ("Publication Year", book_data[3]), + ("Pages", book_data[4]), + ("Genre", book_data[5]), + ("Review ID", book_data[6]), + ("Rating", book_data[7]), + ("Date Read", book_data[8]), + ("My Review", book_data[9]), + ] + + for field, value in display_data: + display_value = str(value) if value is not None else "Not set" + table.add_row(field, display_value) + + # Show reading lists that contain this book + book_id = book_data[0] + reading_lists = ReadingListBook.get_lists_for_book(db, book_id) + + if reading_lists: + table.add_row("Reading Lists", ", ".join(reading_lists)) + + except sqlite3.Error as e: + self.notify(f"Database error: {e}") + finally: + if "db" in locals(): + db.close() + + def on_button_pressed(self, event) -> None: + """Handle button presses""" + if event.button.id == "close_button": + self.action_close() + + def action_close(self) -> None: + """Close the detail screen""" + self.app.pop_screen() diff --git a/src/libro/tui/utils.py b/src/libro/tui/utils.py new file mode 100644 index 0000000..e9f803f --- /dev/null +++ b/src/libro/tui/utils.py @@ -0,0 +1,4 @@ +"""Utility functions for the TUI interface""" + +# This file is reserved for shared utilities across TUI components +# Currently empty but provides structure for future utilities From 7d25e40545f0150fe06a2590c53e011f524531cf Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 24 Aug 2025 07:59:08 -0700 Subject: [PATCH 04/11] Selecting year checkpoint - works --- src/libro/tui/app.py | 18 +++++++++++++++++- src/libro/tui/screens/__init__.py | 3 ++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index 15b981a..ee76527 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -10,6 +10,7 @@ from libro.actions.show import get_reviews from .screens.book_detail import BookDetailScreen from .screens.add_book import AddBookScreen +from .screens.year_select import YearSelectScreen class LibroTUI(App): @@ -31,6 +32,7 @@ class LibroTUI(App): Binding("q", "quit", "Quit"), Binding("r", "refresh", "Refresh"), Binding("a", "add_book", "Add Book"), + Binding("y", "select_year", "Select Year"), Binding("b", "books_view", "Books"), Binding("l", "lists_view", "Lists"), Binding("enter", "view_details", "View Details"), @@ -47,7 +49,9 @@ def compose(self) -> ComposeResult: yield Header() yield DataTable(id="books_table", cursor_type="row") yield Container( - Label("q: Quit | r: Refresh | a: Add Book | Enter: View Details | ?: Help"), + Label( + "q: Quit | r: Refresh | a: Add Book | y: Select Year | Enter: View Details | ?: Help" + ), classes="footer-menu", ) @@ -182,6 +186,18 @@ def action_add_book(self) -> None: """Add a new book and review""" self.push_screen(AddBookScreen(self.db_path)) + def action_select_year(self) -> None: + """Open year selection dialog""" + self.push_screen(YearSelectScreen(self.db_path, self.current_year)) + + def change_year(self, new_year: int) -> None: + """Change the current year and reload data""" + self.current_year = new_year + self.sub_title = f"Books Read in {self.current_year}" + self.notify(f"Loading books for {self.current_year}") + self.load_books_data() + self.notify(f"Books loaded for {self.current_year}") + def action_books_view(self) -> None: """Switch to books-only view (placeholder for now)""" self.notify("Books view coming soon!") diff --git a/src/libro/tui/screens/__init__.py b/src/libro/tui/screens/__init__.py index f5fb5ec..5d3d38b 100644 --- a/src/libro/tui/screens/__init__.py +++ b/src/libro/tui/screens/__init__.py @@ -2,5 +2,6 @@ from .book_detail import BookDetailScreen from .add_book import AddBookScreen +from .year_select import YearSelectScreen -__all__ = ["BookDetailScreen", "AddBookScreen"] +__all__ = ["BookDetailScreen", "AddBookScreen", "YearSelectScreen"] From 52e052d9abacb7c06c273ff39846a3e8d256fa8b Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 24 Aug 2025 08:01:52 -0700 Subject: [PATCH 05/11] Selecting year checkpoint - works --- src/libro/tui/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index ee76527..410b3e3 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -104,7 +104,11 @@ def load_books_data(self) -> None: ) genre_key = current_genre or "Unknown" genre_header = f"{genre_display} ({genre_counts[genre_key]})" - table.add_row("", genre_header, "", "", "", "") + + # Add genre header row that spans all columns + row_key = table.add_row("", f"[bold cyan]{genre_header}[/bold cyan]", "", "", "", "") + # Style the genre header row + table.set_row_style(row_key, "bold on $accent") # Format date date_str = book["date_read"] @@ -194,9 +198,7 @@ def change_year(self, new_year: int) -> None: """Change the current year and reload data""" self.current_year = new_year self.sub_title = f"Books Read in {self.current_year}" - self.notify(f"Loading books for {self.current_year}") self.load_books_data() - self.notify(f"Books loaded for {self.current_year}") def action_books_view(self) -> None: """Switch to books-only view (placeholder for now)""" From 03ffce7f512121f920f9ce2560e36c88807b062f Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Sun, 24 Aug 2025 08:30:06 -0700 Subject: [PATCH 06/11] Better genre view per year --- src/libro/tui/app.py | 136 +++++++++++++++------------ src/libro/tui/screens/year_select.py | 101 ++++++++++++++++++++ 2 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 src/libro/tui/screens/year_select.py diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index 410b3e3..f2bcee6 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -6,6 +6,7 @@ from textual.containers import Container from textual.widgets import DataTable, Header, Label from textual.binding import Binding +from rich.text import Text from libro.actions.show import get_reviews from .screens.book_detail import BookDetailScreen @@ -26,6 +27,15 @@ class LibroTUI(App): color: $text; content-align: center middle; } + + .genre-table { + margin-bottom: 0; + } + + .header-label { + margin-top: 1; + } + """ BINDINGS = [ @@ -47,7 +57,7 @@ def __init__(self, db_path: str): def compose(self) -> ComposeResult: """Create the UI layout""" yield Header() - yield DataTable(id="books_table", cursor_type="row") + yield Container(id="books_container") yield Container( Label( "q: Quit | r: Refresh | a: Add Book | y: Select Year | Enter: View Details | ?: Help" @@ -62,7 +72,7 @@ def on_mount(self) -> None: self.load_books_data() def load_books_data(self) -> None: - """Load and display books read in current year""" + """Load and display books read in current year with separate tables per genre""" try: db = sqlite3.connect(self.db_path) db.row_factory = sqlite3.Row @@ -70,71 +80,73 @@ def load_books_data(self) -> None: # Get books for current year (same logic as CLI report command) books = get_reviews(db, year=self.current_year) - table = self.query_one("#books_table", DataTable) - table.clear(columns=True) - - # Add columns - table.add_column("Review ID", width=10) - table.add_column("Title", width=30) - table.add_column("Author", width=25) - table.add_column("Genre", width=15) - table.add_column("Rating", width=8) - table.add_column("Date Read", width=12) + # Clear the books container + container = self.query_one("#books_container", Container) + container.remove_children() if not books: - table.add_row("No books found for current year", "", "", "", "", "") + container.mount(Label("No books found for current year")) return - # Group by genre and add rows - current_genre = None - genre_counts: dict[str, int] = {} + # Group books by genre + books_by_genre: dict[str, list] = {} for book in books: genre_key = book["genre"] or "Unknown" - genre_counts[genre_key] = genre_counts.get(genre_key, 0) + 1 + if genre_key not in books_by_genre: + books_by_genre[genre_key] = [] + books_by_genre[genre_key].append(book) + + # Create a table for each genre + for genre, genre_books in books_by_genre.items(): + genre_display = ( + genre.title() if genre and genre != "Unknown" else "Unknown" + ) + genre_count = len(genre_books) - for book in books: - # Add genre separator if genre changes - if book["genre"] != current_genre: - if current_genre is not None: - table.add_row("", "", "", "", "", "") # Empty separator row - - current_genre = book["genre"] - genre_display = ( - current_genre.title() if current_genre else "Unknown" - ) - genre_key = current_genre or "Unknown" - genre_header = f"{genre_display} ({genre_counts[genre_key]})" - - # Add genre header row that spans all columns - row_key = table.add_row("", f"[bold cyan]{genre_header}[/bold cyan]", "", "", "", "") - # Style the genre header row - table.set_row_style(row_key, "bold on $accent") - - # Format date - date_str = book["date_read"] - if date_str: - try: - date_obj = datetime.strptime(date_str, "%Y-%m-%d") - formatted_date = date_obj.strftime("%b %d") - except ValueError: - formatted_date = date_str - else: - formatted_date = "" - - table.add_row( - str(book["review_id"]), - book["title"], - book["author"], - book["genre"] or "", - str(book["rating"]) if book["rating"] else "", - formatted_date, + # Add genre header label + header_label = Label( + f"[bold cyan]{genre_display} ({genre_count})[/bold cyan]", + classes="header-label", ) + container.mount(header_label) + + # Create table for this genre + table = DataTable(cursor_type="row", classes="genre-table") + table.add_column("Review ID", width=10) + table.add_column("Title", width=30) + table.add_column("Author", width=25) + table.add_column("Genre", width=15) + table.add_column("Rating", width=8) + table.add_column("Date Read", width=12) + + # Add books for this genre + for book in genre_books: + # Format date + date_str = book["date_read"] + if date_str: + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + formatted_date = date_obj.strftime("%b %d") + except ValueError: + formatted_date = date_str + else: + formatted_date = "" + + table.add_row( + str(book["review_id"]), + book["title"], + book["author"], + book["genre"] or "", + str(book["rating"]) if book["rating"] else "", + formatted_date, + ) + + container.mount(table) except sqlite3.Error as e: - table = self.query_one("#books_table", DataTable) - table.clear(columns=True) - table.add_column("Error", width=50) - table.add_row(f"Database error: {e}") + container = self.query_one("#books_container", Container) + container.remove_children() + container.mount(Label(f"Database error: {e}")) finally: if "db" in locals(): db.close() @@ -157,7 +169,13 @@ def on_data_table_row_selected(self, event) -> None: def _view_selected_book(self) -> None: """View details of the currently selected book""" - table = self.query_one("#books_table", DataTable) + # Find the currently focused table + focused_widget = self.focused + if not isinstance(focused_widget, DataTable): + self.notify("Select a book row first") + return + + table = focused_widget if table.cursor_row is None: self.notify("No row selected") @@ -173,7 +191,7 @@ def _view_selected_book(self) -> None: # The first column should be the Review ID review_id_str = str(row_data[0]) - # Skip empty rows and genre headers + # Skip empty rows if not review_id_str or review_id_str == "": self.notify("Select a book row to view details") return diff --git a/src/libro/tui/screens/year_select.py b/src/libro/tui/screens/year_select.py new file mode 100644 index 0000000..182e179 --- /dev/null +++ b/src/libro/tui/screens/year_select.py @@ -0,0 +1,101 @@ +"""Year selection screen for filtering books by year""" + +import sqlite3 +from datetime import datetime +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Label, OptionList +from textual.screen import ModalScreen +from textual.binding import Binding + + +class YearSelectScreen(ModalScreen): + """Modal screen for selecting a year to view books from""" + + CSS = """ + YearSelectScreen { + align: center middle; + } + + .year-container { + width: 50; + height: auto; + background: $surface; + border: thick $primary; + padding: 1; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + ] + + def __init__(self, db_path: str, current_year: int): + super().__init__() + self.db_path = db_path + self.current_year = current_year + self.available_years = self._get_available_years() + + def _get_available_years(self): + """Get all years that have books with reviews in the database""" + try: + db = sqlite3.connect(self.db_path) + cursor = db.cursor() + cursor.execute(""" + SELECT DISTINCT strftime('%Y', r.date_read) as year + FROM reviews r + WHERE r.date_read IS NOT NULL + ORDER BY year DESC + """) + + years = [] + for row in cursor.fetchall(): + year = row[0] # Keep as string + years.append(year) + + # If no years found, add current year as fallback + if not years: + current = str(datetime.now().year) + years.append(current) + + return years + + except sqlite3.Error: + # Fallback to current year if DB query fails + current = str(datetime.now().year) + return [current] + finally: + if "db" in locals(): + db.close() + + def compose(self) -> ComposeResult: + """Create the year selection interface""" + with Container(classes="year-container"): + yield Label("Select Year to View") + yield Label(f"Currently showing: {self.current_year}") + yield OptionList(*self.available_years, id="year_options") + + + def on_option_list_option_selected(self, event) -> None: + """Handle year selection from option list""" + if event.option_list.id == "year_options": + selected_year_str = str(event.option.prompt) + selected_year = int(selected_year_str) + + # Get the main app screen properly + main_app = self.app + if hasattr(main_app, "change_year"): + main_app.change_year(selected_year) + else: + # Try to access the main screen through screen stack + if main_app.screen_stack: + main_screen = main_app.screen_stack[0] + if hasattr(main_screen, "change_year"): + main_screen.change_year(selected_year) + + # Close the year selection screen + self.app.pop_screen() + + def action_cancel(self) -> None: + """Cancel year selection""" + self.app.pop_screen() From bf60eef7d54babe80f619d79c95ba76cbc6ea5d9 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Mon, 25 Aug 2025 05:28:35 -0700 Subject: [PATCH 07/11] Update book detail --- src/libro/tui/app.py | 2 +- src/libro/tui/screens/book_detail.py | 109 ++++++++++++++++++++------- 2 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index f2bcee6..c931404 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -165,7 +165,7 @@ def action_view_details(self) -> None: def on_data_table_row_selected(self, event) -> None: """Handle row selection in the data table""" - pass # Just for navigation, Enter key will trigger view details + self._view_selected_book() def _view_selected_book(self) -> None: """View details of the currently selected book""" diff --git a/src/libro/tui/screens/book_detail.py b/src/libro/tui/screens/book_detail.py index 208a1a2..8aed482 100644 --- a/src/libro/tui/screens/book_detail.py +++ b/src/libro/tui/screens/book_detail.py @@ -2,8 +2,8 @@ import sqlite3 from textual.app import ComposeResult -from textual.containers import Container -from textual.widgets import Button, DataTable, Label +from textual.containers import Container, Vertical +from textual.widgets import Button, Label, TextArea from textual.screen import ModalScreen from textual.binding import Binding @@ -23,12 +23,30 @@ class BookDetailScreen(ModalScreen): height: auto; background: $surface; border: thick $primary; - padding: 1; + padding: 0 1 1 1; } - .detail-table { + .section-card { + border: round $accent; + padding: 0 1 1 1; + margin: 1 0; height: auto; - margin-bottom: 1; + } + + .section-header { + color: $text; + background: $accent; + padding: 0 1; + margin: -1 -1 1 -1; + text-style: bold; + } + + .field-row { + margin: 0 1; + } + + .review { + height: 6; } .close-button { @@ -49,8 +67,20 @@ def __init__(self, db_path: str, review_id: int): def compose(self) -> ComposeResult: """Create the book detail view""" with Container(classes="detail-container"): - yield Label(f"Review Details - ID: {self.review_id}", classes="title") - yield DataTable(id="detail_table", classes="detail-table") + yield Label(f"Book & Review Details - Review ID: {self.review_id}") + + # Book Details Card + with Container(classes="section-card", id="book_section"): + yield Label("Book Information", classes="section-header") + + # Review Details Card + with Container(classes="section-card", id="review_section"): + yield Label("Review Information", classes="section-header") + + # Reading Lists Card + with Container(classes="section-card", id="lists_section"): + yield Label("Reading Lists", classes="section-header") + yield Button("Close", id="close_button", classes="close-button") def on_mount(self) -> None: @@ -58,12 +88,12 @@ def on_mount(self) -> None: self.load_book_details() def load_book_details(self) -> None: - """Load and display book and review details""" + """Load and display book and review details in cards""" try: db = sqlite3.connect(self.db_path) db.row_factory = sqlite3.Row - # Get book and review details (same query as CLI show command) + # Get book and review details cursor = db.cursor() cursor.execute( """SELECT b.id, b.title, b.author, b.pub_year, b.pages, b.genre, @@ -80,35 +110,58 @@ def load_book_details(self) -> None: self.app.pop_screen() return - # Set up the detail table - table = self.query_one("#detail_table", DataTable) - table.add_column("Field", width=20) - table.add_column("Value", width=50) - - # Field mappings - display_data = [ + # Populate Book Information card + book_section = self.query_one("#book_section", Container) + book_fields = [ ("Book ID", book_data[0]), ("Title", book_data[1]), ("Author", book_data[2]), - ("Publication Year", book_data[3]), - ("Pages", book_data[4]), - ("Genre", book_data[5]), - ("Review ID", book_data[6]), - ("Rating", book_data[7]), - ("Date Read", book_data[8]), - ("My Review", book_data[9]), + ("Publication Year", book_data[3] if book_data[3] else "Unknown"), + ("Pages", book_data[4] if book_data[4] else "Unknown"), + ("Genre", book_data[5] if book_data[5] else "Unknown"), ] - for field, value in display_data: - display_value = str(value) if value is not None else "Not set" - table.add_row(field, display_value) + for field, value in book_fields: + book_section.mount(Label(f"{field}: {value}", classes="field-row")) + + # Populate Review Information card + review_section = self.query_one("#review_section", Container) + review_fields = [ + ("Review ID", book_data[6]), + ("Rating", f"{book_data[7]}/5" if book_data[7] else "Not rated"), + ("Date Read", book_data[8] if book_data[8] else "Not set"), + ] - # Show reading lists that contain this book + for field, value in review_fields: + review_section.mount(Label(f"{field}: {value}", classes="field-row")) + + # Add review text if it exists + if book_data[9]: + review_section.mount(Label("Review:", classes="field-row")) + review_section.mount( + TextArea( + f"{book_data[9]}", + classes="review", + read_only=True, + ) + ) + else: + review_section.mount( + Label("Review: No review written", classes="field-row") + ) + + # Populate Reading Lists card + lists_section = self.query_one("#lists_section", Container) book_id = book_data[0] reading_lists = ReadingListBook.get_lists_for_book(db, book_id) if reading_lists: - table.add_row("Reading Lists", ", ".join(reading_lists)) + for list_name in reading_lists: + lists_section.mount(Label(f"• {list_name}", classes="field-row")) + else: + lists_section.mount( + Label("Not in any reading lists", classes="field-row") + ) except sqlite3.Error as e: self.notify(f"Database error: {e}") From a9461056b77fa19ad0e2a8e80498b486e5bbad2c Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Mon, 25 Aug 2025 05:38:59 -0700 Subject: [PATCH 08/11] Add reading list view and selectable from detail --- src/libro/models.py | 18 +++ src/libro/tui/app.py | 2 +- src/libro/tui/screens/book_detail.py | 52 ++++++- src/libro/tui/screens/reading_list.py | 212 ++++++++++++++++++++++++++ src/libro/tui/screens/year_select.py | 1 - 5 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/libro/tui/screens/reading_list.py diff --git a/src/libro/models.py b/src/libro/models.py index 0a774b6..000b669 100644 --- a/src/libro/models.py +++ b/src/libro/models.py @@ -414,3 +414,21 @@ def get_lists_for_book(cls, db: sqlite3.Connection, book_id: int) -> list[str]: (book_id,), ) return [row["name"] for row in cursor.fetchall()] + + @classmethod + def get_lists_with_ids_for_book( + cls, db: sqlite3.Connection, book_id: int + ) -> list[tuple[int, str]]: + """Get all reading lists (ID and name) that contain a specific book.""" + cursor = db.cursor() + cursor.execute( + """ + SELECT rl.id, rl.name + FROM reading_list_books rlb + JOIN reading_lists rl ON rlb.list_id = rl.id + WHERE rlb.book_id = ? + ORDER BY rl.name + """, + (book_id,), + ) + return [(row["id"], row["name"]) for row in cursor.fetchall()] diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index c931404..176dee9 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -67,7 +67,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: """Initialize the table when the app starts""" - self.theme = "gruvbox" + self.theme = "textual-dark" self.sub_title = f"Books Read in {self.current_year}" self.load_books_data() diff --git a/src/libro/tui/screens/book_detail.py b/src/libro/tui/screens/book_detail.py index 8aed482..286d054 100644 --- a/src/libro/tui/screens/book_detail.py +++ b/src/libro/tui/screens/book_detail.py @@ -53,6 +53,26 @@ class BookDetailScreen(ModalScreen): width: 100%; margin-top: 1; } + + .field-row Button { + width: 100%; + text-align: left; + background: $surface; + border: none; + height: 1; + margin: 0; + padding: 0 1; + } + + .field-row Button:hover { + background: $primary-lighten-1; + color: $text; + } + + .field-row Button:focus { + background: $primary; + color: $text; + } """ BINDINGS = [ @@ -63,6 +83,7 @@ def __init__(self, db_path: str, review_id: int): super().__init__() self.db_path = db_path self.review_id = review_id + self.reading_lists: list[tuple[int, str]] = [] def compose(self) -> ComposeResult: """Create the book detail view""" @@ -153,11 +174,20 @@ def load_book_details(self) -> None: # Populate Reading Lists card lists_section = self.query_one("#lists_section", Container) book_id = book_data[0] - reading_lists = ReadingListBook.get_lists_for_book(db, book_id) + self.reading_lists = ReadingListBook.get_lists_with_ids_for_book( + db, book_id + ) - if reading_lists: - for list_name in reading_lists: - lists_section.mount(Label(f"• {list_name}", classes="field-row")) + if self.reading_lists: + for list_id, list_name in self.reading_lists: + button = Button( + f"📚 {list_name}", + id=f"list_button_{list_id}", + classes="field-row", + ) + button.styles.width = "100%" + button.styles.text_align = "left" + lists_section.mount(button) else: lists_section.mount( Label("Not in any reading lists", classes="field-row") @@ -173,6 +203,20 @@ def on_button_pressed(self, event) -> None: """Handle button presses""" if event.button.id == "close_button": self.action_close() + elif event.button.id and event.button.id.startswith("list_button_"): + # Extract list ID from button ID + try: + list_id_str = event.button.id.replace("list_button_", "") + list_id = int(list_id_str) + self.open_reading_list(list_id) + except ValueError: + self.notify("Invalid reading list ID") + + def open_reading_list(self, list_id: int) -> None: + """Open the reading list screen""" + from .reading_list import ReadingListScreen + + self.app.push_screen(ReadingListScreen(self.db_path, list_id)) def action_close(self) -> None: """Close the detail screen""" diff --git a/src/libro/tui/screens/reading_list.py b/src/libro/tui/screens/reading_list.py new file mode 100644 index 0000000..97d83de --- /dev/null +++ b/src/libro/tui/screens/reading_list.py @@ -0,0 +1,212 @@ +"""Reading list screen for displaying books in a specific list""" + +import sqlite3 +from datetime import datetime +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import DataTable, Label +from textual.screen import ModalScreen +from textual.binding import Binding + +from libro.models import ReadingList, ReadingListBook + + +class ReadingListScreen(ModalScreen): + """Modal screen to display books in a specific reading list""" + + CSS = """ + ReadingListScreen { + align: center middle; + } + + .list-container { + width: 95; + height: 80; + background: $surface; + border: thick $primary; + padding: 1; + } + + .list-header { + color: $text; + background: $accent; + padding: 0 1; + margin: 0 0 1 0; + text-style: bold; + } + + .list-table { + height: 1fr; + margin: 1 0; + } + + .stats-row { + margin: 1 0 0 0; + color: $text-muted; + } + """ + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("enter", "view_book", "View Book"), + ] + + def __init__(self, db_path: str, list_id: int): + super().__init__() + self.db_path = db_path + self.list_id = list_id + + def compose(self) -> ComposeResult: + """Create the reading list view""" + with Container(classes="list-container"): + yield Label("Loading...", classes="list-header", id="list_header") + yield DataTable(cursor_type="row", classes="list-table", id="list_table") + yield Label("", classes="stats-row", id="stats_label") + + def on_mount(self) -> None: + """Load reading list details when screen opens""" + self.load_list_details() + + def load_list_details(self) -> None: + """Load and display reading list contents""" + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Get reading list details + reading_list = ReadingList.get_by_id(db, self.list_id) + if not reading_list: + self.notify(f"Reading list with ID {self.list_id} not found") + self.app.pop_screen() + return + + if reading_list.id is None: + self.notify("Reading list has no ID") + self.app.pop_screen() + return + + # Update header + header_text = f"📚 {reading_list.name}" + if reading_list.description: + header_text += f" - {reading_list.description}" + + header_label = self.query_one("#list_header", Label) + header_label.update(header_text) + + # Get books in this list + books = ReadingListBook.get_books_in_list(db, reading_list.id) + + # Set up the table + table = self.query_one("#list_table", DataTable) + table.clear(columns=True) + table.add_column("ID", width=8) + table.add_column("Status", width=8) + table.add_column("Title", width=30) + table.add_column("Author", width=25) + table.add_column("Genre", width=15) + table.add_column("Rating", width=8) + table.add_column("Date Read", width=12) + + if not books: + table.add_row("", "", "No books in this list", "", "", "", "") + else: + # Sort books: unread first, then by added date + sorted_books = sorted( + books, key=lambda x: (x["is_read"], x["added_date"]) + ) + + for book in sorted_books: + status = "✅" if book["is_read"] else "📖" + rating_str = str(book["rating"]) if book["rating"] else "—" + date_str = book["date_read"] if book["date_read"] else "—" + + # Format date if it exists + if date_str and date_str != "—": + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + date_str = date_obj.strftime("%b %d") + except ValueError: + pass # Keep original format if parsing fails + + table.add_row( + str(book["book_id"]), + status, + book["title"], + book["author"], + book["genre"] or "", + rating_str, + date_str, + key=str( + book["book_id"] + ), # Use book_id as row key for navigation + ) + + # Get and display statistics + stats = ReadingListBook.get_list_stats(db, reading_list.id) + progress_text = f"{stats['completion_percentage']:.1f}%" + stats_text = ( + f"📊 Progress: {stats['books_read']} read, " + f"{stats['books_unread']} unread ({progress_text} complete)" + ) + + stats_label = self.query_one("#stats_label", Label) + stats_label.update(stats_text) + + except sqlite3.Error as e: + self.notify(f"Database error: {e}") + finally: + if "db" in locals(): + db.close() + + def action_view_book(self) -> None: + """View details of the selected book""" + table = self.query_one("#list_table", DataTable) + + if table.cursor_row is None: + self.notify("No book selected") + return + + row_data = table.get_row_at(table.cursor_row) + if not row_data or len(row_data) == 0: + self.notify("Invalid selection") + return + + # Get the book ID from the first column + book_id_str = str(row_data[0]) + if not book_id_str or book_id_str == "": + self.notify("No book to view") + return + + try: + book_id = int(book_id_str) + + # Find a review for this book to show in book detail screen + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + cursor = db.cursor() + cursor.execute( + "SELECT id FROM reviews WHERE book_id = ? ORDER BY date_read DESC LIMIT 1", + (book_id,), + ) + review = cursor.fetchone() + db.close() + + if review: + from .book_detail import BookDetailScreen + + self.app.push_screen(BookDetailScreen(self.db_path, review["id"])) + else: + self.notify("No review found for this book") + + except ValueError: + self.notify("Invalid book ID") + except sqlite3.Error as e: + self.notify(f"Database error: {e}") + + def on_data_table_row_selected(self, event) -> None: + """Handle row selection in the data table""" + self.action_view_book() + + def action_close(self) -> None: + """Close the reading list screen""" + self.app.pop_screen() diff --git a/src/libro/tui/screens/year_select.py b/src/libro/tui/screens/year_select.py index 182e179..6959012 100644 --- a/src/libro/tui/screens/year_select.py +++ b/src/libro/tui/screens/year_select.py @@ -75,7 +75,6 @@ def compose(self) -> ComposeResult: yield Label(f"Currently showing: {self.current_year}") yield OptionList(*self.available_years, id="year_options") - def on_option_list_option_selected(self, event) -> None: """Handle year selection from option list""" if event.option_list.id == "year_options": From 11ca701ff7260be3d3ca755e42420423a7e15558 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Mon, 25 Aug 2025 05:57:44 -0700 Subject: [PATCH 09/11] Reading Lists view --- src/libro/tui/app.py | 5 +- src/libro/tui/screens/reading_list.py | 3 +- src/libro/tui/screens/reading_lists.py | 173 +++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 src/libro/tui/screens/reading_lists.py diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index 176dee9..21c222d 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -12,6 +12,7 @@ from .screens.book_detail import BookDetailScreen from .screens.add_book import AddBookScreen from .screens.year_select import YearSelectScreen +from .screens.reading_lists import ReadingListsScreen class LibroTUI(App): @@ -223,8 +224,8 @@ def action_books_view(self) -> None: self.notify("Books view coming soon!") def action_lists_view(self) -> None: - """Switch to reading lists view (placeholder for now)""" - self.notify("Lists view coming soon!") + """Switch to reading lists view""" + self.push_screen(ReadingListsScreen(self.db_path)) def action_help(self) -> None: """Show help dialog (placeholder for now)""" diff --git a/src/libro/tui/screens/reading_list.py b/src/libro/tui/screens/reading_list.py index 97d83de..df203a3 100644 --- a/src/libro/tui/screens/reading_list.py +++ b/src/libro/tui/screens/reading_list.py @@ -21,7 +21,7 @@ class ReadingListScreen(ModalScreen): .list-container { width: 95; - height: 80; + height: 60; background: $surface; border: thick $primary; padding: 1; @@ -196,6 +196,7 @@ def action_view_book(self) -> None: self.app.push_screen(BookDetailScreen(self.db_path, review["id"])) else: + # For books without reviews, show book information in a simple way self.notify("No review found for this book") except ValueError: diff --git a/src/libro/tui/screens/reading_lists.py b/src/libro/tui/screens/reading_lists.py new file mode 100644 index 0000000..ea4b72f --- /dev/null +++ b/src/libro/tui/screens/reading_lists.py @@ -0,0 +1,173 @@ +"""Reading lists overview screen for displaying all reading lists""" + +import sqlite3 +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import DataTable, Label +from textual.screen import ModalScreen +from textual.binding import Binding + +from libro.models import ReadingList, ReadingListBook + + +class ReadingListsScreen(ModalScreen): + """Modal screen to display all reading lists with statistics""" + + CSS = """ + ReadingListsScreen { + align: center middle; + } + + .lists-container { + width: 95; + height: 45; + max-height: 20; + background: $surface; + border: thick $primary; + padding: 1; + } + + .lists-header { + color: $text; + background: $accent; + padding: 0 1; + margin: 0 0 1 0; + text-style: bold; + text-align: center; + } + + .lists-table { + height: auto; + max-height: 15; + margin: 1 0; + } + + .help-text { + margin: 1 0 0 0; + color: $text-muted; + text-align: center; + } + """ + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("enter", "view_list", "View List"), + ] + + def __init__(self, db_path: str): + super().__init__() + self.db_path = db_path + + def compose(self) -> ComposeResult: + """Create the reading lists view""" + with Container(classes="lists-container"): + yield Label("📚 Reading Lists", classes="lists-header") + yield DataTable(cursor_type="row", classes="lists-table", id="lists_table") + + def on_mount(self) -> None: + """Load reading lists when screen opens""" + self.load_reading_lists() + + def load_reading_lists(self) -> None: + """Load and display all reading lists with statistics""" + try: + db = sqlite3.connect(self.db_path) + db.row_factory = sqlite3.Row + + # Get all reading lists + lists = ReadingList.get_all(db) + + # Set up the table + table = self.query_one("#lists_table", DataTable) + table.clear(columns=True) + table.add_column("ID", width=6, key="id") + table.add_column("Name", width=35, key="name") + table.add_column("Total", width=8, key="total") + table.add_column("Read", width=6, key="read") + table.add_column("Unread", width=8, key="unread") + table.add_column("Progress", width=15, key="progress") + + if not lists: + table.add_row("", "No reading lists found", "", "", "", "") + table.add_row( + "", + "Create a list with: libro list create ", + "", + "", + "", + "", + ) + else: + for reading_list in lists: + if reading_list.id is None: + continue # Skip lists without IDs + + # Get statistics for this list + stats = ReadingListBook.get_list_stats(db, reading_list.id) + + # Create progress bar representation + progress_text = f"{stats['completion_percentage']:.0f}%" + if stats["total_books"] > 0: + progress_bar = "█" * int(stats["completion_percentage"] / 10) + progress_bar += "░" * ( + 10 - int(stats["completion_percentage"] / 10) + ) + progress_display = f"{progress_bar} {progress_text}" + else: + progress_display = "—" + + table.add_row( + str(reading_list.id), + reading_list.name, + str(stats["total_books"]), + str(stats["books_read"]), + str(stats["books_unread"]), + progress_display, + key=str( + reading_list.id + ), # Use list ID as row key for navigation + ) + + except sqlite3.Error as e: + table = self.query_one("#lists_table", DataTable) + table.clear(columns=True) + table.add_column("Error", width=50) + table.add_row(f"Database error: {e}") + finally: + if "db" in locals(): + db.close() + + def action_view_list(self) -> None: + """View details of the selected reading list""" + table = self.query_one("#lists_table", DataTable) + + if table.cursor_row is None: + self.notify("No reading list selected") + return + + row_data = table.get_row_at(table.cursor_row) + if not row_data or len(row_data) == 0: + self.notify("Invalid selection") + return + + # Get the list ID from the first column + list_id_str = str(row_data[0]) + if not list_id_str or list_id_str == "": + self.notify("No reading list to view") + return + + try: + list_id = int(list_id_str) + from .reading_list import ReadingListScreen + + self.app.push_screen(ReadingListScreen(self.db_path, list_id)) + except ValueError: + self.notify("Invalid reading list ID") + + def on_data_table_row_selected(self, event) -> None: + """Handle row selection in the data table""" + self.action_view_list() + + def action_close(self) -> None: + """Close the reading lists screen""" + self.app.pop_screen() From 6f1624515d910ab6cd3142fd25f15770572a4289 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Mon, 25 Aug 2025 06:34:26 -0700 Subject: [PATCH 10/11] lint fix --- src/libro/tui/app.py | 1 - src/libro/tui/screens/book_detail.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index 21c222d..dc36386 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -6,7 +6,6 @@ from textual.containers import Container from textual.widgets import DataTable, Header, Label from textual.binding import Binding -from rich.text import Text from libro.actions.show import get_reviews from .screens.book_detail import BookDetailScreen diff --git a/src/libro/tui/screens/book_detail.py b/src/libro/tui/screens/book_detail.py index 286d054..5f246a0 100644 --- a/src/libro/tui/screens/book_detail.py +++ b/src/libro/tui/screens/book_detail.py @@ -2,7 +2,7 @@ import sqlite3 from textual.app import ComposeResult -from textual.containers import Container, Vertical +from textual.containers import Container from textual.widgets import Button, Label, TextArea from textual.screen import ModalScreen from textual.binding import Binding From cf37cbaf7815ee24c4445d36fbd256698e56022b Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Mon, 25 Aug 2025 06:42:28 -0700 Subject: [PATCH 11/11] Lint and Type fixes --- src/libro/tui/app.py | 6 +----- src/libro/tui/screens/add_book.py | 4 ++-- src/libro/tui/screens/reading_list.py | 4 ---- src/libro/tui/screens/reading_lists.py | 4 ---- 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/libro/tui/app.py b/src/libro/tui/app.py index dc36386..876217d 100644 --- a/src/libro/tui/app.py +++ b/src/libro/tui/app.py @@ -111,7 +111,7 @@ def load_books_data(self) -> None: container.mount(header_label) # Create table for this genre - table = DataTable(cursor_type="row", classes="genre-table") + table: DataTable = DataTable(cursor_type="row", classes="genre-table") table.add_column("Review ID", width=10) table.add_column("Title", width=30) table.add_column("Author", width=25) @@ -177,10 +177,6 @@ def _view_selected_book(self) -> None: table = focused_widget - if table.cursor_row is None: - self.notify("No row selected") - return - # Get the selected row data row_data = table.get_row_at(table.cursor_row) diff --git a/src/libro/tui/screens/add_book.py b/src/libro/tui/screens/add_book.py index d07bee1..b825176 100644 --- a/src/libro/tui/screens/add_book.py +++ b/src/libro/tui/screens/add_book.py @@ -18,9 +18,9 @@ class AuthorSuggester(Suggester): def __init__(self, db_path: str): super().__init__(use_cache=True, case_sensitive=False) self.db_path = db_path - self._authors = None + self._authors: list[str] | None = None - def _get_authors(self): + def _get_authors(self) -> list[str]: """Get all unique authors from the database""" if self._authors is None: try: diff --git a/src/libro/tui/screens/reading_list.py b/src/libro/tui/screens/reading_list.py index df203a3..23dba4d 100644 --- a/src/libro/tui/screens/reading_list.py +++ b/src/libro/tui/screens/reading_list.py @@ -162,10 +162,6 @@ def action_view_book(self) -> None: """View details of the selected book""" table = self.query_one("#list_table", DataTable) - if table.cursor_row is None: - self.notify("No book selected") - return - row_data = table.get_row_at(table.cursor_row) if not row_data or len(row_data) == 0: self.notify("Invalid selection") diff --git a/src/libro/tui/screens/reading_lists.py b/src/libro/tui/screens/reading_lists.py index ea4b72f..9891cbb 100644 --- a/src/libro/tui/screens/reading_lists.py +++ b/src/libro/tui/screens/reading_lists.py @@ -141,10 +141,6 @@ def action_view_list(self) -> None: """View details of the selected reading list""" table = self.query_one("#lists_table", DataTable) - if table.cursor_row is None: - self.notify("No reading list selected") - return - row_data = table.get_row_at(table.cursor_row) if not row_data or len(row_data) == 0: self.notify("Invalid selection")