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 | | `f` | Mark as finished/unfinished |
| `d` | Download/delete from cache | | `d` | Download/delete from cache |
| `s` | Show stats screen | | `s` | Show stats screen |
| `/` | Filter library |
| `q` | Quit the application | | `q` | Quit the application |
## Roadmap ## 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] mark a book as finished or unfinished
- [x] make ui responsive - [x] make ui responsive
- [x] get your stats in a separated pane - [x] get your stats in a separated pane
- [ ] search/filter within your library - [x] search/filter within your library
- [ ] installation setup - [ ] installation setup
## Auth / credentials ## Auth / credentials

View File

@@ -27,7 +27,7 @@ from .table_utils import (
filter_unfinished_items, filter_unfinished_items,
format_item_as_row, format_item_as_row,
) )
from .ui import HelpScreen, StatsScreen from .ui import FilterScreen, HelpScreen, StatsScreen
if TYPE_CHECKING: if TYPE_CHECKING:
from textual.widgets._data_table import ColumnKey from textual.widgets._data_table import ColumnKey
@@ -42,6 +42,8 @@ class Auditui(App):
BINDINGS = [ BINDINGS = [
("?", "show_help", "Help"), ("?", "show_help", "Help"),
("s", "show_stats", "Stats"), ("s", "show_stats", "Stats"),
("/", "filter", "Filter"),
("escape", "clear_filter", "Clear filter"),
("n", "sort", "Sort by name"), ("n", "sort", "Sort by name"),
("p", "sort_by_progress", "Sort by progress"), ("p", "sort_by_progress", "Sort by progress"),
("a", "show_all", "All/Unfinished"), ("a", "show_all", "All/Unfinished"),
@@ -73,7 +75,9 @@ class Auditui(App):
self.all_items: list[dict] = [] self.all_items: list[dict] = []
self.current_items: list[dict] = [] self.current_items: list[dict] = []
self._search_text_cache: dict[int, str] = {}
self.show_all_mode = False self.show_all_mode = False
self.filter_text = ""
self.title_sort_reverse = False self.title_sort_reverse = False
self.progress_sort_reverse = False self.progress_sort_reverse = False
self.title_column_key: ColumnKey | None = None self.title_column_key: ColumnKey | None = None
@@ -220,6 +224,8 @@ class Auditui(App):
def on_library_loaded(self, items: list[dict]) -> None: def on_library_loaded(self, items: list[dict]) -> None:
"""Handle successful library load.""" """Handle successful library load."""
self.all_items = items self.all_items = items
self._search_text_cache.clear()
self._prime_search_cache(items)
self.update_status(f"Loaded {len(items)} books") self.update_status(f"Loaded {len(items)} books")
self.show_unfinished() self.show_unfinished()
@@ -256,17 +262,14 @@ class Auditui(App):
if not self.all_items: if not self.all_items:
return return
self.show_all_mode = True self.show_all_mode = True
self._populate_table(self.all_items) self._refresh_filtered_view()
def show_unfinished(self) -> None: def show_unfinished(self) -> None:
"""Display only unfinished books in the table.""" """Display only unfinished books in the table."""
if not self.all_items or not self.library_client: if not self.all_items or not self.library_client:
return return
self.show_all_mode = False self.show_all_mode = False
unfinished_items = filter_unfinished_items( self._refresh_filtered_view()
self.all_items, self.library_client)
self._populate_table(unfinished_items)
def action_sort(self) -> None: def action_sort(self) -> None:
"""Sort table by title, toggling direction on each press.""" """Sort table by title, toggling direction on each press."""
@@ -422,6 +425,80 @@ class Auditui(App):
"""Show the stats screen with listening statistics.""" """Show the stats screen with listening statistics."""
self.push_screen(StatsScreen()) 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: def _check_playback_status(self) -> None:
"""Check if playback process has finished and update state accordingly.""" """Check if playback process has finished and update state accordingly."""
message = self.playback.check_status() message = self.playback.check_status()

View File

@@ -233,4 +233,45 @@ StatsScreen .help_list > ListItem > Label {
margin-top: 1; margin-top: 1;
border-top: solid #4b5165; 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 import json
from datetime import date, datetime 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.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen 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 from .constants import AUTH_PATH, CONFIG_PATH
@@ -37,7 +38,7 @@ KEY_COLOR = "#f9e2af"
DESC_COLOR = "#cdd6f4" DESC_COLOR = "#cdd6f4"
class AppContextMixin: class AppContextMixin(ModalScreen):
"""Mixin to provide a typed app accessor.""" """Mixin to provide a typed app accessor."""
def _app(self) -> _AppContext: def _app(self) -> _AppContext:
@@ -299,6 +300,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
) )
except Exception: except Exception:
return None return None
return None
def _get_email_from_config(self, app: _AppContext) -> str | None: def _get_email_from_config(self, app: _AppContext) -> str | None:
"""Extract email from the config file.""" """Extract email from the config file."""
@@ -313,6 +315,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
) )
except Exception: except Exception:
return None return None
return None
def _get_email_from_auth_file(self, app: _AppContext) -> str | None: def _get_email_from_auth_file(self, app: _AppContext) -> str | None:
"""Extract email from the auth file.""" """Extract email from the auth file."""
@@ -328,6 +331,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
) )
except Exception: except Exception:
return None return None
return None
def _get_email_from_account_info(self, app: _AppContext) -> str | None: def _get_email_from_account_info(self, app: _AppContext) -> str | None:
"""Extract email from the account info API.""" """Extract email from the account info API."""
@@ -352,6 +356,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
) )
except Exception: except Exception:
return None return None
return None
def _first_email(self, *values: str | None) -> str | None: def _first_email(self, *values: str | None) -> str | None:
"""Return the first non-empty, non-Unknown email value.""" """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: async def action_dismiss(self, result: Any | None = None) -> None:
await self.dismiss(result) 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()