Skip to content

Commit

Permalink
choice
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Sep 9, 2024
1 parent c097170 commit 5d74aa5
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 25 deletions.
4 changes: 3 additions & 1 deletion mininterface/FormDict.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
FormDict is not a real class, just a normal dict. But we need to put somewhere functions related to it.
"""
import logging
from types import FunctionType, MethodType
from typing import Any, Callable, Optional, TypeVar, Union, get_type_hints

from .FormField import FormField
Expand Down Expand Up @@ -71,7 +72,8 @@ def dataclass_to_formdict(env: EnvClass, descr: dict, _path="") -> FormDict:
val = False
logger.warn(f"Annotation {annotation} of `{param}` not supported by Mininterface."
"None converted to False.")
if hasattr(val, "__dict__"): # nested config hierarchy
if hasattr(val, "__dict__") and not isinstance(val, (FunctionType, MethodType)): # nested config hierarchy
# Why checking the isinstance? See FormField._is_a_callable.
subdict[param] = dataclass_to_formdict(val, descr, _path=f"{_path}{param}.")
else:
params = {"val": val,
Expand Down
36 changes: 19 additions & 17 deletions mininterface/FormField.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from ast import literal_eval
from dataclasses import dataclass, fields
from types import UnionType
from types import FunctionType, MethodType, UnionType
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Self, TypeVar, get_args, get_type_hints

from .auxiliary import flatten
Expand Down Expand Up @@ -41,7 +41,7 @@

@dataclass
class FormField:
""" Enrich a value with a description, validation etc.
""" Wrapper around a value that manages a description, validation etc.
When you provide a value to an interface, you may instead use this object.
Bridge between the input values and a UI widget. The widget is created with the help of this object,
Expand All @@ -67,11 +67,7 @@ class FormField:
If not set, will be determined automatically from the `val` type.
"""
name: str | None = None
""" Name displayed in the UI.
NOTE: Only TextualInterface uses this by now.
GuiInterface reads the name from the dict only.
Thus, it is not easy to change the dict key as the user expects the original one in the dict.
"""
""" Name displayed in the UI. """

validation: Callable[["FormField"], ValidationResult | tuple[ValidationResult,
FieldValue]] | None = None
Expand Down Expand Up @@ -112,6 +108,9 @@ class Env:
I am not sure whether to store the transformed value in the ui_value or fixed_value.
"""

choices: list[str] = None
# TODO

_src_dict: TD | None = None
""" The original dict to be updated when UI ends."""

Expand Down Expand Up @@ -195,16 +194,19 @@ def __repr__(self):

def _fetch_from(self, ff: "Self"):
""" Fetches public attributes from another instance. """
if self.val is None:
self.val = ff.val
if self.description is None:
self.description = ff.description
if self.annotation is None:
self.annotation = ff.annotation
if self.name is None:
self.name = ff.name
if self.validation is None:
self.validation = ff.validation
for attr in ['val', 'description', 'annotation', 'name', 'validation', 'choices']:
if getattr(self, attr) is None:
setattr(self, attr, getattr(ff, attr))

def _is_a_callable(self) -> bool:
""" True, if the value is a callable function.
Why not checking isinstance(self.annotation, Callable)?
Because a str is a Callable too. We disburden the user when instructing them to write
`my_var: Callable = x` instead of `my_var: FunctionType = x`
but then, we need this check.
"""
return isinstance(self.annotation, (FunctionType, MethodType)) \
or isinstance(self.annotation, Callable) and isinstance(self.val, (FunctionType, MethodType))

def set_error_text(self, s):
self._original_desc = o = self.description
Expand Down
23 changes: 19 additions & 4 deletions mininterface/GuiInterface.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import sys
from tkinter import Widget
from types import FunctionType, LambdaType, MethodType
from typing import Any, Callable

try:
Expand All @@ -12,7 +14,7 @@

from .common import InterfaceNotAvailable
from .FormDict import FormDict, FormDictOrEnv, dataclass_to_formdict, dict_to_formdict, formdict_to_widgetdict
from .auxiliary import recursive_set_focus, flatten
from .auxiliary import replace_widget_with, widgets_to_dict, recursive_set_focus, flatten
from .Redirectable import RedirectTextTkinter, Redirectable
from .FormField import FormField
from .Mininterface import BackendAdaptor, Cancelled, EnvClass, Mininterface
Expand Down Expand Up @@ -102,7 +104,7 @@ def widgetize(ff: FormField) -> Value:
v = str(v)
return Value(v, ff.description)

def run_dialog(self, formDict: FormDict, title: str = "") -> FormDict:
def run_dialog(self, form: FormDict, title: str = "") -> FormDict:
""" Let the user edit the form_dict values in a GUI window.
On abrupt window close, the program exits.
"""
Expand All @@ -111,11 +113,24 @@ def run_dialog(self, formDict: FormDict, title: str = "") -> FormDict:
label.pack(pady=10)
self.form = Form(self.frame,
name_form="",
form_dict=formdict_to_widgetdict(formDict, self.widgetize),
form_dict=formdict_to_widgetdict(form, self.widgetize),
name_config="Ok",
)
self.form.pack()

# Add radio
nested_widgets = widgets_to_dict(self.form.widgets)
for ff, (label, widget) in zip(flatten(form), flatten(nested_widgets)):
if ff.choices:
replace_widget_with("radio", widget, label.cget("text"), ff)
if ff._is_a_callable():
replace_widget_with("button", widget, label.cget("text"), ff)

# Change label name as the field name might have changed (ex. highlighted by an asterisk)
# But we cannot change the dict key itself
# as the user expects the consistency – the original one in the dict.
label.config(text=ff.name)

# Set the submit and exit options
self.form.button.config(command=self._ok)
tip, keysym = ("Enter", "<Return>")
Expand All @@ -125,7 +140,7 @@ def run_dialog(self, formDict: FormDict, title: str = "") -> FormDict:

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

def validate(self, formDict: FormDict, title: str) -> FormDict:
if not FormField.submit_values(zip(flatten(formDict), flatten(self.form.get()))):
Expand Down
66 changes: 64 additions & 2 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import os
import re
from argparse import ArgumentParser
from typing import Iterable, TypeVar
from tkinter import Button, StringVar, Variable
from tkinter.ttk import Frame, Radiobutton
from types import SimpleNamespace
from typing import Iterable, Literal, TypeVar
from warnings import warn

from mininterface import FormField

try:
from tkinter import Entry, Widget
Expand All @@ -12,6 +18,7 @@

T = TypeVar("T")


def flatten(d: dict[str, T | dict]) -> Iterable[T]:
""" Recursively traverse whole dict """
for v in d.values():
Expand All @@ -32,15 +39,70 @@ def get_terminal_size():
except (OSError, ValueError):
return 0, 0


def get_descriptions(parser: ArgumentParser) -> dict:
""" Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help or "")
for action in parser._actions}


def recursive_set_focus(widget: Widget):
for child in widget.winfo_children():
if isinstance(child, (Entry, Checkbutton, Combobox)):
if isinstance(child, (Entry, Checkbutton, Combobox, Radiobutton)):
child.focus_set()
return True
if recursive_set_focus(child):
return True


class AnyVariable(Variable):
""" Original Variable is not able to hold lambdas. """

def __init__(self, val):
self.val = val

def set(self, val):
self.val = val

def get(self):
return self.val


def replace_widget_with(target: Literal["button"] | Literal["radio"], widget: Widget, name, value: FormField) -> Widget:
if widget.winfo_manager() == 'grid':
grid_info = widget.grid_info()
widget.grid_forget()

master = widget.master

# TODO tab order broken, injected to another position
match target:
case "radio":
choices = value.choices
master._Form__vars[name] = variable = Variable(value=value.val)
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)
radio.grid(row=i, column=1)
case "button":
master._Form__vars[name] = AnyVariable(value.val)
radio = Button(master, text=name, command=lambda ff=value: ff.val(ff))
radio.grid(row=grid_info['row'], column=grid_info['column'])
else:
warn(f"GuiInterface: Cannot tackle the form, unknown winfo_manager {widget.winfo_manager()}.")


def widgets_to_dict(widgets_dict) -> dict:
""" Convert tkinter_form.widgets to a dict """
result = {}
for key, value in widgets_dict.items():
if isinstance(value, dict):
result[key] = widgets_to_dict(value)
elif hasattr(value, 'widgets'):
# this is another tkinter_form.Form, recursively parse
result[key] = widgets_to_dict(value.widgets)
else: # value is a tuple of (Label, Widget (like Entry))
result[key] = value
return result
9 changes: 9 additions & 0 deletions mininterface/facet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# TODO
class Facet:
""" A frontend side of the interface. While a dialog is open,
this allows to set frontend properties like the heading. """

def set_heading(self, text):
# label = self.form.winfo_children()[1].winfo_children()[1]
# label.config(text=text)
pass
2 changes: 1 addition & 1 deletion tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def test_run_ask_empty(self):
self.assertEqual("", stdout.getvalue().strip())

def test_run_ask_for_missing(self):
form = """Asking the form {'token': FormField(val='', description='', annotation=<class 'str'>, name='token', validation=not_empty)}"""
form = """Asking the form {'token': FormField(val='', description='', annotation=<class 'str'>, name='token', validation=not_empty, choices=None)}"""
# Ask for missing, no interference with ask_on_empty_cli
with patch('sys.stdout', new_callable=StringIO) as stdout:
run(FurtherEnv2, True, interface=Mininterface)
Expand Down

0 comments on commit 5d74aa5

Please sign in to comment.