|
| 1 | +from ast import literal_eval |
| 2 | +from dataclasses import _MISSING_TYPE, dataclass, field |
| 3 | +from types import UnionType |
| 4 | +from typing import Any, Self, get_args, override |
| 5 | +from dataclasses import fields |
| 6 | +from textual import events |
| 7 | +from textual.app import App, ComposeResult |
| 8 | +from textual.containers import VerticalScroll, Container |
| 9 | +from textual.widgets import Checkbox, Header, Footer, Input, Label, Welcome, Button, Static |
| 10 | +from textual.binding import Binding |
| 11 | + |
| 12 | +from mininterface import TuiInterface |
| 13 | +from .common import InterfaceNotAvailable |
| 14 | + |
| 15 | +from .Mininterface import Cancelled, Mininterface |
| 16 | +from .auxiliary import ConfigInstance, FormDict, FormField, config_from_dict, config_to_formdict, dict_to_formdict, flatten |
| 17 | + |
| 18 | +from textual.widgets import Checkbox, Input |
| 19 | + |
| 20 | +# TODO |
| 21 | +# 1. TuiInterface -> TextInterface. |
| 22 | +# 1. TextualInterface inherits from TextInterface. |
| 23 | +# 2. TextualInterface is the default for TuiInterface |
| 24 | +# Add to docs |
| 25 | + |
| 26 | +@dataclass |
| 27 | +class FormFieldTextual(FormField): |
| 28 | + """ Bridge between the values given in CLI, TUI and real needed values (str to int conversion etc). """ |
| 29 | + |
| 30 | + def get_widget(self): |
| 31 | + if self.annotation is bool or not self.annotation and self.val in [True, False]: |
| 32 | + o = Checkbox(self.name, self.val) |
| 33 | + else: |
| 34 | + o = Input(str(self.val), placeholder=self.name or "") |
| 35 | + o._link = self # The Textual widgets need to get back to this value |
| 36 | + return o |
| 37 | + |
| 38 | + |
| 39 | +@dataclass |
| 40 | +class DummyWrapper: |
| 41 | + """ Value wrapped, since I do not know how to get it from textual app. |
| 42 | + False would mean direct exit. """ |
| 43 | + val: Any |
| 44 | + |
| 45 | + |
| 46 | +class TextualInterface(TuiInterface): |
| 47 | + |
| 48 | + def alert(self, text: str) -> None: |
| 49 | + """ Display the OK dialog with text. """ |
| 50 | + TextualButtonApp().buttons(text, [("Ok", None)]).run() |
| 51 | + |
| 52 | + def ask(self, text: str = None): |
| 53 | + return self.ask_form({text: ""})[text] |
| 54 | + |
| 55 | + def ask_args(self) -> ConfigInstance: |
| 56 | + """ Display a window form with all parameters. """ |
| 57 | + params_ = config_to_formdict(self.args, self.descriptions, factory=FormFieldTextual) |
| 58 | + |
| 59 | + # fetch the dict of dicts values from the form back to the namespace of the dataclasses |
| 60 | + TextualApp.run_dialog(TextualApp(), params_) |
| 61 | + return self.args |
| 62 | + |
| 63 | + def ask_form(self, form: FormDict, title: str = "") -> dict: |
| 64 | + TextualApp.run_dialog(TextualApp(), dict_to_formdict(form, factory=FormFieldTextual), title) |
| 65 | + return form |
| 66 | + |
| 67 | + # NOTE we should implement better, now the user does not know it needs an int |
| 68 | + # def ask_number(self, text): |
| 69 | + |
| 70 | + def is_yes(self, text): |
| 71 | + return TextualButtonApp().yes_no(text, False).val |
| 72 | + |
| 73 | + def is_no(self, text): |
| 74 | + return TextualButtonApp().yes_no(text, True).val |
| 75 | + |
| 76 | + |
| 77 | +class TextualApp(App[bool | None]): |
| 78 | + |
| 79 | + BINDINGS = [ |
| 80 | + ("up", "go_up", "Go up"), |
| 81 | + ("down", "go_up", "Go down"), |
| 82 | + # Form confirmation |
| 83 | + # * ctrl/alt+enter does not work |
| 84 | + # * enter without priority is consumed by input fields |
| 85 | + # * enter with priority is not shown in the footer |
| 86 | + Binding("enter", "confirm", "Ok", show=True, priority=True), |
| 87 | + Binding("Enter", "confirm", "Ok"), |
| 88 | + ("escape", "exit", "Cancel"), |
| 89 | + ] |
| 90 | + |
| 91 | + def __init__(self): |
| 92 | + super().__init__() |
| 93 | + self.title = "" |
| 94 | + self.widgets = None |
| 95 | + self.focused_i: int = 0 |
| 96 | + |
| 97 | + def setup(self, title, widgets, focused_i): |
| 98 | + |
| 99 | + self.focused_i = focused_i |
| 100 | + return self |
| 101 | + |
| 102 | + # Why class method? I do not know how to re-create the dialog if needed. |
| 103 | + @classmethod |
| 104 | + def run_dialog(cls, window, formDict: FormDict, title: str = "") -> None: # TODO changed from dict, change everywhere |
| 105 | + if title: |
| 106 | + window.title = title |
| 107 | + |
| 108 | + # NOTE Sections (~ nested dicts) are not implemented, they flatten |
| 109 | + fd: dict[str, FormFieldTextual] = formDict |
| 110 | + widgets: list[Checkbox | Input] = [f.get_widget() for f in flatten(fd)] |
| 111 | + window.widgets = widgets |
| 112 | + |
| 113 | + if not window.run(): |
| 114 | + raise Cancelled |
| 115 | + |
| 116 | + # validate and store the UI value → FormField value → original value |
| 117 | + if not all(field._link.update(field.value) for field in widgets): |
| 118 | + return cls.run_dialog(TextualApp(), formDict, title) |
| 119 | + |
| 120 | + def compose(self) -> ComposeResult: |
| 121 | + if self.title: |
| 122 | + yield Header() |
| 123 | + yield Footer() |
| 124 | + with VerticalScroll(): |
| 125 | + for fieldt in self.widgets: |
| 126 | + fieldt: FormFieldTextual |
| 127 | + if isinstance(fieldt, Input): |
| 128 | + yield Label(fieldt.placeholder) |
| 129 | + yield fieldt |
| 130 | + yield Label(fieldt._link.description) |
| 131 | + yield Label("") |
| 132 | + |
| 133 | + def on_mount(self): |
| 134 | + self.widgets[self.focused_i].focus() |
| 135 | + |
| 136 | + def action_confirm(self): |
| 137 | + # next time, start on the same widget |
| 138 | + # NOTE the functionality is probably not used |
| 139 | + self.focused_i = next((i for i, inp in enumerate(self.widgets) if inp == self.focused), None) |
| 140 | + self.exit(True) |
| 141 | + |
| 142 | + def action_exit(self): |
| 143 | + self.exit() |
| 144 | + |
| 145 | + def on_key(self, event: events.Key) -> None: |
| 146 | + try: |
| 147 | + index = self.widgets.index(self.focused) |
| 148 | + except ValueError: # probably some other element were focused |
| 149 | + return |
| 150 | + match event.key: |
| 151 | + case "down": |
| 152 | + self.widgets[(index + 1) % len(self.widgets)].focus() |
| 153 | + case "up": |
| 154 | + self.widgets[(index - 1) % len(self.widgets)].focus() |
| 155 | + case letter if len(letter) == 1: # navigate by letters |
| 156 | + for inp_ in self.widgets[index+1:] + self.widgets[:index]: |
| 157 | + label = inp_.label if isinstance(inp_, Checkbox) else inp_.placeholder |
| 158 | + if str(label).casefold().startswith(letter): |
| 159 | + inp_.focus() |
| 160 | + break |
| 161 | + |
| 162 | + |
| 163 | +class TextualButtonApp(App): |
| 164 | + CSS = """ |
| 165 | + Screen { |
| 166 | + layout: grid; |
| 167 | + grid-size: 2; |
| 168 | + grid-gutter: 2; |
| 169 | + padding: 2; |
| 170 | + } |
| 171 | + #question { |
| 172 | + width: 100%; |
| 173 | + height: 100%; |
| 174 | + column-span: 2; |
| 175 | + content-align: center bottom; |
| 176 | + text-style: bold; |
| 177 | + } |
| 178 | +
|
| 179 | + Button { |
| 180 | + width: 100%; |
| 181 | + } |
| 182 | + """ |
| 183 | + |
| 184 | + BINDINGS = [ |
| 185 | + ("escape", "exit", "Cancel"), |
| 186 | + ] |
| 187 | + |
| 188 | + def __init__(self): |
| 189 | + super().__init__() |
| 190 | + self.title = "" |
| 191 | + self.text: str = "" |
| 192 | + self._buttons = None |
| 193 | + self.focused_i: int = 0 |
| 194 | + self.values = {} |
| 195 | + |
| 196 | + def yes_no(self, text: str, focus_no=True) -> DummyWrapper: |
| 197 | + return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)) |
| 198 | + |
| 199 | + def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 0): |
| 200 | + self.text = text |
| 201 | + self._buttons = buttons |
| 202 | + self.focused_i = focused |
| 203 | + |
| 204 | + ret = self.run() |
| 205 | + if not ret: |
| 206 | + raise Cancelled |
| 207 | + return ret |
| 208 | + |
| 209 | + def compose(self) -> ComposeResult: |
| 210 | + yield Footer() |
| 211 | + yield Label(self.text, id="question") |
| 212 | + |
| 213 | + self.values.clear() |
| 214 | + for i, (text, value) in enumerate(self._buttons): |
| 215 | + id_ = "button"+str(i) |
| 216 | + self.values[id_] = value |
| 217 | + b = Button(text, id=id_) |
| 218 | + if i == self.focused_i: |
| 219 | + b.focus() |
| 220 | + yield b |
| 221 | + |
| 222 | + def on_button_pressed(self, event: Button.Pressed) -> None: |
| 223 | + self.exit(DummyWrapper(self.values[event.button.id])) |
| 224 | + |
| 225 | + def action_exit(self): |
| 226 | + self.exit() |
0 commit comments