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.
@@ -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