Files

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)