141 lines
5.6 KiB
Python
141 lines
5.6 KiB
Python
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)
|