From afe239c9a706073382cf41930b6dd7a3ebc46d2f Mon Sep 17 00:00:00 2001 From: Edvard Rejthar Date: Wed, 27 Nov 2024 13:10:29 +0100 Subject: [PATCH] date entry integration --- asset/datetimetag_date_calendar.avif | Bin 0 -> 5406 bytes asset/datetimetag_datetime.avif | Bin 0 -> 3012 bytes docs/Changelog.md | 1 + mininterface/cli_parser.py | 8 +- mininterface/tag_factory.py | 10 +- mininterface/tk_interface/date_entry.py | 132 +++++++++++------------- mininterface/tk_interface/utils.py | 23 ++--- mininterface/types.py | 88 ++++++++++++++-- pyproject.toml | 3 +- tests/configs.py | 8 ++ tests/tests.py | 15 ++- 11 files changed, 184 insertions(+), 104 deletions(-) create mode 100644 asset/datetimetag_date_calendar.avif create mode 100644 asset/datetimetag_datetime.avif diff --git a/asset/datetimetag_date_calendar.avif b/asset/datetimetag_date_calendar.avif new file mode 100644 index 0000000000000000000000000000000000000000..1bf3bed726d160fe3df97d83aa3399fc783f7844 GIT binary patch literal 5406 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 literal 0 HcmV?d00001 diff --git a/asset/datetimetag_datetime.avif b/asset/datetimetag_datetime.avif new file mode 100644 index 0000000000000000000000000000000000000000..65978592942a15cb52cf8c7802a19acda58ffb29 GIT binary patch literal 3012 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 hFw+F@ZT5NurlAT literal 0 HcmV?d00001 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..169c2d2 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]} "\ + f"{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)"):