Skip to content

Commit 6389c30

Browse files
authored
Experimental TUI Interface (#20)
* Show unknown (not break) if genre not set * Init commit for TUI * TUI: checkpoint with add form working well * Selecting year checkpoint - works * Selecting year checkpoint - works * Better genre view per year * Update book detail * Add reading list view and selectable from detail * Reading Lists view * lint fix * Lint and Type fixes
1 parent 4e22695 commit 6389c30

13 files changed

Lines changed: 1266 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"appdirs>=1.4",
2121
"rich>=13.3",
2222
"prompt_toolkit",
23+
"textual>=5.3.0",
2324
]
2425

2526
[project.urls]

src/libro/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ def init_args() -> Dict:
154154
"--description", type=str, help="Description for new reading list"
155155
)
156156

157+
# TUI command
158+
subparsers.add_parser("tui", help="Launch interactive TUI interface")
159+
157160
args = vars(parser.parse_args())
158161

159162
if args["version"]:

src/libro/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from libro.actions.db import init_db, migrate_db
1616
from libro.actions.importer import import_books
1717
from libro.actions.lists import manage_lists
18+
from libro.tui import launch_tui
1819

1920

2021
def main():
@@ -116,6 +117,8 @@ def main():
116117
except ValueError:
117118
print(f"Unknown review action or invalid ID: {action_or_id}")
118119
print("Valid actions: add, edit, or a review ID number")
120+
case "tui":
121+
launch_tui(str(dbfile))
119122
case _:
120123
print("Not yet implemented")
121124

src/libro/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,21 @@ def get_lists_for_book(cls, db: sqlite3.Connection, book_id: int) -> list[str]:
414414
(book_id,),
415415
)
416416
return [row["name"] for row in cursor.fetchall()]
417+
418+
@classmethod
419+
def get_lists_with_ids_for_book(
420+
cls, db: sqlite3.Connection, book_id: int
421+
) -> list[tuple[int, str]]:
422+
"""Get all reading lists (ID and name) that contain a specific book."""
423+
cursor = db.cursor()
424+
cursor.execute(
425+
"""
426+
SELECT rl.id, rl.name
427+
FROM reading_list_books rlb
428+
JOIN reading_lists rl ON rlb.list_id = rl.id
429+
WHERE rlb.book_id = ?
430+
ORDER BY rl.name
431+
""",
432+
(book_id,),
433+
)
434+
return [(row["id"], row["name"]) for row in cursor.fetchall()]

src/libro/tui/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""TUI interface for Libro using Textual"""
2+
3+
from .app import LibroTUI
4+
from .screens import BookDetailScreen, AddBookScreen
5+
6+
7+
def launch_tui(db_path: str) -> None:
8+
"""Launch the TUI application"""
9+
app = LibroTUI(db_path)
10+
app.run()
11+
12+
13+
__all__ = ["launch_tui", "LibroTUI", "BookDetailScreen", "AddBookScreen"]

src/libro/tui/app.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""Main TUI application for Libro"""
2+
3+
import sqlite3
4+
from datetime import datetime
5+
from textual.app import App, ComposeResult
6+
from textual.containers import Container
7+
from textual.widgets import DataTable, Header, Label
8+
from textual.binding import Binding
9+
10+
from libro.actions.show import get_reviews
11+
from .screens.book_detail import BookDetailScreen
12+
from .screens.add_book import AddBookScreen
13+
from .screens.year_select import YearSelectScreen
14+
from .screens.reading_lists import ReadingListsScreen
15+
16+
17+
class LibroTUI(App):
18+
"""Main TUI application for Libro"""
19+
20+
TITLE = "Libro"
21+
22+
CSS = """
23+
.footer-menu {
24+
dock: bottom;
25+
height: 1;
26+
background: $surface;
27+
color: $text;
28+
content-align: center middle;
29+
}
30+
31+
.genre-table {
32+
margin-bottom: 0;
33+
}
34+
35+
.header-label {
36+
margin-top: 1;
37+
}
38+
39+
"""
40+
41+
BINDINGS = [
42+
Binding("q", "quit", "Quit"),
43+
Binding("r", "refresh", "Refresh"),
44+
Binding("a", "add_book", "Add Book"),
45+
Binding("y", "select_year", "Select Year"),
46+
Binding("b", "books_view", "Books"),
47+
Binding("l", "lists_view", "Lists"),
48+
Binding("enter", "view_details", "View Details"),
49+
Binding("question_mark", "help", "Help"),
50+
]
51+
52+
def __init__(self, db_path: str):
53+
super().__init__()
54+
self.db_path = db_path
55+
self.current_year = datetime.now().year
56+
57+
def compose(self) -> ComposeResult:
58+
"""Create the UI layout"""
59+
yield Header()
60+
yield Container(id="books_container")
61+
yield Container(
62+
Label(
63+
"q: Quit | r: Refresh | a: Add Book | y: Select Year | Enter: View Details | ?: Help"
64+
),
65+
classes="footer-menu",
66+
)
67+
68+
def on_mount(self) -> None:
69+
"""Initialize the table when the app starts"""
70+
self.theme = "textual-dark"
71+
self.sub_title = f"Books Read in {self.current_year}"
72+
self.load_books_data()
73+
74+
def load_books_data(self) -> None:
75+
"""Load and display books read in current year with separate tables per genre"""
76+
try:
77+
db = sqlite3.connect(self.db_path)
78+
db.row_factory = sqlite3.Row
79+
80+
# Get books for current year (same logic as CLI report command)
81+
books = get_reviews(db, year=self.current_year)
82+
83+
# Clear the books container
84+
container = self.query_one("#books_container", Container)
85+
container.remove_children()
86+
87+
if not books:
88+
container.mount(Label("No books found for current year"))
89+
return
90+
91+
# Group books by genre
92+
books_by_genre: dict[str, list] = {}
93+
for book in books:
94+
genre_key = book["genre"] or "Unknown"
95+
if genre_key not in books_by_genre:
96+
books_by_genre[genre_key] = []
97+
books_by_genre[genre_key].append(book)
98+
99+
# Create a table for each genre
100+
for genre, genre_books in books_by_genre.items():
101+
genre_display = (
102+
genre.title() if genre and genre != "Unknown" else "Unknown"
103+
)
104+
genre_count = len(genre_books)
105+
106+
# Add genre header label
107+
header_label = Label(
108+
f"[bold cyan]{genre_display} ({genre_count})[/bold cyan]",
109+
classes="header-label",
110+
)
111+
container.mount(header_label)
112+
113+
# Create table for this genre
114+
table: DataTable = DataTable(cursor_type="row", classes="genre-table")
115+
table.add_column("Review ID", width=10)
116+
table.add_column("Title", width=30)
117+
table.add_column("Author", width=25)
118+
table.add_column("Genre", width=15)
119+
table.add_column("Rating", width=8)
120+
table.add_column("Date Read", width=12)
121+
122+
# Add books for this genre
123+
for book in genre_books:
124+
# Format date
125+
date_str = book["date_read"]
126+
if date_str:
127+
try:
128+
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
129+
formatted_date = date_obj.strftime("%b %d")
130+
except ValueError:
131+
formatted_date = date_str
132+
else:
133+
formatted_date = ""
134+
135+
table.add_row(
136+
str(book["review_id"]),
137+
book["title"],
138+
book["author"],
139+
book["genre"] or "",
140+
str(book["rating"]) if book["rating"] else "",
141+
formatted_date,
142+
)
143+
144+
container.mount(table)
145+
146+
except sqlite3.Error as e:
147+
container = self.query_one("#books_container", Container)
148+
container.remove_children()
149+
container.mount(Label(f"Database error: {e}"))
150+
finally:
151+
if "db" in locals():
152+
db.close()
153+
154+
async def action_quit(self) -> None:
155+
"""Exit the application"""
156+
self.exit()
157+
158+
def action_refresh(self) -> None:
159+
"""Refresh the current view"""
160+
self.load_books_data()
161+
162+
def action_view_details(self) -> None:
163+
"""View details of the selected book"""
164+
self._view_selected_book()
165+
166+
def on_data_table_row_selected(self, event) -> None:
167+
"""Handle row selection in the data table"""
168+
self._view_selected_book()
169+
170+
def _view_selected_book(self) -> None:
171+
"""View details of the currently selected book"""
172+
# Find the currently focused table
173+
focused_widget = self.focused
174+
if not isinstance(focused_widget, DataTable):
175+
self.notify("Select a book row first")
176+
return
177+
178+
table = focused_widget
179+
180+
# Get the selected row data
181+
row_data = table.get_row_at(table.cursor_row)
182+
183+
if not row_data or len(row_data) == 0:
184+
self.notify("Invalid selection")
185+
return
186+
187+
# The first column should be the Review ID
188+
review_id_str = str(row_data[0])
189+
190+
# Skip empty rows
191+
if not review_id_str or review_id_str == "":
192+
self.notify("Select a book row to view details")
193+
return
194+
195+
try:
196+
review_id = int(review_id_str)
197+
# Open the book detail screen
198+
self.push_screen(BookDetailScreen(self.db_path, review_id))
199+
except ValueError:
200+
self.notify("Select a book row to view details")
201+
return
202+
203+
def action_add_book(self) -> None:
204+
"""Add a new book and review"""
205+
self.push_screen(AddBookScreen(self.db_path))
206+
207+
def action_select_year(self) -> None:
208+
"""Open year selection dialog"""
209+
self.push_screen(YearSelectScreen(self.db_path, self.current_year))
210+
211+
def change_year(self, new_year: int) -> None:
212+
"""Change the current year and reload data"""
213+
self.current_year = new_year
214+
self.sub_title = f"Books Read in {self.current_year}"
215+
self.load_books_data()
216+
217+
def action_books_view(self) -> None:
218+
"""Switch to books-only view (placeholder for now)"""
219+
self.notify("Books view coming soon!")
220+
221+
def action_lists_view(self) -> None:
222+
"""Switch to reading lists view"""
223+
self.push_screen(ReadingListsScreen(self.db_path))
224+
225+
def action_help(self) -> None:
226+
"""Show help dialog (placeholder for now)"""
227+
self.notify("Help: Use arrow keys to navigate, Enter to select, q to quit")

src/libro/tui/screens/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""TUI screens for Libro application"""
2+
3+
from .book_detail import BookDetailScreen
4+
from .add_book import AddBookScreen
5+
from .year_select import YearSelectScreen
6+
7+
__all__ = ["BookDetailScreen", "AddBookScreen", "YearSelectScreen"]

0 commit comments

Comments
 (0)