Replace content CLI with Textual TUI

This commit is contained in:
SirBlob committed 2026-11-01 20:46:34 -05:00
1 parent 0d58ddd430
commit 737700a9a8
28 files changed
+2403 -167

No files matched your search

View File
Whitespace-only changes.
+252
View File
@@ -0,0 +1,252 @@
Screen {
background: $background;
}
Header {
background: $panel;
color: $foreground;
}
HeaderTitle {
color: $primary;
text-style: bold;
}
TabbedContent {
height: 1fr;
}
ContentTabs {
background: $background;
}
Tab {
color: $text-muted;
}
Tab.-active {
color: $primary;
text-style: bold;
}
Tab:hover {
color: $foreground;
}
Underline > .underline--bar {
color: $primary;
background: $panel;
}
TabPane {
padding: 0;
}
ContentPane {
height: 1fr;
margin: 1 1 0 1;
background: $surface;
border: solid $border-blurred;
border-title-color: $text-muted;
border-subtitle-color: $text-muted;
}
ContentPane:focus-within {
border: solid $primary;
border-title-color: $primary;
border-subtitle-color: $accent;
}
ContentPane DataTable {
height: 1fr;
background: $surface;
}
DataTable > .datatable--header {
background: $surface;
color: $accent;
text-style: bold;
}
DataTable > .datatable--even-row {
background: $surface;
}
DataTable > .datatable--odd-row {
background: $panel 40%;
}
.split-layout {
height: 1fr;
}
.split-layout DataTable {
width: 3fr;
}
.detail-scroll {
width: 2fr;
padding: 0 1;
background: $surface;
border-left: solid $border-blurred;
}
ContentPane:focus-within .detail-scroll {
border-left: solid $primary;
}
Footer {
background: $panel;
}
FormDialog,
ConfirmDialog {
align: center middle;
background: $background 60%;
}
.dialog {
width: 72;
height: auto;
max-width: 95%;
padding: 1 2;
background: $surface;
border: solid $primary;
}
.dialog-title {
color: $primary;
text-style: bold;
}
.field-label {
margin-top: 1;
color: $text-muted;
}
.dialog Input,
.dialog Select {
width: 100%;
}
.dialog Input {
background: $background;
border: solid $border-blurred;
}
.dialog Input:focus {
border: solid $primary;
}
#dialog-error {
height: auto;
margin-top: 1;
color: $error;
}
.dialog-buttons {
height: auto;
margin-top: 1;
align-horizontal: right;
}
.dialog-buttons Button {
margin-left: 1;
border: none;
min-width: 10;
}
Toast {
background: $panel;
border-left: outer $primary;
}
RecordEditor {
background: $background;
}
.editor-title {
width: 100%;
padding: 0 2;
background: $panel;
color: $primary;
text-style: bold;
}
.editor-form {
margin: 1 2 0 2;
padding: 0 2 1 2;
background: $surface;
border: solid $primary;
}
.editor-form Input,
.editor-form Select {
width: 100%;
background: $background;
border: solid $border-blurred;
}
.editor-form Input:focus,
.editor-form TextArea:focus {
border: solid $primary;
}
.editor-area {
height: 6;
background: $background;
border: solid $border-blurred;
}
.editor-area.longtext {
height: 8;
}
#editor-error {
height: auto;
margin-top: 1;
color: $error;
}
.field-row {
height: auto;
}
.field-row Input {
width: 1fr;
}
.browse-button {
min-width: 10;
margin-left: 1;
border: none;
}
.swatch {
width: 8;
height: 3;
margin-left: 1;
content-align: center middle;
border: solid $border-blurred;
}
.picker {
width: 80;
height: 80%;
}
.picker OptionList {
height: 1fr;
margin-top: 1;
background: $background;
border: solid $border-blurred;
}
.picker OptionList:focus {
border: solid $primary;
}
#category-select {
width: 100%;
margin: 0 0 1 0;
}
+162
View File
@@ -0,0 +1,162 @@
from rich.markup import escape
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import books
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
def book_details(book: dict) -> str:
rows = [
("Shelf", book.get("shelf")),
("Your rating", f"{book['rating']}/10" if book.get("rating") is not None else None),
("Goodreads", f"{book['averageRating']} from {book.get('ratingCount') or 0:,} ratings" if book.get("averageRating") else None),
("Pages", book.get("pages")),
("Published", book.get("published")),
("Genres", ", ".join(book.get("genres") or [])),
("Added", book.get("added")),
]
lines = [f"[b]{escape(book.get('title', ''))}[/b]", escape(", ".join(book.get("authors") or [])), ""]
lines += [f"[dim]{label:<12}[/dim]{escape(str(value))}" for label, value in rows if value]
if book.get("description"):
lines += ["", escape(book["description"])]
lines += ["", f"[dim]{escape(book.get('url', ''))}[/dim]"]
return "\n".join(lines)
class BooksPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "rate", "Rate"),
Binding("s", "shelf", "Shelf"),
Binding("d", "delete", "Delete"),
Binding("u", "refresh", "Refresh"),
Binding("U", "refresh_all", "Refresh all"),
Binding("r", "reload", "Reload"),
]
content_file = "books.json"
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("Add a book with [b]a[/b] and a Goodreads link.", id="book-detail")
def on_mount(self):
table = self.table()
table.add_column("Shelf", key="shelf")
table.add_column("Rating", key="rating")
table.add_column("Title", key="title")
table.add_column("Authors", key="authors")
self.reload()
def book_by_id(self, identifier: str | None) -> dict | None:
return next((book for book in books.load_books() if book.get("goodreadsId") == identifier), None)
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
shelf_order = {shelf: index for index, shelf in enumerate(books.SHELVES)}
sorted_books = sorted(books.load_books(), key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1)))
for book in sorted_books:
rating = f"{book['rating']}/10" if book.get("rating") is not None else "-"
table.add_row(book.get("shelf", ""), rating, book.get("title", ""), ", ".join(book.get("authors") or []), key=book["goodreadsId"])
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
def show_details(self, identifier: str | None):
book = self.book_by_id(identifier)
detail = self.query_one("#book-detail", Static)
detail.update(book_details(book) if book else "Add a book with [b]a[/b] and a Goodreads link.")
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
def action_add(self):
fields = [
FormField("link", "Goodreads link", placeholder="https://www.goodreads.com/book/show/170448.Animal_Farm", required=True),
FormField("shelf", "Shelf", kind="select", options=books.SHELVES, value="read"),
FormField("rating", "Your rating (0 to 10, optional)", kind="rating"),
]
self.app.push_screen(FormDialog("Add a book", fields, "Add"), self.add_book)
def add_book(self, values: dict | None):
if not values:
return
def added(book: dict):
self.reload(book["goodreadsId"])
self.notify(f"Added {book['title']} to {book['shelf']}")
self.run_in_background("Reading the book from Goodreads...", lambda: books.add_book(values["link"], values["shelf"], values["rating"]), added)
def action_rate(self):
book = self.book_by_id(self.selected_key())
if not book:
return
current = "" if book.get("rating") is None else str(book["rating"])
fields = [FormField("rating", f"Rating for {book['title']} (empty to clear)", kind="rating", value=current)]
def rated(values: dict | None):
if values is not None:
self.run_now(lambda: books.rate_book(book["goodreadsId"], values["rating"]), f"Updated {book['title']}", book["goodreadsId"])
self.app.push_screen(FormDialog("Change rating", fields), rated)
def action_shelf(self):
book = self.book_by_id(self.selected_key())
if not book:
return
fields = [FormField("shelf", f"Shelf for {book['title']}", kind="select", options=books.SHELVES, value=book.get("shelf", "read"))]
def moved(values: dict | None):
if values:
self.run_now(lambda: books.move_book(book["goodreadsId"], values["shelf"]), f"Moved {book['title']} to {values['shelf']}", book["goodreadsId"])
self.app.push_screen(FormDialog("Move to shelf", fields, "Move"), moved)
def action_delete(self):
book = self.book_by_id(self.selected_key())
if not book:
return
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: books.remove_book(book["goodreadsId"]), f"Removed {book['title']}")
self.app.push_screen(ConfirmDialog(f"Remove {book['title']}?"), confirmed)
def action_refresh(self):
book = self.book_by_id(self.selected_key())
if not book:
return
def refreshed(updated: dict):
self.reload(updated["goodreadsId"])
self.notify(f"Refreshed {updated['title']}")
self.run_in_background(f"Refreshing {book['title']}...", lambda: books.refresh_book(book["goodreadsId"]), refreshed)
def action_refresh_all(self):
self.notify("Refreshing every book from Goodreads...")
self.refresh_every_book()
@work(thread=True, exclusive=True, group="refresh")
def refresh_every_book(self):
failures = 0
all_books = books.load_books()
for book in all_books:
try:
books.refresh_book(book["goodreadsId"])
except (OSError, ValueError) as error:
failures += 1
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
summary = f"Refreshed {len(all_books) - failures} of {len(all_books)} books"
self.app.call_from_thread(self.notify, summary)
+65
View File
@@ -0,0 +1,65 @@
from typing import Any, Callable
from textual import work
from textual.containers import Vertical
from textual.widgets import DataTable
class ContentPane(Vertical):
content_file = ""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_content_file(self.content_file)
def set_content_file(self, name: str):
self.content_file = name
self.border_title = f"[b]kitty[/b] ~/content/{name}"
def table(self) -> DataTable:
return self.query_one(DataTable)
def selected_key(self) -> str | None:
table = self.table()
if table.row_count == 0:
return None
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
return row_key.value
def restore_cursor(self, key: str | None):
table = self.table()
if key is None or table.row_count == 0:
return
try:
table.move_cursor(row=table.get_row_index(key))
except Exception:
table.move_cursor(row=0)
def reload(self, focus_key: str | None = None):
raise NotImplementedError
def action_reload(self):
self.reload(self.selected_key())
self.notify("Reloaded from disk")
def run_now(self, task: Callable[[], Any], message: str | Callable[[Any], str], focus_key: str | None = None):
try:
result = task()
except (OSError, ValueError) as error:
self.notify(str(error), title="Error", severity="error")
return
self.reload(focus_key)
self.notify(message(result) if callable(message) else message)
def run_in_background(self, waiting_message: str, task: Callable[[], Any], on_success: Callable[[Any], None]):
self.notify(waiting_message)
self.background_task(task, on_success)
@work(thread=True)
def background_task(self, task: Callable[[], Any], on_success: Callable[[Any], None]):
try:
result = task()
except (OSError, ValueError) as error:
self.app.call_from_thread(self.notify, str(error), title="Error", severity="error")
return
self.app.call_from_thread(on_success, result)
+106
View File
@@ -0,0 +1,106 @@
from dataclasses import dataclass, field
from textual import on
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Select
@dataclass
class FormField:
name: str
label: str
kind: str = "text"
placeholder: str = ""
required: bool = False
value: str = ""
options: list[str] = field(default_factory=list)
class FormDialog(ModalScreen[dict | None]):
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(self, title: str, fields: list[FormField], submit_label: str = "Save"):
super().__init__()
self.form_title = title
self.fields = fields
self.submit_label = submit_label
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(self.form_title, classes="dialog-title")
for form_field in self.fields:
yield Label(form_field.label, classes="field-label")
yield self.field_widget(form_field)
yield Label("", id="dialog-error")
with Horizontal(classes="dialog-buttons"):
yield Button(self.submit_label, variant="primary", id="submit")
yield Button("Cancel", id="cancel")
def field_widget(self, form_field: FormField):
widget_id = f"field-{form_field.name}"
if form_field.kind == "select":
options = [(option, option) for option in form_field.options]
return Select(options, value=form_field.value or form_field.options[0], allow_blank=False, id=widget_id)
input_type = "integer" if form_field.kind == "rating" else "text"
return Input(value=form_field.value, placeholder=form_field.placeholder, type=input_type, id=widget_id)
def show_error(self, message: str):
self.query_one("#dialog-error", Label).update(message)
def read_value(self, form_field: FormField):
text = str(self.query_one(f"#field-{form_field.name}").value or "").strip()
if form_field.required and not text:
raise ValueError(f"{form_field.label} is required")
if form_field.kind != "rating":
return text or None
if not text:
return None
rating = int(text)
if not 0 <= rating <= 10:
raise ValueError("Rating must be between 0 and 10")
return rating
def submit(self):
try:
values = {form_field.name: self.read_value(form_field) for form_field in self.fields}
except ValueError as error:
self.show_error(str(error))
return
self.dismiss(values)
@on(Input.Submitted)
def submit_from_input(self):
self.submit()
@on(Button.Pressed, "#submit")
def submit_from_button(self):
self.submit()
@on(Button.Pressed, "#cancel")
def action_cancel(self):
self.dismiss(None)
class ConfirmDialog(ModalScreen[bool]):
BINDINGS = [("y", "confirm", "Yes"), ("n", "cancel", "No"), ("escape", "cancel", "No")]
def __init__(self, message: str):
super().__init__()
self.message = message
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(self.message, classes="dialog-title")
with Horizontal(classes="dialog-buttons"):
yield Button("Delete", variant="error", id="confirm")
yield Button("Cancel", id="cancel")
@on(Button.Pressed, "#confirm")
def action_confirm(self):
self.dismiss(True)
@on(Button.Pressed, "#cancel")
def action_cancel(self):
self.dismiss(False)
+68
View File
@@ -0,0 +1,68 @@
from rich.text import Text
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Input, Label, OptionList
from textual.widgets.option_list import Option
import static_images
class ImagePicker(ModalScreen[str | None]):
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("down", "focus_list", "List", show=False),
]
def __init__(self, current: str = "", start_filter: str = ""):
super().__init__()
self.current = current
self.start_filter = start_filter
self.images: list[dict] = []
def compose(self) -> ComposeResult:
with Vertical(classes="dialog picker"):
yield Label("Pick an image from static/", classes="dialog-title")
yield Input(value=self.start_filter, placeholder="Type to filter, Enter to pick", id="image-filter")
yield OptionList(id="image-list")
yield Label("", id="image-count", classes="field-label")
def on_mount(self):
self.images = static_images.list_images()
self.show_images(self.start_filter)
def show_images(self, text: str):
query = text.strip().lower()
matches = [image for image in self.images if query in image["path"].lower()]
option_list = self.query_one(OptionList)
option_list.clear_options()
option_list.add_options(
Option(Text.assemble(image["path"], (" " + static_images.format_size(image["size"]), "dim")), id=image["path"])
for image in matches
)
current_index = next((index for index, image in enumerate(matches) if image["path"] == self.current), 0)
if matches:
option_list.highlighted = current_index
self.query_one("#image-count", Label).update(f"{len(matches)} of {len(self.images)} images")
@on(Input.Changed, "#image-filter")
def filter_changed(self, event: Input.Changed):
self.show_images(event.value)
@on(Input.Submitted, "#image-filter")
def pick_highlighted(self):
option_list = self.query_one(OptionList)
if option_list.highlighted is not None and option_list.option_count:
self.dismiss(option_list.get_option_at_index(option_list.highlighted).id)
@on(OptionList.OptionSelected)
def pick_option(self, event: OptionList.OptionSelected):
self.dismiss(event.option.id)
def action_focus_list(self):
self.query_one(OptionList).focus()
def action_cancel(self):
self.dismiss(None)
+95
View File
@@ -0,0 +1,95 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable
import imdb
import media
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
class MediaPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "rate", "Rate"),
Binding("d", "delete", "Delete"),
Binding("r", "reload", "Reload"),
]
content_file = "about.json"
def __init__(self, kind: str, **kwargs):
super().__init__(**kwargs)
self.kind = kind
self.label = media.MEDIA_KINDS[kind]["label"]
self.border_subtitle = media.MEDIA_KINDS[kind]["key"]
def compose(self) -> ComposeResult:
yield DataTable(cursor_type="row", zebra_stripes=True)
def on_mount(self):
table = self.table()
table.add_column("Rating", key="rating")
table.add_column("Title", key="title")
table.add_column("IMDb", key="imdb")
self.reload()
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for item in media.list_items(self.kind):
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
table.add_row(f"{item.get('rating', '?')}/10", item.get("name", ""), identifier, key=identifier)
self.restore_cursor(focus_key)
def action_add(self):
fields = [
FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True),
FormField("rating", "Your rating (0 to 10)", kind="rating", required=True),
FormField("name", "Name (looked up on OMDb when empty)"),
]
self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item)
def add_item(self, values: dict | None):
if not values:
return
identifier = imdb.imdb_id(values["link"])
def added(item: dict):
self.reload(identifier)
self.notify(f"Added {item['name']} ({item['rating']}/10)")
self.run_in_background(
f"Adding {identifier or values['link']}...",
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"]),
added,
)
def action_rate(self):
identifier = self.selected_key()
if not identifier:
return
current = next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), {})
fields = [FormField("rating", f"Rating for {current.get('name', identifier)}", kind="rating", required=True, value=str(current.get("rating", "")))]
def rated(values: dict | None):
if values:
self.run_now(
lambda: media.rate_item(self.kind, identifier, values["rating"]),
lambda item: f"Rated {item['name']} {item['rating']}/10",
identifier,
)
self.app.push_screen(FormDialog("Change rating", fields), rated)
def action_delete(self):
identifier = self.selected_key()
if not identifier:
return
name = self.table().get_cell(identifier, "title")
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}")
self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed)
+144
View File
@@ -0,0 +1,144 @@
from rich.markup import escape
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import music
import youtube
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
LOADING = "..."
DESCRIPTION_PREVIEW_LENGTH = 500
EMPTY_DETAILS = "Add a song with [b]a[/b] and a YouTube link."
def song_details(identifier: str, details: dict | None) -> str:
if not details:
return f"[b]{escape(identifier)}[/b]\n\n[dim]Loading details from YouTube...[/dim]"
rows = [
("Artist", details.get("artist")),
("Length", youtube.format_duration(details["durationSeconds"]) if details.get("durationSeconds") else None),
("Views", f"{details['views']:,}" if details.get("views") is not None else None),
("Published", details.get("published")),
("Video ID", identifier),
]
lines = [f"[b]{escape(details.get('title') or 'Title not found')}[/b]", ""]
lines += [f"[dim]{label:<11}[/dim]{escape(str(value))}" for label, value in rows if value]
description = details.get("description")
if description:
preview = description[:DESCRIPTION_PREVIEW_LENGTH] + ("..." if len(description) > DESCRIPTION_PREVIEW_LENGTH else "")
lines += ["", escape(preview)]
lines += ["", f"[dim]{escape(details['url'])}[/dim]", f"[dim]{escape(details['cover'])}[/dim]"]
return "\n".join(lines)
class MusicPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("d", "delete", "Delete"),
Binding("K", "move(-1)", "Move up"),
Binding("J", "move(1)", "Move down"),
Binding("u", "refresh_details", "Refresh"),
Binding("r", "reload", "Reload"),
]
content_file = "music.json"
song_info: dict[str, dict] = {}
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static(EMPTY_DETAILS, id="song-detail")
def on_mount(self):
table = self.table()
table.add_column("#", key="number")
table.add_column("Title", key="title")
table.add_column("Artist", key="artist")
table.add_column("Length", key="length")
self.reload()
def row_cells(self, identifier: str) -> tuple[str, str, str]:
details = self.song_info.get(identifier)
if not details:
return identifier, LOADING, LOADING
return details.get("title") or identifier, details.get("artist") or "-", youtube.format_duration(details.get("durationSeconds"))
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for number, identifier in enumerate(music.list_songs(), start=1):
table.add_row(str(number), *self.row_cells(identifier), key=identifier)
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
self.update_summary()
self.load_details()
def update_summary(self):
songs = music.list_songs()
known_lengths = [self.song_info[song].get("durationSeconds") for song in songs if song in self.song_info]
total = sum(length for length in known_lengths if length)
length_text = youtube.format_duration(total) if known_lengths else LOADING
self.border_subtitle = f"{len(songs)} songs · {length_text}"
def show_details(self, identifier: str | None):
detail = self.query_one("#song-detail", Static)
detail.update(song_details(identifier, self.song_info.get(identifier)) if identifier else EMPTY_DETAILS)
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@work(thread=True, exclusive=True, group="details")
def load_details(self, refresh_ids: tuple[str, ...] = ()):
for identifier in music.list_songs():
if identifier in self.song_info and identifier not in refresh_ids:
continue
self.song_info[identifier] = youtube.video_details(identifier)
self.app.call_from_thread(self.show_song, identifier)
def show_song(self, identifier: str):
table = self.table()
try:
for column, value in zip(["title", "artist", "length"], self.row_cells(identifier)):
table.update_cell(identifier, column, value, update_width=True)
except Exception:
return
self.update_summary()
if self.selected_key() == identifier:
self.show_details(identifier)
def action_refresh_details(self):
identifier = self.selected_key()
if identifier:
self.notify("Refreshing from YouTube...")
self.load_details((identifier,))
def action_add(self):
fields = [FormField("link", "YouTube link or video ID", placeholder="https://music.youtube.com/watch?v=...", required=True)]
self.app.push_screen(FormDialog("Add a song", fields, "Add"), self.add_song)
def add_song(self, values: dict | None):
if values:
self.run_now(lambda: music.add_song(values["link"]), lambda identifier: f"Added {identifier}", youtube.video_id(values["link"]))
def action_delete(self):
identifier = self.selected_key()
if not identifier:
return
title = (self.song_info.get(identifier) or {}).get("title") or identifier
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: music.remove_song(identifier), f"Removed {title}")
self.app.push_screen(ConfirmDialog(f"Remove {title} from the playlist?"), confirmed)
def action_move(self, step: int):
identifier = self.selected_key()
if identifier:
self.run_now(lambda: music.move_song(identifier, step), "Moved", identifier)
+73
View File
@@ -0,0 +1,73 @@
from rich.markup import escape
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import portfolio
from fields import get_path, to_form_text
from tui.content_pane import ContentPane
from tui.record_editor import RecordEditor
PREVIEW_LENGTH = 60
class ProfilePane(ContentPane):
BINDINGS = [
Binding("e", "edit", "Edit"),
Binding("r", "reload", "Reload"),
]
content_file = portfolio.PROFILE.file
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("", id="profile-detail")
def on_mount(self):
table = self.table()
table.add_column("Field", key="field")
table.add_column("Value", key="value")
self.reload()
def reload(self, focus_key: str | None = None):
profile = portfolio.load_records(portfolio.PROFILE)
table = self.table()
table.clear()
for spec in portfolio.PROFILE.fields:
text = to_form_text(spec.kind, get_path(profile, spec.path)).replace("\n", " · ")
preview = text if len(text) <= PREVIEW_LENGTH else text[:PREVIEW_LENGTH] + "..."
table.add_row(spec.label, preview, key=spec.path)
self.restore_cursor(focus_key)
self.show_field(self.selected_key())
def show_field(self, path: str | None):
spec = next((item for item in portfolio.PROFILE.fields if item.path == path), None)
detail = self.query_one("#profile-detail", Static)
if not spec:
detail.update("")
return
value = to_form_text(spec.kind, get_path(portfolio.load_records(portfolio.PROFILE), spec.path))
hint = f"\n[dim]{spec.hint}[/dim]" if spec.hint else ""
detail.update(f"[b]{escape(spec.label)}[/b]{hint}\n\n{escape(value) or '[dim]empty[/dim]'}\n\n[dim]Press e or Enter to edit the profile.[/dim]")
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_field(event.row_key.value if event.row_key else None)
@on(DataTable.RowSelected)
def row_selected(self):
self.action_edit()
def action_edit(self):
profile = portfolio.load_records(portfolio.PROFILE)
focus_key = self.selected_key()
def saved(result: dict | None):
if result is not None:
self.run_now(lambda: portfolio.save_record(portfolio.PROFILE, None, result), "Saved profile", focus_key)
self.app.push_screen(RecordEditor("Edit profile", portfolio.PROFILE.fields, profile), saved)
+30
View File
@@ -0,0 +1,30 @@
from textual import on
from textual.app import ComposeResult
from textual.widgets import Select
import portfolio
from tui.record_pane import RecordListPane
IMAGE_FOLDERS = {"hackathons": "hacks", "organizations": "organizations", "opensource": "projects"}
class ProjectsPane(RecordListPane):
def __init__(self, **kwargs):
super().__init__(portfolio.PROJECT_COLLECTIONS[0], **kwargs)
def compose_header(self) -> ComposeResult:
options = [(collection.title, collection.id) for collection in portfolio.PROJECT_COLLECTIONS]
yield Select(options, value=self.collection.id, allow_blank=False, id="category-select")
@on(Select.Changed, "#category-select")
def category_changed(self, event: Select.Changed):
if event.value == self.collection.id:
return
self.collection = portfolio.find_collection(str(event.value))
self.set_content_file(self.collection.file)
self.build_columns()
self.reload()
self.table().focus()
def image_folder(self) -> str:
return IMAGE_FOLDERS.get(self.collection.id, "")
+140
View File
@@ -0,0 +1,140 @@
import copy
from typing import Callable
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.color import Color
from textual.containers import Horizontal, VerticalScroll
from textual.screen import Screen
from textual.widgets import Button, Footer, Input, Label, Select, Static, Switch, TextArea
from fields import MULTILINE_KINDS, FieldSpec, from_form_text, get_path, set_path, to_form_text
from tui.image_picker import ImagePicker
class RecordEditor(Screen[dict | None]):
BINDINGS = [
Binding("ctrl+s", "save", "Save"),
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, title: str, fields: list[FieldSpec], record: dict, validate: Callable[[dict], None] | None = None, image_folder: str = ""):
super().__init__()
self.editor_title = title
self.fields = fields
self.record = copy.deepcopy(record)
self.validate = validate
self.image_folder = image_folder
def compose(self) -> ComposeResult:
yield Label(self.editor_title, classes="editor-title")
with VerticalScroll(classes="editor-form"):
for index, spec in enumerate(self.fields):
hint = f" [dim]({spec.hint})[/dim]" if spec.hint else ""
required = " [b]*[/b]" if spec.required else ""
yield Label(f"{spec.label}{required}{hint}", classes="field-label")
yield from self.field_widgets(index, spec)
yield Label("", id="editor-error")
with Horizontal(classes="dialog-buttons"):
yield Button("Save", variant="primary", id="save")
yield Button("Cancel", id="cancel")
yield Footer()
def field_widgets(self, index: int, spec: FieldSpec):
widget_id = f"field-{index}"
value = get_path(self.record, spec.path)
if spec.kind == "flag":
yield Switch(value=bool(value), id=widget_id)
elif spec.kind == "select":
yield Select([(option, option) for option in spec.options], value=value or spec.options[0], allow_blank=False, id=widget_id)
elif spec.kind in MULTILINE_KINDS:
yield TextArea(to_form_text(spec.kind, value), id=widget_id, soft_wrap=True, classes=f"editor-area {spec.kind}")
if spec.kind == "images":
yield Button("Add image", id=f"browse-{index}", classes="browse-button")
elif spec.kind == "image":
with Horizontal(classes="field-row"):
yield Input(to_form_text(spec.kind, value), id=widget_id)
yield Button("Browse", id=f"browse-{index}", classes="browse-button")
elif spec.kind == "color":
with Horizontal(classes="field-row"):
yield Input(to_form_text(spec.kind, value), id=widget_id, classes="color-input")
yield Static(" ", id=f"swatch-{index}", classes="swatch")
else:
yield Input(to_form_text(spec.kind, value), id=widget_id)
def on_mount(self):
for index, spec in enumerate(self.fields):
if spec.kind == "color":
self.update_swatch(index)
def update_swatch(self, index: int):
text = self.query_one(f"#field-{index}", Input).value.strip()
swatch = self.query_one(f"#swatch-{index}", Static)
try:
swatch.styles.background = Color.parse(text)
swatch.update(" ")
except Exception:
swatch.styles.background = None
swatch.update("?")
@on(Input.Changed, ".color-input")
def color_changed(self, event: Input.Changed):
self.update_swatch(int(event.input.id.removeprefix("field-")))
def read_record(self) -> dict:
record = copy.deepcopy(self.record)
for index, spec in enumerate(self.fields):
widget = self.query_one(f"#field-{index}")
if spec.kind == "flag":
value = widget.value
elif spec.kind == "select":
value = widget.value
elif isinstance(widget, TextArea):
value = from_form_text(spec.kind, widget.text)
else:
value = from_form_text(spec.kind, widget.value)
if spec.required and not value:
raise ValueError(f"{spec.label} is required")
set_path(record, spec.path, value)
return record
def action_save(self):
try:
record = self.read_record()
if self.validate:
self.validate(record)
except ValueError as error:
self.query_one("#editor-error", Label).update(str(error))
self.notify(str(error), title="Not saved", severity="error")
return
self.dismiss(record)
def action_cancel(self):
self.dismiss(None)
@on(Button.Pressed, "#save")
def save_pressed(self):
self.action_save()
@on(Button.Pressed, "#cancel")
def cancel_pressed(self):
self.action_cancel()
@on(Button.Pressed, ".browse-button")
def browse_pressed(self, event: Button.Pressed):
index = int(event.button.id.removeprefix("browse-"))
widget = self.query_one(f"#field-{index}")
current = widget.value if isinstance(widget, Input) else ""
def picked(path: str | None):
if not path:
return
if isinstance(widget, TextArea):
existing = widget.text.rstrip()
widget.load_text(f"{existing}\n{path}" if existing else path)
else:
widget.value = path
widget.focus()
self.app.push_screen(ImagePicker(current, self.image_folder), picked)
+155
View File
@@ -0,0 +1,155 @@
from rich.markup import escape
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import portfolio
from fields import get_path, summary_text, to_form_text
from portfolio import Collection
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog
from tui.record_editor import RecordEditor
def record_details(collection: Collection, record: dict) -> str:
lines = [f"[b]{escape(portfolio.record_title(collection, record))}[/b]", ""]
for spec in collection.fields[1:]:
value = get_path(record, spec.path)
if value in (None, "", [], {}, False):
continue
text = "yes" if value is True else to_form_text(spec.kind, value)
if "\n" in text or len(text) > 60:
lines += [f"[dim]{escape(spec.label)}[/dim]", escape(text), ""]
else:
lines.append(f"[dim]{escape(spec.label):<14}[/dim]{escape(text)}")
return "\n".join(lines)
class RecordListPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "edit", "Edit"),
Binding("d", "delete", "Delete"),
Binding("f", "toggle_featured", "Featured"),
Binding("K", "move(-1)", "Move up"),
Binding("J", "move(1)", "Move down"),
Binding("r", "reload", "Reload"),
]
def __init__(self, collection: Collection, **kwargs):
super().__init__(**kwargs)
self.collection = collection
self.set_content_file(collection.file)
def compose(self) -> ComposeResult:
yield from self.compose_header()
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("", classes="record-detail")
def compose_header(self) -> ComposeResult:
yield from ()
def on_mount(self):
self.build_columns()
self.reload()
def build_columns(self):
table = self.table()
table.clear(columns=True)
table.add_column("#", key="number")
for label, path in self.collection.columns:
table.add_column(label, key=path)
def records(self) -> list[dict]:
return portfolio.load_records(self.collection)
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for index, record in enumerate(self.records()):
cells = [summary_text(get_path(record, path)) for _, path in self.collection.columns]
table.add_row(str(index + 1), *cells, key=str(index))
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
def selected_index(self) -> int | None:
key = self.selected_key()
return int(key) if key is not None else None
def show_details(self, key: str | None):
records = self.records()
detail = self.query_one(".record-detail", Static)
if key is None or int(key) >= len(records):
detail.update(f"No {self.collection.title.lower()} yet. Press [b]a[/b] to add one.")
return
detail.update(record_details(self.collection, records[int(key)]))
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@on(DataTable.RowSelected)
def row_selected(self):
self.action_edit()
def open_editor(self, index: int | None, record: dict):
title = f"Edit {portfolio.record_title(self.collection, record)}" if index is not None else f"New {self.collection.title.lower()} entry"
def saved(result: dict | None):
if result is None:
return
self.run_now(
lambda: portfolio.save_record(self.collection, index, result),
f"Saved {portfolio.record_title(self.collection, result)}",
str(index) if index is not None else str(len(self.records())),
)
self.app.push_screen(RecordEditor(title, self.collection.fields, record, image_folder=self.image_folder()), saved)
def image_folder(self) -> str:
return ""
def action_add(self):
self.open_editor(None, {})
def action_edit(self):
index = self.selected_index()
if index is not None:
self.open_editor(index, self.records()[index])
def action_delete(self):
index = self.selected_index()
if index is None:
return
title = portfolio.record_title(self.collection, self.records()[index])
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: portfolio.remove_record(self.collection, index), f"Removed {title}", str(max(0, index - 1)))
self.app.push_screen(ConfirmDialog(f"Remove {title}?"), confirmed)
def action_move(self, step: int):
index = self.selected_index()
if index is None:
return
target = index + step
self.run_now(lambda: portfolio.move_record(self.collection, index, step), "Moved", str(target))
def action_toggle_featured(self):
index = self.selected_index()
if index is None:
return
if not any(spec.path == "featured" for spec in self.collection.fields):
self.notify(f"{self.collection.title} entries have no featured flag", severity="warning")
return
record = self.records()[index]
record["featured"] = not record.get("featured", False)
if not record["featured"]:
record.pop("featured")
state = "Featured" if record.get("featured") else "Unfeatured"
self.run_now(lambda: portfolio.save_record(self.collection, index, record), f"{state} {portfolio.record_title(self.collection, record)}", str(index))
+57
View File
@@ -0,0 +1,57 @@
from textual.theme import Theme
import site_themes
def to_textual_theme(theme: dict) -> Theme:
colors = theme["colors"]
border_start, border_end = theme.get("activeBorder") or (colors["accent"], colors["accent"])
return Theme(
name=theme["id"],
primary=border_start,
secondary=colors["blue"],
accent=border_end,
foreground=colors["foreground"],
background=colors["dark_background"],
surface=colors["background"],
panel=colors["lighter_background"],
boost=colors["selection"],
warning=colors["yellow"],
error=colors["red"],
success=colors["green"],
dark=theme.get("mode") != "light",
variables={
"text-muted": colors["muted"],
"border": border_start,
"border-blurred": colors["selection"],
"block-cursor-background": colors["accent"],
"block-cursor-foreground": colors["dark_background"],
"block-cursor-text-style": "bold",
"block-cursor-blurred-background": colors["selection"],
"block-hover-background": colors["selection"],
"footer-key-foreground": colors["accent"],
"input-selection-background": f"{colors['accent']} 35%",
"input-cursor-background": colors["foreground"],
"scrollbar": colors["selection"],
"scrollbar-hover": colors["muted"],
"scrollbar-active": colors["accent"],
},
)
def load_site_themes() -> list[Theme]:
try:
themes = site_themes.load_themes()
except (OSError, ValueError):
return []
textual_themes = []
for theme in themes:
try:
textual_themes.append(to_textual_theme(theme))
except (KeyError, ValueError):
continue
return textual_themes
def default_theme_id() -> str | None:
return site_themes.default_theme_id()
+174
View File
@@ -0,0 +1,174 @@
import copy
from rich.markup import escape
from rich.text import Text
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import site_themes
from fields import FieldSpec
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog
from tui.record_editor import RecordEditor
SWATCH_KEYS = ["accent", "red", "yellow", "orange", "green", "cyan", "blue", "magenta"]
THEME_FIELDS = [
FieldSpec("id", "Id", required=True),
FieldSpec("name", "Name", required=True),
FieldSpec("mode", "Mode", "select", options=site_themes.THEME_MODES),
FieldSpec("wallpaper", "Wallpaper", "image"),
FieldSpec("borderStart", "Active border start", "color"),
FieldSpec("borderEnd", "Active border end", "color"),
*[FieldSpec(f"colors.{key}", key.replace("_", " ").capitalize(), "color", required=True) for key in site_themes.COLOR_KEYS],
]
def palette_swatches(theme: dict) -> Text:
colors = theme.get("colors", {})
return Text.assemble(*[("██", colors[key]) for key in SWATCH_KEYS if key in colors])
def to_form_record(theme: dict) -> dict:
record = copy.deepcopy(theme)
border = record.pop("activeBorder", None) or []
if len(border) == 2:
record["borderStart"], record["borderEnd"] = border
return record
def from_form_record(record: dict) -> dict:
theme = copy.deepcopy(record)
start, end = theme.pop("borderStart", None), theme.pop("borderEnd", None)
if start and end:
theme["activeBorder"] = [start, end]
elif start or end:
raise ValueError("Set both active border colors, or leave both empty")
ordered_keys = ["id", "name", "mode", "wallpaper", "activeBorder", "colors"]
return {key: theme[key] for key in ordered_keys if key in theme}
def theme_details(theme: dict, is_default: bool) -> str:
colors = theme.get("colors", {})
lines = [f"[b]{escape(theme.get('name', theme.get('id', '')))}[/b]{' [dim](default)[/dim]' if is_default else ''}", ""]
lines.append(f"[dim]{'Id':<20}[/dim]{escape(theme.get('id', ''))}")
lines.append(f"[dim]{'Mode':<20}[/dim]{escape(theme.get('mode', ''))}")
if theme.get("wallpaper"):
lines.append(f"[dim]{'Wallpaper':<20}[/dim]{escape(theme['wallpaper'])}")
if theme.get("activeBorder"):
start, end = theme["activeBorder"]
lines.append(f"[dim]{'Active border':<20}[/dim][{start}]██[/] {start} [{end}]██[/] {end}")
lines.append("")
for key in site_themes.COLOR_KEYS:
value = colors.get(key, "")
swatch = f"[{value}]██████[/]" if value else " "
lines.append(f"{swatch} [dim]{key.replace('_', ' '):<19}[/dim]{escape(value)}")
return "\n".join(lines)
class ThemesPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Duplicate"),
Binding("e", "edit", "Edit"),
Binding("d", "delete", "Delete"),
Binding("s", "set_default", "Set default"),
Binding("p", "preview", "Preview"),
Binding("r", "reload", "Reload"),
]
content_file = site_themes.THEMES_FILE
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("", id="theme-detail")
def on_mount(self):
table = self.table()
table.add_column("", key="default")
table.add_column("Id", key="id")
table.add_column("Name", key="name")
table.add_column("Mode", key="mode")
table.add_column("Palette", key="palette")
self.reload()
def reload(self, focus_key: str | None = None):
default_id = site_themes.default_theme_id()
table = self.table()
table.clear()
for theme in site_themes.load_themes():
marker = "*" if theme.get("id") == default_id else ""
table.add_row(marker, theme.get("id", ""), theme.get("name", ""), theme.get("mode", ""), palette_swatches(theme), key=theme.get("id"))
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
def selected_theme(self) -> dict | None:
return site_themes.find_theme(site_themes.load_themes(), self.selected_key() or "")
def show_details(self, theme_id: str | None):
theme = site_themes.find_theme(site_themes.load_themes(), theme_id or "")
detail = self.query_one("#theme-detail", Static)
detail.update(theme_details(theme, theme_id == site_themes.default_theme_id()) if theme else "")
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@on(DataTable.RowSelected)
def row_selected(self):
self.action_edit()
def open_editor(self, original_id: str | None, theme: dict, title: str):
def saved(record: dict | None):
if record is None:
return
try:
updated = from_form_record(record)
site_themes.save_theme(original_id, updated)
except (OSError, ValueError) as error:
self.notify(str(error), title="Not saved", severity="error")
return
self.app.reload_site_themes(updated["id"])
self.reload(updated["id"])
self.notify(f"Saved {updated['name']}")
validate = lambda record: site_themes.check_theme(from_form_record(record))
editor = RecordEditor(title, THEME_FIELDS, to_form_record(theme), validate=validate, image_folder="wallpapers")
self.app.push_screen(editor, saved)
def action_edit(self):
theme = self.selected_theme()
if theme:
self.open_editor(theme["id"], theme, f"Edit theme {theme.get('name', theme['id'])}")
def action_add(self):
template = self.selected_theme() or {"mode": "dark", "colors": {}}
theme = copy.deepcopy(template)
theme["id"] = f"{template.get('id', 'theme')}-copy"
theme["name"] = f"{template.get('name', 'New theme')} Copy"
self.open_editor(None, theme, "New theme")
def action_delete(self):
theme = self.selected_theme()
if not theme:
return
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: site_themes.remove_theme(theme["id"]), f"Removed {theme.get('name', theme['id'])}")
self.app.push_screen(ConfirmDialog(f"Remove the {theme.get('name', theme['id'])} theme?"), confirmed)
def action_set_default(self):
theme = self.selected_theme()
if theme:
self.run_now(lambda: site_themes.set_default_theme(theme["id"]), f"{theme.get('name', theme['id'])} is now the default theme", theme["id"])
def action_preview(self):
theme = self.selected_theme()
if theme:
self.app.reload_site_themes(theme["id"])
self.notify(f"Previewing {theme.get('name', theme['id'])}")