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
@@ -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)
|
||||
Reference in new issue
Block a user