Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"appdirs>=1.4",
"rich>=13.3",
"prompt_toolkit",
"textual>=5.3.0",
]

[project.urls]
Expand Down
3 changes: 2 additions & 1 deletion src/libro/actions/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
"",
"",
"",
Expand Down
3 changes: 3 additions & 0 deletions src/libro/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
3 changes: 3 additions & 0 deletions src/libro/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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")

Expand Down
18 changes: 18 additions & 0 deletions src/libro/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
13 changes: 13 additions & 0 deletions src/libro/tui/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
227 changes: 227 additions & 0 deletions src/libro/tui/app.py
Original file line number Diff line number Diff line change
@@ -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")
7 changes: 7 additions & 0 deletions src/libro/tui/screens/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading