diff --git a/asset/datetimetag_date_calendar.avif b/asset/datetimetag_date_calendar.avif new file mode 100644 index 0000000..1bf3bed Binary files /dev/null and b/asset/datetimetag_date_calendar.avif differ diff --git a/asset/datetimetag_datetime.avif b/asset/datetimetag_datetime.avif new file mode 100644 index 0000000..6597859 Binary files /dev/null and b/asset/datetimetag_datetime.avif differ diff --git a/docs/Changelog.md b/docs/Changelog.md index 33be22b..a1662fa 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -3,6 +3,7 @@ ## 0.7.1 * GUI scrollbars if window is bigger than the screen * [non-interactive][mininterface.Mininterface.__enter__] session support +* [datetime](Types/#mininterface.types.DatetimeTag) support ## 0.7.0 (2024-11-08) * hidden [`--integrate-to-system`](Overview.md#bash-completion) argument diff --git a/mininterface/cli_parser.py b/mininterface/cli_parser.py index efd910c..5814d0c 100644 --- a/mininterface/cli_parser.py +++ b/mininterface/cli_parser.py @@ -15,13 +15,13 @@ import yaml from tyro import cli from tyro._argparse_formatter import TyroArgumentParser -from tyro._singleton import NonpropagatingMissingType +from tyro._fields import NonpropagatingMissingType +# NOTE in the future versions of tyro, include that way: +# from tyro._singleton import NonpropagatingMissingType from tyro.extras import get_parser -from .form_dict import MissingTagValue - from .auxiliary import yield_annotations, yield_defaults -from .form_dict import EnvClass +from .form_dict import EnvClass, MissingTagValue from .tag import Tag from .tag_factory import tag_factory from .validators import not_empty diff --git a/mininterface/tag_factory.py b/mininterface/tag_factory.py index 2343dd6..1f2932d 100644 --- a/mininterface/tag_factory.py +++ b/mininterface/tag_factory.py @@ -1,5 +1,5 @@ from copy import copy -from datetime import datetime +from datetime import date, datetime, time from pathlib import Path from typing import Type, get_type_hints @@ -7,7 +7,7 @@ from .tag import Tag from .type_stubs import TagCallback -from .types import CallbackTag, DateTag, PathTag +from .types import CallbackTag, DatetimeTag, PathTag def _get_annotation_from_class_hierarchy(cls, key): @@ -28,8 +28,8 @@ def get_type_hint_from_class_hierarchy(cls, key): def _get_tag_type(tag: Tag) -> Type[Tag]: if tag._is_subclass(Path): return PathTag - if tag._is_subclass(datetime): - return DateTag + if tag._is_subclass(date) or tag._is_subclass(time): + return DatetimeTag return Tag @@ -39,7 +39,7 @@ def tag_fetch(tag: Tag, ref: dict | None): 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: + if (type_ := _get_tag_type(tag)) is not Tag and not isinstance(tag, type_): return type_(annotation=tag.annotation)._fetch_from(tag) return tag diff --git a/mininterface/tk_interface/date_entry.py b/mininterface/tk_interface/date_entry.py index e642d72..0f04fcc 100644 --- a/mininterface/tk_interface/date_entry.py +++ b/mininterface/tk_interface/date_entry.py @@ -1,42 +1,66 @@ import tkinter as tk import re from datetime import datetime +from typing import TYPE_CHECKING try: from tkcalendar import Calendar except ImportError: Calendar = None -class DateEntry(tk.Frame): - def __init__(self, master=None, **kwargs): +from ..types import DatetimeTag +if TYPE_CHECKING: + from tk_window import TkWindow + + +class DateEntryFrame(tk.Frame): + def __init__(self, master, tk_app: "TkWindow", tag: DatetimeTag, variable: tk.Variable, **kwargs): super().__init__(master, **kwargs) - self.create_widgets() - self.pack(expand=True, fill=tk.BOTH) + + self.tk_app = tk_app + self.tag = tag + + # Date entry + self.spinbox = self.create_spinbox(variable) + + # Frame holding the calendar + self.frame = tk.Frame(self) + + # The calendar widget + if Calendar: + # Toggle calendar button + tk.Button(self, text="…", command=self.toggle_calendar).grid(row=0, column=1) + + # Add a calendar widget + self.calendar = Calendar(self.frame, selectmode='day', date_pattern='yyyy-mm-dd') + # Bind date selection event + self.calendar.bind("<>", self.on_date_select) + self.calendar.grid() + # Initialize calendar with the current date + self.update_calendar(self.spinbox.get(), '%Y-%m-%d %H:%M:%S.%f') + else: + self.calendar = None + self.bind_all_events() - def create_widgets(self): - self.spinbox = tk.Spinbox(self, font=("Arial", 16), width=30, wrap=True) - self.spinbox.pack(padx=20, pady=20) - self.spinbox.insert(0, datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-4]) - self.spinbox.focus_set() - self.spinbox.icursor(8) + def create_spinbox(self, variable: tk.Variable): + spinbox = tk.Spinbox(self, font=("Arial", 16), width=30, wrap=True, textvariable=variable) + spinbox.grid() + if not variable.get(): + spinbox.insert(0, datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-4]) + spinbox.focus_set() + spinbox.icursor(8) # Bind up/down arrow keys - self.spinbox.bind("", self.increment_value) - self.spinbox.bind("", self.decrement_value) + spinbox.bind("", self.increment_value) + spinbox.bind("", self.decrement_value) # Bind mouse click on spinbox arrows - self.spinbox.bind("", self.on_spinbox_click) + spinbox.bind("", self.on_spinbox_click) # Bind key release event to update calendar when user changes the input field - self.spinbox.bind("", self.on_spinbox_change) - - # Toggle calendar button - self.toggle_button = tk.Button(self, text="Show/Hide Calendar", command=self.toggle_calendar) - self.toggle_button.pack(pady=10) - - if Calendar: - self.create_calendar() + spinbox.bind("", self.on_spinbox_change) + return spinbox def bind_all_events(self): # Copy to clipboard with ctrl+c @@ -51,27 +75,15 @@ def bind_all_events(self): # Toggle calendar widget with ctrl+shift+c self.bind_all("", lambda event: self.toggle_calendar()) - def create_calendar(self): - # Create a frame to hold the calendar - self.frame = tk.Frame(self) - self.frame.pack(padx=20, pady=20, expand=True, fill=tk.BOTH) - - # Add a calendar widget - self.calendar = Calendar(self.frame, selectmode='day', date_pattern='yyyy-mm-dd') - self.calendar.place(relwidth=0.7, relheight=0.8, anchor='n', relx=0.5) - - # Bind date selection event - self.calendar.bind("<>", self.on_date_select) - - # Initialize calendar with the current date - self.update_calendar(self.spinbox.get(), '%Y-%m-%d %H:%M:%S.%f') - def toggle_calendar(self, event=None): - if Calendar: - if hasattr(self, 'frame') and self.frame.winfo_ismapped(): - self.frame.pack_forget() - else: - self.frame.pack(padx=20, pady=20, expand=True, fill=tk.BOTH) + if not self.calendar: + return + if self.calendar.winfo_ismapped(): + self.frame.grid_forget() + else: + self.frame.grid(row=1, column=0) + self.tk_app._refresh_size() + return def increment_value(self, event=None): self.change_date(1) @@ -112,7 +124,8 @@ def change_date(self, delta): split_input[part_index] = str(new_number).zfill(len(split_input[part_index])) if time: - new_value_str = f"{split_input[0]}-{split_input[1]}-{split_input[2]} {split_input[3]}:{split_input[4]}:{split_input[5]}.{split_input[6][:2]}" + new_value_str = f"{split_input[0]}-{split_input[1]}-{split_input[2] + } {split_input[3]}:{split_input[4]}:{split_input[5]}.{split_input[6][:2]}" string_format = '%Y-%m-%d %H:%M:%S.%f' else: new_value_str = f"{split_input[0]}-{split_input[1]}-{split_input[2]}" @@ -139,9 +152,9 @@ def get_part_index(self, caret_pos, split_length): elif split_length > 3: if caret_pos < 14: # hour return 3 - elif caret_pos < 17: # minute + elif caret_pos < 17: # minute return 4 - elif caret_pos < 20: # second + elif caret_pos < 20: # second return 5 else: # millisecond return 6 @@ -187,7 +200,7 @@ def show_popup(self, message): # Position the popup window in the top-left corner of the widget x = self.winfo_rootx() y = self.winfo_rooty() - + # Position of the popup window has to be "inside" the main window or it will be focused on popup popup.geometry(f"400x100+{x+200}+{y-150}") @@ -197,7 +210,6 @@ def show_popup(self, message): # Keep focus on the spinbox self.spinbox.focus_force() - def select_all(self, event=None): self.spinbox.selection_range(0, tk.END) self.spinbox.focus_set() @@ -207,31 +219,3 @@ def select_all(self, event=None): def paste_from_clipboard(self, event=None): self.spinbox.delete(0, tk.END) self.spinbox.insert(0, self.clipboard_get()) - -if __name__ == "__main__": - root = tk.Tk() - # Get the screen width and height - # This is calculating the position of the TOTAL dimentions of all screens combined - # How to calculate the position of the window on the current screen? - screen_width = root.winfo_screenwidth() - screen_height = root.winfo_screenheight() - - print(screen_width, screen_height) - - # Calculate the position to center the window - x = (screen_width // 2) - 400 - y = (screen_height // 2) - 600 - - print(x, y) - - # Set the position of the window - root.geometry(f"800x600+{x}+{y}") - # keep the main widget on top all the time - root.wm_attributes("-topmost", False) - root.wm_attributes("-topmost", True) - root.title("Date Editor") - - date_entry = DateEntry(root) - date_entry.pack(expand=True, fill=tk.BOTH) - root.mainloop() - diff --git a/mininterface/tk_interface/utils.py b/mininterface/tk_interface/utils.py index bea9b62..e9db548 100644 --- a/mininterface/tk_interface/utils.py +++ b/mininterface/tk_interface/utils.py @@ -1,16 +1,17 @@ -from typing import TYPE_CHECKING -from autocombobox import AutoCombobox from pathlib import Path, PosixPath -from tkinter import Button, Entry, Label, TclError, Variable, Widget +from tkinter import Button, Entry, Label, TclError, Variable, Widget, Spinbox from tkinter.filedialog import askopenfilename, askopenfilenames from tkinter.ttk import Checkbutton, Combobox, Frame, Radiobutton, Widget +from typing import TYPE_CHECKING +from autocombobox import AutoCombobox -from ..types import DateTag, PathTag from ..auxiliary import flatten, flatten_keys from ..experimental import MININTERFACE_CONFIG, FacetCallback, SubmitButton from ..form_dict import TagDict from ..tag import Tag +from ..types import DatetimeTag, PathTag +from .date_entry import DateEntryFrame if TYPE_CHECKING: from tk_window import TkWindow @@ -132,14 +133,12 @@ def _fetch(variable): widget2 = Button(master, text='…', command=choose_file_handler(variable, tag)) widget2.grid(row=grid_info['row'], column=grid_info['column']+1) - # TODO # Calendar - # elif isinstance(tag, DateTag): - # grid_info = widget.grid_info() - # nested_frame = Frame(master) - # nested_frame.grid(row=grid_info['row'], column=grid_info['column']) - # widget = DateEntry(nested_frame) - # widget.pack() + elif isinstance(tag, DatetimeTag): + grid_info = widget.grid_info() + nested_frame = DateEntryFrame(master, tk_app, tag, variable) + nested_frame.grid(row=grid_info['row'], column=grid_info['column']) + widget = nested_frame.spinbox # Special type: Submit button elif tag.annotation is SubmitButton: # NOTE EXPERIMENTAL @@ -162,7 +161,7 @@ def inner(tag: Tag): h = on_change_handler(variable, tag) if isinstance(w, Combobox): w.bind("<>", h) - elif isinstance(w, Entry): + elif isinstance(w, (Entry, Spinbox)): w.bind("", h) elif isinstance(w, Checkbutton): w.configure(command=h) diff --git a/mininterface/types.py b/mininterface/types.py index c79f02a..9f88da9 100644 --- a/mininterface/types.py +++ b/mininterface/types.py @@ -1,6 +1,7 @@ from dataclasses import dataclass +from datetime import date, datetime, time from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Optional from typing_extensions import Self, override from .auxiliary import common_iterables @@ -155,8 +156,83 @@ def __post_init__(self): break -# TODO -# @dataclass -# class DateTag(Tag): -# """ TODO """ -# pass +@dataclass(repr=False) +class DatetimeTag(Tag): + """ + !!! warning + Experimental. Still in development. + + Datetime is supported. + + ```python3 + from datetime import datetime + from dataclasses import dataclass + from mininterface import run + + @dataclass + class Env: + my_date: datetime + + m = run(Env) + ``` + + The arrows change the day (or the datetime part the keyboard caret is currently editing). + + ![Datetime](asset/datetimetag_datetime.avif) + + In this code, we want only the date part. + + ```python3 + from datetime import date + from dataclasses import dataclass + from mininterface import run + + @dataclass + class Env: + my_date: date + + m = run(Env) + ``` + + After clicking the button (or hitting `Ctrl+Shift+C`), a calendar appear. + + ![Date with calendar](asset/datetimetag_date_calendar.avif) + """ + + # NOTE, document using full_precision. + # You may use the DatetimeTag to specify more options. + + # ```python3 + # from mininterface import run + # from mininterface.types import DatetimeTag + + # run().form({ + # "my_date": DatetimeTag(time=True) + # }) + # ``` + + # ![Time only](asset/datetime_time.avif) + + # NOTE: It would be nice we might put any date format to be parsed. + # NOTE: The parameters are still ignored. + + date: bool = False + """ The date part is active """ + + time: bool = False + """ The time part is active """ + + full_precision: Optional[bool] = None + """ Include full time precison, seconds, microseconds. """ + + def __post_init__(self): + super().__post_init__() + if self.annotation: + self.date = issubclass(self.annotation, date) + self.time = issubclass(self.annotation, time) or issubclass(self.annotation, datetime) + if not self.date and not self.time: + self.date = self.time = True + # NOTE: self.full_precision ... + + def _make_default_value(self): + return datetime.now() diff --git a/pyproject.toml b/pyproject.toml index c3d8123..e8bd331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" [tool.poetry.dependencies] # Minimal requirements python = "^3.10" -tyro = ">0.9.2" +tyro = "0.8.14" # NOTE: 0.9 brings some test breaking changes typing_extensions = "*" pyyaml = "*" # Standard requirements @@ -24,6 +24,7 @@ textual = "~0.84" tkinter-tooltip = "*" tkinter_form = "0.1.5.2" tkscrollableframe = "*" +tkcalendar = "*" # TODO put into extras? [tool.poetry.extras] web = ["textual-serve"] diff --git a/tests/configs.py b/tests/configs.py index b0deb6c..0113cee 100644 --- a/tests/configs.py +++ b/tests/configs.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from datetime import date, datetime, time from enum import Enum from pathlib import Path from typing import Annotated, Callable, Optional @@ -171,6 +172,13 @@ class PathTagClass: # files2: Annotated[list, PathTag(name="Custom name")] = field(default_factory=list) +@dataclass +class DatetimeTagClass: + p1: datetime = datetime.fromisoformat("2024-09-10 17:35:39.922044") + p2: time = time.fromisoformat("17:35:39.922044") + p3: date = date.fromisoformat("2024-09-10") + + @dataclass class MissingPositional: files: Positional[list[Path]] diff --git a/tests/tests.py b/tests/tests.py index 1ad772d..1d47785 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -12,7 +12,7 @@ from attrs_configs import AttrsModel, AttrsNested, AttrsNestedRestraint from configs import (AnnotatedClass, ColorEnum, ColorEnumSingle, - ConflictingEnv, ConstrainedEnv, FurtherEnv2, + ConflictingEnv, ConstrainedEnv, DatetimeTagClass, FurtherEnv2, InheritedAnnotatedClass, MissingPositional, MissingUnderscore, MissingNonscalar, NestedDefaultedEnv, NestedMissingEnv, OptionalFlagEnv, ParametrizedGeneric, PathTagClass, @@ -31,7 +31,7 @@ from mininterface.subcommands import SubcommandPlaceholder from mininterface.tag import Tag from mininterface.text_interface import AssureInteractiveTerminal -from mininterface.types import CallbackTag, PathTag +from mininterface.types import CallbackTag, DatetimeTag, PathTag from mininterface.validators import limit, not_empty SYS_ARGV = None # To be redirected @@ -547,6 +547,17 @@ def test_path_class(self): # self.assertEqual(d["files2"].multiple, True) +class TestTypes(TestAbstract): + def test_datetime_tag(self): + m = runm(DatetimeTagClass) + d = dataclass_to_tagdict(m.env)[""] + for key, expected_date, expected_time in [("p1", True, True), ("p2", False, True), ("p3", True, False)]: + tag = d[key] + self.assertIsInstance(tag, DatetimeTag) + self.assertEqual(expected_date, tag.date) + self.assertEqual(expected_time, tag.time) + + class TestRun(TestAbstract): def test_run_ask_empty(self): with self.assertOutputs("Asking the form SimpleEnv(test=False, important_number=4)"):