mininterface 0.7.1__tar.gz → 0.7.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {mininterface-0.7.1 → mininterface-0.7.3}/PKG-INFO +10 -5
  2. {mininterface-0.7.1 → mininterface-0.7.3}/README.md +5 -1
  3. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/cli_parser.py +33 -22
  4. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tag.py +2 -2
  5. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tag_factory.py +5 -1
  6. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/textual_interface/textual_adaptor.py +5 -0
  7. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/textual_interface/textual_app.py +9 -0
  8. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/textual_interface/textual_button_app.py +3 -0
  9. mininterface-0.7.3/mininterface/textual_interface/textual_facet.py +64 -0
  10. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tk_interface/date_entry.py +134 -64
  11. mininterface-0.7.3/mininterface/tk_interface/external_fix.py +74 -0
  12. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tk_interface/tk_facet.py +7 -4
  13. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tk_interface/tk_window.py +3 -4
  14. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tk_interface/utils.py +16 -12
  15. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/types.py +4 -11
  16. {mininterface-0.7.1 → mininterface-0.7.3}/pyproject.toml +7 -6
  17. mininterface-0.7.1/mininterface/textual_interface/textual_facet.py +0 -31
  18. {mininterface-0.7.1 → mininterface-0.7.3}/LICENSE +0 -0
  19. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/ValidationFail.py +0 -0
  20. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/__init__.py +0 -0
  21. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/__main__.py +0 -0
  22. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/auxiliary.py +0 -0
  23. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/exceptions.py +0 -0
  24. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/experimental.py +0 -0
  25. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/facet.py +0 -0
  26. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/form_dict.py +0 -0
  27. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/interfaces.py +0 -0
  28. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/mininterface.py +0 -0
  29. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/redirectable.py +0 -0
  30. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/showcase.py +0 -0
  31. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/start.py +0 -0
  32. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/subcommands.py +0 -0
  33. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/text_interface.py +0 -0
  34. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/textual_interface/__init__.py +0 -0
  35. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/textual_interface/widgets.py +0 -0
  36. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tk_interface/__init__.py +0 -0
  37. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/tk_interface/redirect_text_tkinter.py +0 -0
  38. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/type_stubs.py +0 -0
  39. {mininterface-0.7.1 → mininterface-0.7.3}/mininterface/validators.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: mininterface
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: A minimal access to GUI, TUI, CLI and config
5
5
  Home-page: https://github.com/CZ-NIC/mininterface
6
6
  License: GPL-3.0-or-later
@@ -14,15 +14,16 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Provides-Extra: all
17
+ Provides-Extra: gui
17
18
  Provides-Extra: img
19
+ Provides-Extra: tui
18
20
  Provides-Extra: web
19
21
  Requires-Dist: autocombobox (==1.4.2)
20
22
  Requires-Dist: humanize
21
23
  Requires-Dist: pyyaml
22
24
  Requires-Dist: textual (>=0.84,<0.85)
23
- Requires-Dist: tkcalendar
24
25
  Requires-Dist: tkinter-tooltip
25
- Requires-Dist: tkinter_form (==0.1.5.2)
26
+ Requires-Dist: tkinter_form (==0.2.1)
26
27
  Requires-Dist: tkscrollableframe
27
28
  Requires-Dist: typing_extensions
28
29
  Requires-Dist: tyro (==0.8.14)
@@ -132,7 +133,7 @@ The config variables needed by your program are kept in cozy dataclasses. Write
132
133
  Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
133
134
 
134
135
  ```bash
135
- pip install mininterface
136
+ pip install mininterface[all] # GPLv3 and compatible
136
137
  ```
137
138
 
138
139
  ## Minimal installation
@@ -144,6 +145,10 @@ pip install --no-dependencies mininterface
144
145
  pip install tyro typing_extensions pyyaml
145
146
  ```
146
147
 
148
+ ## MacOS GUI
149
+
150
+ If the GUI does not work on MacOS, you might need to launch: `brew install python-tk`
151
+
147
152
  # Docs
148
153
  See the docs overview at [https://cz-nic.github.io/mininterface/](https://cz-nic.github.io/mininterface/Overview/).
149
154
 
@@ -102,7 +102,7 @@ The config variables needed by your program are kept in cozy dataclasses. Write
102
102
  Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
103
103
 
104
104
  ```bash
105
- pip install mininterface
105
+ pip install mininterface[all] # GPLv3 and compatible
106
106
  ```
107
107
 
108
108
  ## Minimal installation
@@ -114,6 +114,10 @@ pip install --no-dependencies mininterface
114
114
  pip install tyro typing_extensions pyyaml
115
115
  ```
116
116
 
117
+ ## MacOS GUI
118
+
119
+ If the GUI does not work on MacOS, you might need to launch: `brew install python-tk`
120
+
117
121
  # Docs
118
122
  See the docs overview at [https://cz-nic.github.io/mininterface/](https://cz-nic.github.io/mininterface/Overview/).
119
123
 
@@ -150,7 +150,7 @@ def run_tyro_parser(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
150
150
  if ask_for_missing and getattr(e, "code", None) == 2 and eavesdrop:
151
151
  # Some required arguments are missing. Determine which.
152
152
  wf = {}
153
- for arg in eavesdrop.partition(":")[2].strip().split(", "):
153
+ for arg in _fetch_eavesdrop_args():
154
154
  treat_missing(type_form, kwargs, parser, wf, arg)
155
155
 
156
156
  # Second attempt to parse CLI
@@ -195,7 +195,6 @@ def treat_missing(env_class, kwargs: dict, parser: ArgumentParser, wf: dict, arg
195
195
  # However, the UI then is not able to use ex. the number filtering capabilities.
196
196
  # Putting there None is not a good idea as dataclass_to_tagdict fails if None is not allowed by the annotation.
197
197
  tag = wf[field_name] = tag_factory(MissingTagValue(),
198
- # tag = wf[field_name] = tag_factory(MISSING,
199
198
  argument.help.replace("(required)", ""),
200
199
  validation=not_empty,
201
200
  _src_class=env_class,
@@ -205,9 +204,17 @@ def treat_missing(env_class, kwargs: dict, parser: ArgumentParser, wf: dict, arg
205
204
  # A None would be enough because Mininterface will ask for the missing values
206
205
  # promply, however, Pydantic model would fail.
207
206
  # As it serves only for tyro parsing and the field is marked wrong, the made up value is never used or seen.
208
- if "default" not in kwargs:
209
- kwargs["default"] = SimpleNamespace()
210
- setattr(kwargs["default"], field_name, tag._make_default_value())
207
+ set_default(kwargs, field_name, tag._make_default_value())
208
+
209
+
210
+ def _fetch_eavesdrop_args():
211
+ return eavesdrop.partition(":")[2].strip().split(", ")
212
+
213
+
214
+ def set_default(kwargs, field_name, val):
215
+ if "default" not in kwargs:
216
+ kwargs["default"] = SimpleNamespace()
217
+ setattr(kwargs["default"], field_name, val)
211
218
 
212
219
 
213
220
  def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
@@ -228,15 +235,20 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
228
235
  Returns:
229
236
  Configuration namespace.
230
237
  """
238
+ if isinstance(env_or_list, list):
239
+ subcommands, env = env_or_list, None
240
+ else:
241
+ subcommands, env = None, env_or_list
242
+
231
243
  # Load config file
232
- if config_file and isinstance(env_or_list, list):
233
- # NOTE. Reading config files when using subcommands is not implemented.
244
+ if config_file and subcommands:
245
+ # Reading config files when using subcommands is not implemented.
234
246
  static = {}
235
247
  kwargs["default"] = None
236
248
  warnings.warn(f"Config file {config_file} is ignored because subcommands are used."
237
- "It is not easy to set who this should work. "
238
- "Describe the developer your usecase so that they might implement this.")
239
- if "default" not in kwargs and not isinstance(env_or_list, list):
249
+ " It is not easy to set how this should work."
250
+ " Describe the developer your usecase so that they might implement this.")
251
+ if "default" not in kwargs and not subcommands:
240
252
  # Undocumented feature. User put a namespace into kwargs["default"]
241
253
  # that already serves for defaults. We do not fetch defaults yet from a config file.
242
254
  disk = {}
@@ -244,29 +256,28 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
244
256
  disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
245
257
  # Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
246
258
  for key in (key for key, val in disk.items() if isinstance(val, dict)):
247
- disk[key] = env_or_list.__annotations__[key](**disk[key])
259
+ disk[key] = env.__annotations__[key](**disk[key])
248
260
 
249
261
  # Fill default fields
250
- if pydantic and issubclass(env_or_list, BaseModel):
262
+ if pydantic and issubclass(env, BaseModel):
251
263
  # Unfortunately, pydantic needs to fill the default with the actual values,
252
264
  # the default value takes the precedence over the hard coded one, even if missing.
253
- static = {key: env_or_list.model_fields.get(key).default
254
- for ann in yield_annotations(env_or_list) for key in ann if not key.startswith("__") and not key in disk}
255
- # static = {key: env_or_list.model_fields.get(key).default
256
- # for key, _ in iterate_attributes(env_or_list) if not key in disk}
257
- elif attr and attr.has(env_or_list):
265
+ static = {key: env.model_fields.get(key).default
266
+ for ann in yield_annotations(env) for key in ann if not key.startswith("__") and not key in disk}
267
+ # static = {key: env_.model_fields.get(key).default
268
+ # for key, _ in iterate_attributes(env_) if not key in disk}
269
+ elif attr and attr.has(env):
258
270
  # Unfortunately, attrs needs to fill the default with the actual values,
259
271
  # the default value takes the precedence over the hard coded one, even if missing.
260
272
  # NOTE Might not work for inherited models.
261
273
  static = {key: field.default
262
- for key, field in attr.fields_dict(env_or_list).items() if not key.startswith("__") and not key in disk}
274
+ for key, field in attr.fields_dict(env).items() if not key.startswith("__") and not key in disk}
263
275
  else:
264
276
  # To ensure the configuration file does not need to contain all keys, we have to fill in the missing ones.
265
277
  # Otherwise, tyro will spawn warnings about missing fields.
266
278
  static = {key: val
267
- for key, val in yield_defaults(env_or_list) if not key.startswith("__") and not key in disk}
268
- kwargs["default"] = SimpleNamespace(**(disk | static))
279
+ for key, val in yield_defaults(env) if not key.startswith("__") and not key in disk}
280
+ kwargs["default"] = SimpleNamespace(**(static | disk))
269
281
 
270
282
  # Load configuration from CLI
271
- env, wrong_fields = run_tyro_parser(env_or_list, kwargs, add_verbosity, ask_for_missing, args)
272
- return env, wrong_fields
283
+ return run_tyro_parser(subcommands or env, kwargs, add_verbosity, ask_for_missing, args)
@@ -1,6 +1,6 @@
1
1
  from ast import literal_eval
2
2
  from dataclasses import dataclass, fields
3
- from datetime import datetime
3
+ from datetime import date, time
4
4
  from enum import Enum
5
5
  from types import FunctionType, MethodType, NoneType, UnionType
6
6
  from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Type, TypeVar, Union, get_args, get_origin
@@ -683,7 +683,7 @@ class Tag:
683
683
  except (SyntaxError, ValueError):
684
684
  self.set_error_text(f"Not a valid {self._repr_annotation()}")
685
685
  return False
686
- elif self.annotation is datetime:
686
+ elif self._is_subclass((time, date)):
687
687
  try:
688
688
  out_value = self.annotation.fromisoformat(ui_value)
689
689
  except ValueError:
@@ -72,7 +72,11 @@ def tag_factory(val=None, description=None, annotation=None, *args, _src_obj=Non
72
72
  new = copy(metadata)
73
73
  new.val = val if val is not None else new.val
74
74
  new.description = description or new.description
75
- return new._fetch_from(Tag(*args, **kwargs))
75
+ if new.annotation is None:
76
+ # pAnnot: Annotated[date, Tag(name="hello")] = datetime.fromisoformat(...)
77
+ # -> DatetimeTag(date=True)
78
+ new.annotation = annotation
79
+ return tag_assure_type(new._fetch_from(Tag(*args, **kwargs)))
76
80
  # NOTE The mechanism is not perfect. When done, we may test configs.PathTagClass.
77
81
  # * fetch_from will not transfer PathTag.multiple
78
82
  # * copy will not transfer list[Path] from `Annotated[list[Path], Tag(...)]`
@@ -28,6 +28,8 @@ class TextualAdaptor(BackendAdaptor):
28
28
  def __init__(self, interface: "TextualInterface"):
29
29
  self.interface = interface
30
30
  self.facet = interface.facet = TextualFacet(self, interface.env)
31
+ self.app: TextualApp | None = None
32
+ self.layout_elements = []
31
33
 
32
34
  @staticmethod
33
35
  def widgetize(tag: Tag) -> Widget | Changeable:
@@ -73,6 +75,9 @@ class TextualAdaptor(BackendAdaptor):
73
75
 
74
76
  def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True) -> TagDict:
75
77
  super().run_dialog(form, title, submit)
78
+ # Unfortunately, there seems to be no way to reuse the app.
79
+ # Which blocks using multiple form external .form() calls from the web interface.
80
+ # Textual cannot run in a thread, it seems it cannot run in another process, self.suspend() is of no use.
76
81
  self.app = app = TextualApp(self, submit)
77
82
  if title:
78
83
  app.title = title
@@ -26,6 +26,14 @@ class TextualApp(App[bool | None]):
26
26
  # ("down", "go_up", "Go down"),
27
27
  # ]
28
28
 
29
+ DEFAULT_CSS = """
30
+ ImageViewer{
31
+
32
+ height: 20;
33
+ }
34
+ """
35
+ """ Limit layout image size """
36
+
29
37
  def __init__(self, adaptor: "TextualAdaptor", submit: str | bool = True):
30
38
  super().__init__()
31
39
  self.title = adaptor.facet._title
@@ -52,6 +60,7 @@ class TextualApp(App[bool | None]):
52
60
  yield Label(text, id="buffered_text")
53
61
  focus_set = False
54
62
  with VerticalScroll():
63
+ yield from self.adaptor.layout_elements
55
64
  for i, fieldt in enumerate(self.widgets):
56
65
  if isinstance(fieldt, Input):
57
66
  yield Label(fieldt.placeholder)
@@ -2,6 +2,7 @@ from dataclasses import dataclass
2
2
  from typing import TYPE_CHECKING, Any
3
3
 
4
4
  from textual.app import App, ComposeResult
5
+ from textual.containers import VerticalScroll
5
6
  from textual.widgets import Button, Footer, Label
6
7
 
7
8
  from ..exceptions import Cancelled
@@ -60,6 +61,7 @@ class TextualButtonApp(App):
60
61
  self.focused_i: int = 0
61
62
  self.values = {}
62
63
  self.interface = interface
64
+ self.adaptor = self.interface.adaptor
63
65
 
64
66
  def yes_no(self, text: str, focus_no=True) -> DummyWrapper:
65
67
  return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no))
@@ -78,6 +80,7 @@ class TextualButtonApp(App):
78
80
  yield Footer()
79
81
  if text := self.interface._redirected.join():
80
82
  yield Label(text, id="buffered_text")
83
+ yield from self.adaptor.layout_elements
81
84
  yield Label(self.text, id="question")
82
85
 
83
86
  self.values.clear()
@@ -0,0 +1,64 @@
1
+ from datetime import datetime
2
+ from typing import TYPE_CHECKING
3
+ from warnings import warn
4
+ from pathlib import Path
5
+
6
+ from textual.widgets import (Checkbox, Footer, Header, Input, Label,
7
+ RadioButton, Static)
8
+
9
+ from humanize import naturalsize
10
+
11
+ from ..exceptions import DependencyRequired
12
+ from ..facet import Facet, Image, LayoutElement
13
+ if TYPE_CHECKING:
14
+ from .textual_adaptor import TextualAdaptor
15
+
16
+
17
+ class TextualFacet(Facet):
18
+ adaptor: "TextualAdaptor"
19
+
20
+ def __init__(self, *args, **kwargs):
21
+ super().__init__(*args, **kwargs)
22
+ # Since TextualApp turns off, we need to have its values stored somewhere
23
+ self._title = ""
24
+
25
+ # NOTE: multiline title will not show up
26
+ def set_title(self, title: str):
27
+ self._title = title
28
+ try:
29
+ self.adaptor.app.title = title
30
+ except:
31
+ # NOTE: When you receive Facet in Command.init, the app does not exist yet
32
+ warn("Setting textual title not implemented well.")
33
+
34
+ def _layout(self, elements: list[LayoutElement]):
35
+ append = self.adaptor.layout_elements.append
36
+ try:
37
+ from PIL import Image as ImagePIL
38
+ from textual_imageview.viewer import ImageViewer
39
+ PIL = True
40
+ except:
41
+ PIL = False
42
+
43
+ for el in elements:
44
+ match el:
45
+ case Image():
46
+ if not PIL:
47
+ raise DependencyRequired("img")
48
+ append(ImageViewer(ImagePIL.open(el.src)))
49
+ case Path():
50
+ size = naturalsize(el.stat().st_size)
51
+ mtime = datetime.fromtimestamp(el.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
52
+ append(Label(f"{el} / {size} / {mtime}"))
53
+ case str():
54
+ append(Label(el))
55
+ case _:
56
+ append(Label("Error in the layout: Unknown {el}"))
57
+
58
+ def submit(self, *args, **kwargs):
59
+ super().submit(*args, **kwargs)
60
+ try:
61
+ self.adaptor.app.action_confirm()
62
+ except:
63
+ # NOTE: When you receive Facet in Command.init, the app does not exist yet
64
+ warn("Setting textual title not implemented well.")
@@ -14,11 +14,24 @@ if TYPE_CHECKING:
14
14
 
15
15
 
16
16
  class DateEntryFrame(tk.Frame):
17
+
17
18
  def __init__(self, master, tk_app: "TkWindow", tag: DatetimeTag, variable: tk.Variable, **kwargs):
18
19
  super().__init__(master, **kwargs)
19
20
 
20
21
  self.tk_app = tk_app
21
22
  self.tag = tag
23
+ if tag.date and tag.time:
24
+ if tag.full_precision:
25
+ self.datetimeformat = '%Y-%m-%d %H:%M:%S'
26
+ else:
27
+ self.datetimeformat = '%Y-%m-%d %H:%M'
28
+ elif tag.time and not tag.date:
29
+ if tag.full_precision:
30
+ self.datetimeformat = '%H:%M:%S'
31
+ else:
32
+ self.datetimeformat = '%H:%M'
33
+ else:
34
+ self.datetimeformat = '%Y-%m-%d'
22
35
 
23
36
  # Date entry
24
37
  self.spinbox = self.create_spinbox(variable)
@@ -27,7 +40,7 @@ class DateEntryFrame(tk.Frame):
27
40
  self.frame = tk.Frame(self)
28
41
 
29
42
  # The calendar widget
30
- if Calendar:
43
+ if Calendar and tag.date:
31
44
  # Toggle calendar button
32
45
  tk.Button(self, text="…", command=self.toggle_calendar).grid(row=0, column=1)
33
46
 
@@ -37,19 +50,20 @@ class DateEntryFrame(tk.Frame):
37
50
  self.calendar.bind("<<CalendarSelected>>", self.on_date_select)
38
51
  self.calendar.grid()
39
52
  # Initialize calendar with the current date
40
- self.update_calendar(self.spinbox.get(), '%Y-%m-%d %H:%M:%S.%f')
53
+ self.update_calendar(self.spinbox.get(), self.datetimeformat)
41
54
  else:
42
55
  self.calendar = None
43
56
 
44
- self.bind_all_events()
45
-
46
57
  def create_spinbox(self, variable: tk.Variable):
47
- spinbox = tk.Spinbox(self, font=("Arial", 16), width=30, wrap=True, textvariable=variable)
58
+ spinbox = tk.Spinbox(self, wrap=True, textvariable=variable)
48
59
  spinbox.grid()
49
60
  if not variable.get():
50
- spinbox.insert(0, datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-4])
61
+ spinbox.insert(0, datetime.now().strftime(self.datetimeformat))
51
62
  spinbox.focus_set()
52
- spinbox.icursor(8)
63
+ if (not self.tag.date and self.tag.time):
64
+ spinbox.icursor(0)
65
+ else:
66
+ spinbox.icursor(8)
53
67
 
54
68
  # Bind up/down arrow keys
55
69
  spinbox.bind("<Up>", self.increment_value)
@@ -60,20 +74,20 @@ class DateEntryFrame(tk.Frame):
60
74
 
61
75
  # Bind key release event to update calendar when user changes the input field
62
76
  spinbox.bind("<KeyRelease>", self.on_spinbox_change)
63
- return spinbox
64
77
 
65
- def bind_all_events(self):
66
- # Copy to clipboard with ctrl+c
67
- self.bind_all("<Control-c>", self.copy_to_clipboard)
78
+ # Toggle calendar widget with ctrl+shift+c
79
+ spinbox.bind("<Control-Shift-C>", self.toggle_calendar)
68
80
 
69
81
  # Select all in the spinbox with ctrl+a
70
- self.bind_all("<Control-a>", lambda event: self.select_all())
82
+ spinbox.bind("<Control-a>", self.select_all)
83
+
84
+ # Copy to clipboard with ctrl+c
85
+ spinbox.bind("<Control-c>", self.copy_to_clipboard)
71
86
 
72
87
  # Paste from clipboard with ctrl+v
73
- self.bind_all("<Control-v>", lambda event: self.paste_from_clipboard())
88
+ spinbox.bind("<Control-v>", self.paste_from_clipboard)
74
89
 
75
- # Toggle calendar widget with ctrl+shift+c
76
- self.bind_all("<Control-Shift-C>", lambda event: self.toggle_calendar())
90
+ return spinbox
77
91
 
78
92
  def toggle_calendar(self, event=None):
79
93
  if not self.calendar:
@@ -102,7 +116,10 @@ class DateEntryFrame(tk.Frame):
102
116
  def find_valid_time(self):
103
117
  input = self.spinbox.get()
104
118
  # use regex to find the time part
105
- time_part = re.search(r'\d{2}:\d{2}:\d{2}', input)
119
+ if self.tag.full_precision:
120
+ time_part = re.search(r'\d{2}:\d{2}:\d{2}', input)
121
+ else:
122
+ time_part = re.search(r'\d{2}:\d{2}', input)
106
123
  if time_part:
107
124
  return time_part.group()
108
125
  return False
@@ -114,51 +131,82 @@ class DateEntryFrame(tk.Frame):
114
131
  date = self.find_valid_date()
115
132
  time = self.find_valid_time()
116
133
 
117
- if date:
118
- split_input = re.split(r'[- :.]', date_str)
119
- part_index = self.get_part_index(caret_pos, len(split_input))
134
+ if date and not time:
135
+ split_input = re.split(r'[-]', date)
136
+ new_value_str = self.increment_part(split_input, caret_pos, delta, '-')
137
+ elif date and time:
138
+ split_input = re.split(r'[- :]', date_str)
139
+ new_value_str = self.increment_part(split_input, caret_pos, delta, ' ')
140
+ elif not date and time:
141
+ split_input = re.split(r'[:]', time)
142
+ new_value_str = self.increment_part(split_input, caret_pos, delta, ':')
143
+ else:
144
+ return
120
145
 
121
- # Increment or decrement the relevant part
122
- number = int(split_input[part_index])
123
- new_number = number + delta
124
- split_input[part_index] = str(new_number).zfill(len(split_input[part_index]))
146
+ # Validate the new date
147
+ try:
148
+ datetime.strptime(new_value_str, self.datetimeformat)
149
+ self.spinbox.delete(0, tk.END)
150
+ self.spinbox.insert(0, new_value_str)
151
+ self.spinbox.icursor(caret_pos)
152
+ if Calendar:
153
+ self.update_calendar(new_value_str, self.datetimeformat)
154
+ except ValueError as e:
155
+ pass
125
156
 
126
- if time:
127
- new_value_str = f"{split_input[0]}-{split_input[1]}-{split_input[2]} "\
128
- f"{split_input[3]}:{split_input[4]}:{split_input[5]}.{split_input[6][:2]}"
129
- string_format = '%Y-%m-%d %H:%M:%S.%f'
157
+ def increment_part(self, split_input, caret_pos, delta, separator):
158
+ part_index = self.get_part_index(caret_pos)
159
+ if part_index > len(split_input) - 1:
160
+ return separator.join(split_input)
161
+
162
+ # Increment or decrement the relevant part
163
+ number = int(split_input[part_index])
164
+ new_number = number + delta
165
+ split_input[part_index] = str(new_number).zfill(len(split_input[part_index]))
166
+
167
+ if self.tag.full_precision and separator == ' ':
168
+ return f"{split_input[0]}-{split_input[1]}-{split_input[2]} "\
169
+ f"{split_input[3]}:{split_input[4]}:{split_input[5]}"
170
+ elif separator == ' ':
171
+ return f"{split_input[0]}-{split_input[1]}-{split_input[2]} "\
172
+ f"{split_input[3]}:{split_input[4]}"
173
+ elif separator == ':':
174
+ if self.tag.full_precision:
175
+ return f"{split_input[0]}:{split_input[1]}:{split_input[2]}"
130
176
  else:
131
- new_value_str = f"{split_input[0]}-{split_input[1]}-{split_input[2]}"
132
- string_format = '%Y-%m-%d'
133
-
134
- # Validate the new date
135
- try:
136
- datetime.strptime(new_value_str, string_format)
137
- self.spinbox.delete(0, tk.END)
138
- self.spinbox.insert(0, new_value_str)
139
- self.spinbox.icursor(caret_pos)
140
- if Calendar:
141
- self.update_calendar(new_value_str, string_format)
142
- except ValueError:
143
- pass
144
-
145
- def get_part_index(self, caret_pos, split_length):
146
- if caret_pos < 5: # year
147
- return 0
148
- elif caret_pos < 8: # month
149
- return 1
150
- elif caret_pos < 11: # day
151
- return 2
152
- elif split_length > 3:
153
- if caret_pos < 14: # hour
177
+ return f"{split_input[0]}:{split_input[1]}"
178
+ else:
179
+ return separator.join(split_input)
180
+
181
+ def get_part_index(self, caret_pos):
182
+ if self.tag.date and self.tag.time:
183
+ if caret_pos < 5: # year
184
+ return 0
185
+ elif caret_pos < 8: # month
186
+ return 1
187
+ elif caret_pos < 11: # day
188
+ return 2
189
+ elif caret_pos < 14: # hour
154
190
  return 3
155
191
  elif caret_pos < 17: # minute
156
192
  return 4
157
- elif caret_pos < 20: # second
193
+ else: # second
158
194
  return 5
159
- else: # millisecond
160
- return 6
161
- return 2
195
+ elif self.tag.date:
196
+ if caret_pos < 5: # year
197
+ return 0
198
+ elif caret_pos < 8: # month
199
+ return 1
200
+ elif caret_pos < 11: # day
201
+ return 2
202
+ elif self.tag.time:
203
+ if caret_pos < 3: # hour
204
+ return 0
205
+ elif caret_pos < 6: # minute
206
+ return 1
207
+ else: # second
208
+ return 2
209
+ return 0
162
210
 
163
211
  def on_spinbox_click(self, event):
164
212
  # Check if the click was on the spinbox arrows
@@ -168,21 +216,39 @@ class DateEntryFrame(tk.Frame):
168
216
  self.decrement_value()
169
217
 
170
218
  def on_date_select(self, event):
171
- selected_date = self.calendar.selection_get()
219
+
220
+ # find caret position to keep it in the same place
221
+ caret_pos = self.spinbox.index(tk.INSERT)
222
+
223
+ selected_date = self.calendar.selection_get().strftime('%Y-%m-%d')
224
+ if self.tag.time:
225
+ time = self.find_valid_time()
226
+ if time:
227
+ selected_date += f" {time}"
228
+ else:
229
+ if self.tag.full_precision:
230
+ selected_date += " 00:00:00"
231
+ else:
232
+ selected_date += " 00:00"
233
+
234
+
172
235
  self.spinbox.delete(0, tk.END)
173
- self.spinbox.insert(0, selected_date.strftime('%Y-%m-%d'))
174
- self.spinbox.icursor(len(self.spinbox.get()))
236
+ self.spinbox.insert(0, selected_date)
237
+
238
+ # Keep the caret position
239
+ self.spinbox.icursor(caret_pos)
175
240
 
176
241
  def on_spinbox_change(self, event):
177
242
  if Calendar:
178
243
  self.update_calendar(self.spinbox.get())
179
244
 
180
245
  def update_calendar(self, date_str, string_format='%Y-%m-%d'):
181
- try:
182
- date = datetime.strptime(date_str, string_format)
183
- self.calendar.selection_set(date)
184
- except ValueError:
185
- pass
246
+ if self.tag.date:
247
+ try:
248
+ date = datetime.strptime(date_str, string_format)
249
+ self.calendar.selection_set(date)
250
+ except ValueError:
251
+ pass
186
252
 
187
253
  def copy_to_clipboard(self, event=None):
188
254
  self.clipboard_clear()
@@ -194,7 +260,7 @@ class DateEntryFrame(tk.Frame):
194
260
  popup = tk.Toplevel(self)
195
261
  popup.wm_title("")
196
262
 
197
- label = tk.Label(popup, text=message, font=("Arial", 12))
263
+ label = tk.Label(popup, text=message)
198
264
  label.pack(side="top", fill="x", pady=10, padx=10)
199
265
 
200
266
  # Position the popup window in the top-left corner of the widget
@@ -218,4 +284,8 @@ class DateEntryFrame(tk.Frame):
218
284
 
219
285
  def paste_from_clipboard(self, event=None):
220
286
  self.spinbox.delete(0, tk.END)
221
- self.spinbox.insert(0, self.clipboard_get())
287
+
288
+ def round_time(self, dt):
289
+ if self.tag.full_precision:
290
+ return dt
291
+ return dt[:-4]
@@ -0,0 +1,74 @@
1
+ # The purpose of the file is to put the descriptions to the bottom of the widgets
2
+ # as it was in the former version of the tkinter_form and to limit their width.
3
+ from tkinter import ttk
4
+
5
+ from tkinter_form import Form, Value, FieldForm
6
+
7
+ orig = Form._Form__create_widgets
8
+
9
+
10
+ def __create_widgets_monkeypatched(
11
+ self, form_dict: dict, name_config: str, button_command: callable
12
+ ) -> None:
13
+ """
14
+ Create form widgets
15
+
16
+ Args:
17
+ form_dict (dict): form dict base
18
+ name_config (str): name_config
19
+ button (bool): button_config
20
+ """
21
+
22
+ index = 0
23
+ for _, (name_key, value) in enumerate(form_dict.items()):
24
+ index += 1
25
+ description = None
26
+ if isinstance(value, Value):
27
+ value, description = value.val, value.description
28
+
29
+ self.rowconfigure(index, weight=1)
30
+
31
+ if isinstance(value, dict):
32
+ widget = Form(self, name_key, value)
33
+ widget.grid(row=index, column=0, columnspan=3, sticky="nesw")
34
+
35
+ self.fields[name_key] = widget
36
+ last_index = index
37
+ continue
38
+
39
+ variable = self._Form__type_vars[type(value)]()
40
+ widget = self._Form__type_widgets[type(value)](self)
41
+
42
+ self.columnconfigure(1, weight=1)
43
+ widget.grid(row=index, column=1, sticky="nesw", padx=2, pady=2)
44
+ label = ttk.Label(self, text=name_key)
45
+ self.columnconfigure(0, weight=1)
46
+ label.grid(row=index, column=0, sticky="nes", padx=2, pady=2)
47
+
48
+ # Add a further description to the row below the widget
49
+ description_label = None
50
+ if not description is None:
51
+ index += 1
52
+ description_label = ttk.Label(self, text=description, wraplength=1000)
53
+ description_label.grid(row=index, column=1, columnspan=2, sticky="nesw", padx=2, pady=2)
54
+
55
+ self.fields[name_key] = FieldForm(
56
+ master=self,
57
+ label=label,
58
+ widget=widget,
59
+ variable=variable,
60
+ value=value,
61
+ description=description_label,
62
+ )
63
+
64
+ last_index = index
65
+
66
+ if button_command:
67
+ self._Form__command = button_command
68
+ self.button = ttk.Button(
69
+ self, text=name_config, command=self._Form__command_button
70
+ )
71
+ self.button.grid(row=last_index + 1, column=0, columnspan=3, sticky="nesw")
72
+
73
+
74
+ Form._Form__create_widgets = __create_widgets_monkeypatched
@@ -36,10 +36,13 @@ class TkFacet(Facet):
36
36
  raise DependencyRequired("img")
37
37
  filename = el.src
38
38
  img = ImagePIL.open(filename)
39
- img = img.resize((250, 250))
40
- img = ImageTk.PhotoImage(img)
41
- panel = Label(self.adaptor.frame, image=img)
42
- panel.image = img
39
+ max_width, max_height = 250, 250
40
+ w_o, h_o = img.size
41
+ scale = min(max_width / w_o, max_height / h_o)
42
+ img = img.resize((int(w_o * scale), int(h_o * scale)), ImagePIL.LANCZOS)
43
+ img_p = ImageTk.PhotoImage(img)
44
+ panel = Label(self.adaptor.frame, image=img_p)
45
+ panel.image = img_p
43
46
  panel.pack()
44
47
  case Path():
45
48
  size = naturalsize(el.stat().st_size)
@@ -72,17 +72,16 @@ class TkWindow(Tk, BackendAdaptor):
72
72
  self.form = Form(self.frame,
73
73
  name_form="",
74
74
  form_dict=formdict_to_widgetdict(form, self.widgetize),
75
- name_config=submit if isinstance(submit, str) else "Ok",
76
- button=bool(submit)
75
+ name_button=submit if isinstance(submit, str) else "Ok",
76
+ button_command=self._ok if submit else None
77
77
  )
78
78
  self.form.pack()
79
79
 
80
80
  # Add radio etc.
81
- replace_widgets(self, self.form.widgets, form)
81
+ replace_widgets(self, self.form.fields, form)
82
82
 
83
83
  # Set the submit and exit options
84
84
  if self.form.button:
85
- self.form.button.config(command=self._ok)
86
85
  tip, keysym = ("Enter", "<Return>")
87
86
  ToolTip(self.form.button, msg=tip) # NOTE is not destroyed in _clear
88
87
  self._bind_event(keysym, self._ok)
@@ -6,12 +6,15 @@ from typing import TYPE_CHECKING
6
6
 
7
7
  from autocombobox import AutoCombobox
8
8
 
9
+ from tkinter_form.tkinter_form import Form, FieldForm
10
+
9
11
  from ..auxiliary import flatten, flatten_keys
10
12
  from ..experimental import MININTERFACE_CONFIG, FacetCallback, SubmitButton
11
13
  from ..form_dict import TagDict
12
14
  from ..tag import Tag
13
15
  from ..types import DatetimeTag, PathTag
14
16
  from .date_entry import DateEntryFrame
17
+ from .external_fix import __create_widgets_monkeypatched
15
18
 
16
19
  if TYPE_CHECKING:
17
20
  from tk_window import TkWindow
@@ -42,13 +45,12 @@ class AnyVariable(Variable):
42
45
 
43
46
 
44
47
  def ready_to_replace(widget: Widget,
45
- name,
46
- tag: "Tag",
47
- variable: Variable) -> tuple[Widget, dict]:
48
+ variable: Variable,
49
+ field_form: FieldForm) -> tuple[Widget, dict]:
48
50
  if widget.winfo_manager() == 'grid':
49
51
  grid_info = widget.grid_info()
50
52
  widget.grid_forget()
51
- widget.master._Form__vars[name] = variable
53
+ field_form.variable = variable
52
54
  return grid_info
53
55
  else:
54
56
  raise ValueError(f"GuiInterface: Cannot tackle the form, unknown winfo_manager {widget.winfo_manager()}.")
@@ -87,17 +89,18 @@ def _set_true(variable: Variable, tag: Tag):
87
89
 
88
90
  def replace_widgets(tk_app: "TkWindow", nested_widgets, form: TagDict):
89
91
  def _fetch(variable):
90
- return ready_to_replace(widget, var_name, tag, variable)
92
+ return ready_to_replace(widget, variable, field_form)
91
93
 
92
94
  # NOTE tab order broken, injected to another position
93
95
  # NOTE should the button receive tag or directly
94
96
  # the whole facet (to change the current form)? Specifiable by experimental.FacetCallback.
95
97
  nested_widgets = widgets_to_dict(nested_widgets)
96
- for (var_name, tag), (label1, widget) in zip(flatten_keys(form), flatten(nested_widgets)):
98
+ for tag, field_form in zip(flatten(form), flatten(nested_widgets)):
97
99
  tag: Tag
98
- label1: Widget
99
- widget: Widget
100
- variable = widget.master._Form__vars[var_name]
100
+ field_form: FieldForm
101
+ label1: Widget = field_form.label
102
+ widget: Widget = field_form.widget
103
+ variable = field_form.variable
101
104
  subwidgets = []
102
105
  master = widget.master
103
106
 
@@ -136,6 +139,7 @@ def replace_widgets(tk_app: "TkWindow", nested_widgets, form: TagDict):
136
139
  # Calendar
137
140
  elif isinstance(tag, DatetimeTag):
138
141
  grid_info = widget.grid_info()
142
+ widget.grid_forget()
139
143
  nested_frame = DateEntryFrame(master, tk_app, tag, variable)
140
144
  nested_frame.grid(row=grid_info['row'], column=grid_info['column'])
141
145
  widget = nested_frame.spinbox
@@ -184,15 +188,15 @@ def create_button(master, _fetch, tag, label1, command=None):
184
188
  return variable, widget2
185
189
 
186
190
 
187
- def widgets_to_dict(widgets_dict) -> dict:
191
+ def widgets_to_dict(widgets_dict) -> dict[str, dict | FieldForm]:
188
192
  """ Convert tkinter_form.widgets to a dict """
189
193
  result = {}
190
194
  for key, value in widgets_dict.items():
191
195
  if isinstance(value, dict):
192
196
  result[key] = widgets_to_dict(value)
193
- elif hasattr(value, 'widgets'):
197
+ elif isinstance(value, Form):
194
198
  # this is another tkinter_form.Form, recursively parse
195
- result[key] = widgets_to_dict(value.widgets)
199
+ result[key] = widgets_to_dict(value.fields)
196
200
  else: # value is a tuple of (Label, Widget (like Entry))
197
201
  result[key] = value
198
202
  return result
@@ -159,10 +159,7 @@ class PathTag(Tag):
159
159
  @dataclass(repr=False)
160
160
  class DatetimeTag(Tag):
161
161
  """
162
- !!! warning
163
- Experimental. Still in development.
164
-
165
- Datetime is supported.
162
+ Datetime, date and time types are supported.
166
163
 
167
164
  ```python3
168
165
  from datetime import datetime
@@ -214,15 +211,14 @@ class DatetimeTag(Tag):
214
211
  # ![Time only](asset/datetime_time.avif)
215
212
 
216
213
  # NOTE: It would be nice we might put any date format to be parsed.
217
- # NOTE: The parameters are still ignored.
218
214
 
219
215
  date: bool = False
220
- """ The date part is active """
216
+ """ The date part is active. True for datetime and date. """
221
217
 
222
218
  time: bool = False
223
- """ The time part is active """
219
+ """ The time part is active. True for datetime and time. """
224
220
 
225
- full_precision: Optional[bool] = None
221
+ full_precision: bool = False
226
222
  """ Include full time precison, seconds, microseconds. """
227
223
 
228
224
  def __post_init__(self):
@@ -230,9 +226,6 @@ class DatetimeTag(Tag):
230
226
  if self.annotation:
231
227
  self.date = issubclass(self.annotation, date)
232
228
  self.time = issubclass(self.annotation, time) or issubclass(self.annotation, datetime)
233
- if not self.date and not self.time:
234
- self.date = self.time = True
235
- # NOTE: self.full_precision ...
236
229
 
237
230
  def _make_default_value(self):
238
231
  return datetime.now()
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "mininterface"
7
- version = "0.7.1"
7
+ version = "0.7.3"
8
8
  description = "A minimal access to GUI, TUI, CLI and config"
9
9
  authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
10
10
  license = "GPL-3.0-or-later"
@@ -19,17 +19,18 @@ typing_extensions = "*"
19
19
  pyyaml = "*"
20
20
  # Standard requirements
21
21
  autocombobox = "1.4.2"
22
- humanize = "*" # used only in the TkInterface, hence it is not a minimal requirement
22
+ humanize = "*" # used only in the TkInterface, hence it is not a minimal requirement
23
23
  textual = "~0.84"
24
24
  tkinter-tooltip = "*"
25
- tkinter_form = "0.1.5.2"
25
+ tkinter_form = "0.2.1"
26
26
  tkscrollableframe = "*"
27
- tkcalendar = "*" # TODO put into extras?
28
27
 
29
28
  [tool.poetry.extras]
30
29
  web = ["textual-serve"]
31
- img = ["pillow"]
32
- all = ["textual-serve", "pillow"]
30
+ img = ["pillow", "textual_imageview"]
31
+ tui = ["textual_imageview"]
32
+ gui = ["pillow", "tkcalendar"]
33
+ all = ["textual-serve", "pillow", "tkcalendar", "textual_imageview"]
33
34
 
34
35
  [tool.poetry.scripts]
35
36
  mininterface = "mininterface.__main__:main"
@@ -1,31 +0,0 @@
1
- from typing import TYPE_CHECKING
2
- from warnings import warn
3
- from ..facet import Facet
4
- if TYPE_CHECKING:
5
- from .textual_adaptor import TextualAdaptor
6
-
7
-
8
- class TextualFacet(Facet):
9
- adaptor: "TextualAdaptor"
10
-
11
- def __init__(self, *args, **kwargs):
12
- super().__init__(*args, **kwargs)
13
- # Since TextualApp turns off, we need to have its values stored somewhere
14
- self._title = ""
15
-
16
- # NOTE: multiline title will not show up
17
- def set_title(self, title: str):
18
- self._title = title
19
- try:
20
- self.adaptor.app.title = title
21
- except:
22
- # NOTE: When you receive Facet in Command.init, the app does not exist yet
23
- warn("Setting textual title not implemented well.")
24
-
25
- def submit(self, *args, **kwargs):
26
- super().submit(*args, **kwargs)
27
- try:
28
- self.adaptor.app.action_confirm()
29
- except:
30
- # NOTE: When you receive Facet in Command.init, the app does not exist yet
31
- warn("Setting textual title not implemented well.")
File without changes