Replace content CLI with Textual TUI
This commit is contained in:
1 parent
0d58ddd430
commit
737700a9a8
28 files changed
+2403
-167
No files matched your search
Whitespace-only changes.
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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, "")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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'])}")
|
||||
Reference in new issue
Block a user