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)