Compare commits

...

4 Commits

Author SHA1 Message Date
8e41d0b002 feat: redesign help as two-column cheat sheet 2026-01-04 11:21:52 +01:00
74691f3322 feat: compact duration formatting 2026-01-04 11:21:35 +01:00
ff1030f4bd feat: massive UI revamp 2026-01-04 11:21:12 +01:00
1bbd28888b feat: replace footer with custom top bar 2026-01-04 11:21:00 +01:00
4 changed files with 119 additions and 148 deletions

View File

@@ -6,10 +6,12 @@ from typing import TYPE_CHECKING
from textual import work from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.events import Key, Resize from textual.events import Key, Resize
from textual.widgets import DataTable, Footer, Header, ProgressBar, Static from textual.widgets import DataTable, ProgressBar, Static
from textual.worker import get_current_worker from textual.worker import get_current_worker
from . import __version__
from .constants import ( from .constants import (
PROGRESS_COLUMN_INDEX, PROGRESS_COLUMN_INDEX,
SEEK_SECONDS, SEEK_SECONDS,
@@ -77,7 +79,12 @@ class Auditui(App):
self.progress_column_index = PROGRESS_COLUMN_INDEX self.progress_column_index = PROGRESS_COLUMN_INDEX
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Horizontal(
Static("? Help", id="top_left"),
Static(f"Auditui v{__version__}", id="top_center"),
Static("q Quit", id="top_right"),
id="top_bar",
)
yield Static("Loading...", id="status") yield Static("Loading...", id="status")
table: DataTable = DataTable() table: DataTable = DataTable()
table.zebra_stripes = True table.zebra_stripes = True
@@ -85,7 +92,6 @@ class Auditui(App):
yield table yield table
yield Static("", id="progress_info") yield Static("", id="progress_info")
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100) yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
yield Footer(show_command_palette=False)
def on_mount(self) -> None: def on_mount(self) -> None:
"""Initialize the table and start fetching library data.""" """Initialize the table and start fetching library data."""
@@ -161,6 +167,7 @@ class Auditui(App):
def update_status(self, message: str) -> None: def update_status(self, message: str) -> None:
"""Update the status message in the UI.""" """Update the status message in the UI."""
status = self.query_one("#status", Static) status = self.query_one("#status", Static)
status.display = True
status.update(message) status.update(message)
def _apply_column_widths(self, table: DataTable) -> None: def _apply_column_widths(self, table: DataTable) -> None:
@@ -171,19 +178,19 @@ class Auditui(App):
column_keys = list(table.columns.keys()) column_keys = list(table.columns.keys())
ratios = [ratio for _, ratio in TABLE_COLUMN_DEFS] ratios = [ratio for _, ratio in TABLE_COLUMN_DEFS]
total_ratio = sum(ratios) or len(column_keys) total_ratio = sum(ratios) or len(column_keys)
available_width = table.size.width - 2 - (len(column_keys) - 1) content_width = table.scrollable_content_region.width
available_width = content_width
if available_width <= 0: if available_width <= 0:
return return
remaining = available_width
widths: list[int] = [] widths: list[int] = []
for ratio in ratios: for ratio in ratios:
width = max(1, (available_width * ratio) // total_ratio) width = max(1, (available_width * ratio) // total_ratio)
widths.append(width) widths.append(width)
remaining -= width
if remaining > 0: remainder = available_width - sum(widths)
widths[0] += remaining for i in range(remainder):
widths[i % len(widths)] += 1
for column_key, width in zip(column_keys, widths): for column_key, width in zip(column_keys, widths):
column = table.columns[column_key] column = table.columns[column_key]
@@ -235,8 +242,8 @@ class Auditui(App):
progress, downloaded, key=title) progress, downloaded, key=title)
self.current_items = items self.current_items = items
mode = "all" if self.show_all_mode else "unfinished" status = self.query_one("#status", Static)
self.update_status(f"Showing {len(items)} books ({mode})") status.display = False
def _refresh_table(self) -> None: def _refresh_table(self) -> None:
"""Refresh the table with current items.""" """Refresh the table with current items."""
@@ -392,8 +399,7 @@ class Auditui(App):
is_currently_finished = self.library_client.is_finished(selected_item) is_currently_finished = self.library_client.is_finished(selected_item)
if is_currently_finished: if is_currently_finished:
success = self.library_client.mark_as_unfinished( success = self.library_client.mark_as_unfinished(asin)
asin, selected_item)
message = "Marked as unfinished" if success else "Failed to mark as unfinished" message = "Marked as unfinished" if success else "Failed to mark as unfinished"
else: else:
success = self.library_client.mark_as_finished(asin, selected_item) success = self.library_client.mark_as_finished(asin, selected_item)

View File

@@ -10,9 +10,9 @@ MIN_FILE_SIZE = 1024 * 1024
DEFAULT_CHUNK_SIZE = 8192 DEFAULT_CHUNK_SIZE = 8192
TABLE_COLUMN_DEFS = ( TABLE_COLUMN_DEFS = (
("Title", 4), ("Title", 2),
("Author", 3), ("Author", 2),
("Length", 2), ("Length", 1),
("Progress", 1), ("Progress", 1),
("Downloaded", 1), ("Downloaded", 1),
) )
@@ -24,128 +24,103 @@ SEEK_SECONDS = 30.0
TABLE_CSS = """ TABLE_CSS = """
Screen { Screen {
background: #1e1e2e; background: #141622;
} }
Header { #top_bar {
background: #181825; background: #10131f;
color: #cdd6f4; color: #d5d9f0;
text-style: bold; text-style: bold;
} height: 1;
Footer {
background: #181825;
color: #bac2de;
height: 2;
padding: 0 1;
scrollbar-size: 0 0;
overflow-x: auto;
overflow-y: hidden;
}
Footer > HorizontalGroup > KeyGroup,
Footer > HorizontalGroup > KeyGroup.-compact {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #181825;
} }
FooterKey, #top_left,
FooterKey.-grouped, #top_center,
Footer.-compact FooterKey { #top_right {
background: #181825; width: 1fr;
padding: 0; padding: 0 1;
margin: 0 1 0 0; background: #10131f;
margin: 0;
} }
FooterKey .footer-key--key { #top_left {
color: #f9e2af; text-align: left;
background: #181825;
text-style: bold;
padding: 0 1 0 0;
} }
FooterKey .footer-key--description { #top_center {
color: #cdd6f4; text-align: center;
background: #181825;
padding: 0;
} }
FooterKey:hover { #top_right {
background: #313244; text-align: right;
color: #cdd6f4;
}
FooterKey:hover .footer-key--key,
FooterKey:hover .footer-key--description {
background: #313244;
} }
DataTable { DataTable {
height: 1fr; height: 1fr;
background: #1e1e2e; background: #141622;
color: #cdd6f4; color: #d6dbf2;
border: solid #585b70; border: solid #2b2f45;
scrollbar-size-horizontal: 0;
} }
DataTable:focus { DataTable:focus {
border: solid #89b4fa; border: solid #7aa2f7;
} }
DataTable > .datatable--header { DataTable > .datatable--header {
background: #45475a; background: #1b2033;
color: #bac2de; color: #b9c3e3;
text-style: bold; text-style: bold;
} }
DataTable > .datatable--cursor { DataTable > .datatable--cursor {
background: #313244; background: #232842;
color: #cdd6f4; color: #e6ebff;
} }
DataTable > .datatable--odd-row { DataTable > .datatable--odd-row {
background: #181825; background: #121422;
} }
DataTable > .datatable--even-row { DataTable > .datatable--even-row {
background: #1e1e2e; background: #15182a;
} }
Static { Static {
height: 1; height: 1;
text-align: center; text-align: center;
background: #181825; background: #10131f;
color: #cdd6f4; color: #c7cfe8;
} }
Static#status { Static#status {
color: #bac2de; color: #b6bfdc;
} }
Static#progress_info { Static#progress_info {
color: #89b4fa; color: #7aa2f7;
text-style: bold; text-style: bold;
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%;
} }
ProgressBar#progress_bar { ProgressBar#progress_bar {
height: 1; height: 1;
background: #181825; background: #10131f;
border: none; border: none;
margin: 0; margin: 0;
padding: 0 1; padding: 0 1;
width: 100%;
align: center middle; align: center middle;
} }
ProgressBar#progress_bar > .progress-bar--track { ProgressBar#progress_bar > .progress-bar--track {
background: #45475a; background: #262a3f;
} }
ProgressBar#progress_bar > .progress-bar--bar { ProgressBar#progress_bar > .progress-bar--bar {
background: #a6e3a1; background: #8bd5ca;
} }
HelpScreen { HelpScreen {
@@ -153,93 +128,69 @@ HelpScreen {
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
} }
HelpScreen Static {
background: transparent;
}
#help_container { #help_container {
width: 88%; width: 88%;
max-width: 120; max-width: 120;
min-width: 48; min-width: 48;
height: auto; height: auto;
max-height: 90%; max-height: 80%;
min-height: 16; min-height: 14;
background: #181a2a; background: #181a2a;
border: heavy #7aa2f7; border: heavy #7aa2f7;
padding: 1 2; padding: 1 2;
} }
#help_title { #help_title {
width: 100%;
height: 3;
text-align: center; text-align: center;
text-style: bold; text-style: bold;
color: #7aa2f7; color: #7aa2f7;
content-align: center middle;
margin-bottom: 1; margin-bottom: 1;
padding-bottom: 1;
border-bottom: solid #4b5165; border-bottom: solid #4b5165;
height: 3;
align: center middle;
} }
#help_content { #help_content {
width: 100%; width: 100%;
height: 1fr; height: auto;
padding: 1 0; padding: 0;
margin: 1 0;
overflow-y: auto;
scrollbar-size: 0 1;
}
#help_content > .scrollbar--vertical {
background: #313244;
}
#help_content > .scrollbar--vertical > .scrollbar--track {
background: #181825;
}
#help_content > .scrollbar--vertical > .scrollbar--handle {
background: #585b70;
}
#help_content > .scrollbar--vertical > .scrollbar--handle:hover {
background: #45475a;
}
.help_row {
height: 3;
margin: 0 0 1 0; margin: 0 0 1 0;
}
.help_list {
width: 1fr;
height: auto;
background: transparent;
padding: 0 1; padding: 0 1;
scrollbar-size: 0 0;
}
.help_list > ListItem {
background: #1b1f33; background: #1b1f33;
border: tall #2b2f45; padding: 0 1;
align: left middle;
} }
.help_row:hover { .help_list > ListItem:hover {
background: #2a2f45; background: #2a2f45;
border: tall #3b4160;
} }
.help_key { .help_list > ListItem > Label {
width: 22%; width: 100%;
min-width: 12; padding: 0;
text-align: right;
padding: 0 1 0 0;
color: #f9e2af;
text-style: bold;
align: right middle;
}
.help_action {
width: 78%;
text-align: left;
padding: 0 0 0 2;
color: #cdd6f4;
align: left middle;
} }
#help_footer { #help_footer {
width: 100%;
height: 3;
text-align: center; text-align: center;
content-align: center middle;
color: #bac2de; color: #bac2de;
margin-top: 1; margin-top: 1;
padding-top: 1;
border-top: solid #4b5165; border-top: solid #4b5165;
height: 3;
align: center middle;
} }
""" """

View File

@@ -217,7 +217,7 @@ class LibraryClient:
def format_duration( def format_duration(
value: int | None, unit: str = "minutes", default_none: str | None = None value: int | None, unit: str = "minutes", default_none: str | None = None
) -> str | None: ) -> str | None:
"""Format duration value into a human-readable string.""" """Format duration value into a compact string."""
if value is None or value <= 0: if value is None or value <= 0:
return default_none return default_none
@@ -227,13 +227,9 @@ class LibraryClient:
hours, minutes = divmod(total_minutes, 60) hours, minutes = divmod(total_minutes, 60)
parts = [] if hours > 0:
if hours: return f"{hours}h{minutes:02d}" if minutes else f"{hours}h"
parts.append(f"{hours} hour{'s' if hours != 1 else ''}") return f"{minutes}m"
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
return " ".join(parts) if parts else default_none
def _get_total_duration(self, asin: str, item: dict | None = None) -> float | None: def _get_total_duration(self, asin: str, item: dict | None = None) -> float | None:
"""Get total duration in seconds, trying item data first, then API.""" """Get total duration in seconds, trying item data first, then API."""
@@ -274,7 +270,7 @@ class LibraryClient:
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
return None return None
def mark_as_unfinished(self, asin: str, item: dict | None = None) -> bool: def mark_as_unfinished(self, asin: str) -> bool:
"""Mark a book as unfinished by restoring saved position.""" """Mark a book as unfinished by restoring saved position."""
saved_position = self._saved_positions.pop(asin, None) saved_position = self._saved_positions.pop(asin, None)
if saved_position is None: if saved_position is None:

View File

@@ -1,9 +1,9 @@
"""UI components for the Auditui application.""" """UI components for the Auditui application."""
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer from textual.containers import Container, Horizontal
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import Static from textual.widgets import Label, ListItem, ListView, Static
KEY_DISPLAY_MAP = { KEY_DISPLAY_MAP = {
@@ -17,6 +17,7 @@ KEY_DISPLAY_MAP = {
} }
KEY_COLOR = "#f9e2af" KEY_COLOR = "#f9e2af"
DESC_COLOR = "#cdd6f4"
class HelpScreen(ModalScreen): class HelpScreen(ModalScreen):
@@ -42,17 +43,34 @@ class HelpScreen(ModalScreen):
description = binding.description description = binding.description
return key, description return key, description
def compose(self) -> ComposeResult: def _make_item(self, binding: tuple | object) -> ListItem:
with Container(id="help_container"): """Create a ListItem for a single binding."""
yield Static("Key Bindings", id="help_title")
with ScrollableContainer(id="help_content"):
for binding in self.app.BINDINGS:
key, description = self._parse_binding(binding) key, description = self._parse_binding(binding)
key_display = self._format_key_display(key) key_display = self._format_key_display(key)
with Horizontal(classes="help_row"): text = f"[bold {KEY_COLOR}]{key_display:>12}[/] [{DESC_COLOR}]{description}[/]"
yield Static(f"[bold {KEY_COLOR}]{key_display}[/]", classes="help_key") return ListItem(Label(text))
yield Static(description, classes="help_action")
yield Static(f"Press [bold {KEY_COLOR}]?[/] or [bold {KEY_COLOR}]Escape[/] to close", id="help_footer") def compose(self) -> ComposeResult:
bindings = list(self.app.BINDINGS)
mid = (len(bindings) + 1) // 2
left_bindings = bindings[:mid]
right_bindings = bindings[mid:]
with Container(id="help_container"):
yield Static("Key Bindings", id="help_title")
with Horizontal(id="help_content"):
yield ListView(
*[self._make_item(b) for b in left_bindings],
classes="help_list",
)
yield ListView(
*[self._make_item(b) for b in right_bindings],
classes="help_list",
)
yield Static(
f"Press [bold {KEY_COLOR}]?[/] or [bold {KEY_COLOR}]Escape[/] to close",
id="help_footer",
)
def action_dismiss(self) -> None: def action_dismiss(self) -> None:
self.dismiss() self.dismiss()