aboutsummaryrefslogtreecommitdiff
path: root/tools/tui.py
blob: e1eda05cdb2c6dc53e8cfe02af1699787fdecbb3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
#!/usr/bin/env python3
"""Minimal curses TUI widgets for the interactive tools.

One screen per decision: a directory browser, an expandable checkbox
tree, a single-line text editor, a single-choice menu, and a yes/no
confirm. There is no framework — every widget is a function that runs
its own key loop on a curses window and returns the chosen value.

Common key bindings:

  Up/Down (or k/j)   move the cursor
  Enter              accept
  Esc                abort the whole wizard (raises WizardCancelled)

On screens without typed text (menus, confirm, tree, browser) 'q' also
aborts; inside text editors it is an ordinary character.
"""

import os
from pathlib import Path
from typing import Callable, List, Optional, Sequence, Tuple

# Make Esc register quickly instead of pausing for an escape sequence.
os.environ.setdefault("ESCDELAY", "25")


class WizardCancelled(Exception):
    """Raised when the user presses Esc to abort the wizard."""


# ---------------------------------------------------------------------------
# Shared drawing helpers
# ---------------------------------------------------------------------------

def _addstr(scr, y: int, x: int, text: str, attr: int = 0) -> None:
    """addstr that ignores out-of-bounds and terminal-capability errors."""
    try:
        scr.addstr(y, x, text, attr)
    except Exception:
        pass


def _fit(text: str, width: int) -> str:
    """Truncate TEXT to WIDTH columns, appending '~' when cut."""
    if width < 1:
        return ""
    if len(text) <= width:
        return text
    return text[: max(0, width - 1)] + "~"


class Frame:
    """A screen frame: title, scrolling body rows, message and footer.

    Widgets append styled body rows via mark(), call draw() after every
    state change, and read keys through get_key()/edit_line().
    """

    def __init__(self, scr, title: str, footer: str):
        import curses
        self.curses = curses
        self.scr = scr
        self.title = title
        self.footer = footer
        self.message = ""            # transient status line
        self.message_attr = None     # None -> bold reverse video
        self.rows: List[dict] = []   # {text, attr, indent}
        self.scroll = 0
        self.cursor = 0              # highlighted row index

    def mark(self, text: str, attr: int = 0, indent: int = 0) -> None:
        self.rows.append({"text": text, "attr": attr, "indent": indent})

    def draw(self) -> None:
        curses = self.curses
        scr = self.scr
        scr.erase()
        height, width = scr.getmaxyx()
        if height < 6 or width < 20:
            _addstr(scr, 0, 0, _fit("Terminal too small", width - 1),
                    curses.A_BOLD)
            scr.refresh()
            return
        top = 2
        visible = height - 3 - top
        if visible < 1:
            visible = 1
        # Keep the cursor inside the viewport.
        if self.cursor < self.scroll:
            self.scroll = self.cursor
        elif self.cursor >= self.scroll + visible:
            self.scroll = self.cursor - visible + 1
        if self.scroll + visible > len(self.rows):
            self.scroll = max(0, len(self.rows) - visible)
        scrolling = len(self.rows) > visible
        indicator = f" {self.cursor + 1}/{len(self.rows)} " if scrolling else ""
        title_width = width - 1 - (len(indicator) if indicator else 0)
        _addstr(scr, 0, 0, _fit(self.title, title_width),
                curses.A_BOLD | curses.A_UNDERLINE)
        for index in range(self.scroll,
                           min(len(self.rows), self.scroll + visible)):
            row = self.rows[index]
            line = "  " * row["indent"] + row["text"]
            attr = row["attr"]
            if index == self.cursor:
                attr |= curses.A_REVERSE
            _addstr(scr, top + index - self.scroll, 0,
                    _fit(line, width - 1), attr)
        if indicator:
            _addstr(scr, 0, max(0, width - len(indicator)), indicator,
                    curses.A_DIM)
        if self.message:
            attr = self.message_attr
            if attr is None:
                attr = curses.A_BOLD | curses.A_REVERSE
            _addstr(scr, height - 2, 0, _fit(self.message, width - 1), attr)
        _addstr(scr, height - 1, 0, _fit(self.footer, width - 1), curses.A_DIM)
        scr.refresh()

    # -- key helpers ------------------------------------------------------

    def get_key(self, cancel_keys: Sequence[int] = (27,)) -> int:
        """Read one key; cancel keys and Ctrl-C raise WizardCancelled."""
        try:
            key = self.scr.getch()
        except KeyboardInterrupt:
            raise WizardCancelled() from None
        if key == 3:  # Ctrl-C
            raise WizardCancelled()
        if key in cancel_keys:
            raise WizardCancelled()
        return key

    def edit_line(self, start: str, prompt: str = ""
                  ) -> Optional[str]:
        """Run an inline editor on the message line.

        Returns the edited string on Enter, or None when the user backs
        out with Esc (the caller decides what that means).
        """
        curses = self.curses
        text = start
        while True:
            height, width = self.scr.getmaxyx()
            self.message = ""
            self.draw()
            room = max(1, width - 2 - len(prompt))
            shown = text if len(text) < room else ">" + text[-(room - 2):]
            _addstr(self.scr, height - 2, 0,
                    _fit(f"{prompt}{shown}_", width - 1), curses.A_BOLD)
            self.scr.refresh()
            try:
                key = self.scr.getch()
            except KeyboardInterrupt:
                raise WizardCancelled() from None
            if key == 27:
                return None
            if key in (10, 13):  # Enter
                return text
            if key in (curses.KEY_BACKSPACE, 8, 127):
                text = text[:-1]
            elif 32 <= key < 127:
                text += chr(key)


# ---------------------------------------------------------------------------
# Widget: yes/no confirm
# ---------------------------------------------------------------------------

def confirm(scr, question: str, default: bool = False,
            body: Optional[Sequence[str]] = None) -> bool:
    """Ask a yes/no QUESTION; Enter takes DEFAULT, Esc aborts.

    BODY lines are shown above the question (a summary, for example).
    """
    frame = Frame(scr, question,
                  "y = yes  n = no  Enter = default  Esc = cancel")
    cancel = (27, ord("q"))
    while True:
        frame.rows = []
        for line in body or []:
            frame.mark(line)
        if body:
            frame.mark("")
        hint = "[Y/n]" if default else "[y/N]"
        frame.mark(f"{question} {hint}")
        frame.cursor = len(frame.rows) - 1
        frame.draw()
        key = frame.get_key(cancel)
        if key in (ord("y"), ord("Y")):
            return True
        if key in (ord("n"), ord("N")):
            return False
        if key in (10, 13):
            return default


# ---------------------------------------------------------------------------
# Widget: single-choice menu
# ---------------------------------------------------------------------------

def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0):
    """Show OPTIONS as (label, value) pairs; return the chosen value.

    The cursor starts on DEFAULT_INDEX; Enter returns the highlighted
    option's value.
    """
    frame = Frame(scr, title,
                  "Up/Down = move  Enter = select  Esc = cancel")
    cancel = (27, ord("q"))
    cursor = max(0, min(default_index, len(options) - 1))
    while True:
        frame.rows = []
        for label, _ in options:
            frame.mark(label)
        frame.cursor = cursor
        frame.draw()
        curses = frame.curses
        key = frame.get_key(cancel)
        if key in (curses.KEY_UP, ord("k")):
            cursor = (cursor - 1) % len(options)
        elif key in (curses.KEY_DOWN, ord("j")):
            cursor = (cursor + 1) % len(options)
        elif key in (10, 13):
            return options[cursor][1]


# ---------------------------------------------------------------------------
# Widget: single-line text editor
# ---------------------------------------------------------------------------

def line_edit(scr, title: str, default: str,
              validate: Optional[Callable[[str], Optional[str]]] = None
              ) -> str:
    """Edit one line of text, pre-filled with DEFAULT; Enter accepts.

    VALIDATE receives the entered string and returns an error message or
    None; Enter on an invalid value shows the message and keeps editing.
    Esc aborts the wizard ('q' is an ordinary character here).
    """
    frame = Frame(scr, title,
                  "type to edit  Backspace = erase  Enter = accept  "
                  "Esc = cancel")
    text = default
    error = ""
    while True:
        frame.rows = []
        frame.mark("")
        frame.mark(f"  {text}_")
        frame.cursor = 1
        frame.message = error
        frame.draw()
        curses = frame.curses
        key = frame.get_key()  # Esc only; 'q' must stay typeable
        if key in (10, 13):
            if validate is None:
                return text
            error = validate(text)
            if error is None:
                return text
            error = f"{error}  (edit, then Enter)"
            continue
        if key in (curses.KEY_BACKSPACE, 8, 127):
            text = text[:-1]
        elif 32 <= key < 127:
            text += chr(key)


# ---------------------------------------------------------------------------
# Widget: directory browser
# ---------------------------------------------------------------------------

def _list_dirs(path: Path) -> List[Path]:
    """Return the subdirectories of PATH, sorted, dot-dirs excluded."""
    try:
        entries = [child for child in path.iterdir()
                   if child.is_dir() and not child.name.startswith(".")]
    except OSError:
        return []
    return sorted(entries, key=lambda child: child.name.lower())


def browse_directory(scr, title: str,
                     validate: Optional[Callable[[Path], Optional[str]]] = None,
                     start: Optional[Path] = None
                     ) -> Path:
    """Pick a directory; Enter accepts the directory being listed.

    Right (or l) descends into the highlighted entry, Left/Backspace/u
    goes to the parent, and e edits the path directly. VALIDATE receives
    the listed directory and returns an error message or None; Enter on
    an invalid directory is refused with that message. Esc aborts the
    wizard.
    """
    footer = ("Up/Down = move  Right = open  Left = parent  e = edit "
              "path  Enter = choose this directory  Esc = cancel")
    frame = Frame(scr, title, footer)
    cancel = (27, ord("q"))
    current = Path(start) if start is not None else Path.cwd()
    try:
        current = current.resolve()
    except OSError:
        current = Path.cwd()
    cursor = 0

    def validation_error() -> Optional[str]:
        if validate is None:
            return None
        return validate(current)

    while True:
        entries = _list_dirs(current)
        cursor = max(0, min(cursor, max(0, len(entries) - 1)))
        frame.rows = []
        frame.mark(f"Directory: {current}", frame.curses.A_BOLD)
        error = validation_error()
        if error is None:
            frame.mark("  This directory is a valid choice. Press Enter.",
                       frame.curses.A_DIM)
        else:
            frame.mark(f"  {error}", frame.curses.A_BOLD)
        frame.mark("")
        if not entries:
            frame.mark("  (no subdirectories)")
        for entry in entries:
            frame.mark(f"  {entry.name}/")
        header = 3  # directory line, validity line, blank separator
        frame.cursor = header + (cursor if entries else 0)
        frame.message = ""
        frame.draw()
        curses = frame.curses
        key = frame.get_key(cancel)
        if key in (curses.KEY_UP, ord("k")):
            cursor = max(0, cursor - 1)
        elif key in (curses.KEY_DOWN, ord("j")):
            if entries:
                cursor = min(len(entries) - 1, cursor + 1)
        elif key in (curses.KEY_RIGHT, ord("l")):
            if entries:
                current = entries[cursor]
                cursor = 0
        elif key in (curses.KEY_LEFT, ord("h"), ord("u"),
                     curses.KEY_BACKSPACE, 8, 127):
            parent = current.parent
            if parent != current:
                current = parent
                cursor = 0
        elif key == ord("e"):
            result = frame.edit_line("", prompt="path: ")
            if result is not None:
                candidate = Path(os.path.expanduser(result))
                if not candidate.is_absolute():
                    candidate = current / candidate
                try:
                    candidate = candidate.resolve()
                except OSError:
                    pass
                if candidate.is_dir():
                    current = candidate
                    cursor = 0
                else:
                    frame.message = f"Not a directory: {candidate}"
                    frame.draw()
                    frame.get_key(cancel)
                    frame.get_key(cancel)
        elif key in (10, 13):  # Enter: accept the listed directory
            error = validation_error()
            if error is None:
                return current
            frame.message = f"{error}  (keep browsing)"
            frame.draw()
            frame.get_key(cancel)


# ---------------------------------------------------------------------------
# Widget: expandable checkbox tree
# ---------------------------------------------------------------------------

def checkbox_tree(scr, title: str, families: List[dict],
                  footer: Optional[str] = None,
                  expand_all: bool = False) -> List[Tuple[int, str]]:
    """Pick model families and packages from an expandable tree.

    FAMILIES is a list of dicts (one per family) shaped like::

        {
            "label": "Qwen3-TTS (qwen3_tts)",
            "detail": "tts, cloning, design",
            "options": [
                {"key": "Base-GGUF", "label": "base", "recommended": True},
                {"key": "VoiceDesign-GGUF", "label": "voicedesign",
                 "recommended": False},
            ],
        }

    Space on a family row checks its recommended option (or clears every
    option when one is already checked); Space on an option row toggles
    that option. Tab/Right expands or collapses the family under the
    cursor. Enter returns the flat list of (family_index, option_key)
    pairs for every checked option, in tree order; at least one checked
    option is required. The first family's recommended option starts
    checked (the prompt flow's default), and with EXPAND_ALL every
    family starts expanded.
    """
    footer = footer or ("Up/Down = move  Tab/Right = expand  Space = check  "
                        "Enter = accept  Esc = cancel")
    frame = Frame(scr, title, footer)
    cancel = (27, ord("q"))
    expanded = {index for index in range(len(families))} if expand_all else set()
    checked = set()  # (family_index, option_key)

    if families:
        expanded.add(0)
        first = families[0]["options"]
        for option in first:
            if option.get("recommended"):
                checked.add((0, option["key"]))
                break
        else:
            if first:
                checked.add((0, first[0]["key"]))

    def family_checked(index: int) -> bool:
        return any(pair[0] == index for pair in checked)

    def accept() -> List[Tuple[int, str]]:
        return [(index, option["key"])
                for index, family in enumerate(families)
                for option in family["options"]
                if (index, option["key"]) in checked]

    def visible_nodes() -> List[tuple]:
        nodes: List[tuple] = []  # ("family", i) or ("option", i, key)
        for index, family in enumerate(families):
            nodes.append(("family", index))
            if index in expanded:
                for option in family["options"]:
                    nodes.append(("option", index, option["key"]))
        return nodes

    cursor = 0
    while True:
        nodes = visible_nodes()
        cursor = max(0, min(cursor, len(nodes) - 1))
        frame.rows = []
        for node in nodes:
            if node[0] == "family":
                index = node[1]
                family = families[index]
                mark = "x" if family_checked(index) else " "
                arrow = "-" if index in expanded else "+"
                attr = frame.curses.A_BOLD if family_checked(index) else 0
                frame.mark(f"[{mark}] {arrow} {family['label']}", attr)
            else:
                _, index, option_key = node
                option = next(opt for opt in families[index]["options"]
                              if opt["key"] == option_key)
                is_on = (index, option_key) in checked
                mark = "x" if is_on else " "
                note = "  [recommended]" if option.get("recommended") else ""
                frame.mark(f"    [{mark}] {option['label']}{note}")
        frame.cursor = cursor
        node = nodes[cursor]
        frame.message = families[node[1]].get("detail", "")
        frame.message_attr = frame.curses.A_DIM
        frame.draw()
        curses = frame.curses
        key = frame.get_key(cancel)
        if key in (curses.KEY_UP, ord("k")):
            cursor = (cursor - 1) % len(nodes)
        elif key in (curses.KEY_DOWN, ord("j")):
            cursor = (cursor + 1) % len(nodes)
        elif key in (9, curses.KEY_RIGHT, ord("l")) and node[0] == "family":
            index = node[1]
            if index in expanded:
                expanded.discard(index)
            else:
                expanded.add(index)
        elif key == curses.KEY_LEFT and node[0] == "family":
            expanded.discard(node[1])
        elif key == ord(" "):
            if node[0] == "family":
                index = node[1]
                options = families[index]["options"]
                if family_checked(index):
                    for option in options:
                        checked.discard((index, option["key"]))
                else:
                    for option in options:
                        if option.get("recommended"):
                            checked.add((index, option["key"]))
                            break
                    else:
                        if options:
                            checked.add((index, options[0]["key"]))
                    expanded.add(index)
            else:
                _, index, option_key = node
                if (index, option_key) in checked:
                    checked.discard((index, option_key))
                else:
                    checked.add((index, option_key))
        elif key in (10, 13):  # Enter: accept the checked selection
            selection = accept()
            if selection:
                return selection
            frame.message = "Check at least one model package (Space)"
            frame.message_attr = None
            frame.draw()
            frame.get_key(cancel)
            frame.message_attr = frame.curses.A_DIM