Skip to content

Commit

Permalink
Tag morph
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Nov 16, 2024
1 parent dea85be commit 6ded5b6
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 39 deletions.
8 changes: 5 additions & 3 deletions mininterface/form_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from .auxiliary import get_description
from .tag import Tag, TagValue
from .tag_factory import tag_factory
from .tag_factory import tag_assure_type, tag_fetch, tag_factory

if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self`
from typing import Self
Expand Down Expand Up @@ -105,7 +105,8 @@ def dict_to_tagdict(data: dict, mininterface: Optional["Mininterface"] = None) -
if not isinstance(val, Tag):
tag = Tag(val, "", name=key, _src_dict=data, _src_key=key, **d)
else:
tag = val._fetch_from(Tag(**d))
tag = tag_fetch(val, d)
tag = tag_assure_type(tag)
fd[key] = tag
return fd

Expand Down Expand Up @@ -198,6 +199,7 @@ def dataclass_to_tagdict(env: EnvClass | Type[EnvClass], mininterface: Optional[
if not isinstance(val, Tag):
tag = tag_factory(val, _src_key=param, _src_obj=env, **d)
else:
tag = val._fetch_from(Tag(**d))
tag = tag_fetch(val, d)
tag = tag_assure_type(tag)
(subdict if _nested else main)[param] = tag
return subdict
2 changes: 1 addition & 1 deletion mininterface/mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ class Color(Enum):
# The form dict might be a default dict but we want output just the dict (it's shorter).
f = dict(f)
print(f"Asking the form {title}".strip(), f)
return self._form(form, title, MinAdaptor(self))
return self._form(form, title, MinAdaptor(self), submit)

def _form(self,
form: DataClass | Type[DataClass] | FormDict | None,
Expand Down
11 changes: 2 additions & 9 deletions mininterface/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,13 +417,6 @@ def _is_subclass(self, class_type: type | tuple[type]):
return True
return False

def _morph(self, class_type: "Self", morph_if: type | tuple[type]):
""" To be overrided by the subclasses.
The user used a Path within a Tag and that will turn it into a PathTag when the UI needs it.
"""
if self._is_subclass(morph_if): # return a blank PathTag
return class_type(self.val, annotation=self.annotation)

def _get_possible_types(self) -> list[tuple]:
""" Possible types we can cast the value to.
For annotation `list[int] | tuple[str] | str | None`,
Expand All @@ -443,7 +436,7 @@ def _(annot):
elif annot is not None and annot is not NoneType:
# from UnionType, we get a NoneType
return None, annot
return None # to be filtered out
return None # to be filtered out
out = _(self.annotation)
return [x for x in (out if isinstance(out, list) else [out]) if x is not None]

Expand Down Expand Up @@ -498,7 +491,7 @@ def _get_ui_val(self):
"""
for origin, _ in self._get_possible_types():
if origin:
return origin(str(v)for v in self.val)
return origin(str(v) for v in self.val)
if isinstance(self.val, Enum):
return self.val.value
return self.val
Expand Down
40 changes: 32 additions & 8 deletions mininterface/tag_factory.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from copy import copy
from pathlib import Path
from typing import Type, get_type_hints

from .tag import Tag
from .type_stubs import TagCallback
from .types import CallbackTag


from typing import get_type_hints
from .types import CallbackTag, PathTag


def _get_annotation_from_class_hierarchy(cls, key):
Expand All @@ -21,6 +22,23 @@ def get_type_hint_from_class_hierarchy(cls, key):
return None


def _get_tag_type(tag: Tag) -> Type[Tag]:
if tag._is_subclass(Path):
return PathTag
return Tag


def tag_fetch(tag: Tag, ref: dict | None):
return tag._fetch_from(Tag(**ref))


def tag_assure_type(tag: Tag):
# morph to correct class `Tag("", annotation=Path)` -> `PathTag("", annotation=Path)`
if (type_ := _get_tag_type(tag)) is not Tag:
return type_(annotation=tag.annotation)._fetch_from(tag)
return tag


def tag_factory(val=None, description=None, annotation=None, *args, _src_obj=None, _src_key=None, _src_class=None, **kwargs):
if _src_obj and not _src_class:
# NOTE it seems _src_obj is sometimes accepts Type[DataClass], and not a DataClass,
Expand All @@ -45,7 +63,13 @@ def tag_factory(val=None, description=None, annotation=None, *args, _src_obj=Non
if isinstance(metadata, Tag): # NOTE might fetch from a pydantic model too
# The type of the Tag is another Tag
# Ex: `my_field: Validation(...) = 4`
# Why fetching metadata name? The name would be taken from _src_obj.
# But the user defined in metadata is better.
return Tag(val, description, name=metadata.name, *args, **kwargs)._fetch_from(metadata)
return Tag(val, description, annotation, *args, **kwargs)

new = copy(metadata)
new.val = val if val is not None else new.val
new.description = description or new.description
return new._fetch_from(Tag(*args, **kwargs))
# NOTE The mechanism is not perfect. When done, we may test configs.PathTagClass.
# * fetch_from will not transfer PathTag.multiple
# * copy will not transfer list[Path] from `Annotated[list[Path], Tag(...)]`
return type(metadata)(val, description, name=metadata.name, annotation=annotation, *args, **kwargs)._fetch_from(metadata)
return tag_assure_type(Tag(val, description, annotation, *args, **kwargs))
127 changes: 127 additions & 0 deletions mininterface/tk_interface/dategui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import tkinter as tk
from tkcalendar import Calendar
import re
from datetime import datetime

def increment_date(event=None):
change_date(1)

def decrement_date(event=None):
change_date(-1)

def change_date(delta):
date_str = spinbox.get()
caret_pos = spinbox.index(tk.INSERT)

# Split the date string by multiple delimiters
split_input = re.split(r'[- :.]', date_str)

# Determine which part of the date the caret is on
# 0 -> day
# 1 -> month
# 2 -> year
# 3 -> hour
# 4 -> minute
# 5 -> second
# 6 -> microsecond
if caret_pos < 3:
part_index = 0
elif caret_pos < 6:
part_index = 1
elif caret_pos < 11:
part_index = 2
elif caret_pos < 14:
part_index = 3
elif caret_pos < 17:
part_index = 4
elif caret_pos < 20:
part_index = 5
else:
part_index = 6

# Increment or decrement the relevant part
number = int(split_input[part_index])
new_number = number + delta
split_input[part_index] = str(new_number).zfill(len(split_input[part_index]))

# Reconstruct the date string
new_date_str = f"{split_input[0]}-{split_input[1]}-{split_input[2]} {split_input[3]}:{split_input[4]}:{split_input[5]}.{split_input[6][:2]}"

# Validate the new date
try:
datetime.strptime(new_date_str, '%d-%m-%Y %H:%M:%S.%f')
spinbox.delete(0, tk.END)
spinbox.insert(0, new_date_str)
spinbox.icursor(caret_pos)
update_calendar(new_date_str)
except ValueError:
pass

def on_spinbox_click(event):
# Check if the click was on the spinbox arrows
if spinbox.identify(event.x, event.y) == "buttonup":
increment_date()
elif spinbox.identify(event.x, event.y) == "buttondown":
decrement_date()

def on_date_select(event):
selected_date = calendar.selection_get()
current_time = datetime.now().strftime("%H:%M:%S.%f")[:-4]
new_date_str = f"{selected_date.strftime('%d-%m-%Y')} {current_time}"
spinbox.delete(0, tk.END)
spinbox.insert(0, new_date_str)
update_calendar(new_date_str)

def update_calendar(date_str):
try:
date_obj = datetime.strptime(date_str, '%d-%m-%Y %H:%M:%S.%f')
calendar.selection_set(date_obj)
except ValueError:
pass

def on_spinbox_change(event):
update_calendar(spinbox.get())

def copy_to_clipboard():
root.clipboard_clear()
root.clipboard_append(spinbox.get())
root.update() # now it stays on the clipboard after the window is closed

root = tk.Tk()
root.geometry("800x600")
root.title("Date Editor")

spinbox = tk.Spinbox(root, font=("Arial", 16), width=30, wrap=True)
spinbox.pack(padx=20, pady=20)
spinbox.insert(0, datetime.now().strftime("%d-%m-%Y %H:%M:%S.%f")[:-4])

# Bind up/down arrow keys
spinbox.bind("<Up>", increment_date)
spinbox.bind("<Down>", decrement_date)

# Bind mouse click on spinbox arrows
spinbox.bind("<ButtonRelease-1>", on_spinbox_click)

# Bind key release event to update calendar when user changes the input field
spinbox.bind("<KeyRelease>", on_spinbox_change)

# Create a frame to hold the calendar and copy button
frame = tk.Frame(root)
frame.pack(padx=20, pady=20, expand=True, fill=tk.BOTH)

# Add a calendar widget
calendar = Calendar(frame, selectmode='day', date_pattern='dd-mm-yyyy')
calendar.place(relwidth=0.7, relheight=0.8, anchor='n', relx=0.5)

# Bind date selection event
calendar.bind("<<CalendarSelected>>", on_date_select)

# Add a copy-to-clipboard button
copy_button = tk.Button(frame, text="Copy to Clipboard", command=copy_to_clipboard, height=1)
copy_button.place(relwidth=0.2, relheight=0.1, anchor='n', relx=0.5, rely=0.85)

# Initialize calendar with the current date
update_calendar(spinbox.get())

root.mainloop()

5 changes: 2 additions & 3 deletions mininterface/tk_interface/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,10 @@ def _fetch(variable):
variable.set(choice_label)

# File dialog
elif path_tag := tag._morph(PathTag, (PosixPath, Path)):
# TODO this probably happens at ._factoryTime, get rid of _morph. I do not know, touch-timestamp uses nested Tag.
elif isinstance(tag, PathTag):
grid_info = widget.grid_info()

widget2 = Button(master, text='…', command=choose_file_handler(variable, path_tag))
widget2 = Button(master, text='…', command=choose_file_handler(variable, tag))
widget2.grid(row=grid_info['row'], column=grid_info['column']+1)

# Special type: Submit button
Expand Down
9 changes: 2 additions & 7 deletions mininterface/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _run_callable(self):
@dataclass
class PathTag(Tag):
"""
Use this helper object to select files.
Contains a Path or their list. Use this helper object to select files.
In the following example, we see that it is not always needed to use this object.
Expand Down Expand Up @@ -141,7 +141,7 @@ class PathTag(Tag):
# NOTE turn SubmitButton into a Tag too and turn this into a types module.
# NOTE Missing in textual. Might implement file filter and be used for validation. (ex: file_exist, is_dir)
# NOTE Path multiple is not recognized: "File 4": Tag([], annotation=list[Path])
multiple: str = False
multiple: bool = False
""" The user can select multiple files. """

def __post_init__(self):
Expand All @@ -153,8 +153,3 @@ def __post_init__(self):
if origin in common_iterables:
self.multiple = True
break

@override
def _morph(self, class_type: "Self", morph_if: type | tuple[type]):
if class_type == PathTag:
return self
14 changes: 11 additions & 3 deletions tests/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from mininterface import Tag
from mininterface.subcommands import Command
from mininterface.types import CallbackTag, Choices, Validation
from mininterface.types import CallbackTag, Choices, PathTag, Validation
from mininterface.validators import not_empty


Expand Down Expand Up @@ -124,13 +124,21 @@ class ParametrizedGeneric:

@dataclass
class ComplicatedTypes:
# NOTE not used yet
p1: Callable = callback_raw
p2: Annotated[Callable, CallbackTag(description="Foo")] = callback_tag
# Not supported: p3: CallbackTag = callback_tag
# Not supported: p4: CallbackTag = field(default_factory=CallbackTag(callback_tag))
# Not supported: p5: Annotated[Callable, Tag(description="Bar", annotation=CallbackTag)] = callback_tag
# NOTE add PathTag
# NOTE not used yet


@dataclass
class PathTagClass:
files: Positional[list[Path]] = field(default_factory=list)
# NOTE this should become PathTag(multiple=True)
# files2: Annotated[list, Tag(name="Custom name")] = field(default_factory=list)
# NOTE this should become PathTag(multiple=True)
# files2: Annotated[list, PathTag(name="Custom name")] = field(default_factory=list)


@dataclass
Expand Down
Loading

0 comments on commit 6ded5b6

Please sign in to comment.