Skip to content

Commit

Permalink
TextualInterface prototype fully working
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Jul 24, 2024
1 parent 689ef15 commit 0ce0f7b
Show file tree
Hide file tree
Showing 10 changed files with 423 additions and 75 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con
Prompt the user to fill up whole form.
* `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
The dict can be nested, it can contain a subgroup.
The default value might be `mininterface.Value` that allows you to add descriptions.
A checkbox example: `{"my label": Value(True, "my description")}`
The default value might be `mininterface.FormField` that allows you to add descriptions.
A checkbox example: `{"my label": FormField(True, "my description")}`
* `title`: Optional form title.
### `ask_number(self, text: str) -> int`
Prompt the user to input a number. Empty input = 0.
Expand Down
32 changes: 18 additions & 14 deletions mininterface/GuiInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@


from .common import InterfaceNotAvailable
from .auxiliary import FormDict, RedirectText, config_to_dict, config_from_dict, recursive_set_focus, normalize_types
from .auxiliary import FormDict, RedirectText, config_to_formdict, config_from_dict, flatten, recursive_set_focus, fix_types
from .Mininterface import Cancelled, ConfigInstance, Mininterface


class GuiInterface(Mininterface):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
super().__init__(*args, **kwargs)
self.window = TkWindow(self)
except TclError:
raise InterfaceNotAvailable
self.window = TkWindow(self)
self._always_shown = False
self._original_stdout = sys.stdout

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

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

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

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

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

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

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

def validate(self, formDict: FormDict, title: str):
if data := normalize_types(formDict, self.form.get()):
return data
return self.run_dialog(formDict, title)
if not all(ff.update(ui_value) for ff, ui_value in zip(flatten(formDict), flatten(self.form.get()))):
return self.run_dialog(formDict, title)

# NOTE remove:
# if data := fix_types(formDict, self.form.get()):
# return data
# return self.run_dialog(formDict, title)

def yes_no(self, text: str, focus_no=True):
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)
Expand Down
10 changes: 5 additions & 5 deletions mininterface/Mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ def ask_args(self) -> ConfigInstance:
print("Asking the args", self.args)
return self.args

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

def ask_number(self, text: str) -> int:
""" Prompt the user to input a number. Empty input = 0. """
Expand Down
226 changes: 226 additions & 0 deletions mininterface/TextualInterface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
from ast import literal_eval
from dataclasses import _MISSING_TYPE, dataclass, field
from types import UnionType
from typing import Any, Self, get_args, override
from dataclasses import fields
from textual import events
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll, Container
from textual.widgets import Checkbox, Header, Footer, Input, Label, Welcome, Button, Static
from textual.binding import Binding

from mininterface import TuiInterface
from .common import InterfaceNotAvailable

from .Mininterface import Cancelled, Mininterface
from .auxiliary import ConfigInstance, FormDict, FormField, config_from_dict, config_to_formdict, dict_to_formdict, flatten

from textual.widgets import Checkbox, Input

# TODO
# 1. TuiInterface -> TextInterface.
# 1. TextualInterface inherits from TextInterface.
# 2. TextualInterface is the default for TuiInterface
# Add to docs

@dataclass
class FormFieldTextual(FormField):
""" Bridge between the values given in CLI, TUI and real needed values (str to int conversion etc). """

def get_widget(self):
if self.annotation is bool or not self.annotation and self.val in [True, False]:
o = Checkbox(self.name, self.val)
else:
o = Input(str(self.val), placeholder=self.name or "")
o._link = self # The Textual widgets need to get back to this value
return o


@dataclass
class DummyWrapper:
""" Value wrapped, since I do not know how to get it from textual app.
False would mean direct exit. """
val: Any


class TextualInterface(TuiInterface):

def alert(self, text: str) -> None:
""" Display the OK dialog with text. """
TextualButtonApp().buttons(text, [("Ok", None)]).run()

def ask(self, text: str = None):
return self.ask_form({text: ""})[text]

def ask_args(self) -> ConfigInstance:
""" Display a window form with all parameters. """
params_ = config_to_formdict(self.args, self.descriptions, factory=FormFieldTextual)

# fetch the dict of dicts values from the form back to the namespace of the dataclasses
TextualApp.run_dialog(TextualApp(), params_)
return self.args

def ask_form(self, form: FormDict, title: str = "") -> dict:
TextualApp.run_dialog(TextualApp(), dict_to_formdict(form, factory=FormFieldTextual), title)
return form

# NOTE we should implement better, now the user does not know it needs an int
# def ask_number(self, text):

def is_yes(self, text):
return TextualButtonApp().yes_no(text, False).val

def is_no(self, text):
return TextualButtonApp().yes_no(text, True).val


class TextualApp(App[bool | None]):

BINDINGS = [
("up", "go_up", "Go up"),
("down", "go_up", "Go down"),
# Form confirmation
# * ctrl/alt+enter does not work
# * enter without priority is consumed by input fields
# * enter with priority is not shown in the footer
Binding("enter", "confirm", "Ok", show=True, priority=True),
Binding("Enter", "confirm", "Ok"),
("escape", "exit", "Cancel"),
]

def __init__(self):
super().__init__()
self.title = ""
self.widgets = None
self.focused_i: int = 0

def setup(self, title, widgets, focused_i):

self.focused_i = focused_i
return self

# Why class method? I do not know how to re-create the dialog if needed.
@classmethod
def run_dialog(cls, window, formDict: FormDict, title: str = "") -> None: # TODO changed from dict, change everywhere
if title:
window.title = title

# NOTE Sections (~ nested dicts) are not implemented, they flatten
fd: dict[str, FormFieldTextual] = formDict
widgets: list[Checkbox | Input] = [f.get_widget() for f in flatten(fd)]
window.widgets = widgets

if not window.run():
raise Cancelled

# validate and store the UI value → FormField value → original value
if not all(field._link.update(field.value) for field in widgets):
return cls.run_dialog(TextualApp(), formDict, title)

def compose(self) -> ComposeResult:
if self.title:
yield Header()
yield Footer()
with VerticalScroll():
for fieldt in self.widgets:
fieldt: FormFieldTextual
if isinstance(fieldt, Input):
yield Label(fieldt.placeholder)
yield fieldt
yield Label(fieldt._link.description)
yield Label("")

def on_mount(self):
self.widgets[self.focused_i].focus()

def action_confirm(self):
# next time, start on the same widget
# NOTE the functionality is probably not used
self.focused_i = next((i for i, inp in enumerate(self.widgets) if inp == self.focused), None)
self.exit(True)

def action_exit(self):
self.exit()

def on_key(self, event: events.Key) -> None:
try:
index = self.widgets.index(self.focused)
except ValueError: # probably some other element were focused
return
match event.key:
case "down":
self.widgets[(index + 1) % len(self.widgets)].focus()
case "up":
self.widgets[(index - 1) % len(self.widgets)].focus()
case letter if len(letter) == 1: # navigate by letters
for inp_ in self.widgets[index+1:] + self.widgets[:index]:
label = inp_.label if isinstance(inp_, Checkbox) else inp_.placeholder
if str(label).casefold().startswith(letter):
inp_.focus()
break


class TextualButtonApp(App):
CSS = """
Screen {
layout: grid;
grid-size: 2;
grid-gutter: 2;
padding: 2;
}
#question {
width: 100%;
height: 100%;
column-span: 2;
content-align: center bottom;
text-style: bold;
}
Button {
width: 100%;
}
"""

BINDINGS = [
("escape", "exit", "Cancel"),
]

def __init__(self):
super().__init__()
self.title = ""
self.text: str = ""
self._buttons = None
self.focused_i: int = 0
self.values = {}

def yes_no(self, text: str, focus_no=True) -> DummyWrapper:
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no))

def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 0):
self.text = text
self._buttons = buttons
self.focused_i = focused

ret = self.run()
if not ret:
raise Cancelled
return ret

def compose(self) -> ComposeResult:
yield Footer()
yield Label(self.text, id="question")

self.values.clear()
for i, (text, value) in enumerate(self._buttons):
id_ = "button"+str(i)
self.values[id_] = value
b = Button(text, id=id_)
if i == self.focused_i:
b.focus()
yield b

def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(DummyWrapper(self.values[event.button.id]))

def action_exit(self):
self.exit()
12 changes: 6 additions & 6 deletions mininterface/TuiInterface.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pprint import pprint
from .auxiliary import ConfigInstance, FormDict, config_to_dict, config_from_dict
from .auxiliary import ConfigInstance, FormDict, config_to_formdict, config_from_dict
from .Mininterface import Cancelled, Mininterface


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

def ask_form(self, args: FormDict) -> dict:
def ask_form(self, form: FormDict) -> dict:
# NOTE: This is minimal implementation that should rather go the ReplInterface.
print("Access `v` (as var) and change values. Then (c)ontinue.")
pprint(args)
v = args
pprint(form)
v = form
try:
import ipdb
ipdb.set_trace()
except ImportError:
import pdb
pdb.set_trace()
print("*Continuing*")
print(args)
return args
print(form)
return form

def ask_number(self, text):
"""
Expand Down
5 changes: 3 additions & 2 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface
from mininterface.TuiInterface import ReplInterface, TuiInterface
from mininterface.auxiliary import Value
from mininterface.TextualInterface import TextualInterface
from mininterface.auxiliary import FormField

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


__all__ = ["run", "Value"]
__all__ = ["run", "FormField"]
Loading

0 comments on commit 0ce0f7b

Please sign in to comment.