Skip to content

Commit 0ce0f7b

Browse files
committed
TextualInterface prototype fully working
1 parent 689ef15 commit 0ce0f7b

10 files changed

+423
-75
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con
161161
Prompt the user to fill up whole form.
162162
* `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
163163
The dict can be nested, it can contain a subgroup.
164-
The default value might be `mininterface.Value` that allows you to add descriptions.
165-
A checkbox example: `{"my label": Value(True, "my description")}`
164+
The default value might be `mininterface.FormField` that allows you to add descriptions.
165+
A checkbox example: `{"my label": FormField(True, "my description")}`
166166
* `title`: Optional form title.
167167
### `ask_number(self, text: str) -> int`
168168
Prompt the user to input a number. Empty input = 0.

mininterface/GuiInterface.py

+18-14
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@
1111

1212

1313
from .common import InterfaceNotAvailable
14-
from .auxiliary import FormDict, RedirectText, config_to_dict, config_from_dict, recursive_set_focus, normalize_types
14+
from .auxiliary import FormDict, RedirectText, config_to_formdict, config_from_dict, flatten, recursive_set_focus, fix_types
1515
from .Mininterface import Cancelled, ConfigInstance, Mininterface
1616

1717

1818
class GuiInterface(Mininterface):
1919
def __init__(self, *args, **kwargs):
20+
super().__init__(*args, **kwargs)
2021
try:
21-
super().__init__(*args, **kwargs)
22+
self.window = TkWindow(self)
2223
except TclError:
2324
raise InterfaceNotAvailable
24-
self.window = TkWindow(self)
2525
self._always_shown = False
2626
self._original_stdout = sys.stdout
2727

@@ -39,29 +39,29 @@ def __exit__(self, *_):
3939

4040
def alert(self, text: str) -> None:
4141
""" Display the OK dialog with text. """
42-
return self.window.buttons(text, [("Ok", None)])
42+
self.window.buttons(text, [("Ok", None)])
4343

4444
def ask(self, text: str) -> str:
4545
return self.window.run_dialog({text: ""})[text]
4646

4747
def ask_args(self) -> ConfigInstance:
4848
""" Display a window form with all parameters. """
49-
params_ = config_to_dict(self.args, self.descriptions)
49+
params_ = config_to_formdict(self.args, self.descriptions)
5050

5151
# fetch the dict of dicts values from the form back to the namespace of the dataclasses
52-
data = self.window.run_dialog(params_)
53-
config_from_dict(self.args, data)
52+
self.window.run_dialog(params_)
53+
# NOTE remove config_from_dict(self.args, data)
5454
return self.args
5555

56-
def ask_form(self, args: FormDict, title: str = "") -> dict:
56+
def ask_form(self, form: FormDict, title: str = "") -> dict:
5757
""" Prompt the user to fill up whole form.
5858
:param args: Dict of `{labels: default value}`. The form widget infers from the default value type.
5959
The dict can be nested, it can contain a subgroup.
60-
The default value might be `mininterface.Value` that allows you to add descriptions.
61-
A checkbox example: {"my label": Value(True, "my description")}
60+
The default value might be `mininterface.FormField` that allows you to add descriptions.
61+
A checkbox example: {"my label": FormField(True, "my description")}
6262
:param title: Optional form title.
6363
"""
64-
return self.window.run_dialog(args, title=title)
64+
return self.window.run_dialog(form, title=title)
6565

6666
def ask_number(self, text: str) -> int:
6767
return self.window.run_dialog({text: 0})[text]
@@ -121,9 +121,13 @@ def run_dialog(self, formDict: FormDict, title: str = "") -> dict:
121121
return self.mainloop(lambda: self.validate(formDict, title))
122122

123123
def validate(self, formDict: FormDict, title: str):
124-
if data := normalize_types(formDict, self.form.get()):
125-
return data
126-
return self.run_dialog(formDict, title)
124+
if not all(ff.update(ui_value) for ff, ui_value in zip(flatten(formDict), flatten(self.form.get()))):
125+
return self.run_dialog(formDict, title)
126+
127+
# NOTE remove:
128+
# if data := fix_types(formDict, self.form.get()):
129+
# return data
130+
# return self.run_dialog(formDict, title)
127131

128132
def yes_no(self, text: str, focus_no=True):
129133
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)

mininterface/Mininterface.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,15 @@ def ask_args(self) -> ConfigInstance:
5454
print("Asking the args", self.args)
5555
return self.args
5656

57-
def ask_form(self, args: FormDict, title: str = "") -> dict:
57+
def ask_form(self, data: FormDict, title: str = "") -> dict:
5858
""" Prompt the user to fill up whole form.
5959
:param args: Dict of `{labels: default value}`. The form widget infers from the default value type.
6060
The dict can be nested, it can contain a subgroup.
61-
The default value might be `mininterface.Value` that allows you to add descriptions.
62-
A checkbox example: `{"my label": Value(True, "my description")}`
61+
The default value might be `mininterface.FormField` that allows you to add descriptions.
62+
A checkbox example: `{"my label": FormField(True, "my description")}`
6363
"""
64-
print(f"Asking the form {title}", args)
65-
return args # NOTE – this should return dict, not FormDict (get rid of auxiliary.Value values)
64+
print(f"Asking the form {title}", data)
65+
return data # NOTE – this should return dict, not FormDict (get rid of auxiliary.FormField values)
6666

6767
def ask_number(self, text: str) -> int:
6868
""" Prompt the user to input a number. Empty input = 0. """

mininterface/TextualInterface.py

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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()

mininterface/TuiInterface.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pprint import pprint
2-
from .auxiliary import ConfigInstance, FormDict, config_to_dict, config_from_dict
2+
from .auxiliary import ConfigInstance, FormDict, config_to_formdict, config_from_dict
33
from .Mininterface import Cancelled, Mininterface
44

55

@@ -26,20 +26,20 @@ def ask_args(self) -> ConfigInstance:
2626
# dict_to_dataclass(self.args, params_)
2727
return self.ask_form(self.args)
2828

29-
def ask_form(self, args: FormDict) -> dict:
29+
def ask_form(self, form: FormDict) -> dict:
3030
# NOTE: This is minimal implementation that should rather go the ReplInterface.
3131
print("Access `v` (as var) and change values. Then (c)ontinue.")
32-
pprint(args)
33-
v = args
32+
pprint(form)
33+
v = form
3434
try:
3535
import ipdb
3636
ipdb.set_trace()
3737
except ImportError:
3838
import pdb
3939
pdb.set_trace()
4040
print("*Continuing*")
41-
print(args)
42-
return args
41+
print(form)
42+
return form
4343

4444
def ask_number(self, text):
4545
"""

mininterface/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface
1515
from mininterface.TuiInterface import ReplInterface, TuiInterface
16-
from mininterface.auxiliary import Value
16+
from mininterface.TextualInterface import TextualInterface
17+
from mininterface.auxiliary import FormField
1718

1819
# TODO auto-handle verbosity https://brentyi.github.io/tyro/examples/04_additional/12_counters/ ?
1920
# TODO example on missing required options.
@@ -53,4 +54,4 @@ def run(config: ConfigClass | None = None,
5354
return interface
5455

5556

56-
__all__ = ["run", "Value"]
57+
__all__ = ["run", "FormField"]

0 commit comments

Comments
 (0)