Skip to content

Commit

Permalink
facet
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Sep 13, 2024
1 parent 332df77 commit d576c1d
Show file tree
Hide file tree
Showing 16 changed files with 255 additions and 66 deletions.
Binary file added asset/choices_default.avif
Binary file not shown.
Binary file added asset/facet_backend.avif
Binary file not shown.
Binary file added asset/facet_frontend.avif
Binary file not shown.
2 changes: 2 additions & 0 deletions docs/Facet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Facet
::: mininterface.facet.Facet
6 changes: 4 additions & 2 deletions docs/Overview.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
## Basic usage
Use a common [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), a Pydantic [BaseModel](https://brentyi.github.io/tyro/examples/04_additional/08_pydantic/) or an [attrs](https://brentyi.github.io/tyro/examples/04_additional/09_attrs/) model to store the configuration. Wrap it to the [run][mininterface.run] method that returns an interface `m`. Access the configuration via [`m.env`][mininterface.Mininterface.env] or use it to prompt the user [`m.is_yes("Is that alright?")`][mininterface.Mininterface.is_yes].

To do any advanced things, stick the value to a powerful [`Tag`][mininterface.Tag]. For a validation only, use its [`Validation alias`](#validation-alias).
To do any advanced things, stick the value to a powerful [`Tag`][mininterface.Tag]. For a validation only, use its [`Validation alias`](Validation.md/#validation-alias).

At last, use [`Facet`](Facet.md) to tackle the interface from the back-end (`m`) or the front-end (`Tag`) side.


## Supported types
Expand Down Expand Up @@ -109,7 +111,7 @@ further:
## All possible interfaces
Normally, you get an interface through [mininterface.run](#run)
Normally, you get an interface through [mininterface.run][]
but if you do not wish to parse CLI and config file, you can invoke one directly.
Several interfaces exist:
Expand Down
31 changes: 31 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,37 @@ m.form(my_dictionary)





































Expand Down
37 changes: 24 additions & 13 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ class Env:
$ program.py # omitting --required-number
# Dialog for `required_number` appears
```
interface: Which interface to prefer. By default, we use the GUI, the fallback is the TUI. See the full [list](/Mininterface#all-possible-interfaces) of possible interfaces.
interface: Which interface to prefer. By default, we use the GUI, the fallback is the TUI. See the full [list](Overview.md#all-possible-interfaces) of possible interfaces.
Kwargs:
The same as for [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html).
Expand All @@ -112,19 +112,26 @@ class Env:
You cay context manager the function by a `with` statement.
The stdout will be redirected to the interface (ex. a GUI window).
TODO add wrap example
Undocumented experimental: The `env_class` may be a function as well. We invoke its parameters.
However, as Mininterface.env stores the output of the function instead of the Argparse namespace,
methods like `Mininterface.form(None)` (to ask for editing the env values) will work unpredictibly.
Also, the config file seems to be fetched only for positional (missing) parameters,
and ignored for keyword (filled) parameters.
It seems to be this is the tyro's deal and hence it might start working any time.
If not, we might help it this way:
`if isinstance(config, FunctionType): config = lambda: config(**kwargs["default"])`
Undocumented experimental: `default` keyword argument for tyro may serve for default values instead of a config file.
```python
with run(Env) as m:
print(f"Your important number is {m.env.important_number}")
boolean = m.is_yes("Is that alright?")
```
![Small window with the text 'Your important number'](asset/hello-with-statement.webp "With statement to redirect the output")
![The same in terminal'](asset/hello-with-statement-tui.avif "With statement in TUI fallback")
"""
# Undocumented experimental: The `env_class` may be a function as well. We invoke its parameters.
# However, as Mininterface.env stores the output of the function instead of the Argparse namespace,
# methods like `Mininterface.form(None)` (to ask for editing the env values) will work unpredictibly.
# Also, the config file seems to be fetched only for positional (missing) parameters,
# and ignored for keyword (filled) parameters.
# It seems to be this is the tyro's deal and hence it might start working any time.
# If not, we might help it this way:
# `if isinstance(config, FunctionType): config = lambda: config(**kwargs["default"])`
#
# Undocumented experimental: `default` keyword argument for tyro may serve for default values instead of a config file.

# Prepare the config file
if config_file is True and not kwargs.get("default") and env_class:
Expand Down Expand Up @@ -153,6 +160,10 @@ class Env:
if "prog" not in kwargs:
kwargs["prog"] = title
try:
if interface == "tui": # undocumented feature
interface = TuiInterface
elif interface == "gui": # undocumented feature
interface = GuiInterface
interface = interface(title, env, descriptions)
except InterfaceNotAvailable: # Fallback to a different interface
interface = TuiInterface(title, env, descriptions)
Expand Down
19 changes: 11 additions & 8 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import re
from argparse import ArgumentParser
from tkinter import Button, StringVar, Variable
from tkinter import Button, Label, StringVar, Variable
from tkinter.ttk import Frame, Radiobutton
from types import SimpleNamespace
from typing import TYPE_CHECKING, Iterable, Literal, TypeVar
Expand Down Expand Up @@ -69,7 +69,7 @@ def get(self):
return self.val


def replace_widget_with(target: Literal["button"] | Literal["radio"], widget: Widget, name, value: "Tag") -> Widget:
def replace_widget_with(target: Literal["button"] | Literal["radio"], widget: Widget, name, tag: "Tag") -> Widget:
if widget.winfo_manager() == 'grid':
grid_info = widget.grid_info()
widget.grid_forget()
Expand All @@ -79,17 +79,20 @@ def replace_widget_with(target: Literal["button"] | Literal["radio"], widget: Wi
# NOTE tab order broken, injected to another position
match target:
case "radio":
choices = value._get_choices()
master._Form__vars[name] = variable = Variable(value=value.val) # the chosen default
choices = tag._get_choices()
master._Form__vars[name] = variable = Variable(value=tag.val) # the chosen default
nested_frame = Frame(master)
nested_frame.grid(row=grid_info['row'], column=grid_info['column'])

for i, choice in enumerate(choices):
radio = Radiobutton(nested_frame, text=choice, variable=variable, value=choice)

for i, (label, val) in enumerate(choices.items()):
radio = Radiobutton(nested_frame, text=label, variable=variable, value=val)
radio.grid(row=i, column=1)
case "button":
master._Form__vars[name] = AnyVariable(value.val)
radio = Button(master, text=name, command=lambda tag=value: tag.val(tag))
# TODO should the button receive tag or directly the whole facet (to change the current form).
# Implement to textual. Docs.
master._Form__vars[name] = AnyVariable(tag.val)
radio = Button(master, text=name, command=lambda tag=tag: tag.val(tag.facet))
radio.grid(row=grid_info['row'], column=grid_info['column'])
else:
warn(f"GuiInterface: Cannot tackle the form, unknown winfo_manager {widget.winfo_manager()}.")
Expand Down
46 changes: 36 additions & 10 deletions mininterface/facet.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,8 @@
from .tag import Tag


class Facet:
""" A frontend side of the interface. While a dialog is open,
this allows to set frontend properties like the heading. """
# Every UI adopts this object through BackendAdaptor methods.
# TODO

def set_heading(self, text):
pass


class BackendAdaptor(ABC):
facet: "Facet"

@staticmethod
@abstractmethod
Expand All @@ -26,3 +17,38 @@ def widgetize(tag: Tag):
def run_dialog(self, form: TagDict, title: str = "") -> TagDict:
""" Let the user edit the dict values. """
pass


class Facet:
""" A frontend side of the interface. While a dialog is open,
this allows to set frontend properties like the heading.
Read [`Tag.facet`][mininterface.Tag.facet] to see how to access from the front-end side.
Read [`Mininterface.facet`][mininterface.Mininterface.facet] to see how to access from the back-end side.
"""
# Every UI adopts this object through BackendAdaptor methods.

@abstractmethod
def __init__(self, window: BackendAdaptor):
...

@abstractmethod
def set_title(self, text):
""" Set the main heading. """
...

# @abstractmethod
# def submit(self, text):
# """ Submits the whole form """
# (ex for checkbox on_change)
# raise NotImplementedError # NOTE
#
# Access to the fields.


class MinFacet(Facet):
""" A mininterface needs a facet and the base Facet is abstract and cannot be instanciated. """

def __init__(self, window=None):
pass
25 changes: 19 additions & 6 deletions mininterface/form_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
"""
import logging
from types import FunctionType, MethodType
from typing import Any, Callable, Optional, Self, TypeVar, Union, get_type_hints
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, get_type_hints

if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self`
from typing import Self


from .tag import Tag, TagValue

if TYPE_CHECKING:
from .facet import Facet

logger = logging.getLogger(__name__)

EnvClass = TypeVar("EnvClass")
Expand Down Expand Up @@ -72,14 +79,20 @@ def formdict_resolve(d: FormDict, extract_main=False, _root=True) -> dict:
return out


def dict_to_tagdict(data: dict) -> TagDict:
def dict_to_tagdict(data: dict, facet: "Facet" = None) -> TagDict:
fd = {}
for key, val in data.items():
if isinstance(val, dict): # nested config hierarchy
fd[key] = dict_to_tagdict(val)
fd[key] = dict_to_tagdict(val, facet)
else: # scalar value
fd[key] = Tag(val, "", name=key, _src_dict=data, _src_key=key) \
if not isinstance(val, Tag) else val
# TODO implement object fetching to the dataclasses below too
# dataclass_to_tagdict
d = {"facet": facet, "_src_dict":data, "_src_key":key}
if not isinstance(val, Tag):
tag = Tag(val, "", name=key, **d)
else:
tag = Tag(**d)._fetch_from(val)
fd[key] = tag
return fd


Expand Down Expand Up @@ -112,7 +125,7 @@ def dataclass_to_tagdict(env: EnvClass, descr: dict, _path="") -> TagDict:
# Since tkinter_form does not handle None yet, this will display as checkbox.
# Which is not probably wanted.
val = False
logger.warn(f"Annotation {annotation} of `{param}` not supported by Mininterface."
logger.warning(f"Annotation {annotation} of `{param}` not supported by Mininterface."
"None converted to False.")
if hasattr(val, "__dict__") and not isinstance(val, (FunctionType, MethodType)): # nested config hierarchy
# Why checking the isinstance? See Tag._is_a_callable.
Expand Down
35 changes: 28 additions & 7 deletions mininterface/gui_interface.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import sys
from typing import Any, Callable

from .facet import BackendAdaptor
from .facet import BackendAdaptor, Facet

try:
from tkinter import TclError, LEFT, Button, Frame, Label, Text, Tk
from tkinter import TclError, LEFT, Button, Frame, Label, Text, Tk, Widget
from tktooltip import ToolTip
from tkinter_form import Form, Value
except ImportError:
Expand Down Expand Up @@ -54,7 +54,7 @@ def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOr
# NOTE should be integrated here when we integrate dataclass, see FormDictOrEnv
return self._ask_env()
else:
return formdict_resolve(self.window.run_dialog(dict_to_tagdict(form), title=title), extract_main=True)
return formdict_resolve(self.window.run_dialog(dict_to_tagdict(form, self.facet), title=title), extract_main=True)

def ask_number(self, text: str) -> int:
return self.form({text: 0})[text]
Expand All @@ -71,6 +71,7 @@ class TkWindow(Tk, BackendAdaptor):

def __init__(self, interface: GuiInterface):
super().__init__()
self.facet = interface.facet = TkFacet(self)
self.params = None
self._result = None
self._event_bindings = {}
Expand All @@ -81,6 +82,9 @@ def __init__(self, interface: GuiInterface):
self.frame = Frame(self)
""" dialog frame """

self.label = Label(self, text="")
self.label.pack_forget()

self.text_widget = Text(self, wrap='word', height=20, width=80)
self.text_widget.pack_forget()
self.pending_buffer = []
Expand All @@ -105,8 +109,8 @@ def run_dialog(self, form: TagDict, title: str = "") -> TagDict:
On abrupt window close, the program exits.
"""
if title:
label = Label(self.frame, text=title)
label.pack(pady=10)
self.facet.set_title(title)

self.form = Form(self.frame,
name_form="",
form_dict=formdict_to_widgetdict(form, self.widgetize),
Expand All @@ -117,10 +121,14 @@ def run_dialog(self, form: TagDict, title: str = "") -> TagDict:
# Add radio
nested_widgets = widgets_to_dict(self.form.widgets)
for tag, (label, widget) in zip(flatten(form), flatten(nested_widgets)):
tag: Tag
label: Widget
widget: Widget
if tag.choices:
replace_widget_with("radio", widget, label.cget("text"), tag)
replace_widget_with("radio", widget, tag.name or label.cget("text"), tag)
if tag._is_a_callable():
replace_widget_with("button", widget, label.cget("text"), tag)
replace_widget_with("button", widget, tag.name or label.cget("text"), tag)
label.grid_forget()

# Change label name as the field name might have changed (ex. highlighted by an asterisk)
# But we cannot change the dict key itself
Expand Down Expand Up @@ -193,3 +201,16 @@ def _clear_dialog(self):
self.unbind(key)
self._event_bindings.clear()
self._result = None


class TkFacet(Facet):
def __init__(self, window: TkWindow):
self.window = window

def set_title(self, title: str):
if not title:
self.window.label.pack_forget()
else:
self.window.label.config(text=title)
self.window.label.pack(pady=10)
pass
Loading

0 comments on commit d576c1d

Please sign in to comment.