Skip to content

Commit

Permalink
submit button
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Oct 24, 2024
1 parent 748faf9 commit df6c695
Show file tree
Hide file tree
Showing 10 changed files with 98 additions and 64 deletions.
23 changes: 19 additions & 4 deletions mininterface/facet.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from abc import ABC, abstractmethod
from typing import Callable, Generic, Optional
from typing import TYPE_CHECKING, Callable, Generic, Optional


from .form_dict import EnvClass, TagDict
from .tag import Tag

if TYPE_CHECKING:
from . import Mininterface


class BackendAdaptor(ABC):
facet: "Facet"
Expand All @@ -15,19 +19,30 @@ def widgetize(tag: Tag):
""" Wrap Tag to a UI widget. """
pass

@abstractmethod
def run_dialog(self, form: TagDict, title: str = "") -> TagDict:
def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True) -> TagDict:
""" Let the user edit the dict values.
Setups the facet._fetch_from_adaptor.
"""
pass
self.facet._fetch_from_adaptor(form)

def submit_done(self):
if self.post_submit_action:
self.post_submit_action()


class MinAdaptor(BackendAdaptor):
def __init__(self, interface: "Mininterface"):
super().__init__()
self.facet = Facet(self, interface.env)

def widgetize(tag: Tag):
pass

def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True) -> TagDict:
return form


class Facet(Generic[EnvClass]):
""" A frontend side of the interface. While a dialog is open,
this allows to set frontend properties like the heading.
Expand Down
22 changes: 13 additions & 9 deletions mininterface/mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .cli_parser import run_tyro_parser
from .common import Cancelled
from .facet import Facet
from .facet import BackendAdaptor, Facet, MinAdaptor
from .form_dict import (DataClass, EnvClass, FormDict, dataclass_to_tagdict,
dict_to_tagdict, formdict_resolve)
from .tag import ChoicesType, Tag, TagValue
Expand Down Expand Up @@ -228,10 +228,11 @@ def form(self, form: Type[DataClass], title: str = "") -> DataClass: ...
@overload
def form(self, form: DataClass, title: str = "") -> DataClass: ...

# NOTE: parameter submit_button = str (button text) or False to do not display the button
def form(self,
form: DataClass | Type[DataClass] | FormDict | None = None,
title: str = ""
title: str = "",
*,
submit: str | bool = True
) -> FormDict | DataClass | EnvClass:
""" Prompt the user to fill up an arbitrary form.
Expand Down Expand Up @@ -273,6 +274,7 @@ class Color(Enum):
* If None, the `self.env` is being used as a form, allowing the user to edit whole configuration.
(Previously fetched from CLI and config file.)
title: Optional form title
submit: Set the submit button text (by default 'Ok') or hide it with False.
Returns:
dataclass:
Expand Down Expand Up @@ -319,20 +321,22 @@ class Color(Enum):
```
"""
print(f"Asking the form {title}".strip(), self.env if form is None else form)
return self._form(form, title, lambda tag_dict, title=None: tag_dict)
return self._form(form, title, MinAdaptor(self)) # TODO does the adaptor works here?

def _form(self,
form: DataClass | Type[DataClass] | FormDict | None = None,
title: str = "",
launch_callback=None) -> FormDict | DataClass | EnvClass:
form: DataClass | Type[DataClass] | FormDict | None,
title: str,
adaptor: BackendAdaptor,
submit: str | bool = True
) -> FormDict | DataClass | EnvClass:
_form = self.env if form is None else form
if isinstance(_form, dict):
return formdict_resolve(launch_callback(dict_to_tagdict(_form, self), title=title), extract_main=True)
return formdict_resolve(adaptor.run_dialog(dict_to_tagdict(_form, self), title=title, submit=submit), extract_main=True)
if isinstance(_form, type): # form is a class, not an instance
_form, wf = run_tyro_parser(_form, {}, False, False, args=[]) # TODO what to do with wf
if is_dataclass(_form): # -> dataclass or its instance
# the original dataclass is updated, hence we do not need to catch the output from launch_callback
launch_callback(dataclass_to_tagdict(_form, self), title=title)
adaptor.run_dialog(dataclass_to_tagdict(_form, self), title=title, submit=submit)
return _form
raise ValueError(f"Unknown form input {_form}")

Expand Down
4 changes: 3 additions & 1 deletion mininterface/text_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def ask(self, text: str = None):

def form(self,
form: DataClass | Type[DataClass] | FormDict | None = None,
title: str = ""
title: str = "",
*,
submit: str | bool = True,
) -> FormDict | DataClass | EnvClass:
# NOTE: This is minimal implementation that should rather go the ReplInterface.
# NOTE: Concerning Dataclass form.
Expand Down
11 changes: 4 additions & 7 deletions mininterface/textual_interface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.adaptor = TextualAdaptor(self)

def _get_app(self):
return self.adaptor
# return TextualAdaptor(self)

def alert(self, text: str) -> None:
""" Display the OK dialog with text. """
TextualButtonApp(self).buttons(text, [("Ok", None)]).run()
Expand All @@ -34,10 +30,11 @@ def ask(self, text: str = None):

def form(self,
form: DataClass | Type[DataClass] | FormDict | None = None,
title: str = ""
title: str = "",
*,
submit: str | bool = True,
) -> FormDict | DataClass | EnvClass:
def clb(form, title, c=self): return self.adaptor.run_dialog(form, title)
return self._form(form, title, clb)
return self._form(form, title, self.adaptor, submit=submit)

def ask_number(self, text: str):
return self.form({text: Tag("", "", int, text)})[text]
Expand Down
12 changes: 7 additions & 5 deletions mininterface/textual_interface/textual_adaptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class TextualAdaptor(BackendAdaptor):
def __init__(self, interface: "TextualInterface"):
self.interface = interface
self.facet = interface.facet = TextualFacet(self, interface.env)
self.app = TextualApp(self)

@staticmethod
def widgetize(tag: Tag) -> Widget | Changeable:
Expand Down Expand Up @@ -72,21 +71,24 @@ def header(self, text: str):
else:
return []

def run_dialog(self, form: TagDict, title: str = "") -> TagDict:
self.facet._fetch_from_adaptor(form)
app = self.app
def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True) -> TagDict:
super().run_dialog(form, title, submit)
self.app = app = TextualApp(self, submit)
if title:
app.title = title

widgets: WidgetList = [f for f in flatten(formdict_to_widgetdict(
form, self.widgetize), include_keys=self.header)]
if len(widgets) and isinstance(widgets[0], Rule):
# there are multiple sections in the list, <hr>ed by Rule elements. However, the first takes much space.
widgets.pop(0)
app.widgets = widgets

if not app.run():
raise Cancelled

# validate and store the UI value → Tag value → original value
if not Tag._submit_values((field._link, field.get_ui_value()) for field in widgets if hasattr(field, "_link")):
return self.run_dialog(TextualApp(app.interface), form, title)
return self.run_dialog(form, title, submit)
self.submit_done()
return form
48 changes: 29 additions & 19 deletions mininterface/textual_interface/textual_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,27 @@


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 with priority is not shown in the footer:
# Binding("enter", "confirm", "Ok", show=True, priority=True),
# * enter without priority is consumed by input fields (and recaught by on_key)
Binding("Enter", "confirm", "Ok"),
("escape", "exit", "Cancel"),
]
# BINDINGS = [ These are being ignored in the input fields, hence we use on_key
# ("up", "go_up", "Go up"),
# ("down", "go_up", "Go down"),
# ]

def __init__(self, adaptor: "TextualAdaptor"):
def __init__(self, adaptor: "TextualAdaptor", submit: str | bool = True):
super().__init__()
self.title = adaptor.facet._title
self.widgets: WidgetList = []
self.focusable: WidgetList = []
""" A subset of self.widgets"""
self.focused_i: int = 0
self.adaptor = adaptor
self.output = Static("")
self.submit = submit

# Form confirmation
# enter w/o priority is still consumed by input fields (and recaught by on_key)
if submit:
self.bind("Enter", "confirm", description=submit if isinstance(submit, str) else "Ok")
self.bind("escape", "exit", description="Cancel")

def compose(self) -> ComposeResult:
if self.title:
Expand All @@ -60,36 +62,44 @@ def compose(self) -> ComposeResult:
self.focused_i = i
yield Label(fieldt._link.description)
yield Label("")
self.focusable = [w for w in self.widgets if isinstance(w, (Input, Changeable))]

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.focused_i = next((i for i, inp in enumerate(self.focusable) 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)
index = self.focusable.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()
self.focusable[(index + 1) % len(self.focusable)].focus()
case "up":
self.widgets[(index - 1) % len(self.widgets)].focus()
self.focusable[(index - 1) % len(self.focusable)].focus()
case "enter":
# NOTE a multiline input might be
# isinstance(self.focused,
self.action_confirm()
if self.submit:
self.action_confirm()
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
for inp_ in self.focusable[index+1:] + self.focusable[:index]:
match inp_:
case Checkbox():
label = inp_.label
case Changeable():
label = inp_._link.name
case _:
label = ""
if str(label).casefold().startswith(letter):
inp_.focus()
break
16 changes: 9 additions & 7 deletions mininterface/tk_interface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,32 @@ class TkInterface(Redirectable, Mininterface):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
self.window = TkWindow(self)
self.adaptor = TkWindow(self)
except TclError:
# even when installed the libraries are installed, display might not be available, hence tkinter fails
raise InterfaceNotAvailable
self._redirected = RedirectTextTkinter(self.window.text_widget, self.window)
self._redirected = RedirectTextTkinter(self.adaptor.text_widget, self.adaptor)

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

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

def form(self,
form: DataClass | Type[DataClass] | FormDict | None = None,
title: str = ""
title: str = "",
*,
submit: str | bool = True
) -> FormDict | DataClass | EnvClass:
return self._form(form, title, self.window.run_dialog)
return self._form(form, title, self.adaptor, submit=submit)

def ask_number(self, text: str) -> int:
return self.form({text: 0})[text]

def is_yes(self, text):
return self.window.yes_no(text, False)
return self.adaptor.yes_no(text, False)

def is_no(self, text):
return self.window.yes_no(text, True)
return self.adaptor.yes_no(text, True)
2 changes: 1 addition & 1 deletion mininterface/tk_interface/tk_facet.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ def set_title(self, title: str):

def submit(self, *args, **kwargs):
super().submit(*args, **kwargs)
self.adaptor.form.button.invoke()
self.adaptor._ok()
22 changes: 12 additions & 10 deletions mininterface/tk_interface/tk_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,38 +55,40 @@ def widgetize(tag: Tag) -> Value:
v = str(v)
return Value(v, tag.description)

def run_dialog(self, form: TagDict, title: str = "") -> TagDict:
def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True) -> TagDict:
""" Let the user edit the form_dict values in a GUI window.
On abrupt window close, the program exits.
"""
self.facet._fetch_from_adaptor(form)
super().run_dialog(form, title, submit)
if title:
self.facet.set_title(title)

self.form = Form(self.frame,
name_form="",
form_dict=formdict_to_widgetdict(form, self.widgetize),
name_config="Ok",
name_config=submit if isinstance(submit, str) else "Ok",
button=bool(submit)
)
self.form.pack()

# Add radio etc.
replace_widgets(self, self.form.widgets, form)

# Set the submit and exit options
self.form.button.config(command=self._ok)
tip, keysym = ("Enter", "<Return>")
ToolTip(self.form.button, msg=tip) # NOTE is not destroyed in _clear
self._bind_event(keysym, self._ok)
if self.form.button:
self.form.button.config(command=self._ok)
tip, keysym = ("Enter", "<Return>")
ToolTip(self.form.button, msg=tip) # NOTE is not destroyed in _clear
self._bind_event(keysym, self._ok)
self.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))

# focus the first element and run
recursive_set_focus(self.form)
return self.mainloop(lambda: self.validate(form, title))
return self.mainloop(lambda: self.validate(form, title, submit))

def validate(self, form: TagDict, title: str) -> TagDict:
def validate(self, form: TagDict, title: str, submit) -> TagDict:
if not Tag._submit(form, self.form.get()):
return self.run_dialog(form, title)
return self.run_dialog(form, title, submit)
self.submit_done()
return form

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ autocombobox = "1.4.2"
tyro = "*"
pyyaml = "*"
requests = "*"
textual = "*"
textual = "~0.84"
tkinter-tooltip = "*"
tkinter_form = "0.1.5.2"

Expand Down

0 comments on commit df6c695

Please sign in to comment.