backlogops-gui 0.4__tar.gz → 0.6__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 (54) hide show
  1. {backlogops_gui-0.4 → backlogops_gui-0.6}/PKG-INFO +8 -11
  2. {backlogops_gui-0.4 → backlogops_gui-0.6}/README_pypi.md +5 -8
  3. {backlogops_gui-0.4 → backlogops_gui-0.6}/backlogops_gui.egg-info/PKG-INFO +8 -11
  4. {backlogops_gui-0.4 → backlogops_gui-0.6}/backlogops_gui.egg-info/SOURCES.txt +5 -0
  5. {backlogops_gui-0.4 → backlogops_gui-0.6}/backlogops_gui.egg-info/requires.txt +2 -1
  6. {backlogops_gui-0.4 → backlogops_gui-0.6}/pyproject.toml +0 -1
  7. {backlogops_gui-0.4 → backlogops_gui-0.6}/setup.py +3 -2
  8. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/application.py +5 -3
  9. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/backlog_dialogs.py +7 -23
  10. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/backlog_io.py +2 -1
  11. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/gui_wizard.py +6 -1
  12. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_dialogs.py +7 -27
  13. backlogops_gui-0.6/src/backlogops_gui/key_list_box.py +45 -0
  14. backlogops_gui-0.6/src/backlogops_gui/wizard_calendar.py +170 -0
  15. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/wizard_form.py +228 -44
  16. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/wizard_path.py +12 -1
  17. backlogops_gui-0.6/src/backlogops_gui/wizard_pick_row.py +154 -0
  18. backlogops_gui-0.6/src/backlogops_gui/wizard_prefill.py +138 -0
  19. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/wizard_table.py +1 -1
  20. backlogops_gui-0.6/src/backlogops_gui/wizard_typed.py +286 -0
  21. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/wizard_window.py +1 -1
  22. {backlogops_gui-0.4 → backlogops_gui-0.6}/LICENSE.txt +0 -0
  23. {backlogops_gui-0.4 → backlogops_gui-0.6}/backlogops_gui.egg-info/dependency_links.txt +0 -0
  24. {backlogops_gui-0.4 → backlogops_gui-0.6}/backlogops_gui.egg-info/entry_points.txt +0 -0
  25. {backlogops_gui-0.4 → backlogops_gui-0.6}/backlogops_gui.egg-info/top_level.txt +0 -0
  26. {backlogops_gui-0.4 → backlogops_gui-0.6}/setup.cfg +0 -0
  27. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/__init__.py +0 -0
  28. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/__main__.py +0 -0
  29. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/_migrate_warn.py +0 -0
  30. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/auto_scroll.py +0 -0
  31. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/backlog_actions.py +0 -0
  32. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/backlog_window.py +0 -0
  33. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/blog_version_reporter.py +0 -0
  34. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/choice_dialogs.py +0 -0
  35. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/close_binding.py +0 -0
  36. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/file_choosers.py +0 -0
  37. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/format_dialogs.py +0 -0
  38. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/gui_style.py +0 -0
  39. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_actions.py +0 -0
  40. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_base.py +0 -0
  41. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_order.py +0 -0
  42. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_rank.py +0 -0
  43. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_read.py +0 -0
  44. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_rename.py +0 -0
  45. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_update.py +0 -0
  46. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/jira_write.py +0 -0
  47. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/log_buffer.py +0 -0
  48. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/modal_dialog.py +0 -0
  49. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/py.typed +0 -0
  50. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/python_version.py +0 -0
  51. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/report_windows.py +0 -0
  52. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/table_view.py +0 -0
  53. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/tcltk_version.py +0 -0
  54. {backlogops_gui-0.4 → backlogops_gui-0.6}/src/backlogops_gui/token_dialog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backlogops-gui
3
- Version: 0.4
3
+ Version: 0.6
4
4
  Summary: Graphical user interface for backlog operations.
5
5
  Author: Tom Björkholm
6
6
  Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
@@ -14,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Operating System :: OS Independent
17
- Classifier: Development Status :: 4 - Beta
18
17
  Classifier: Intended Audience :: End Users/Desktop
19
18
  Classifier: Intended Audience :: Information Technology
20
19
  Classifier: Topic :: Office/Business :: Scheduling
@@ -22,8 +21,9 @@ Classifier: Typing :: Typed
22
21
  Requires-Python: >=3.12
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE.txt
25
- Requires-Dist: backlogops>=0.4
24
+ Requires-Dist: backlogops>=0.6
26
25
  Requires-Dist: argcomplete>=3.7.0
26
+ Requires-Dist: wizard-ui-bridge>=1.1
27
27
  Requires-Dist: versionreporter>=0.4
28
28
  Dynamic: author
29
29
  Dynamic: license-file
@@ -48,15 +48,11 @@ There are 3 related packages for backlog operations:
48
48
 
49
49
  ## Project status
50
50
 
51
- backlogops is in beta. The algorithms intended for its initial scope are in
52
- place, so the main work from here is on the user experience rather than on new
53
- core functionality. The three packages share a version number and are released
54
- together.
51
+ The three packages share a version number and are released together.
55
52
 
56
53
  The library is not meant to ever be "finished": using it for real planning keeps
57
54
  surfacing new ideas. Some of those will be added to backlogops itself, and
58
- others will fit better in separate packages built on top of backlogops. Early
59
- adopters are very welcome, and their feedback shapes what comes next.
55
+ others will fit better in separate packages built on top of backlogops.
60
56
 
61
57
  ## Available functionality
62
58
 
@@ -353,9 +349,10 @@ one with the list of releases. You will want to use the menus.
353
349
 
354
350
  ## Test summary
355
351
 
356
- - Test result: 2638 passed, 7 deselected in 51s
352
+ - Test result: 2450 passed, 9 deselected in 39s
357
353
  - No flake8 warnings.
358
354
  - No mypy errors found.
355
+ - No pylint warnings.
359
356
  - No python layout warnings.
360
- - Built version(s): 0.4
357
+ - Built version(s): 0.6
361
358
  - Build and test using Python 3.14.6
@@ -16,15 +16,11 @@ There are 3 related packages for backlog operations:
16
16
 
17
17
  ## Project status
18
18
 
19
- backlogops is in beta. The algorithms intended for its initial scope are in
20
- place, so the main work from here is on the user experience rather than on new
21
- core functionality. The three packages share a version number and are released
22
- together.
19
+ The three packages share a version number and are released together.
23
20
 
24
21
  The library is not meant to ever be "finished": using it for real planning keeps
25
22
  surfacing new ideas. Some of those will be added to backlogops itself, and
26
- others will fit better in separate packages built on top of backlogops. Early
27
- adopters are very welcome, and their feedback shapes what comes next.
23
+ others will fit better in separate packages built on top of backlogops.
28
24
 
29
25
  ## Available functionality
30
26
 
@@ -321,9 +317,10 @@ one with the list of releases. You will want to use the menus.
321
317
 
322
318
  ## Test summary
323
319
 
324
- - Test result: 2638 passed, 7 deselected in 51s
320
+ - Test result: 2450 passed, 9 deselected in 39s
325
321
  - No flake8 warnings.
326
322
  - No mypy errors found.
323
+ - No pylint warnings.
327
324
  - No python layout warnings.
328
- - Built version(s): 0.4
325
+ - Built version(s): 0.6
329
326
  - Build and test using Python 3.14.6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backlogops-gui
3
- Version: 0.4
3
+ Version: 0.6
4
4
  Summary: Graphical user interface for backlog operations.
5
5
  Author: Tom Björkholm
6
6
  Author-email: Tom Björkholm <klausuler_linnet0q@icloud.com>
@@ -14,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Operating System :: OS Independent
17
- Classifier: Development Status :: 4 - Beta
18
17
  Classifier: Intended Audience :: End Users/Desktop
19
18
  Classifier: Intended Audience :: Information Technology
20
19
  Classifier: Topic :: Office/Business :: Scheduling
@@ -22,8 +21,9 @@ Classifier: Typing :: Typed
22
21
  Requires-Python: >=3.12
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE.txt
25
- Requires-Dist: backlogops>=0.4
24
+ Requires-Dist: backlogops>=0.6
26
25
  Requires-Dist: argcomplete>=3.7.0
26
+ Requires-Dist: wizard-ui-bridge>=1.1
27
27
  Requires-Dist: versionreporter>=0.4
28
28
  Dynamic: author
29
29
  Dynamic: license-file
@@ -48,15 +48,11 @@ There are 3 related packages for backlog operations:
48
48
 
49
49
  ## Project status
50
50
 
51
- backlogops is in beta. The algorithms intended for its initial scope are in
52
- place, so the main work from here is on the user experience rather than on new
53
- core functionality. The three packages share a version number and are released
54
- together.
51
+ The three packages share a version number and are released together.
55
52
 
56
53
  The library is not meant to ever be "finished": using it for real planning keeps
57
54
  surfacing new ideas. Some of those will be added to backlogops itself, and
58
- others will fit better in separate packages built on top of backlogops. Early
59
- adopters are very welcome, and their feedback shapes what comes next.
55
+ others will fit better in separate packages built on top of backlogops.
60
56
 
61
57
  ## Available functionality
62
58
 
@@ -353,9 +349,10 @@ one with the list of releases. You will want to use the menus.
353
349
 
354
350
  ## Test summary
355
351
 
356
- - Test result: 2638 passed, 7 deselected in 51s
352
+ - Test result: 2450 passed, 9 deselected in 39s
357
353
  - No flake8 warnings.
358
354
  - No mypy errors found.
355
+ - No pylint warnings.
359
356
  - No python layout warnings.
360
- - Built version(s): 0.4
357
+ - Built version(s): 0.6
361
358
  - Build and test using Python 3.14.6
@@ -33,6 +33,7 @@ src/backlogops_gui/jira_read.py
33
33
  src/backlogops_gui/jira_rename.py
34
34
  src/backlogops_gui/jira_update.py
35
35
  src/backlogops_gui/jira_write.py
36
+ src/backlogops_gui/key_list_box.py
36
37
  src/backlogops_gui/log_buffer.py
37
38
  src/backlogops_gui/modal_dialog.py
38
39
  src/backlogops_gui/py.typed
@@ -41,7 +42,11 @@ src/backlogops_gui/report_windows.py
41
42
  src/backlogops_gui/table_view.py
42
43
  src/backlogops_gui/tcltk_version.py
43
44
  src/backlogops_gui/token_dialog.py
45
+ src/backlogops_gui/wizard_calendar.py
44
46
  src/backlogops_gui/wizard_form.py
45
47
  src/backlogops_gui/wizard_path.py
48
+ src/backlogops_gui/wizard_pick_row.py
49
+ src/backlogops_gui/wizard_prefill.py
46
50
  src/backlogops_gui/wizard_table.py
51
+ src/backlogops_gui/wizard_typed.py
47
52
  src/backlogops_gui/wizard_window.py
@@ -1,3 +1,4 @@
1
- backlogops>=0.4
1
+ backlogops>=0.6
2
2
  argcomplete>=3.7.0
3
+ wizard-ui-bridge>=1.1
3
4
  versionreporter>=0.4
@@ -21,7 +21,6 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.13",
22
22
  "Programming Language :: Python :: 3.14",
23
23
  "Operating System :: OS Independent",
24
- "Development Status :: 4 - Beta",
25
24
  "Intended Audience :: End Users/Desktop",
26
25
  "Intended Audience :: Information Technology",
27
26
  "Topic :: Office/Business :: Scheduling",
@@ -5,7 +5,7 @@ from setuptools import setup
5
5
 
6
6
  setup(
7
7
  name='backlogops-gui',
8
- version='0.4',
8
+ version='0.6',
9
9
  description='Graphical user interface for backlog operations.',
10
10
  author='Tom Björkholm',
11
11
  author_email='klausuler_linnet0q@icloud.com',
@@ -19,8 +19,9 @@ setup(
19
19
  ]
20
20
  },
21
21
  install_requires=[
22
- 'backlogops >= 0.4',
22
+ 'backlogops >= 0.6',
23
23
  'argcomplete >= 3.7.0',
24
+ 'wizard-ui-bridge >= 1.1',
24
25
  'versionreporter >= 0.4'
25
26
  ]
26
27
  )
@@ -538,8 +538,9 @@ class BacklogApp:
538
538
  return
539
539
  value = options.config_value
540
540
  try:
541
- data = read_backlog(path, value, presets, self.log, self.levels(),
542
- self.status_map())
541
+ data = read_backlog(path, value, presets, self.log,
542
+ levels=self.levels(),
543
+ status_map=self.status_map())
543
544
  except IO_ERRORS as error:
544
545
  self.show_error('Could not read file', str(error))
545
546
  return
@@ -564,7 +565,8 @@ class BacklogApp:
564
565
  """Re-read the file, reporting a failure and applying success."""
565
566
  try:
566
567
  data = read_backlog(path, value, self.in_presets(), self.log,
567
- self.levels(), self.status_map())
568
+ levels=self.levels(),
569
+ status_map=self.status_map())
568
570
  except IO_ERRORS as error:
569
571
  self.show_error('Could not read file', str(error))
570
572
  return
@@ -13,15 +13,15 @@ returns it, or None when the dialog is cancelled.
13
13
  # MIT License
14
14
 
15
15
  import tkinter as tk
16
- from tkinter import filedialog, messagebox, ttk
16
+ from tkinter import messagebox, ttk
17
17
  from dataclasses import dataclass
18
18
  from datetime import date
19
19
  from typing import Optional, TextIO
20
- from backlogops import DependencyMode, DEFAULT_LEVELS, read_key_list
20
+ from backlogops import DependencyMode, DEFAULT_LEVELS
21
21
  from backlogops_gui.gui_style import style_input
22
+ from backlogops_gui.key_list_box import build_key_box, load_keys_into
22
23
  from backlogops_gui.modal_dialog import ModalDialog
23
24
 
24
- KEY_READ_ERRORS = (ValueError, TypeError, KeyError, OSError)
25
25
  DEFAULT_BUFFER_DAYS = 5
26
26
 
27
27
 
@@ -63,29 +63,13 @@ class KeysDialog(ModalDialog):
63
63
 
64
64
  def _build_text(self) -> tk.Text:
65
65
  """Add the entry label, text box and the load-from-file button."""
66
- tk.Label(self._win, text='Enter keys separated by spaces or '
67
- 'newlines:').pack(anchor='w', padx=12, pady=(10, 2))
68
- text = tk.Text(self._win, width=40, height=8)
69
- style_input(text)
70
- text.pack(padx=12, pady=2)
71
- tk.Button(self._win, text='Load from file…',
72
- command=self._load).pack(anchor='w', padx=12, pady=4)
73
- return text
66
+ return build_key_box(self._win,
67
+ 'Enter keys separated by spaces or newlines:',
68
+ self._load)
74
69
 
75
70
  def _load(self) -> None:
76
71
  """Read a key list file into the text box, reporting failures."""
77
- name = filedialog.askopenfilename(parent=self._win,
78
- title='Read key list')
79
- if not name:
80
- return
81
- try:
82
- keys = read_key_list(name, stderr_file=self._sink)
83
- except KEY_READ_ERRORS as error:
84
- messagebox.showerror('Could not read key list', str(error),
85
- parent=self._win)
86
- return
87
- self._text.delete('1.0', 'end')
88
- self._text.insert('end', '\n'.join(keys))
72
+ load_keys_into(self._win, self._text, self._sink)
89
73
 
90
74
  def _confirm(self) -> None:
91
75
  """Split the text on whitespace and close the dialog."""
@@ -27,9 +27,10 @@ def _sink(sink: Optional[TextIO]) -> TextIO:
27
27
  return sink if sink is not None else NoTextIO()
28
28
 
29
29
 
30
+ # pylint: disable-next=too-many-arguments
30
31
  def read_backlog(path: str, value: Optional[str],
31
32
  presets: Optional[dict[str, InputFormatConfig]],
32
- sink: Optional[TextIO] = None,
33
+ sink: Optional[TextIO] = None, *,
33
34
  levels: Optional[Levels] = None,
34
35
  status_map: Optional[dict[str, Status]] = None
35
36
  ) -> BacklogReleases:
@@ -18,10 +18,11 @@ session happens in a single pop-up that does not jump around the display.
18
18
  from typing import Optional, Sequence, TextIO
19
19
  from pathlib import Path
20
20
  import tkinter as tk
21
- from tableio_cfg_json import AnswerFields, AskFields, PartialCheck, \
21
+ from wizard_ui_bridge import AnswerFields, AskField, AskFields, PartialCheck, \
22
22
  PartialFormValidator, PathAskOptions, TableCell, TableColumn, \
23
23
  WizardUiBridge
24
24
  from backlogops import NoTextIO
25
+ from backlogops_gui.wizard_form import handles_field
25
26
  from backlogops_gui.wizard_window import WizardWindow
26
27
 
27
28
 
@@ -112,6 +113,10 @@ class TkWizardBridge(WizardUiBridge):
112
113
  return self._window_obj().ask_form(long_question, ask_fields,
113
114
  re_ask_reason, partial_validator)
114
115
 
116
+ def supports_form_field(self, field: AskField) -> bool:
117
+ """Report that the Tk form shows every current field type."""
118
+ return handles_field(field)
119
+
115
120
  def show(self, message: str) -> None:
116
121
  """Show an informational message to the user."""
117
122
  self._window_obj().show(message)
@@ -18,18 +18,15 @@ masked pass phrase for an encrypted Jira API token.
18
18
  # MIT License
19
19
 
20
20
  import tkinter as tk
21
- from tkinter import filedialog, messagebox, ttk
21
+ from tkinter import messagebox, ttk
22
22
  from dataclasses import dataclass
23
23
  from collections.abc import Mapping
24
24
  from typing import Optional, Sequence, TextIO
25
- from backlogops import (
26
- JiraRankAnchor, OnMissingKey, ReleaseRename, read_key_list)
25
+ from backlogops import JiraRankAnchor, OnMissingKey, ReleaseRename
27
26
  from backlogops_gui.gui_style import style_input
27
+ from backlogops_gui.key_list_box import build_key_box, load_keys_into
28
28
  from backlogops_gui.modal_dialog import ModalDialog
29
29
 
30
- KEY_READ_ERRORS = (ValueError, TypeError, KeyError, OSError)
31
- """Errors caught when loading a key list file into the rank dialog."""
32
-
33
30
  MISSING_MODE_TEXT = {
34
31
  OnMissingKey.RAISE: 'Stop with an error',
35
32
  OnMissingKey.IGNORE: 'Ignore it',
@@ -550,15 +547,9 @@ class JiraRankDialog(ModalDialog):
550
547
 
551
548
  def _build_keys(self) -> tk.Text:
552
549
  """Add the key entry label, text box and load-from-file button."""
553
- tk.Label(self._win,
554
- text='Keys to move (separated by spaces or newlines):'
555
- ).pack(anchor='w', padx=12, pady=(8, 2))
556
- text = tk.Text(self._win, width=40, height=8)
557
- style_input(text)
558
- text.pack(padx=12, pady=2)
559
- tk.Button(self._win, text='Load from file…',
560
- command=self._load).pack(anchor='w', padx=12, pady=4)
561
- return text
550
+ return build_key_box(self._win,
551
+ 'Keys to move (separated by spaces or newlines):',
552
+ self._load, label_pady=(8, 2))
562
553
 
563
554
  def _preset_changed(self, _event: object) -> None:
564
555
  """Show the selected preset's default issue filter."""
@@ -566,18 +557,7 @@ class JiraRankDialog(ModalDialog):
566
557
 
567
558
  def _load(self) -> None:
568
559
  """Read a key list file into the text box, reporting failures."""
569
- name = filedialog.askopenfilename(parent=self._win,
570
- title='Read key list')
571
- if not name:
572
- return
573
- try:
574
- keys = read_key_list(name, stderr_file=self._sink)
575
- except KEY_READ_ERRORS as error:
576
- messagebox.showerror('Could not read key list', str(error),
577
- parent=self._win)
578
- return
579
- self._text.delete('1.0', 'end')
580
- self._text.insert('end', '\n'.join(keys))
560
+ load_keys_into(self._win, self._text, self._sink)
581
561
 
582
562
  def _confirm(self) -> None:
583
563
  """Store the preset, filter, keys and end, requiring preset+keys."""
@@ -0,0 +1,45 @@
1
+ #! /usr/local/bin/python3
2
+ """Shared key-list text box with a load-from-file button for dialogs.
3
+
4
+ The order-by-keys dialog and the Jira rank dialog both let the user type
5
+ or paste a list of keys and load it from a file. This module holds that
6
+ shared widget and the file reading so the two dialogs do not repeat it.
7
+ """
8
+
9
+ # Copyright (c) 2026, Tom Björkholm
10
+ # MIT License
11
+
12
+ import tkinter as tk
13
+ from tkinter import filedialog, messagebox
14
+ from collections.abc import Callable
15
+ from typing import TextIO
16
+ from backlogops import read_key_list
17
+ from backlogops_gui.gui_style import style_input
18
+
19
+ KEY_READ_ERRORS = (ValueError, TypeError, KeyError, OSError)
20
+
21
+
22
+ def build_key_box(win: tk.Misc, label: str, command: Callable[[], None], *,
23
+ label_pady: tuple[int, int] = (10, 2)) -> tk.Text:
24
+ """Add a key-entry label, text box and load-from-file button."""
25
+ tk.Label(win, text=label).pack(anchor='w', padx=12, pady=label_pady)
26
+ text = tk.Text(win, width=40, height=8)
27
+ style_input(text)
28
+ text.pack(padx=12, pady=2)
29
+ tk.Button(win, text='Load from file…',
30
+ command=command).pack(anchor='w', padx=12, pady=4)
31
+ return text
32
+
33
+
34
+ def load_keys_into(win: tk.Misc, text: tk.Text, sink: TextIO) -> None:
35
+ """Read a key list file into the text box, reporting failures."""
36
+ name = filedialog.askopenfilename(parent=win, title='Read key list')
37
+ if not name:
38
+ return
39
+ try:
40
+ keys = read_key_list(name, stderr_file=sink)
41
+ except KEY_READ_ERRORS as error:
42
+ messagebox.showerror('Could not read key list', str(error), parent=win)
43
+ return
44
+ text.delete('1.0', 'end')
45
+ text.insert('end', '\n'.join(keys))
@@ -0,0 +1,170 @@
1
+ #! /usr/local/bin/python3
2
+ """A modal month calendar for the Tkinter date and date-time fields.
3
+
4
+ A date field, and the date part of a date-time field, are shown in the
5
+ Tkinter form as a text entry paired with a Pick button. Pressing that
6
+ button, or typing the ``?`` token into the entry, opens this calendar.
7
+ The user steps between months and years and clicks a day to return it;
8
+ Cancel or closing the window returns nothing so the entry keeps its
9
+ value. Days outside a field's inclusive bounds are shown disabled, so the
10
+ calendar only offers acceptable dates.
11
+
12
+ The calendar is event driven: a day or Cancel button calls the picked
13
+ callback and destroys the window. No nested wait loop is entered, so the
14
+ one already running for the wizard window keeps processing events while
15
+ the calendar is open.
16
+
17
+ The wizard window holds a modal grab, which would otherwise starve this
18
+ separate window of pointer and keyboard events. The calendar therefore
19
+ takes the grab (and the keyboard focus) while it is open and hands it back
20
+ to the wizard window when it closes.
21
+ """
22
+
23
+ # Copyright (c) 2026, Tom Björkholm
24
+ # MIT License
25
+
26
+ import calendar
27
+ import tkinter as tk
28
+ from datetime import date
29
+ from functools import partial
30
+ from typing import Callable, Optional
31
+ from backlogops_gui.wizard_typed import value_out_of_range
32
+
33
+ CALENDAR_TITLE = 'Pick a date'
34
+ WEEKDAYS = ('Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su')
35
+
36
+
37
+ def month_weeks(year: int, month: int) -> list[list[int]]:
38
+ """Return the weeks of a month as day numbers, 0 for padding days."""
39
+ return calendar.Calendar().monthdayscalendar(year, month)
40
+
41
+
42
+ def shift_month(year: int, month: int, action: str) -> tuple[int, int]:
43
+ """Return the year and month reached by one navigation action."""
44
+ if action == 'prev_year':
45
+ year -= 1
46
+ elif action == 'next_year':
47
+ year += 1
48
+ elif action == 'prev_month':
49
+ year, month = (year, month - 1) if month > 1 else (year - 1, 12)
50
+ else:
51
+ year, month = (year, month + 1) if month < 12 else (year + 1, 1)
52
+ return (min(max(year, date.min.year), date.max.year), month)
53
+
54
+
55
+ def day_out_of_range(day: date, minimum: Optional[date],
56
+ maximum: Optional[date]) -> bool:
57
+ """Return whether a day lies outside the inclusive date bounds."""
58
+ return value_out_of_range(day, minimum, maximum)
59
+
60
+
61
+ def _restore_grab(widget: Optional[tk.Misc]) -> None:
62
+ """Give the modal grab back to the widget's window, if it survives."""
63
+ if widget is None or not widget.winfo_exists():
64
+ return
65
+ try:
66
+ widget.winfo_toplevel().grab_set()
67
+ except tk.TclError:
68
+ pass
69
+
70
+
71
+ # pylint: disable-next=too-few-public-methods
72
+ class CalendarPicker:
73
+ """A month calendar window returning the date the user clicks."""
74
+
75
+ def __init__(self, parent: tk.Misc, seed: date, minimum: Optional[date],
76
+ maximum: Optional[date],
77
+ on_pick: Callable[[Optional[date]], None]) -> None:
78
+ """Build the calendar window on the seed month and show it."""
79
+ self._bounds = (minimum, maximum)
80
+ self._on_pick = on_pick
81
+ self._year = seed.year
82
+ self._month = seed.month
83
+ self._win = tk.Toplevel(parent)
84
+ self._win.title(CALENDAR_TITLE)
85
+ self._win.transient(parent.winfo_toplevel())
86
+ self._win.protocol('WM_DELETE_WINDOW', self._cancel)
87
+ self._win.bind('<Escape>', lambda _event: self._cancel())
88
+ self._title = tk.Label(self._win)
89
+ self._title.pack(pady=4)
90
+ self._add_nav()
91
+ self._grid = tk.Frame(self._win)
92
+ self._grid.pack(padx=6, pady=6)
93
+ tk.Button(self._win, text='Cancel', command=self._cancel).pack(pady=4)
94
+ self._show_month()
95
+ self._grab()
96
+
97
+ def _grab(self) -> None:
98
+ """Take the modal grab and focus, retrying until the window shows.
99
+
100
+ Grabbing fails while the new window is not yet viewable, so it is
101
+ retried on the wizard's event loop until it succeeds.
102
+ """
103
+ if not self._win.winfo_exists():
104
+ return
105
+ try:
106
+ self._win.grab_set()
107
+ except tk.TclError:
108
+ self._win.after(50, self._grab)
109
+ return
110
+ self._win.focus_set()
111
+
112
+ def _add_nav(self) -> None:
113
+ """Add the previous and next month and year navigation buttons."""
114
+ box = tk.Frame(self._win)
115
+ box.pack()
116
+ for text, action in (('<< year', 'prev_year'), ('< month',
117
+ 'prev_month'), ('month >', 'next_month'),
118
+ ('year >>', 'next_year')):
119
+ tk.Button(box, text=text,
120
+ command=partial(self._navigate, action)).pack(
121
+ side='left', padx=2)
122
+
123
+ def _navigate(self, action: str) -> None:
124
+ """Move the shown month by one navigation action and redraw."""
125
+ self._year, self._month = shift_month(self._year, self._month, action)
126
+ self._show_month()
127
+
128
+ def _show_month(self) -> None:
129
+ """Show the current month's title and rebuild the day grid."""
130
+ self._title.configure(text=f'{calendar.month_name[self._month]} '
131
+ f'{self._year}')
132
+ for child in self._grid.winfo_children():
133
+ child.destroy()
134
+ for column, name in enumerate(WEEKDAYS):
135
+ tk.Label(self._grid, text=name).grid(row=0, column=column, padx=1)
136
+ self._place_days()
137
+
138
+ def _place_days(self) -> None:
139
+ """Place one day button, or a blank cell, per day of the month."""
140
+ for week, days in enumerate(month_weeks(self._year, self._month), 1):
141
+ for column, day in enumerate(days):
142
+ self._day_widget(day).grid(row=week, column=column, padx=1,
143
+ pady=1)
144
+
145
+ def _day_widget(self, day: int) -> tk.Widget:
146
+ """Return a blank cell for a padding day, else a day button."""
147
+ if day == 0:
148
+ return tk.Label(self._grid, text='')
149
+ button = tk.Button(self._grid, text=str(day), width=2,
150
+ command=partial(self._pick, day))
151
+ minimum, maximum = self._bounds
152
+ if day_out_of_range(date(self._year, self._month, day), minimum,
153
+ maximum):
154
+ button['state'] = 'disabled'
155
+ return button
156
+
157
+ def _pick(self, day: int) -> None:
158
+ """Return the clicked day's date and close the calendar."""
159
+ self._finish(date(self._year, self._month, day))
160
+
161
+ def _cancel(self) -> None:
162
+ """Close the calendar without returning a date."""
163
+ self._finish(None)
164
+
165
+ def _finish(self, chosen: Optional[date]) -> None:
166
+ """Report the outcome, destroy the window and restore the grab."""
167
+ parent = self._win.master
168
+ self._on_pick(chosen)
169
+ self._win.destroy()
170
+ _restore_grab(parent)