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/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]", "", "", "", 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..ed9a49f 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.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") 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/__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..876217d --- /dev/null +++ b/src/libro/tui/app.py @@ -0,0 +1,227 @@ +"""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 +from .screens.year_select import YearSelectScreen +from .screens.reading_lists import ReadingListsScreen + + +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; + } + + .genre-table { + margin-bottom: 0; + } + + .header-label { + margin-top: 1; + } + + """ + + BINDINGS = [ + 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"), + 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 Container(id="books_container") + yield Container( + Label( + "q: Quit | r: Refresh | a: Add Book | y: Select Year | Enter: View Details | ?: Help" + ), + classes="footer-menu", + ) + + def on_mount(self) -> None: + """Initialize the table when the app starts""" + self.theme = "textual-dark" + 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 with separate tables per genre""" + 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) + + # Clear the books container + container = self.query_one("#books_container", Container) + container.remove_children() + + if not books: + container.mount(Label("No books found for current year")) + return + + # Group books by genre + books_by_genre: dict[str, list] = {} + for book in books: + genre_key = book["genre"] or "Unknown" + 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) + + # 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 = 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: + container = self.query_one("#books_container", Container) + container.remove_children() + container.mount(Label(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""" + self._view_selected_book() + + def _view_selected_book(self) -> None: + """View details of the currently selected book""" + # 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 + + # 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 + 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_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.load_books_data() + + 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""" + self.push_screen(ReadingListsScreen(self.db_path)) + + 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..5d3d38b --- /dev/null +++ b/src/libro/tui/screens/__init__.py @@ -0,0 +1,7 @@ +"""TUI screens for Libro application""" + +from .book_detail import BookDetailScreen +from .add_book import AddBookScreen +from .year_select import YearSelectScreen + +__all__ = ["BookDetailScreen", "AddBookScreen", "YearSelectScreen"] diff --git a/src/libro/tui/screens/add_book.py b/src/libro/tui/screens/add_book.py new file mode 100644 index 0000000..b825176 --- /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: list[str] | None = None + + def _get_authors(self) -> list[str]: + """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..5f246a0 --- /dev/null +++ b/src/libro/tui/screens/book_detail.py @@ -0,0 +1,223 @@ +"""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, Label, TextArea +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: 0 1 1 1; + } + + .section-card { + border: round $accent; + padding: 0 1 1 1; + margin: 1 0; + height: auto; + } + + .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 { + 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 = [ + 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 + self.reading_lists: list[tuple[int, str]] = [] + + def compose(self) -> ComposeResult: + """Create the book detail view""" + with Container(classes="detail-container"): + 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: + """Load book details when screen opens""" + self.load_book_details() + + def load_book_details(self) -> None: + """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 + 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 + + # 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] 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 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"), + ] + + 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] + self.reading_lists = ReadingListBook.get_lists_with_ids_for_book( + db, book_id + ) + + 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") + ) + + 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() + 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""" + self.app.pop_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..23dba4d --- /dev/null +++ b/src/libro/tui/screens/reading_list.py @@ -0,0 +1,209 @@ +"""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: 60; + 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) + + 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: + # For books without reviews, show book information in a simple way + 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/reading_lists.py b/src/libro/tui/screens/reading_lists.py new file mode 100644 index 0000000..9891cbb --- /dev/null +++ b/src/libro/tui/screens/reading_lists.py @@ -0,0 +1,169 @@ +"""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) + + 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() diff --git a/src/libro/tui/screens/year_select.py b/src/libro/tui/screens/year_select.py new file mode 100644 index 0000000..6959012 --- /dev/null +++ b/src/libro/tui/screens/year_select.py @@ -0,0 +1,100 @@ +"""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() 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