diff --git a/README.md b/README.md index 229e900..f7652e0 100644 --- a/README.md +++ b/README.md @@ -236,27 +236,39 @@ list of hashed directory names — and installing into the wrong one looks exactly like the theme not working: ``` -Several Firefox profiles exist: - 1. default-release [Release] …/Profiles/8f2b1a.default-release (Enter) - 2. dev-edition-default [Developer Edition] …/Profiles/c41d9e.dev-edition-default - 3. work [unrecognised] …/Profiles/7ab3.work + Which profile? 3 found in ~/Library/Application Support/Firefox/Profiles +› 1. default-release Release 8f2b1a.default-release (Enter) + 2. dev-edition-default Developer Edition c41d9e.dev-edition-default + 3. work unrecognised 7ab3.work + + Which one? ↑↓ or 1-3 [1] ``` The label comes from the directory suffix Firefox itself assigns, so a profile -you named yourself reads `[unrecognised]` rather than being guessed at. +you named yourself reads `unrecognised` rather than being guessed at. Run it without `--with` and it offers the theme's optional stylesheets rather than leaving you to find them in the repository: ``` - This theme ships optional stylesheets: - 1. compact-tabs - 2. theme-dracula - … - Numbers separated by commas, `all`, or Enter for none. - Include: + Optional stylesheets 30 · pick any + › 1. compact-tabs 11. theme-dracula 21. tabs-below-url + 2✓ hide-sidebar-icon 12. theme-nord 22. urlbar-centred + … + + Include which? ↑↓←→ and space to tick, or e.g. 1,3 or all [2] ``` +Every numbered question answers to the keyboard at a terminal. ↑ and ↓ move +the `›` (← and → cross columns), Space ticks a stylesheet, Enter takes the +answer and Esc stops. Typing the number works as it always has, and moves the +pointer as you type. Through a pipe, in CI, with `TERM=dumb` or in a terminal +narrower than 60 columns, the question is a plain typed prompt; set +`FXCSS_PLAIN_PROMPTS=1` to keep it that way everywhere, for example for a +screen reader that copes badly with lines redrawn in place. Each question +ends by naming the option that skips it next time (`--profile`, `--with`, +`--firefox`, `--subpath`, `--ref` or `--commit`). + #### Options that cancel each other out Some of a theme's optional sheets are alternatives rather than additions. diff --git a/fxcss/cli.py b/fxcss/cli.py index 1ff52ce..023beac 100644 --- a/fxcss/cli.py +++ b/fxcss/cli.py @@ -262,59 +262,70 @@ def _parse_choice(raw, count, default): return index if 0 <= index < count else default -def _menu(question, note, lines, default): +def _menu(question, note, count, row, default): """A numbered question, asked the same way by every command. - A bold heading with a dim note, one line per choice (rendered by the - caller, the default marked), then `Which one? 1-N [d]`. Enter takes the - default, and so does anything unusable. Returns a zero-based index. + A bold heading with a dim note, one line per choice, then `Which one? + 1-N [d]`. Enter takes the default, and so does anything unusable. + `row(i, current, pointer)` draws choice i, `current` being the one Enter + takes. At a terminal that can redraw, the arrow keys move a pointer over + the rows as well; the typed answer works either way. Returns an index. """ - from . import style + from . import prompt, style print() print(style.heading_line(question, note)) - for line in lines: - print(line) + height = sum(row(i, False, False).count("\n") + 1 for i in range(count)) + 2 + if prompt.arrows_ok(height): + return prompt.pick_one(count, default, row) + for i in range(count): + print(row(i, current=i == default, pointer=False)) print() try: - raw = input(style.ask("Which one?", f"1-{len(lines)}", str(default + 1))) + raw = input(style.ask("Which one?", f"1-{count}", str(default + 1))) except EOFError: raw = "" - return _parse_choice(raw, len(lines), default) + return _parse_choice(raw, count, default) -def _menu_row(number, name, cells=(), default=False, path=False): +def _menu_row(number, name, cells=(), default=False, path=False, pointer=False): """One choice: its number, the name in bold, then dim detail. The last cell is cut to the terminal rather than wrapped back under the number, so every choice stays on one line and the numbers stay a column; `path` says it is a path, which keeps its tail. A last cell with too little room left to say anything is left out rather than cut to a stub. + Room for `(Enter)` is kept on every row, because with the arrow keys the + marker moves, and a row must not change length as it passes. + + `pointer` puts the `›` in the margin and the name in the brand colour: + the choice the arrow keys are on. """ from . import style - lead = f" {number:>2}. " + lead = f"{style.brand(style.POINTER) if pointer else ' '} {number:>2}. " + width = len(f" {number:>2}. ") cells = [c for c in cells if c] - tail = " (Enter)" if default else "" if cells: - used = len(lead) + len(name) + sum(len(c) + 2 for c in cells[:-1]) + 2 + len(tail) + used = width + len(name) + sum(len(c) + 2 for c in cells[:-1]) + 2 + len(" (Enter)") room = style.term_width() - 1 - used if room < 16: cells.pop() else: cells[-1] = style.clip(cells[-1], room, path=path) - line = lead + style.strong(name) + "".join(f" {style.muted(c)}" for c in cells) + title = style.brand(name) if pointer else style.strong(name) + line = lead + title + "".join(f" {style.muted(c)}" for c in cells) return line + (f" {style.muted('(Enter)')}" if default else "") -def _skip_hint(value, option): +def _skip_hint(value, option, verb="using"): """`using nightly --firefox nightly skips this question`, after a menu. Every menu ends this way: what was picked, and the option that picks it next time, so a question answered once need never be asked again. """ from . import style - used = f" {style.muted('using')} {style.strong(value)}" + used = f" {style.muted(verb)} {style.strong(value)}" hint = f"{style.cmd(option)}{style.muted(' skips this question')}" - if len(f" using {value} {option} skips this question") < style.term_width(): + if len(f" {verb} {value} {option} skips this question") < style.term_width(): return f"{used} {hint}" return f"{used}\n {hint}" @@ -355,11 +366,13 @@ def choose_firefox(explicit): label_w = max(len(build["label"]) for build in builds) version_w = max(len(v) for v in versions) folder, places = _shared_parent([style.short_path(_app_bundle(b["path"])) for b in builds]) - lines = [_menu_row(i, build["label"].ljust(label_w), [version.ljust(version_w), place], - default=i - 1 == default, path=True) - for i, (build, version, place) in enumerate(zip(builds, versions, places), 1)] + def row(i, current, pointer): + return _menu_row(i + 1, builds[i]["label"].ljust(label_w), + [versions[i].ljust(version_w), places[i]], + default=current, pointer=pointer, path=True) + note = f"{len(builds)} installed" + (f" in {folder}" if folder else "") - picked = builds[_menu("Which Gecko build?", note, lines, default)] + picked = builds[_menu("Which Gecko build?", note, len(builds), row, default)] print(_skip_hint(picked["label"], f"--firefox {picked['label']}")) print() return picked["path"] @@ -460,10 +473,12 @@ def _plan_source(repo_root, args, interactive, workdir, remote=False, quiet=Fals parts.append((key, about.replace("; ", f" {style.DOT} ") + (f" [{warning}" if bracket else ""))) key_w = max(len(key) for key, _ in parts) - lines = [_menu_row(i, key.ljust(key_w), [about], default=i == 1) - for i, (key, about) in enumerate(parts, 1)] + def row(i, current, pointer): + key, about = parts[i] + return _menu_row(i + 1, key.ljust(key_w), [about], default=current, pointer=pointer) + chosen = candidates[_menu("Which theme?", f"this repository holds {len(candidates)}", - lines, 0)] + len(candidates), row, 0)] print(_skip_hint(chosen["key"], f"--subpath {shlex.quote(chosen['key'])}")) plan = layout.discover(repo_root, subpath=chosen["key"], entry=entry, workdir=workdir / "layout") @@ -1161,17 +1176,18 @@ def _choose_profile(explicit, interactive, prefer=None): name_w = max(len(p["name"]) for p in profiles) kind_w = max(len(p.get("kind") or "unrecognised") for p in profiles) folder, places = _shared_parent([_short_path(p["path"]) for p in profiles]) - lines = [] - for i, (profile, place) in enumerate(zip(profiles, places), 1): - kind = (profile.get("kind") or "unrecognised").ljust(kind_w) - line = _menu_row(i, profile["name"].ljust(name_w), [kind, place], - default=i - 1 == default, path=True) - notes = _profile_notes(profile) - if notes: - line += "\n " + style.brand(f"{style.ARROW} " + ", ".join(notes)) - lines.append(line) + notes = [_profile_notes(profile) for profile in profiles] + + def row(i, current, pointer): + kind = (profiles[i].get("kind") or "unrecognised").ljust(kind_w) + line = _menu_row(i + 1, profiles[i]["name"].ljust(name_w), [kind, places[i]], + default=current, pointer=pointer, path=True) + if notes[i]: + line += "\n " + style.brand(f"{style.ARROW} " + ", ".join(notes[i])) + return line + note = f"{len(profiles)} found" + (f" in {folder}" if folder else "") - picked = profiles[_menu("Which profile?", note, lines, default)] + picked = profiles[_menu("Which profile?", note, len(profiles), row, default)] print(_skip_hint(picked["name"], f"--profile {shlex.quote(picked['name'])}")) return picked @@ -1263,13 +1279,19 @@ def _choose_sheets(variants, interactive): """ if not interactive or not variants: return [] - from . import style - print() - print(style.heading_line("Optional stylesheets", - f"{len(variants)} {style.DOT} pick any by number")) - for line in style.columns([sheet.stem for sheet in variants], indent=4, numbered=True): - print(line) + from . import prompt, style + names = [sheet.stem for sheet in variants] + grid = style.columns(names, indent=4, numbered=True) + # The arrow keys and Space tick sheets at a terminal that can redraw the + # grid; typing the numbers works there too, and is all there is elsewhere. + arrows = prompt.arrows_ok(len(grid) + 2) print() + print(style.heading_line("Optional stylesheets", f"{len(variants)} {style.DOT} " + + ("pick any" if arrows else "pick any by number"))) + if not arrows: + for line in grid: + print(line) + print() # `all` is offered above and is right for most themes, but wrong for one # with a family of alternatives in it -- so a picked set is checked and # the question asked again, rather than the answer being refused. @@ -1278,10 +1300,16 @@ def _choose_sheets(variants, interactive): # stray escape sequence in the line turned "20, 4" into sheet 4 alone. chosen = [] for attempt in range(3): - try: - raw = input(style.ask("Include which?", "e.g. 1,3 or all", "none")) - except EOFError: - raw = "" + # A retry is typed: the question it asks ("retype the whole list") is + # about the text, and the grid has scrolled up behind the warnings. + if arrows and attempt == 0: + raw = prompt.pick_many(names, "Include which?", "e.g. 1,3 or all", + lambda text: scan_selection(text, len(names))[0]) + else: + try: + raw = input(style.ask("Include which?", "e.g. 1,3 or all", "none")) + except EOFError: + raw = "" # Empty means "none" the first time and "keep what you read" on a # retry, so accepting a partial answer never costs the whole list. if attempt and not raw.strip(): @@ -1303,7 +1331,9 @@ def _choose_sheets(variants, interactive): f"{len(indices) + len(ignored)} you asked for. Retype the " "whole list, or Enter to keep it.") if chosen: - print(f" {style.muted('including')} {style.strong(', '.join(s.stem for s in chosen))}") + stems = [s.stem for s in chosen] + print(_skip_hint(", ".join(stems), f"--with {shlex.quote(','.join(stems))}", + verb="including")) return chosen @@ -1330,11 +1360,14 @@ def _choose_ref(info, prefer, interactive): label_w = max(len(option["label"]) for option in options) date_w = max(len(option["date"]) for option in options) - lines = [_menu_row(i, option["label"].ljust(label_w), - [option["date"].ljust(date_w), option["note"]], default=i == 1) - for i, option in enumerate(options, 1)] + def row(i, current, pointer): + option = options[i] + return _menu_row(i + 1, option["label"].ljust(label_w), + [option["date"].ljust(date_w), option["note"]], + default=current, pointer=pointer) + picked = options[_menu("Which version?", "the default branch has moved on since the " - "latest release", lines, 0)] + "latest release", len(options), row, 0)] branch = picked["ref"] == info.get("default_branch") print(_skip_hint(picked["label"], "--commit" if branch else f"--ref {shlex.quote(picked['ref'])}")) return picked["ref"], picked["why"] @@ -3262,6 +3295,13 @@ def main(argv=None): try: _resolve_layouts(args) return args.func(args) + except KeyboardInterrupt: + # Ctrl+C, or Esc at a menu: stop where we are, without a traceback. + # Commands that need tidying up (a browser to close) do it in their + # own finally blocks on the way here. + from . import style + print(f"\n {style.muted('Stopped.')}", file=sys.stderr) + return 130 except LayoutError as exc: print(f"\nerror: {exc}", file=sys.stderr) return 2 diff --git a/fxcss/prompt.py b/fxcss/prompt.py new file mode 100644 index 0000000..481cc73 --- /dev/null +++ b/fxcss/prompt.py @@ -0,0 +1,322 @@ +"""Answering fxcss's questions: type the number, or move to it with the arrows. + +Every numbered question takes a typed answer, exactly as it always has: that +is what works through a pipe, in a dumb terminal, under a screen reader and +in a script. At a real terminal the same prompt also listens for keys. ↑ and +↓ move a pointer, a typed number moves it too, and Enter takes it; in a +multiple choice, Space ticks. Only the menu's own lines are redrawn, never +the whole screen, and what is left in the scrollback reads like the typed +answer would have. + +FXCSS_PLAIN_PROMPTS=1 turns the keys off and keeps the typed prompt, for +anyone whose terminal or screen reader copes badly with lines redrawn in +place. +""" + +import os +import sys +from contextlib import contextmanager, nullcontext + +from . import style + +# A terminal narrower than the style layer's 60-column floor wraps rows, and +# a wrapped row cannot be redrawn in place. +MIN_COLUMNS = 60 + + +def arrows_ok(height=0): + """Arrow keys only for a person at a terminal that can redraw `height` lines. + + Both ends must be the real terminal: a test that stands in for stdout, + or output piped to a file, gets the typed prompt. + """ + if os.environ.get("FXCSS_PLAIN_PROMPTS") or os.environ.get("TERM") == "dumb": + return False + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + if not (os.isatty(sys.stdin.fileno()) and os.isatty(sys.stdout.fileno())): + return False + size = os.get_terminal_size(sys.stdout.fileno()) + except (AttributeError, ValueError, OSError): + return False + if size.columns < MIN_COLUMNS or size.lines <= height + 1: + return False + if os.name == "nt": + return _windows_vt() + try: + import termios # noqa: F401 + except ImportError: + return False + return True + + +# Keys, as tokens: "up", "down", "left", "right", "home", "end", "enter", +# "space", "backspace", "esc", "ctrl-c", "eof", or the character typed. + +_CSI = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", + "1~": "home", "7~": "home", "4~": "end", "8~": "end"} + +_WINDOWS = {"H": "up", "P": "down", "K": "left", "M": "right", "G": "home", "O": "end"} + + +def decode(first, more=lambda: ""): + """One key from the characters a terminal sends for it. + + `first` is the first character; after an Esc, `more()` returns what + followed it in the same burst ("" when nothing did, which is Esc itself). + Returns a token, or None for a key that means nothing here. + """ + if first == "\x1b": + seq = more() + if not seq: + return "esc" + if seq[0] in "[O" and len(seq) > 1: + body = seq[1:] + # A modifier rides in the middle: Ctrl+↑ is "[1;5A", Ctrl+Home + # "[1;5H" or "[7;5~". The final letter, or the number, is the key. + key = body[-1] if body[-1].isalpha() else body.split(";")[0].rstrip("~") + "~" + return _CSI.get(key) + return None + if first in ("\r", "\n"): + return "enter" + if first in ("\x7f", "\x08"): + return "backspace" + if first == "\x03": + return "ctrl-c" + if first in ("\x04", "\x1a"): + return "eof" + if first == " ": + return "space" + if len(first) == 1 and " " < first < "\x7f": + return first + return None + + +@contextmanager +def _posix_keys(): + import select + import termios + import tty + + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + + def read(timeout=None): + if timeout is not None and not select.select([fd], [], [], timeout)[0]: + return "" + data = os.read(fd, 1) + return data.decode("latin-1") if data else None + + def more(): + seq = "" + while len(seq) < 8: + ch = read(0.05) + if not ch: + break + seq += ch + if len(seq) > 1 and (ch.isalpha() or ch == "~"): + break + return seq + + def keys(): + while True: + first = read() + if first is None: + yield "eof" + return + key = decode(first, more) + if key: + yield key + + # cbreak, not raw: Ctrl+C still raises KeyboardInterrupt, and the + # terminal's own settings come back however this ends. + tty.setcbreak(fd) + try: + yield keys() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + + +@contextmanager +def _windows_keys(): # pragma: no cover - needs a Windows console + import msvcrt + + def keys(): + while True: + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + key = _WINDOWS.get(msvcrt.getwch()) + else: + key = decode(ch) + if key == "ctrl-c": + raise KeyboardInterrupt + if key: + yield key + + yield keys() + + +def _windows_vt(): # pragma: no cover - needs a Windows console + """Let a Windows console act on the cursor codes the redraw uses.""" + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) + mode = ctypes.c_uint32() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return False + return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004)) + except (AttributeError, OSError): + return False + + +def keypresses(): + return _windows_keys() if os.name == "nt" else _posix_keys() + + +class Frame: + """The menu's own lines at the foot of the terminal, redrawn in place.""" + + def __init__(self, out=None): + self.out = out or sys.stdout + self.height = 0 + + def draw(self, lines, final=False): + rows = "\n".join(lines).split("\n") + up = f"\033[{self.height - 1}A" if self.height > 1 else "" + text = ("\r" + up if self.height else "") + "\n".join(f"\033[2K{r}" for r in rows) + self.out.write(text + "\033[J" + ("\n" if final else "")) + self.out.flush() + self.height = len(rows) + + +def _cancel(frame): + frame.out.write("\n") + frame.out.flush() + raise KeyboardInterrupt + + +def pick_one(count, default, row, question="Which one?", keys=None, out=None): + """One of `count` choices; returns its zero-based index. + + `row(i, current, pointer)` draws choice i: `current` marks the one Enter + takes, `pointer` the one the arrow keys are on. A typed number moves the + pointer as it is typed; Backspace takes digits back. + """ + current, typed = default, "" + frame = Frame(out) + + def lines(final=False): + body = [row(i, current=not final and i == current, pointer=i == current) + for i in range(count)] + if final: + ask = style.ask(question, f"1-{count}", str(default + 1)) + str(current + 1) + else: + ask = style.ask(question, f"{style.UPDOWN} or 1-{count}", str(current + 1)) + typed + return body + ["", ask] + + with keypresses() if keys is None else nullcontext(iter(keys)) as stream: + frame.draw(lines()) + for key in stream: + if key in ("enter", "eof"): + break + if key in ("esc", "ctrl-c"): + _cancel(frame) + if key in ("up", "down", "home", "end"): + typed = "" + current = {"up": (current - 1) % count, "down": (current + 1) % count, + "home": 0, "end": count - 1}[key] + elif key == "backspace": + typed = typed[:-1] + if typed and 1 <= int(typed) <= count: + current = int(typed) - 1 + elif key.isdigit(): + for candidate in (typed + key, key): + if 1 <= int(candidate) <= count: + typed, current = candidate, int(candidate) - 1 + break + else: + continue + else: + continue + frame.draw(lines()) + frame.draw(lines(final=True), final=True) + return current + + +def pick_many(names, question, typed_hint, parse, keys=None, out=None, indent=4): + """Any of `names`; returns the answer as text, as if it had been typed. + + Space ticks the item at the pointer and ←→ cross columns. Typing works as + in the plain prompt (`1,3`, `all`), and what is typed is ticked as it is + typed. Once something is typed, Space is part of the text (`20 4`), as + it would be in the plain prompt. The answer comes back as the text the + plain prompt would have read, so one parser checks both. + """ + count = len(names) + step = style.column_rows(names, indent=indent, numbered=True) + current, ticks, typed = 0, set(), "" + frame = Frame(out) + + def shown(): + return ticks | set(parse(typed)) + + def answer(): + text = typed.strip() + if text.lower() in ("all", "*", "a") or not ticks: + return text + numbers = ",".join(str(i + 1) for i in sorted(ticks)) + return f"{numbers},{text}" if text else numbers + + def summary(): + got = sorted(shown()) + if not got: + return "none" + if len(got) == count > 1: + return "all" + if len(got) > 6: + return f"{len(got)} ticked" + return ",".join(str(i + 1) for i in got) + + def lines(final=False): + grid = style.columns(names, indent=indent, numbered=True, + pointer=None if final else current, ticked=shown()) + if final: + ask = style.ask(question, typed_hint, "none") + answer() + else: + ask = style.ask(question, f"{style.ARROWS} and space to tick, or {typed_hint}", + summary()) + typed + return grid + ["", ask] + + with keypresses() if keys is None else nullcontext(iter(keys)) as stream: + frame.draw(lines()) + for key in stream: + if key in ("enter", "eof"): + break + if key in ("esc", "ctrl-c"): + _cancel(frame) + if key in ("up", "down"): + current = (current + (1 if key == "down" else -1)) % count + elif key in ("left", "right"): + moved = current + (step if key == "right" else -step) + if 0 <= moved < count: + current = moved + elif key == "right" and current // step < (count - 1) // step: + current = count - 1 + elif key in ("home", "end"): + current = 0 if key == "home" else count - 1 + elif key == "space" and not typed: + ticks ^= {current} + elif key == "backspace": + typed = typed[:-1] + elif key == "space" or len(key) == 1: + typed += " " if key == "space" else key + last = typed.replace(",", " ").split()[-1:] if typed.strip() else [] + if last and last[0].isdigit() and 1 <= int(last[0]) <= count: + current = int(last[0]) - 1 + else: + continue + frame.draw(lines()) + frame.draw(lines(final=True), final=True) + return answer() diff --git a/fxcss/style.py b/fxcss/style.py index f91947b..ef19cca 100644 --- a/fxcss/style.py +++ b/fxcss/style.py @@ -49,9 +49,11 @@ def _utf8(): if _utf8(): TICK, BANG, CROSS, ARROW, DOT, BULLET, ELLIPSIS, SEP = ( "✓", "!", "✗", "→", "·", "●", "…", "—") + POINTER, UPDOWN, ARROWS = "›", "↑↓", "↑↓←→" else: # pragma: no cover - depends on the console TICK, BANG, CROSS, ARROW, DOT, BULLET, ELLIPSIS, SEP = ( "ok", "!", "x", "->", "-", "*", "...", "-") + POINTER, UPDOWN, ARROWS = ">", "up/down", "arrows" def set_colour(on): @@ -266,33 +268,68 @@ def check(state, text, detail=None, indent=2, on=None): return out -def columns(items, indent=4, gap=3, width=None, numbered=False): +def _column_layout(labels, indent, gap, width): + for ncols in range(min(len(labels), 8), 0, -1): + nrows = -(-len(labels) // ncols) + cols = [labels[c * nrows:(c + 1) * nrows] for c in range(ncols)] + cols = [c for c in cols if c] + widths = [max(len(s) for s in col) for col in cols] + if indent + sum(widths) + gap * (len(cols) - 1) <= width - 1 or ncols == 1: + return cols, widths, nrows + return [labels], [max(len(s) for s in labels)], len(labels) + + +def _numbered(items): + digits = len(str(len(items))) + return [f"{i:>{digits}}. {name}" for i, name in enumerate(items, 1)] + + +def column_rows(items, indent=4, gap=3, width=None, numbered=False): + """How many rows `columns` would use: the step for moving across a column.""" + labels = _numbered(list(items)) if numbered else list(items) + return _column_layout(labels, indent, gap, width or term_width())[2] if labels else 0 + + +def columns(items, indent=4, gap=3, width=None, numbered=False, pointer=None, ticked=None): """Names packed into as many columns as fit, filled top to bottom. Each column is as wide as its own longest entry, and the gutter between columns is the same everywhere, so no pair of columns runs together. + + For a menu answered with the arrow keys, `pointer` is the index the `›` + stands at (it sits in the gutter, so the layout never shifts) and + `ticked` the indices whose `.` has become a `✓`. """ width = width or term_width() - labels = list(items) - if numbered: - digits = len(str(len(labels))) - labels = [f"{i:>{digits}}. {name}" for i, name in enumerate(labels, 1)] + names = list(items) + labels = _numbered(names) if numbered else list(names) if not labels: return [] - for ncols in range(min(len(labels), 8), 0, -1): - nrows = -(-len(labels) // ncols) - cols = [labels[c * nrows:(c + 1) * nrows] for c in range(ncols)] - cols = [c for c in cols if c] - widths = [max(len(s) for s in col) for col in cols] - if indent + sum(widths) + gap * (len(cols) - 1) <= width - 1 or ncols == 1: - break + cols, widths, nrows = _column_layout(labels, indent, gap, width) + marked = numbered and (pointer is not None or ticked) + ticked = set(ticked or ()) + digits = len(str(len(names))) out = [] for r in range(nrows): - cells = [] + line = "" for c, col in enumerate(cols): - if r < len(col): - cells.append(col[r].ljust(widths[c])) - out.append(" " * indent + (" " * gap).join(cells).rstrip()) + if r >= len(col): + continue + gutter = " " * (indent if c == 0 else gap) + cell = col[r].ljust(widths[c]) + if marked: + index = c * nrows + r + name = names[index] + if index == pointer: + gutter = gutter[:-2] + brand(POINTER) + " " + name = brand(name) + elif index in ticked: + name = strong(name) + mark = good(TICK) if index in ticked else "." + cell = (f"{index + 1:>{digits}}{mark} {name}" + + " " * (widths[c] - len(col[r]))) + line += gutter + cell + out.append(line.rstrip()) return out diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..5826702 --- /dev/null +++ b/tests/test_prompt.py @@ -0,0 +1,256 @@ +"""Menus answered with the arrow keys, and the typed prompt they fall back to.""" + +import io +import os +import re +import sys +import unittest +from unittest import mock + +from fxcss import cli, prompt, style + +ANSI = re.compile(r"\033\[[0-9;]*[A-Za-z]") + + +def visible(text): + return ANSI.sub("", text) + + +def screen(out): + """What the terminal shows at the end: the last frame, codes removed.""" + text = out.getvalue() + last = text.rsplit("\r", 1)[-1] + return [visible(line) for line in last.split("\n")] + + +class DecodeTests(unittest.TestCase): + def test_arrow_and_edit_keys(self): + seqs = {"[A": "up", "[B": "down", "[C": "right", "[D": "left", "OA": "up", + "[H": "home", "[F": "end", "[1~": "home", "[4~": "end", + "[1;5A": "up", "[1;2D": "left", "[7;5~": "home"} + for seq, key in seqs.items(): + self.assertEqual(prompt.decode("\x1b", lambda s=seq: s), key, seq) + + def test_plain_keys(self): + self.assertEqual(prompt.decode("\x1b"), "esc") + self.assertEqual(prompt.decode("\r"), "enter") + self.assertEqual(prompt.decode("\n"), "enter") + self.assertEqual(prompt.decode("\x7f"), "backspace") + self.assertEqual(prompt.decode(" "), "space") + self.assertEqual(prompt.decode("7"), "7") + self.assertEqual(prompt.decode(","), ",") + self.assertEqual(prompt.decode("\x03"), "ctrl-c") + self.assertEqual(prompt.decode("\x04"), "eof") + + def test_keys_that_mean_nothing_here(self): + self.assertIsNone(prompt.decode("\x1b", lambda: "[3~")) # Delete + self.assertIsNone(prompt.decode("\x1b", lambda: "x")) # Alt+x + self.assertIsNone(prompt.decode("\xe9")) # a byte of é + self.assertIsNone(prompt.decode("\x01")) + + +@unittest.skipIf(os.name == "nt", "a pseudo-terminal is POSIX only") +class TerminalTests(unittest.TestCase): + """The real reader, against a pseudo-terminal rather than a stand-in.""" + + def test_keys_arrive_and_the_terminal_is_put_back(self): + import pty + import termios + master, slave = pty.openpty() + try: + before = termios.tcgetattr(slave) + fake_stdin = mock.Mock(fileno=lambda: slave) + with mock.patch.object(sys, "stdin", fake_stdin): + with prompt.keypresses() as keys: + # Typed after cbreak starts: switching mode flushes input + # already waiting, as it should for a stale keystroke. + os.write(master, b"\x1b[B\x1b[B2\x7f\r") + got = [next(keys) for _ in range(5)] + during = termios.tcgetattr(slave) + self.assertEqual(got, ["down", "down", "2", "backspace", "enter"]) + self.assertFalse(during[3] & termios.ICANON) + # BSD kernels set PENDIN whenever a terminal returns to line + # mode; it is theirs, not a setting left behind. + after = termios.tcgetattr(slave) + pendin = getattr(termios, "PENDIN", 0) + after[3] &= ~pendin + before[3] &= ~pendin + self.assertEqual(after, before) + finally: + os.close(master) + os.close(slave) + + +class PickOneTests(unittest.TestCase): + def setUp(self): + style.set_colour(False) + self.addCleanup(style.set_colour, None) + patcher = mock.patch.dict(os.environ, {"COLUMNS": "100"}) + patcher.start() + self.addCleanup(patcher.stop) + + @staticmethod + def row(i, current, pointer): + return cli._menu_row(i + 1, ["stable", "developer", "nightly"][i].ljust(9), + ["155.0.1"], default=current, pointer=pointer) + + def pick(self, keys, default=0): + out = io.StringIO() + return prompt.pick_one(3, default, self.row, keys=keys, out=out), out + + def test_arrows_move_and_enter_takes(self): + self.assertEqual(self.pick(["down", "down", "enter"])[0], 2) + self.assertEqual(self.pick(["up", "enter"])[0], 2) # wraps + self.assertEqual(self.pick(["end", "home", "enter"])[0], 0) + self.assertEqual(self.pick(["enter"], default=1)[0], 1) + + def test_a_typed_number_moves_the_pointer(self): + self.assertEqual(self.pick(["3", "enter"])[0], 2) + self.assertEqual(self.pick(["2", "9", "enter"])[0], 1) # 29 is no choice + self.assertEqual(self.pick(["3", "backspace", "enter"])[0], 2) + self.assertEqual(self.pick(["x", "enter"])[0], 0) + + def test_the_scrollback_reads_like_a_typed_answer(self): + _, out = self.pick(["down", "enter"]) + lines = screen(out) + self.assertIn(" Which one? 1-3 [1] 2", lines) + self.assertIn("› 2. developer 155.0.1", lines) + self.assertNotIn("(Enter)", "\n".join(lines)) + + def test_while_open_the_prompt_names_the_keys(self): + out = io.StringIO() + prompt.Frame(out).draw(["x"]) + _, out = self.pick(["down", "enter"]) + frames = out.getvalue().split("\r") + self.assertIn("Which one? ↑↓ or 1-3 [2]", visible(frames[-2])) + self.assertIn("(Enter)", visible(frames[-2])) + + def test_esc_cancels(self): + with self.assertRaises(KeyboardInterrupt): + self.pick(["down", "esc"]) + + def test_the_pointer_does_not_move_the_columns(self): + plain = visible(self.row(1, False, False)) + pointed = visible(self.row(1, False, True)) + self.assertEqual(len(plain), len(pointed)) + self.assertTrue(pointed.startswith("›")) + + +class PickManyTests(unittest.TestCase): + NAMES = [f"sheet-{n}" for n in range(1, 13)] + + def setUp(self): + style.set_colour(False) + self.addCleanup(style.set_colour, None) + patcher = mock.patch.dict(os.environ, {"COLUMNS": "60"}) + patcher.start() + self.addCleanup(patcher.stop) + + def pick(self, keys): + out = io.StringIO() + parse = lambda text: cli.scan_selection(text, len(self.NAMES))[0] + answer = prompt.pick_many(self.NAMES, "Include which?", "e.g. 1,3 or all", + parse, keys=keys, out=out) + return answer, out + + def test_space_ticks_and_enter_answers_as_text(self): + self.assertEqual(self.pick(["space", "down", "down", "space", "enter"])[0], "1,3") + self.assertEqual(self.pick(["space", "space", "enter"])[0], "") + self.assertEqual(self.pick(["enter"])[0], "") + + def test_typing_works_as_in_the_plain_prompt(self): + self.assertEqual(self.pick(["1", ",", "3", "enter"])[0], "1,3") + self.assertEqual(self.pick(["2", "0", "space", "4", "enter"])[0], "20 4") + self.assertEqual(self.pick(["space", "a", "l", "l", "enter"])[0], "all") + self.assertEqual(self.pick(["space", "4", "enter"])[0], "1,4") + + def test_left_and_right_cross_columns(self): + rows = style.column_rows(self.NAMES, indent=4, numbered=True) + answer, _ = self.pick(["right", "space", "enter"]) + self.assertEqual(answer, str(rows + 1)) + answer, _ = self.pick(["left", "space", "enter"]) # no column left of it + self.assertEqual(answer, "1") + + def test_ticks_replace_the_dot_and_the_layout_holds(self): + plain = style.columns(self.NAMES, indent=4, numbered=True) + marked = [visible(l) for l in style.columns(self.NAMES, indent=4, numbered=True, + pointer=4, ticked={0, 4})] + self.assertEqual([len(l) for l in plain], [len(l) for l in marked]) + self.assertTrue(marked[0].lstrip().startswith("1✓ sheet-1")) + self.assertIn("› 5✓ sheet-5", "\n".join(marked)) # in the gutter + + def test_scrollback_shows_ticks_and_the_typed_equivalent(self): + _, out = self.pick(["space", "down", "down", "space", "enter"]) + lines = screen(out) + self.assertIn(" Include which? e.g. 1,3 or all [none] 1,3", lines) + self.assertNotIn("›", "\n".join(lines)) + self.assertTrue(any("3✓ sheet-3" in line for line in lines)) + + def test_esc_cancels(self): + with self.assertRaises(KeyboardInterrupt): + self.pick(["space", "esc"]) + + +class FallbackTests(unittest.TestCase): + """Anything short of a real, wide-enough terminal gets the typed prompt.""" + + def test_opt_out_dumb_terminal_and_stand_in_streams(self): + with mock.patch.dict(os.environ, {"FXCSS_PLAIN_PROMPTS": "1"}): + self.assertFalse(prompt.arrows_ok()) + with mock.patch.dict(os.environ, {"FXCSS_PLAIN_PROMPTS": "", "TERM": "dumb"}): + self.assertFalse(prompt.arrows_ok()) + with mock.patch.dict(os.environ, {"FXCSS_PLAIN_PROMPTS": "", "TERM": "xterm"}), \ + mock.patch.object(sys, "stdout", io.StringIO()): + self.assertFalse(prompt.arrows_ok()) + + def test_too_small_a_terminal(self): + size = os.terminal_size((50, 40)) + with mock.patch.dict(os.environ, {"FXCSS_PLAIN_PROMPTS": "", "TERM": "xterm"}), \ + mock.patch.object(sys.stdin, "isatty", return_value=True), \ + mock.patch.object(sys.stdout, "isatty", return_value=True), \ + mock.patch("os.isatty", return_value=True), \ + mock.patch.object(sys.stdin, "fileno", return_value=0), \ + mock.patch.object(sys.stdout, "fileno", return_value=1), \ + mock.patch("os.get_terminal_size", return_value=size): + self.assertFalse(prompt.arrows_ok()) + size = os.terminal_size((100, 12)) + with mock.patch("os.get_terminal_size", return_value=size): + self.assertFalse(prompt.arrows_ok(height=20)) + self.assertTrue(prompt.arrows_ok(height=5) or os.name == "nt") + + def test_the_menu_uses_the_typed_prompt_when_arrows_are_off(self): + with mock.patch.object(prompt, "arrows_ok", return_value=False), \ + mock.patch("builtins.input", return_value="2") as typed, \ + mock.patch("builtins.print"): + index = cli._menu("Which one?", "3 found", 3, PickOneTests.row, 0) + self.assertEqual(index, 1) + typed.assert_called_once() + + def test_the_menu_uses_the_keys_when_arrows_are_on(self): + with mock.patch.object(prompt, "arrows_ok", return_value=True), \ + mock.patch.object(prompt, "pick_one", return_value=2) as picked, \ + mock.patch("builtins.input") as typed, \ + mock.patch("builtins.print"): + index = cli._menu("Which one?", "3 found", 3, PickOneTests.row, 0) + self.assertEqual(index, 2) + picked.assert_called_once() + typed.assert_not_called() + + +class StopTests(unittest.TestCase): + def test_an_interrupted_command_stops_without_a_traceback(self): + def interrupted(args): + raise KeyboardInterrupt + err = io.StringIO() + # doctor reads the theme in the working directory before it runs, + # and a checkout with a build/ copy in it holds two: the interrupt + # under test would never be reached. Skip that step. + with mock.patch.object(cli, "cmd_doctor", interrupted), \ + mock.patch.object(cli, "_resolve_layouts", lambda args: None), \ + mock.patch.object(sys, "stderr", err): + self.assertEqual(cli.main(["doctor"]), 130, err.getvalue()) + self.assertIn("Stopped.", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_style.py b/tests/test_terminal_style.py index 436db56..3c8b6da 100644 --- a/tests/test_terminal_style.py +++ b/tests/test_terminal_style.py @@ -3,6 +3,7 @@ import os import re import unittest +from pathlib import Path from unittest import mock from fxcss import cli, style @@ -124,7 +125,8 @@ def ask(self, func, answer, *args): try: with mock.patch("builtins.input", lambda p: prompts.append(p) or answer), \ mock.patch("builtins.print", lambda *a, **k: out.append(" ".join(map(str, a)))), \ - mock.patch.dict(os.environ, {"COLUMNS": "80"}): + mock.patch.dict(os.environ, {"COLUMNS": "80", "FXCSS_PLAIN_PROMPTS": "1"}), \ + mock.patch.object(style, "term_width", return_value=80): result = func(*args) finally: style.set_colour(None) @@ -140,8 +142,9 @@ def test_firefox_menu_matches_the_profile_menu(self): path, lines, prompts = self.ask(cli.choose_firefox, "2", None) self.assertEqual(path, self.BUILDS[1]["path"]) self.assertEqual(prompts, [" Which one? 1-2 [1] "]) - # Both builds live in /Applications, so the heading names it once. - self.assertIn(" Which Gecko build? 2 installed in /Applications", lines) + # Both builds live in /Applications, so the heading names it once + # (with the platform's separator, a backslash on Windows). + self.assertIn(f" Which Gecko build? 2 installed in {Path('/Applications')}", lines) rows = [l for l in lines if re.match(r"\s+\d\. ", l)] self.assertEqual(rows[0], " 1. stable 155.0.1 Firefox.app (Enter)") self.assertEqual(rows[1].index("157.0a1"), rows[0].index("155.0.1"))