backlogops-gui 0.1__py3-none-any.whl
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.
- backlogops_gui/__init__.py +5 -0
- backlogops_gui/__main__.py +11 -0
- backlogops_gui/application.py +313 -0
- backlogops_gui/backlog_io.py +68 -0
- backlogops_gui/backlog_window.py +435 -0
- backlogops_gui/gui_wizard.py +237 -0
- backlogops_gui/io_dialogs.py +491 -0
- backlogops_gui/log_buffer.py +50 -0
- backlogops_gui/py.typed +0 -0
- backlogops_gui/table_view.py +165 -0
- backlogops_gui/tcltk_version.py +63 -0
- backlogops_gui-0.1.dist-info/METADATA +259 -0
- backlogops_gui-0.1.dist-info/RECORD +17 -0
- backlogops_gui-0.1.dist-info/WHEEL +5 -0
- backlogops_gui-0.1.dist-info/entry_points.txt +2 -0
- backlogops_gui-0.1.dist-info/licenses/LICENSE.txt +22 -0
- backlogops_gui-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""A window that shows one backlog and its releases as two tables.
|
|
3
|
+
|
|
4
|
+
The window shows the backlog and the releases as two read-only tables and
|
|
5
|
+
carries a menu with the actions that can be done to the backlog. The
|
|
6
|
+
backlog table fills the window, while the releases table, which has only a
|
|
7
|
+
few columns, is kept narrow so its columns are not spread out. The first
|
|
8
|
+
version offers saving to a file and closing the window. Saving is kept in a
|
|
9
|
+
module function so it can be tested without a display.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
13
|
+
# MIT License
|
|
14
|
+
|
|
15
|
+
import tkinter as tk
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from tkinter import messagebox, ttk
|
|
18
|
+
from typing import Callable, Optional, TextIO
|
|
19
|
+
from tableio import ValueFmt
|
|
20
|
+
from backlogops import (
|
|
21
|
+
AvailableTeams, BacklogReleases, OutputFormatConfig, ReleaseChanges,
|
|
22
|
+
ReleaseDateChanges, format_content_changes, format_date_changes,
|
|
23
|
+
get_keys_in_order, write_content_changes, write_date_changes,
|
|
24
|
+
write_key_list)
|
|
25
|
+
from backlogops_gui.backlog_io import write_backlog
|
|
26
|
+
from backlogops_gui.io_dialogs import (
|
|
27
|
+
ask_buffer_days, ask_dep_options, ask_keys, ask_levels, ask_start_date,
|
|
28
|
+
ask_write_options, choose_changes_output, choose_key_list_output,
|
|
29
|
+
choose_output_file, show_change_list)
|
|
30
|
+
from backlogops_gui.table_view import (
|
|
31
|
+
backlog_table, make_table, release_table)
|
|
32
|
+
|
|
33
|
+
WRITE_ERRORS = (ValueError, TypeError, KeyError, OSError)
|
|
34
|
+
ACTION_ERRORS = (ValueError, TypeError, KeyError, RuntimeError, OSError)
|
|
35
|
+
RELEASE_COLUMN_WIDTH = 110
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
39
|
+
def save_backlog(parent: tk.Misc, data: BacklogReleases,
|
|
40
|
+
presets: Optional[dict[str, OutputFormatConfig]],
|
|
41
|
+
sink: TextIO, on_error: Callable[[str, str], None],
|
|
42
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
43
|
+
"""Ask where and how to save a backlog and write it.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
parent: The window the dialogs are shown over.
|
|
47
|
+
data: The backlog and releases to write.
|
|
48
|
+
presets: Named output presets, or None when none are configured.
|
|
49
|
+
sink: Stream that receives low-level write diagnostics.
|
|
50
|
+
on_error: Callback used to report a write failure.
|
|
51
|
+
on_info: Callback used to report a successful write.
|
|
52
|
+
"""
|
|
53
|
+
path = choose_output_file(parent)
|
|
54
|
+
if path is None:
|
|
55
|
+
return
|
|
56
|
+
names = sorted(presets) if presets else None
|
|
57
|
+
options = ask_write_options(parent, names)
|
|
58
|
+
if options is None:
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
write_backlog(data, path, options.config_value, presets,
|
|
62
|
+
options.releases_first, sink)
|
|
63
|
+
except WRITE_ERRORS as error:
|
|
64
|
+
on_error('Could not write file', str(error))
|
|
65
|
+
return
|
|
66
|
+
on_info('Wrote file', f'Wrote {path}')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
70
|
+
def _apply_change(change: Callable[[], None], refresh: Callable[[], None],
|
|
71
|
+
on_error: Callable[[str, str], None],
|
|
72
|
+
on_info: Callable[[str, str], None], fail_title: str,
|
|
73
|
+
ok_title: str, ok_message: str) -> None:
|
|
74
|
+
"""Run a backlog change, refresh the view and report the outcome.
|
|
75
|
+
|
|
76
|
+
A change that raises one of the known data errors is reported through
|
|
77
|
+
``on_error`` and leaves the view unchanged. A successful change
|
|
78
|
+
refreshes the view and is reported through ``on_info``.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
change()
|
|
82
|
+
except ACTION_ERRORS as error:
|
|
83
|
+
on_error(fail_title, str(error))
|
|
84
|
+
return
|
|
85
|
+
refresh()
|
|
86
|
+
on_info(ok_title, ok_message)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
90
|
+
def order_by_keys(parent: tk.Misc, data: BacklogReleases, sink: TextIO,
|
|
91
|
+
refresh: Callable[[], None],
|
|
92
|
+
on_error: Callable[[str, str], None],
|
|
93
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
94
|
+
"""Ask for leading keys and move those items to the front."""
|
|
95
|
+
keys = ask_keys(parent, sink)
|
|
96
|
+
if keys is None:
|
|
97
|
+
return
|
|
98
|
+
_apply_change(lambda: data.move_keys_first(keys, sink), refresh, on_error,
|
|
99
|
+
on_info, 'Could not order by keys', 'Ordered backlog',
|
|
100
|
+
'Moved the keys to the front.')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
104
|
+
def order_by_deps(parent: tk.Misc, data: BacklogReleases, sink: TextIO,
|
|
105
|
+
refresh: Callable[[], None],
|
|
106
|
+
on_error: Callable[[str, str], None],
|
|
107
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
108
|
+
"""Ask for the options and order the backlog by dependencies."""
|
|
109
|
+
options = ask_dep_options(parent)
|
|
110
|
+
if options is None:
|
|
111
|
+
return
|
|
112
|
+
later, mode = options.later, options.mode
|
|
113
|
+
space = options.space_around
|
|
114
|
+
|
|
115
|
+
def change() -> None:
|
|
116
|
+
"""Order the backlog by dependencies with the chosen options."""
|
|
117
|
+
data.order_by_dependencies(later=later, mode=mode, space_around=space,
|
|
118
|
+
stderr_file=sink)
|
|
119
|
+
_apply_change(change, refresh, on_error, on_info,
|
|
120
|
+
'Could not order by dependencies', 'Ordered backlog',
|
|
121
|
+
'Ordered the backlog by dependencies.')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
125
|
+
def save_changes(parent: tk.Misc,
|
|
126
|
+
write_changes: Optional[Callable[[str], None]],
|
|
127
|
+
on_error: Callable[[str, str], None],
|
|
128
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
129
|
+
"""Ask for a file and write the change list to it.
|
|
130
|
+
|
|
131
|
+
A ``write_changes`` of None means there are no changes, so nothing is
|
|
132
|
+
written and that is reported through ``on_info`` instead.
|
|
133
|
+
"""
|
|
134
|
+
if write_changes is None:
|
|
135
|
+
on_info('No changes', 'There are no changes to write.')
|
|
136
|
+
return
|
|
137
|
+
path = choose_changes_output(parent)
|
|
138
|
+
if path is None:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
write_changes(path)
|
|
142
|
+
except WRITE_ERRORS as error:
|
|
143
|
+
on_error('Could not write file', str(error))
|
|
144
|
+
return
|
|
145
|
+
on_info('Wrote file', f'Wrote {path}')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
149
|
+
def show_changes(parent: tk.Misc, title: str, text: str,
|
|
150
|
+
write_changes: Optional[Callable[[str], None]],
|
|
151
|
+
on_error: Callable[[str, str], None],
|
|
152
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
153
|
+
"""Show the change listing in a pop-up that can save it to a file."""
|
|
154
|
+
show_change_list(parent, title, text,
|
|
155
|
+
lambda: save_changes(parent, write_changes, on_error,
|
|
156
|
+
on_info))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _date_report(changes: ReleaseDateChanges, sink: TextIO
|
|
160
|
+
) -> tuple[str, Optional[Callable[[str], None]]]:
|
|
161
|
+
"""Return the date change listing and a writer, None when empty."""
|
|
162
|
+
writer = None if not changes else \
|
|
163
|
+
(lambda path: write_date_changes(changes, path, sink))
|
|
164
|
+
return format_date_changes(changes), writer
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _content_report(changes: ReleaseChanges, sink: TextIO
|
|
168
|
+
) -> tuple[str, Optional[Callable[[str], None]]]:
|
|
169
|
+
"""Return the content change listing and a writer, None when empty."""
|
|
170
|
+
writer = None if not changes else \
|
|
171
|
+
(lambda path: write_content_changes(changes, path, sink))
|
|
172
|
+
return format_content_changes(changes), writer
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
176
|
+
def _run_change(parent: tk.Misc,
|
|
177
|
+
change: Callable[[], tuple[str,
|
|
178
|
+
Optional[Callable[[str], None]]]],
|
|
179
|
+
refresh: Callable[[], None],
|
|
180
|
+
on_error: Callable[[str, str], None],
|
|
181
|
+
on_info: Callable[[str, str], None], fail_title: str,
|
|
182
|
+
title: str) -> None:
|
|
183
|
+
"""Run a change returning a report, refresh, then show the pop-up.
|
|
184
|
+
|
|
185
|
+
A change that raises one of the known data errors is reported and
|
|
186
|
+
leaves the view unchanged. A successful change refreshes the view and
|
|
187
|
+
shows the change listing in a pop-up that can save it to a file.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
text, write_changes = change()
|
|
191
|
+
except ACTION_ERRORS as error:
|
|
192
|
+
on_error(fail_title, str(error))
|
|
193
|
+
return
|
|
194
|
+
refresh()
|
|
195
|
+
show_changes(parent, title, text, write_changes, on_error, on_info)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
199
|
+
def estimate_date(parent: tk.Misc, data: BacklogReleases,
|
|
200
|
+
teams: Optional[AvailableTeams], sink: TextIO,
|
|
201
|
+
refresh: Callable[[], None],
|
|
202
|
+
on_error: Callable[[str, str], None],
|
|
203
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
204
|
+
"""Ask for the start date and estimate the ready dates."""
|
|
205
|
+
if teams is None:
|
|
206
|
+
on_error('No configuration',
|
|
207
|
+
'There is no teams configuration to estimate from.')
|
|
208
|
+
return
|
|
209
|
+
choice = ask_start_date(parent)
|
|
210
|
+
if choice is None:
|
|
211
|
+
return
|
|
212
|
+
ready_teams, start = teams, choice.start_date
|
|
213
|
+
|
|
214
|
+
def change() -> tuple[str, Optional[Callable[[str], None]]]:
|
|
215
|
+
"""Estimate the dates and return the release date change report."""
|
|
216
|
+
changes = data.estimate_ready_date(ready_teams, start, sink)
|
|
217
|
+
return _date_report(changes, sink)
|
|
218
|
+
_run_change(parent, change, refresh, on_error, on_info,
|
|
219
|
+
'Could not estimate ready date', 'Release date changes')
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def set_plan(data: BacklogReleases, sink: TextIO, refresh: Callable[[], None],
|
|
223
|
+
on_error: Callable[[str, str], None],
|
|
224
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
225
|
+
"""Copy the estimated ready dates to the planned ready dates."""
|
|
226
|
+
_apply_change(lambda: data.set_plan_from_estimate(sink), refresh, on_error,
|
|
227
|
+
on_info, 'Could not set planned date', 'Set planned date',
|
|
228
|
+
'Copied the estimated dates to the planned dates.')
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
232
|
+
def adjust_content(parent: tk.Misc, data: BacklogReleases, sink: TextIO,
|
|
233
|
+
refresh: Callable[[], None],
|
|
234
|
+
on_error: Callable[[str, str], None],
|
|
235
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
236
|
+
"""Ask for a buffer and adjust the release content to the estimate."""
|
|
237
|
+
days = ask_buffer_days(parent)
|
|
238
|
+
if days is None:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
def change() -> tuple[str, Optional[Callable[[str], None]]]:
|
|
242
|
+
"""Adjust the release content and return the change report."""
|
|
243
|
+
changes = data.adjust_release_content(timedelta(days=days), sink)
|
|
244
|
+
return _content_report(changes, sink)
|
|
245
|
+
_run_change(parent, change, refresh, on_error, on_info,
|
|
246
|
+
'Could not adjust release content', 'Release content changes')
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
250
|
+
def plan_dates(parent: tk.Misc, data: BacklogReleases, sink: TextIO,
|
|
251
|
+
refresh: Callable[[], None],
|
|
252
|
+
on_error: Callable[[str, str], None],
|
|
253
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
254
|
+
"""Ask for a buffer and set planned release dates from the estimate."""
|
|
255
|
+
days = ask_buffer_days(parent)
|
|
256
|
+
if days is None:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
def change() -> tuple[str, Optional[Callable[[str], None]]]:
|
|
260
|
+
"""Set the planned release dates and return the change report."""
|
|
261
|
+
changes = data.release_plan_on_estimate(timedelta(days=days), sink)
|
|
262
|
+
return _date_report(changes, sink)
|
|
263
|
+
_run_change(parent, change, refresh, on_error, on_info,
|
|
264
|
+
'Could not set planned release dates', 'Release date changes')
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_keys(parent: tk.Misc, data: BacklogReleases, sink: TextIO,
|
|
268
|
+
on_error: Callable[[str, str], None],
|
|
269
|
+
on_info: Callable[[str, str], None]) -> None:
|
|
270
|
+
"""Ask for levels and a file, then write the backlog keys to it."""
|
|
271
|
+
levels = ask_levels(parent)
|
|
272
|
+
if levels is None:
|
|
273
|
+
return
|
|
274
|
+
path = choose_key_list_output(parent)
|
|
275
|
+
if path is None:
|
|
276
|
+
return
|
|
277
|
+
try:
|
|
278
|
+
keys = get_keys_in_order(data.backlog, levels)
|
|
279
|
+
write_key_list(keys, path, stderr_file=sink)
|
|
280
|
+
except ACTION_ERRORS as error:
|
|
281
|
+
on_error('Could not extract keys', str(error))
|
|
282
|
+
return
|
|
283
|
+
on_info('Wrote keys', f'Wrote {path}')
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# pylint: disable-next=too-few-public-methods,too-many-instance-attributes
|
|
287
|
+
class BacklogWindow:
|
|
288
|
+
"""A top-level window showing one backlog and its releases."""
|
|
289
|
+
|
|
290
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
291
|
+
def __init__(self, root: tk.Misc, data: BacklogReleases, title: str,
|
|
292
|
+
presets: Callable[
|
|
293
|
+
[], Optional[dict[str, OutputFormatConfig]]],
|
|
294
|
+
teams: Callable[[], Optional[AvailableTeams]],
|
|
295
|
+
sink: TextIO) -> None:
|
|
296
|
+
"""Build the window, its menu and the two tables.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
root: The parent window the new window belongs to.
|
|
300
|
+
data: The backlog and releases to show.
|
|
301
|
+
title: The window title, typically the source file name.
|
|
302
|
+
presets: Callable returning the current output presets.
|
|
303
|
+
teams: Callable returning the loaded teams configuration.
|
|
304
|
+
sink: Stream that receives low-level write diagnostics.
|
|
305
|
+
"""
|
|
306
|
+
self._data = data
|
|
307
|
+
self._presets = presets
|
|
308
|
+
self._teams = teams
|
|
309
|
+
self._sink = sink
|
|
310
|
+
self._win = tk.Toplevel(root)
|
|
311
|
+
self._win.title(title)
|
|
312
|
+
self._tables: list[tk.Widget] = []
|
|
313
|
+
self._add_menu()
|
|
314
|
+
self._build_tables()
|
|
315
|
+
|
|
316
|
+
def _report_error(self, title: str, message: str) -> None:
|
|
317
|
+
"""Show an error message over this backlog window."""
|
|
318
|
+
messagebox.showerror(title, message, parent=self._win)
|
|
319
|
+
|
|
320
|
+
def _report_info(self, title: str, message: str) -> None:
|
|
321
|
+
"""Show an informational message over this backlog window."""
|
|
322
|
+
messagebox.showinfo(title, message, parent=self._win)
|
|
323
|
+
|
|
324
|
+
def _build_tables(self) -> None:
|
|
325
|
+
"""Build the backlog and releases tables from the current data."""
|
|
326
|
+
self._tables.append(
|
|
327
|
+
self._add_table('Backlog', *backlog_table(self._data),
|
|
328
|
+
narrow=False))
|
|
329
|
+
self._tables.append(
|
|
330
|
+
self._add_table('Releases', *release_table(self._data),
|
|
331
|
+
narrow=True))
|
|
332
|
+
|
|
333
|
+
def _refresh_tables(self) -> None:
|
|
334
|
+
"""Rebuild the tables after the backlog data has changed."""
|
|
335
|
+
for table in self._tables:
|
|
336
|
+
table.destroy()
|
|
337
|
+
self._tables = []
|
|
338
|
+
self._build_tables()
|
|
339
|
+
|
|
340
|
+
def _add_menu(self) -> None:
|
|
341
|
+
"""Add the backlog menu with the action, save and close items."""
|
|
342
|
+
menubar = tk.Menu(self._win)
|
|
343
|
+
backlog_menu = tk.Menu(menubar, tearoff=False)
|
|
344
|
+
self._add_actions(backlog_menu)
|
|
345
|
+
backlog_menu.add_separator()
|
|
346
|
+
backlog_menu.add_command(label='Save to file…', command=self._save)
|
|
347
|
+
backlog_menu.add_command(label='Close', command=self._win.destroy)
|
|
348
|
+
menubar.add_cascade(label='Backlog', menu=backlog_menu)
|
|
349
|
+
self._win.config(menu=menubar)
|
|
350
|
+
|
|
351
|
+
def _add_actions(self, menu: tk.Menu) -> None:
|
|
352
|
+
"""Add the backlog operation items to the menu."""
|
|
353
|
+
menu.add_command(label='Order by keys…', command=self._order_by_keys)
|
|
354
|
+
menu.add_command(label='Order by dependencies…',
|
|
355
|
+
command=self._order_by_deps)
|
|
356
|
+
menu.add_command(label='Estimate ready date…',
|
|
357
|
+
command=self._estimate_date)
|
|
358
|
+
menu.add_command(label='Set planned date from estimated',
|
|
359
|
+
command=self._set_plan)
|
|
360
|
+
menu.add_command(label='Adjust release content…',
|
|
361
|
+
command=self._adjust_content)
|
|
362
|
+
menu.add_command(label='Adjust planned release dates…',
|
|
363
|
+
command=self._plan_dates)
|
|
364
|
+
menu.add_command(label='Extract keys…', command=self._extract_keys)
|
|
365
|
+
|
|
366
|
+
def _add_table(self, heading: str, columns: list[str],
|
|
367
|
+
rows: list[list[ValueFmt]], narrow: bool) -> tk.Widget:
|
|
368
|
+
"""Add one labeled, scrollable table and return its frame.
|
|
369
|
+
|
|
370
|
+
The narrow table keeps its few columns at a fixed width and does
|
|
371
|
+
not take the spare space, so it stays clearly narrower than the
|
|
372
|
+
backlog table that fills the window.
|
|
373
|
+
"""
|
|
374
|
+
frame = tk.LabelFrame(self._win, text=heading)
|
|
375
|
+
tree = self._make_tree(frame, columns, rows, narrow)
|
|
376
|
+
scroll = ttk.Scrollbar(frame, orient='vertical', command=tree.yview)
|
|
377
|
+
tree.configure(yscrollcommand=scroll.set)
|
|
378
|
+
scroll.pack(side='right', fill='y')
|
|
379
|
+
if narrow:
|
|
380
|
+
frame.pack(padx=8, pady=6, anchor='w')
|
|
381
|
+
tree.pack(side='left')
|
|
382
|
+
else:
|
|
383
|
+
frame.pack(padx=8, pady=6, fill='both', expand=True)
|
|
384
|
+
tree.pack(side='left', fill='both', expand=True)
|
|
385
|
+
return frame
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _make_tree(frame: tk.Misc, columns: list[str],
|
|
389
|
+
rows: list[list[ValueFmt]], narrow: bool) -> ttk.Treeview:
|
|
390
|
+
"""Build the Treeview, keeping a narrow table from stretching."""
|
|
391
|
+
if narrow:
|
|
392
|
+
return make_table(frame, columns, rows, width=RELEASE_COLUMN_WIDTH,
|
|
393
|
+
stretch=False)
|
|
394
|
+
return make_table(frame, columns, rows)
|
|
395
|
+
|
|
396
|
+
def _save(self) -> None:
|
|
397
|
+
"""Save the backlog through the shared save helper."""
|
|
398
|
+
save_backlog(self._win, self._data, self._presets(), self._sink,
|
|
399
|
+
self._report_error, self._report_info)
|
|
400
|
+
|
|
401
|
+
def _order_by_keys(self) -> None:
|
|
402
|
+
"""Order the backlog by leading keys and refresh the tables."""
|
|
403
|
+
order_by_keys(self._win, self._data, self._sink, self._refresh_tables,
|
|
404
|
+
self._report_error, self._report_info)
|
|
405
|
+
|
|
406
|
+
def _order_by_deps(self) -> None:
|
|
407
|
+
"""Order the backlog by dependencies and refresh the tables."""
|
|
408
|
+
order_by_deps(self._win, self._data, self._sink, self._refresh_tables,
|
|
409
|
+
self._report_error, self._report_info)
|
|
410
|
+
|
|
411
|
+
def _estimate_date(self) -> None:
|
|
412
|
+
"""Estimate the ready dates and refresh the tables."""
|
|
413
|
+
estimate_date(self._win, self._data, self._teams(), self._sink,
|
|
414
|
+
self._refresh_tables, self._report_error,
|
|
415
|
+
self._report_info)
|
|
416
|
+
|
|
417
|
+
def _set_plan(self) -> None:
|
|
418
|
+
"""Copy the estimated dates to the planned dates and refresh."""
|
|
419
|
+
set_plan(self._data, self._sink, self._refresh_tables,
|
|
420
|
+
self._report_error, self._report_info)
|
|
421
|
+
|
|
422
|
+
def _adjust_content(self) -> None:
|
|
423
|
+
"""Adjust the release content to the estimate and refresh."""
|
|
424
|
+
adjust_content(self._win, self._data, self._sink, self._refresh_tables,
|
|
425
|
+
self._report_error, self._report_info)
|
|
426
|
+
|
|
427
|
+
def _plan_dates(self) -> None:
|
|
428
|
+
"""Set planned release dates from the estimate and refresh."""
|
|
429
|
+
plan_dates(self._win, self._data, self._sink, self._refresh_tables,
|
|
430
|
+
self._report_error, self._report_info)
|
|
431
|
+
|
|
432
|
+
def _extract_keys(self) -> None:
|
|
433
|
+
"""Extract backlog keys at chosen levels to a key list file."""
|
|
434
|
+
extract_keys(self._win, self._data, self._sink, self._report_error,
|
|
435
|
+
self._report_info)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Graphical bridge that drives the synchronous teams wizard.
|
|
3
|
+
|
|
4
|
+
The teams configuration wizard asks its questions through a
|
|
5
|
+
:class:`WizardUiBridge`, extended here as a :class:`YesNoUiBridge` so
|
|
6
|
+
yes/no questions can offer dedicated buttons. This module answers every
|
|
7
|
+
call by updating one reused, fixed-size window, so the whole wizard session
|
|
8
|
+
happens in a single pop-up that does not jump around the display. A
|
|
9
|
+
cancelled prompt raises :class:`EOFError`, which the wizard documents as
|
|
10
|
+
the way an interrupted input is reported.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
14
|
+
# MIT License
|
|
15
|
+
|
|
16
|
+
import tkinter as tk
|
|
17
|
+
from typing import Callable, Optional, Sequence, TextIO
|
|
18
|
+
from backlogops import NoTextIO, YesNoUiBridge
|
|
19
|
+
|
|
20
|
+
WIZARD_TITLE = 'Configuration wizard'
|
|
21
|
+
WINDOW_SIZE = '560x460'
|
|
22
|
+
WRAP_LENGTH = 500
|
|
23
|
+
MESSAGE_HEIGHT = 8
|
|
24
|
+
CHOICE_HEIGHT = 10
|
|
25
|
+
CANCEL_TEXT = 'Configuration wizard cancelled by the user.'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _WizardWindow:
|
|
29
|
+
"""One reused window that asks every wizard prompt in turn."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, parent: tk.Misc) -> None:
|
|
32
|
+
"""Create the fixed-size window and its lasting message area."""
|
|
33
|
+
self._result: object = ''
|
|
34
|
+
self._cancelled = False
|
|
35
|
+
self._win = tk.Toplevel(parent)
|
|
36
|
+
self._win.title(WIZARD_TITLE)
|
|
37
|
+
self._win.geometry(WINDOW_SIZE)
|
|
38
|
+
self._win.resizable(False, False)
|
|
39
|
+
if isinstance(parent, tk.Wm):
|
|
40
|
+
self._win.transient(parent)
|
|
41
|
+
self._win.protocol('WM_DELETE_WINDOW', self._cancel)
|
|
42
|
+
self._done = tk.IntVar(self._win, 0)
|
|
43
|
+
self._messages = self._build_messages()
|
|
44
|
+
self._content = tk.Frame(self._win)
|
|
45
|
+
self._content.pack(fill='both', expand=True, padx=12, pady=6)
|
|
46
|
+
self._win.grab_set()
|
|
47
|
+
|
|
48
|
+
def _build_messages(self) -> tk.Text:
|
|
49
|
+
"""Build the read-only area that keeps the wizard messages."""
|
|
50
|
+
text = tk.Text(self._win, height=MESSAGE_HEIGHT, wrap='word',
|
|
51
|
+
state='disabled')
|
|
52
|
+
text.pack(fill='x', padx=12, pady=(12, 6))
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
def show(self, message: str) -> None:
|
|
56
|
+
"""Append one lasting message to the message area."""
|
|
57
|
+
self._messages.configure(state='normal')
|
|
58
|
+
self._messages.insert('end', message + '\n')
|
|
59
|
+
self._messages.see('end')
|
|
60
|
+
self._messages.configure(state='disabled')
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Destroy the wizard window."""
|
|
64
|
+
self._win.destroy()
|
|
65
|
+
|
|
66
|
+
def ask(self, question: str, re_ask: Optional[str],
|
|
67
|
+
choices: Optional[Sequence[str]]) -> str | int:
|
|
68
|
+
"""Ask one free-text or choice question and return the answer."""
|
|
69
|
+
if choices is None:
|
|
70
|
+
return self._ask_text(question, re_ask)
|
|
71
|
+
return self._ask_choice(question, re_ask, choices)
|
|
72
|
+
|
|
73
|
+
def ask_yes_no(self, question: str, default: bool) -> bool:
|
|
74
|
+
"""Ask one yes/no question with dedicated buttons."""
|
|
75
|
+
self._begin(question, None)
|
|
76
|
+
box = tk.Frame(self._content)
|
|
77
|
+
box.pack(pady=10)
|
|
78
|
+
yes = tk.Button(box, text='Yes', command=lambda: self._finish(True))
|
|
79
|
+
no = tk.Button(box, text='No', command=lambda: self._finish(False))
|
|
80
|
+
yes.pack(side='left', padx=6)
|
|
81
|
+
no.pack(side='left', padx=6)
|
|
82
|
+
chosen = yes if default else no
|
|
83
|
+
chosen.focus_set()
|
|
84
|
+
self._win.bind('<Return>', lambda event: chosen.invoke())
|
|
85
|
+
result = self._wait()
|
|
86
|
+
assert isinstance(result, bool)
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
def _ask_text(self, question: str, re_ask: Optional[str]) -> str:
|
|
90
|
+
"""Ask one free-text question and return the entered text."""
|
|
91
|
+
self._begin(question, re_ask)
|
|
92
|
+
entry = tk.Entry(self._content, width=44)
|
|
93
|
+
entry.pack(anchor='w', pady=6)
|
|
94
|
+
entry.focus_set()
|
|
95
|
+
self._add_buttons(lambda: self._finish(entry.get()), None)
|
|
96
|
+
self._win.bind('<Return>', lambda event: self._finish(entry.get()))
|
|
97
|
+
result = self._wait()
|
|
98
|
+
assert isinstance(result, str)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def _ask_choice(self, question: str, re_ask: Optional[str],
|
|
102
|
+
choices: Sequence[str]) -> str | int:
|
|
103
|
+
"""Ask one question with a single-selection list of choices."""
|
|
104
|
+
self._begin(question, re_ask)
|
|
105
|
+
listbox = tk.Listbox(self._content, exportselection=False,
|
|
106
|
+
height=min(len(choices), CHOICE_HEIGHT))
|
|
107
|
+
for choice in choices:
|
|
108
|
+
listbox.insert('end', choice)
|
|
109
|
+
listbox.pack(fill='x', pady=6)
|
|
110
|
+
self._add_buttons(lambda: self._pick(listbox),
|
|
111
|
+
lambda: self._finish(''))
|
|
112
|
+
result = self._wait()
|
|
113
|
+
assert isinstance(result, (str, int))
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
def _pick(self, listbox: tk.Listbox) -> None:
|
|
117
|
+
"""Finish a choice question with the selected zero-based index."""
|
|
118
|
+
picks = listbox.curselection() # type: ignore[no-untyped-call]
|
|
119
|
+
if picks:
|
|
120
|
+
self._finish(int(picks[0]))
|
|
121
|
+
|
|
122
|
+
def _begin(self, question: str, re_ask: Optional[str]) -> None:
|
|
123
|
+
"""Clear the content area and show the question and any reason."""
|
|
124
|
+
self._win.unbind('<Return>')
|
|
125
|
+
for child in self._content.winfo_children():
|
|
126
|
+
child.destroy()
|
|
127
|
+
if re_ask is not None:
|
|
128
|
+
self._add_label(re_ask, 'red')
|
|
129
|
+
self._add_label(question, 'black')
|
|
130
|
+
|
|
131
|
+
def _add_label(self, text: str, color: str) -> None:
|
|
132
|
+
"""Add one wrapped label to the content area."""
|
|
133
|
+
label = tk.Label(self._content, text=text, fg=color,
|
|
134
|
+
wraplength=WRAP_LENGTH, justify='left')
|
|
135
|
+
label.pack(anchor='w', pady=4)
|
|
136
|
+
|
|
137
|
+
def _add_buttons(self, on_ok: Callable[[], None],
|
|
138
|
+
on_default: Optional[Callable[[], None]]) -> None:
|
|
139
|
+
"""Add the confirm, optional default, and cancel buttons."""
|
|
140
|
+
box = tk.Frame(self._content)
|
|
141
|
+
box.pack(anchor='w', pady=10)
|
|
142
|
+
ok_button = tk.Button(box, text='OK', command=on_ok)
|
|
143
|
+
ok_button.pack(side='left')
|
|
144
|
+
if on_default is not None:
|
|
145
|
+
default_button = tk.Button(box, text='Use default',
|
|
146
|
+
command=on_default)
|
|
147
|
+
default_button.pack(side='left', padx=6)
|
|
148
|
+
cancel_button = tk.Button(box, text='Cancel', command=self._cancel)
|
|
149
|
+
cancel_button.pack(side='left', padx=6)
|
|
150
|
+
|
|
151
|
+
def _wait(self) -> object:
|
|
152
|
+
"""Block until the current prompt is answered or cancelled."""
|
|
153
|
+
self._win.wait_variable(self._done)
|
|
154
|
+
if self._cancelled:
|
|
155
|
+
raise EOFError(CANCEL_TEXT)
|
|
156
|
+
return self._result
|
|
157
|
+
|
|
158
|
+
def _finish(self, value: object) -> None:
|
|
159
|
+
"""Store the answer and release the waiting prompt."""
|
|
160
|
+
self._result = value
|
|
161
|
+
self._done.set(self._done.get() + 1)
|
|
162
|
+
|
|
163
|
+
def _cancel(self) -> None:
|
|
164
|
+
"""Mark the session cancelled and release the waiting prompt."""
|
|
165
|
+
self._cancelled = True
|
|
166
|
+
self._done.set(self._done.get() + 1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TkWizardBridge(YesNoUiBridge):
|
|
170
|
+
"""Bridge that answers wizard prompts in one reused Tkinter window."""
|
|
171
|
+
|
|
172
|
+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
|
173
|
+
def __init__(self, parent: tk.Misc, log: Optional[TextIO] = None,
|
|
174
|
+
ask_fn: Optional[Callable[
|
|
175
|
+
[str, Optional[str], Optional[Sequence[str]]],
|
|
176
|
+
str | int]] = None,
|
|
177
|
+
show_fn: Optional[Callable[[str], None]] = None,
|
|
178
|
+
yes_no_fn: Optional[Callable[[str, bool], bool]] = None
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Store the parent window, log sink, and optional test callables.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
parent: The window the wizard window is shown over.
|
|
184
|
+
log: Stream that receives low-level wizard diagnostics.
|
|
185
|
+
ask_fn: Replacement for the question prompt, used by tests.
|
|
186
|
+
show_fn: Replacement for the message display, used by tests.
|
|
187
|
+
yes_no_fn: Replacement for the yes/no prompt, used by tests.
|
|
188
|
+
"""
|
|
189
|
+
self._parent = parent
|
|
190
|
+
self._log = log
|
|
191
|
+
self._window: Optional[_WizardWindow] = None
|
|
192
|
+
self._ask = ask_fn
|
|
193
|
+
self._show = show_fn
|
|
194
|
+
self._yes_no = yes_no_fn
|
|
195
|
+
|
|
196
|
+
def ask(self, question: str, re_ask_reason: Optional[str] = None,
|
|
197
|
+
choices: Optional[Sequence[str]] = None) -> str | int:
|
|
198
|
+
"""Ask one question and return the user's answer.
|
|
199
|
+
|
|
200
|
+
Returns the entered text, the zero-based index of a selected
|
|
201
|
+
choice, or an empty string when the user requests the default.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
EOFError: The user cancelled the wizard.
|
|
205
|
+
"""
|
|
206
|
+
if self._ask is not None:
|
|
207
|
+
return self._ask(question, re_ask_reason, choices)
|
|
208
|
+
return self._window_obj().ask(question, re_ask_reason, choices)
|
|
209
|
+
|
|
210
|
+
def ask_yes_no(self, question: str, default: bool) -> bool:
|
|
211
|
+
"""Ask one yes/no question with dedicated buttons."""
|
|
212
|
+
if self._yes_no is not None:
|
|
213
|
+
return self._yes_no(question, default)
|
|
214
|
+
return self._window_obj().ask_yes_no(question, default)
|
|
215
|
+
|
|
216
|
+
def show(self, message: str) -> None:
|
|
217
|
+
"""Show an informational message to the user."""
|
|
218
|
+
if self._show is not None:
|
|
219
|
+
self._show(message)
|
|
220
|
+
return
|
|
221
|
+
self._window_obj().show(message)
|
|
222
|
+
|
|
223
|
+
def error_file(self) -> TextIO:
|
|
224
|
+
"""Return the stream used for low-level wizard diagnostics."""
|
|
225
|
+
return self._log if self._log is not None else NoTextIO()
|
|
226
|
+
|
|
227
|
+
def close(self) -> None:
|
|
228
|
+
"""Close the wizard window when one was opened."""
|
|
229
|
+
if self._window is not None:
|
|
230
|
+
self._window.close()
|
|
231
|
+
self._window = None
|
|
232
|
+
|
|
233
|
+
def _window_obj(self) -> _WizardWindow:
|
|
234
|
+
"""Return the wizard window, creating it on first use."""
|
|
235
|
+
if self._window is None:
|
|
236
|
+
self._window = _WizardWindow(self._parent)
|
|
237
|
+
return self._window
|