Compare commits

...

4 Commits

Author SHA1 Message Date
e620ea8369 docs: add filter shortcut and update roadmap 2026-01-05 21:52:39 +01:00
c1dd38fbe6 feat: add debounced filter screen 2026-01-05 21:52:29 +01:00
fca7329ba1 feat: style filter modal 2026-01-05 21:52:24 +01:00
8fdd517933 feat: add filter view with cached search 2026-01-05 21:52:18 +01:00
4 changed files with 190 additions and 10 deletions

View File

@@ -41,6 +41,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
| `f` | Mark as finished/unfinished |
| `d` | Download/delete from cache |
| `s` | Show stats screen |
| `/` | Filter library |
| `q` | Quit the application |
## Roadmap
@@ -60,7 +61,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
- [x] mark a book as finished or unfinished
- [x] make ui responsive
- [x] get your stats in a separated pane
- [ ] search/filter within your library
- [x] search/filter within your library
- [ ] installation setup
## Auth / credentials

View File

@@ -27,7 +27,7 @@ from .table_utils import (
filter_unfinished_items,
format_item_as_row,
)
from .ui import HelpScreen, StatsScreen
from .ui import FilterScreen, HelpScreen, StatsScreen
if TYPE_CHECKING:
from textual.widgets._data_table import ColumnKey
@@ -42,6 +42,8 @@ class Auditui(App):
BINDINGS = [
("?", "show_help", "Help"),
("s", "show_stats", "Stats"),
("/", "filter", "Filter"),
("escape", "clear_filter", "Clear filter"),
("n", "sort", "Sort by name"),
("p", "sort_by_progress", "Sort by progress"),
("a", "show_all", "All/Unfinished"),
@@ -73,7 +75,9 @@ class Auditui(App):
self.all_items: list[dict] = []
self.current_items: list[dict] = []
self._search_text_cache: dict[int, str] = {}
self.show_all_mode = False
self.filter_text = ""
self.title_sort_reverse = False
self.progress_sort_reverse = False
self.title_column_key: ColumnKey | None = None
@@ -220,6 +224,8 @@ class Auditui(App):
def on_library_loaded(self, items: list[dict]) -> None:
"""Handle successful library load."""
self.all_items = items
self._search_text_cache.clear()
self._prime_search_cache(items)
self.update_status(f"Loaded {len(items)} books")
self.show_unfinished()
@@ -256,17 +262,14 @@ class Auditui(App):
if not self.all_items:
return
self.show_all_mode = True
self._populate_table(self.all_items)
self._refresh_filtered_view()
def show_unfinished(self) -> None:
"""Display only unfinished books in the table."""
if not self.all_items or not self.library_client:
return
self.show_all_mode = False
unfinished_items = filter_unfinished_items(
self.all_items, self.library_client)
self._populate_table(unfinished_items)
self._refresh_filtered_view()
def action_sort(self) -> None:
"""Sort table by title, toggling direction on each press."""
@@ -422,6 +425,80 @@ class Auditui(App):
"""Show the stats screen with listening statistics."""
self.push_screen(StatsScreen())
def action_filter(self) -> None:
"""Show the filter screen to search the library."""
self.push_screen(
FilterScreen(
self.filter_text,
on_change=self._apply_filter,
),
self._apply_filter,
)
def action_clear_filter(self) -> None:
"""Clear the current filter if active."""
if self.filter_text:
self.filter_text = ""
self._refresh_filtered_view()
self.update_status("Filter cleared")
def _apply_filter(self, filter_text: str) -> None:
"""Apply the filter to the library."""
self.filter_text = filter_text
self._refresh_filtered_view()
def _refresh_filtered_view(self) -> None:
"""Refresh the table with current filter and view mode."""
if not self.all_items:
return
items = self.all_items
if self.filter_text:
filter_lower = self.filter_text.lower()
items = [
item for item in items
if filter_lower in self._get_search_text(item)
]
self._populate_table(items)
self.update_status(
f"Filter: '{self.filter_text}' ({len(items)} books)")
return
if not self.show_all_mode and self.library_client:
items = filter_unfinished_items(items, self.library_client)
self._populate_table(items)
def _get_search_text(self, item: dict) -> str:
"""Return cached search text for filtering."""
cache_key = id(item)
cached = self._search_text_cache.get(cache_key)
if cached is not None:
return cached
title = ""
authors = ""
if self.library_client:
title = self.library_client.extract_title(item)
authors = self.library_client.extract_authors(item)
else:
title = item.get("title", "")
authors = ", ".join(
a.get("name", "")
for a in item.get("authors", [])
if isinstance(a, dict) and a.get("name")
)
search_text = f"{title} {authors}".lower()
self._search_text_cache[cache_key] = search_text
return search_text
def _prime_search_cache(self, items: list[dict]) -> None:
"""Precompute search text for a list of items."""
for item in items:
self._get_search_text(item)
def _check_playback_status(self) -> None:
"""Check if playback process has finished and update state accordingly."""
message = self.playback.check_status()

View File

@@ -233,4 +233,45 @@ StatsScreen .help_list > ListItem > Label {
margin-top: 1;
border-top: solid #4b5165;
}
FilterScreen {
align: center middle;
background: rgba(0, 0, 0, 0.7);
}
FilterScreen Static {
background: transparent;
}
#filter_container {
width: 60;
height: auto;
background: #181a2a;
border: heavy #7aa2f7;
padding: 1 2;
}
#filter_title {
width: 100%;
height: 2;
text-align: center;
text-style: bold;
color: #7aa2f7;
content-align: center middle;
margin-bottom: 1;
}
#filter_input {
width: 100%;
margin: 1 0;
}
#filter_footer {
width: 100%;
height: 2;
text-align: center;
content-align: center middle;
color: #bac2de;
margin-top: 1;
}
"""

View File

@@ -2,12 +2,13 @@
import json
from datetime import date, datetime
from typing import Any, Protocol, TYPE_CHECKING, cast
from typing import Any, Callable, Protocol, TYPE_CHECKING, cast
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Label, ListItem, ListView, Static
from textual.timer import Timer
from textual.widgets import Input, Label, ListItem, ListView, Static
from .constants import AUTH_PATH, CONFIG_PATH
@@ -37,7 +38,7 @@ KEY_COLOR = "#f9e2af"
DESC_COLOR = "#cdd6f4"
class AppContextMixin:
class AppContextMixin(ModalScreen):
"""Mixin to provide a typed app accessor."""
def _app(self) -> _AppContext:
@@ -299,6 +300,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
)
except Exception:
return None
return None
def _get_email_from_config(self, app: _AppContext) -> str | None:
"""Extract email from the config file."""
@@ -313,6 +315,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
)
except Exception:
return None
return None
def _get_email_from_auth_file(self, app: _AppContext) -> str | None:
"""Extract email from the auth file."""
@@ -328,6 +331,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
)
except Exception:
return None
return None
def _get_email_from_account_info(self, app: _AppContext) -> str | None:
"""Extract email from the account info API."""
@@ -352,6 +356,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
)
except Exception:
return None
return None
def _first_email(self, *values: str | None) -> str | None:
"""Return the first non-empty, non-Unknown email value."""
@@ -509,3 +514,59 @@ class StatsScreen(AppContextMixin, ModalScreen):
async def action_dismiss(self, result: Any | None = None) -> None:
await self.dismiss(result)
class FilterScreen(ModalScreen[str]):
"""Filter screen for searching the library."""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(
self,
initial_filter: str = "",
on_change: Callable[[str], None] | None = None,
debounce_seconds: float = 0.2,
) -> None:
super().__init__()
self._initial_filter = initial_filter
self._on_change = on_change
self._debounce_seconds = debounce_seconds
self._debounce_timer: Timer | None = None
def compose(self) -> ComposeResult:
with Container(id="filter_container"):
yield Static("Filter Library", id="filter_title")
yield Input(
value=self._initial_filter,
placeholder="Type to filter by title or author...",
id="filter_input",
)
yield Static(
f"Press [bold {KEY_COLOR}]Enter[/] to apply, "
f"[bold {KEY_COLOR}]Escape[/] to clear",
id="filter_footer",
)
def on_mount(self) -> None:
self.query_one("#filter_input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value)
def on_input_changed(self, event: Input.Changed) -> None:
if not self._on_change:
return
if self._debounce_timer:
self._debounce_timer.stop()
value = event.value
self._debounce_timer = self.set_timer(
self._debounce_seconds,
lambda: self._on_change(value),
)
def action_cancel(self) -> None:
self.dismiss("")
def on_unmount(self) -> None:
if self._debounce_timer:
self._debounce_timer.stop()