diff --git a/README.md b/README.md index f7652e0..516eaa4 100644 --- a/README.md +++ b/README.md @@ -252,13 +252,41 @@ than leaving you to find them in the repository: ``` 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 + Tweaks + 1✓ compact-tabs 5. increase-contrast 9. tabs-swapclose + 2. enabletabview 6. noidentity 10. windows-swapclose … - Include which? ↑↓←→ and space to tick, or e.g. 1,3 or all [2] + Colour schemes · pick one + 11. theme-ayu-mirage 21. theme-material-ocean + … + 15. theme-catppuccin-mocha › 25✓ theme-nord + … + + Include which? ↑↓←→ and space to tick, or e.g. 1,3 or all [1,25] ``` +When a theme ships both, colour schemes (named `theme-…`, or after a palette +such as `nord` or `catppuccin-mocha`) are listed apart from the tweaks; any +other theme gets one list. The grouping is only how the menu reads: the +sheets are numbered straight through, and what you pick is imported in the +theme's own order. "Pick one" appears when every pair of the theme's colour +schemes clashes in the way described below, so installing any two of them +would be refused. Pick two anyway and the menu asks which one to keep: + +``` + Keep which colour scheme? both set the same 110 colours; only one can take effect + 1. theme-catppuccin-mocha ██ #11111b ██ #cdd6f4 ██ #cba6f7 ██ #6c7086 +› 2. theme-nord ██ #242933 ██ #eceff4 ██ #88c0d0 ██ #5e81ac (Enter) + + Which one? ↑↓ or 1-2 [2] +``` + +Each row shows the colours that sheet would set, most-used first, as a +swatch in that colour and its hex code (just the hex code where the terminal +cannot show the colour). Enter keeps the one that loads last, which is the one +that would have won anyway. + 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 diff --git a/fxcss/cli.py b/fxcss/cli.py index 023beac..cca2ad7 100644 --- a/fxcss/cli.py +++ b/fxcss/cli.py @@ -673,11 +673,12 @@ def cmd_try(args): print(line) if facts["variants"]: - names = [v.stem for v in facts["variants"]] + shown, groups = _sheet_groups(facts["variants"]) + names = [v.stem for v in shown] print() print(style.heading_line("Optional stylesheets", f"{len(names)} {style.DOT} layer any on with --with name,name")) - for line in style.columns(names, indent=4): + for line in style.column_groups(names, groups, indent=4, numbered=False): print(line) if checklist_items := _checklist_items(plan["repo_root"], theme_root, plan, []): print() @@ -1258,11 +1259,13 @@ def _sheet_conflicts(chosen, indent=" "): do about it differs. An empty list means nothing measurable was found, which is not the same as proof that a combination works: see fxcss.sheets. """ - from . import sheets as sheets_mod + from . import sheets as sheets_mod, style found = sheets_mod.conflicts(chosen) for report in found: - print(f"\n{indent}{sheets_mod.describe(report)}") + print() + for line in style.wrap(sheets_mod.describe(report), indent=len(indent)): + print(line) for example in report["examples"][:2]: print(f"{indent} {example['selector']} {{ " f"{example['property']}: {example['a']} }} vs " @@ -1270,18 +1273,118 @@ def _sheet_conflicts(chosen, indent=" "): return found -def _choose_sheets(variants, interactive): +def _sheet_groups(variants): + """The stylesheet menu's order and groups: (sheets, [(title, count)] or None). + + Tweaks first, then colour schemes, when a theme ships both; any other + theme gets the single list it always had, in the order it was found. + + The colour schemes' title says "pick one" when every pair of them clashes + -- measured, as install measures before refusing a pair, not assumed from + the names -- so nobody has to pick two to find out. + """ + from . import inventory, sheets, style + schemes = [v for v in variants if inventory.is_colour_scheme(v.stem)] + tweaks = [v for v in variants if not inventory.is_colour_scheme(v.stem)] + if not schemes or not tweaks: + return list(variants), None + title = "Colour schemes" + pairs = len(schemes) * (len(schemes) - 1) // 2 + if pairs and len(sheets.conflicts(schemes)) == pairs: + title += f" {style.DOT} pick one" + return tweaks + schemes, [("Tweaks", len(tweaks)), (title, len(schemes))] + + +def _keep_one(chosen): + """Ask which of each set of clashing sheets to keep; returns the rest. + + A set is the worst-clashing pair plus any other chosen sheet that clashes + with all of them, so three colour schemes are one question, not three. + Each sheet is shown with the colours it would set (a swatch and its hex + code), and Enter keeps the one that loads last -- the one that would have + won anyway. Asked again until nothing chosen clashes. + """ + from . import inventory, sheets as sheets_mod, style + chosen = list(chosen) + while True: + reports = sheets_mod.conflicts(chosen) + if not reports: + return chosen + pairs = {frozenset((r["a"], r["b"])) for r in reports} + first = reports[0] + group = [first["a"], first["b"]] + for sheet in chosen: + if sheet.stem not in group and all(frozenset((sheet.stem, g)) in pairs + for g in group): + group.append(sheet.stem) + members = [sheet for sheet in chosen if sheet.stem in group] + if len(members) < 2: # two sheets share a stem: nothing to ask + return chosen + + values = [v for decls in sheets_mod.contested(members).values() for v in decls.values()] + what = "colour" if sum(map(sheets_mod.is_colour, values)) * 2 > len(values) else "property" + kind = sheets_mod.verdict(first) + if kind == "subsumed": + loser = sheets_mod.overridden(first) + other = first["b"] if loser == first["a"] else first["a"] + note = f"{loser} changes nothing alongside {other}" + elif kind == "alternatives": + note = (f"{'both' if len(members) == 2 else 'all'} set the same " + f"{style.plural(first['shared'], what)}; only one can take effect") + else: + note = (f"they set {style.plural(first['conflicting'], what)} differently; " + "the later one wins those") + noun = ("colour scheme" if all(inventory.is_colour_scheme(m.stem) for m in members) + else "stylesheet") + + colours = sheets_mod.palettes(members) + name_width = max(len(m.stem) for m in members) + hex_width = max((len(c) for cs in colours.values() for c in cs), default=0) + # Each colour is " ██ #1e1e2e" (or just the hex where a terminal + # cannot show the block), as many as fit beside the longest name. + cell = 2 + (len(style.BLOCK) + 1 if style.swatch("#000") else 0) + hex_width + room = style.term_width() - 1 - len(" 00. ") - name_width - len(" (Enter)") + fits = min(4, max(0, room // cell)) if hex_width else 0 + shown = max(min(fits, len(c)) for c in colours.values()) + width = len(" 00. ") + name_width + shown * cell + + def row(i, current, pointer): + sheet = members[i] + lead = f"{style.brand(style.POINTER) if pointer else ' '} {i + 1:>2}. " + title = style.brand(sheet.stem) if pointer else style.strong(sheet.stem) + line = lead + title + " " * (name_width - len(sheet.stem)) + for colour in colours[sheet][:fits]: + mark = style.swatch(colour) + line += " " + (mark + " " if mark else "") + style.muted(colour.ljust(hex_width)) + if not current: + return line.rstrip() + line += " " * (width - len(style._visible(line))) + return f"{line} {style.muted('(Enter)')}" + + pick = _menu(f"Keep which {noun}?", note, len(members), row, len(members) - 1) + dropped = {m for i, m in enumerate(members) if i != pick} + chosen = [sheet for sheet in chosen if sheet not in dropped] + + +def _choose_sheets(variants, interactive, force=False): """Offer a theme's optional stylesheets, when there is a human to ask. Only reachable when --with was not given: an explicit flag is an answer and asking again would be rude. Silence (no terminal, or CI) means none, which is what installing without --with has always done. + + The menu may group the sheets (see `_sheet_groups`) and numbers them in + the order shown; what is returned keeps the theme's own order, which is + the order they are imported in. Sheets that cancel each other out are + settled by asking which to keep, unless `force` (install's --force) says + to install them anyway. """ if not interactive or not variants: return [] from . import prompt, style - names = [sheet.stem for sheet in variants] - grid = style.columns(names, indent=4, numbered=True) + shown, groups = _sheet_groups(variants) + names = [sheet.stem for sheet in shown] + grid = style.column_groups(names, groups, indent=4) # 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) @@ -1292,19 +1395,17 @@ def _choose_sheets(variants, interactive): 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. - # - # Rejected tokens get the same treatment. Dropping them quietly is how a - # stray escape sequence in the line turned "20, 4" into sheet 4 alone. + # Rejected tokens get the question asked again. Dropping them quietly is + # how a stray escape sequence in the line turned "20, 4" into sheet 4 + # alone. chosen = [] for attempt in range(3): # 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]) + lambda text: scan_selection(text, len(names))[0], + groups=groups) else: try: raw = input(style.ask("Include which?", "e.g. 1,3 or all", "none")) @@ -1315,25 +1416,29 @@ def _choose_sheets(variants, interactive): if attempt and not raw.strip(): break indices, ignored = scan_selection(raw, len(variants)) - chosen = [variants[i] for i in indices] + picked = {shown[i] for i in indices} + chosen = [sheet for sheet in variants if sheet in picked] for token, why in ignored: # repr() so an escape sequence is shown rather than replayed at # the terminal -- seeing the \x1b is the whole diagnosis. print(style.warn(f" {style.BANG} ignored {token!r}: {why}")) - clashing = [r for r in _sheet_conflicts(chosen, indent=" ") - if r["conflicting"]] - if attempt == 2 or (not clashing and not ignored): + if attempt == 2 or not ignored: break - if clashing: - print("\n Pick one of each pair — the rest would have no effect.") - else: - print(f" That is {len(chosen)} of the " - f"{len(indices) + len(ignored)} you asked for. Retype the " - "whole list, or Enter to keep it.") + print(f" That is {len(chosen)} of the " + f"{len(indices) + len(ignored)} you asked for. Retype the " + "whole list, or Enter to keep it.") + # `all` is offered above and is right for most themes, but wrong for one + # with a family of alternatives in it. Only one of such a family can take + # effect, so which one is the question, rather than retyping the list. + # --force has already answered it: install them all, but say what clashes. + clashing = force and [r for r in _sheet_conflicts(chosen, indent=" ") + if r["conflicting"]] + if not force: + chosen = _keep_one(chosen) if chosen: stems = [s.stem for s in chosen] - print(_skip_hint(", ".join(stems), f"--with {shlex.quote(','.join(stems))}", - verb="including")) + option = f"--with {shlex.quote(','.join(stems))}" + (" --force" if clashing else "") + print(_skip_hint(", ".join(stems), option, verb="including")) return chosen @@ -1434,12 +1539,15 @@ def cmd_install(args): chosen = _match_sheets(args.with_sheets, facts) if chosen is None: return 2 + blocking = [r for r in _sheet_conflicts(chosen) if r["conflicting"]] else: - chosen = _choose_sheets(facts["variants"], interactive) - - # The menu can return its last selection on Enter, EOF or exhausted - # retries. Validate that answer just like an explicit --with list. - blocking = [r for r in _sheet_conflicts(chosen) if r["conflicting"]] + chosen = _choose_sheets(facts["variants"], interactive, force=args.force) + # The menu asks which of a clashing set to keep, so it should + # never hand one back without --force. Validate its answer like + # an explicit --with list all the same; any clash it found it has + # already shown, so this measures without a reprint. + from . import sheets as sheets_mod + blocking = [r for r in sheets_mod.conflicts(chosen) if r["conflicting"]] if blocking and not args.force: print("\n Refusing to install stylesheets that cancel each " "other out — only one\n of them would have any effect, " diff --git a/fxcss/inventory.py b/fxcss/inventory.py index 141b792..ea23288 100644 --- a/fxcss/inventory.py +++ b/fxcss/inventory.py @@ -60,6 +60,18 @@ OPTION_DIRS = ("custom", "optional", "options", "extras", "variants", "tweaks", "presets") +# Optional sheets named as colour schemes, so the install menu can show them +# apart from the tweaks. A name is a convention, not a fact, which is why +# fxcss.sheets measures clashes rather than reading names; grouping a menu is +# only presentation, and a sheet put in the wrong group is still offered, +# numbered and checked the same way. Where store themes ship colour schemes as +# files, they are named like this: `theme-nord`, or after the palette itself. +SCHEME_PREFIXES = ("theme-", "theme_") +PALETTES = frozenset({ + "ayu", "catppuccin", "dracula", "everforest", "frappe", "gruvbox", "kanagawa", + "latte", "macchiato", "mocha", "monokai", "nightfox", "nightowl", "nord", + "onedark", "rosepine", "solarized", "tokyonight"}) + # A bound on how much of a theme is read: the biggest real theme in the # 132-entry catalogue has a few hundred stylesheets. MAX_SHEETS = 4000 @@ -179,6 +191,22 @@ def _is_hook(resolved): return name in HOOK_NAMES or bool(HOOK_STEM_RE.search(Path(resolved).stem)) +def is_colour_scheme(name): + """Whether an optional sheet's name reads as a colour scheme. + + `name` is a file name or stem. True for the `theme-` prefix, or when any + word of the name, or two adjacent words run together (`rose-pine`, + `one-dark`), is a palette in PALETTES. + """ + stem = str(name).lower() + stem = stem[:-4] if stem.endswith(".css") else stem + if stem.startswith(SCHEME_PREFIXES): + return True + words = [w for w in re.split(r"[-_. ]+", stem) if w] + pairs = [a + b for a, b in zip(words, words[1:])] + return any(w in PALETTES for w in words + pairs) + + def _is_option_slot(resolved, option_names): parts = [part.lower() for part in Path(resolved).parts[:-1]] if any(part in OPTION_DIRS for part in parts): diff --git a/fxcss/prompt.py b/fxcss/prompt.py index 481cc73..c410e98 100644 --- a/fxcss/prompt.py +++ b/fxcss/prompt.py @@ -245,7 +245,7 @@ def lines(final=False): return current -def pick_many(names, question, typed_hint, parse, keys=None, out=None, indent=4): +def pick_many(names, question, typed_hint, parse, keys=None, out=None, indent=4, groups=None): """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 @@ -253,9 +253,13 @@ def pick_many(names, question, typed_hint, parse, keys=None, out=None, indent=4) 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. + + `groups` ([(title, count)], as for `style.column_groups`) shows the names + under titles, numbered straight through. ↑↓ run on from one group into + the next; ←→ stay inside the group. """ count = len(names) - step = style.column_rows(names, indent=indent, numbered=True) + spans = style.group_spans(names, groups, indent=indent) current, ticks, typed = 0, set(), "" frame = Frame(out) @@ -280,8 +284,8 @@ def summary(): 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()) + grid = style.column_groups(names, groups, indent=indent, + pointer=None if final else current, ticked=shown()) if final: ask = style.ask(question, typed_hint, "none") + answer() else: @@ -299,11 +303,13 @@ def lines(final=False): 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 + start, size, step = next(s for s in spans if s[0] <= current < s[0] + s[1]) + here = current - start + moved = here + (step if key == "right" else -step) + if 0 <= moved < size: + current = start + moved + elif key == "right" and here // step < (size - 1) // step: + current = start + size - 1 elif key in ("home", "end"): current = 0 if key == "home" else count - 1 elif key == "space" and not typed: diff --git a/fxcss/sheets.py b/fxcss/sheets.py index 1bc5463..d740919 100644 --- a/fxcss/sheets.py +++ b/fxcss/sheets.py @@ -30,7 +30,7 @@ import re from pathlib import Path -from .style import plural as _pl +from .style import plural as _pl, rgb as _rgb # One sheet being ~entirely overridden by another is what "alternatives" # means. Below this they are reported as overlapping, which is ordinary -- @@ -211,6 +211,58 @@ def overlap(first, second): } +HEX_COLOUR = re.compile(r"(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))(?:\s*!important)?", + re.I) + + +def contested(sheets): + """{sheet: {key: value}}: each sheet's declarations that some other sheet + among `sheets` sets differently -- the part of it that the others would + replace, or that would replace theirs.""" + decls = {sheet: read_declarations(sheet) for sheet in sheets} + keys = set() + for i, first in enumerate(sheets): + for second in sheets[i + 1:]: + a, b = decls[first], decls[second] + keys |= {key for key in set(a) & set(b) if a[key] != b[key]} + return {sheet: {k: v for k, v in decls[sheet].items() if k in keys} for sheet in sheets} + + +def is_colour(value): + return bool(HEX_COLOUR.fullmatch(value)) + + +def palettes(sheets, limit=4, apart=48): + """Each sheet's most-used colours where it clashes with the others. + + `sheets` are paths; returns {path: [hex, ...]}, most-used first, at most + `limit` each, from the declarations in `contested`. A colour within + `apart` (RGB distance) of one already taken is skipped, so a palette's + three near-identical background shades do not crowd out its text and + accent colours. Only plain hex values are read: a preview that misread + `var(--x)` or `color-mix()` would show a colour the theme never uses. + """ + out = {} + for sheet, decls in contested(sheets).items(): + counts, order = {}, [] + for value in decls.values(): + match = HEX_COLOUR.fullmatch(value) + if match: + colour = match.group(1).lower() + if colour not in counts: + order.append(colour) + counts[colour] = counts.get(colour, 0) + 1 + taken = [] + for colour in sorted(order, key=lambda c: -counts[c]): + rgb = _rgb(colour) + if all(sum((a - b) ** 2 for a, b in zip(rgb, _rgb(t))) >= apart ** 2 for t in taken): + taken.append(colour) + if len(taken) == limit: + break + out[sheet] = taken + return out + + def verdict(report): """What this pair is: "alternatives", "subsumed", "overlap" or "". diff --git a/fxcss/style.py b/fxcss/style.py index ef19cca..4a92623 100644 --- a/fxcss/style.py +++ b/fxcss/style.py @@ -4,7 +4,9 @@ returns the same words with colour off (piped output, NO_COLOR, --no-colour), so nothing depends on a colour to carry its meaning. Only the 16 ANSI colours plus bold and dim are used: the user's terminal theme picks the shades, which -is what keeps one palette readable on light and dark backgrounds alike. +is what keeps one palette readable on light and dark backgrounds alike. The +one exception is `swatch`, which previews a theme's own colours: those are +the point of showing it, and the hex code beside it says the same in text. Roles, not colours, are what the rest of the code asks for: @@ -49,11 +51,11 @@ def _utf8(): if _utf8(): TICK, BANG, CROSS, ARROW, DOT, BULLET, ELLIPSIS, SEP = ( "✓", "!", "✗", "→", "·", "●", "…", "—") - POINTER, UPDOWN, ARROWS = "›", "↑↓", "↑↓←→" + POINTER, UPDOWN, ARROWS, BLOCK = "›", "↑↓", "↑↓←→", "██" else: # pragma: no cover - depends on the console TICK, BANG, CROSS, ARROW, DOT, BULLET, ELLIPSIS, SEP = ( "ok", "!", "x", "->", "-", "*", "...", "-") - POINTER, UPDOWN, ARROWS = ">", "up/down", "arrows" + POINTER, UPDOWN, ARROWS, BLOCK = ">", "up/down", "arrows", "##" def set_colour(on): @@ -77,6 +79,51 @@ def colour_on(stream=None): return False +def colour_depth(): + """What a colour preview can use here: 24 (any colour), 8 (the 256-colour + palette, which nearly every terminal has) or 0 (none: no colour at all).""" + if not colour_on(): + return 0 + if os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit"): + return 24 + return 8 if "256" in os.environ.get("TERM", "") else 0 + + +def rgb(hex_colour): + """(r, g, b) for #rgb, #rgba, #rrggbb or #rrggbbaa; alpha is dropped.""" + digits = hex_colour.lstrip("#") + if len(digits) in (3, 4): + digits = "".join(c * 2 for c in digits[:3]) + return tuple(int(digits[i:i + 2], 16) for i in (0, 2, 4)) + + +def _nearest_256(rgb): + """The closest of xterm's 6x6x6 colour cube and its 24 greys.""" + levels = (0, 95, 135, 175, 215, 255) + cube = [min(range(6), key=lambda i: abs(levels[i] - c)) for c in rgb] + cube_rgb = tuple(levels[i] for i in cube) + grey = min(range(24), key=lambda i: abs(8 + 10 * i - sum(rgb) / 3)) + grey_rgb = (8 + 10 * grey,) * 3 + + def distance(other): + return sum((a - b) ** 2 for a, b in zip(rgb, other)) + if distance(grey_rgb) < distance(cube_rgb): + return 232 + grey + return 16 + 36 * cube[0] + 6 * cube[1] + cube[2] + + +def swatch(hex_colour): + """A block of `hex_colour` (#rgb or #rrggbb, alpha ignored), or "" where + the terminal cannot show one. Always two columns wide when shown.""" + depth = colour_depth() + if not depth: + return "" + red, green, blue = rgb(hex_colour) + code = (f"38;2;{red};{green};{blue}" if depth == 24 + else f"38;5;{_nearest_256((red, green, blue))}") + return f"\033[{code}m{BLOCK}\033[0m" + + def sgr(text, *names, on=None): if not text or not (colour_on() if on is None else on): return text @@ -279,18 +326,19 @@ def _column_layout(labels, indent, gap, width): 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 _numbered(items, first=1, digits=None): + digits = digits or len(str(first + len(items) - 1)) + return [f"{i:>{digits}}. {name}" for i, name in enumerate(items, first)] -def column_rows(items, indent=4, gap=3, width=None, numbered=False): +def column_rows(items, indent=4, gap=3, width=None, numbered=False, first=1, digits=None): """How many rows `columns` would use: the step for moving across a column.""" - labels = _numbered(list(items)) if numbered else list(items) + labels = _numbered(list(items), first, digits) 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): +def columns(items, indent=4, gap=3, width=None, numbered=False, pointer=None, ticked=None, + first=1, digits=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 @@ -298,17 +346,20 @@ def columns(items, indent=4, gap=3, width=None, numbered=False, pointer=None, ti 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 `✓`. + `ticked` the indices whose `.` has become a `✓`. Both count from 0 + within `items`; `first` is the number shown for the first of them, and + `digits` the width the numbers are padded to, so that groups numbered + straight through (see `column_groups`) line up with each other. """ width = width or term_width() names = list(items) - labels = _numbered(names) if numbered else list(names) + digits = digits or len(str(first + len(names) - 1)) + labels = _numbered(names, first, digits) if numbered else list(names) if not labels: return [] 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): line = "" @@ -326,13 +377,55 @@ def columns(items, indent=4, gap=3, width=None, numbered=False, pointer=None, ti elif index in ticked: name = strong(name) mark = good(TICK) if index in ticked else "." - cell = (f"{index + 1:>{digits}}{mark} {name}" + cell = (f"{first + index:>{digits}}{mark} {name}" + " " * (widths[c] - len(col[r]))) line += gutter + cell out.append(line.rstrip()) return out +def column_groups(names, groups=None, indent=4, gap=3, width=None, pointer=None, ticked=None, + numbered=True): + """`columns` in titled groups, numbered straight through. + + `groups` is [(title, count)] in the order of `names`; None, or a single + untitled group, is the plain grid. A blank line and the title, muted and + in line with the names, start every group. `pointer` and `ticked` count + across all of `names`, as the numbers do. + """ + names = list(names) + groups = groups or [(None, len(names))] + digits = len(str(len(names))) + ticked = set(ticked or ()) + out, start = [], 0 + for title, count in groups: + if title: + out += ([""] if out else []) + [" " * indent + muted(title)] + end = start + count + out += columns(names[start:end], indent, gap, width, numbered=numbered, + pointer=pointer - start if pointer is not None and start <= pointer < end + else None, + ticked={i - start for i in ticked if start <= i < end}, + first=start + 1, digits=digits) + start = end + return out + + +def group_spans(names, groups=None, indent=4, gap=3, width=None): + """[(start, count, rows)] for each of `column_groups`' groups: where the + group begins in `names`, how many it holds, and its column height (the + step ←→ take inside it).""" + names = list(names) + groups = groups or [(None, len(names))] + digits = len(str(len(names))) + spans, start = [], 0 + for _, count in groups: + spans.append((start, count, column_rows(names[start:start + count], indent, gap, width, + numbered=True, first=start + 1, digits=digits))) + start += count + return spans + + def ask(question, choices=None, default=None, indent=2, on=None): """A prompt: the question bold, then the choices and the default.""" tail = [] diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 5826702..d7250c5 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -191,6 +191,73 @@ def test_esc_cancels(self): self.pick(["space", "esc"]) +class GroupedPickTests(unittest.TestCase): + """Tweaks and colour schemes under their own titles, numbered straight through.""" + + NAMES = ["compact-tabs", "noidentity", "tabs-swapclose", + *(f"theme-{n}" for n in ("ayu", "dracula", "everforest", "gruvbox", "nord", + "one-dark", "rose-pine", "solarized", "tokyonight"))] + GROUPS = [("Tweaks", 3), ("Colour schemes", 9)] + + 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, groups=self.GROUPS) + return answer, out + + def test_titles_and_one_run_of_numbers(self): + lines = style.column_groups(self.NAMES, self.GROUPS, indent=4) + self.assertEqual(lines[0], " Tweaks") + self.assertIn("", lines) + self.assertIn(" Colour schemes", lines) + text = "\n".join(lines) + self.assertIn(" 1. compact-tabs", text) + self.assertIn(" 4. theme-ayu", text) # padded to two digits + self.assertIn("12. theme-tokyonight", text) + + def test_one_untitled_group_is_the_plain_grid(self): + plain = style.columns(self.NAMES, indent=4, numbered=True) + self.assertEqual(style.column_groups(self.NAMES, None, indent=4), plain) + self.assertEqual(style.column_groups(self.NAMES, [(None, 12)], indent=4), plain) + + def test_marks_land_on_the_right_group_and_the_layout_holds(self): + plain = style.column_groups(self.NAMES, self.GROUPS, indent=4) + marked = [visible(l) for l in style.column_groups( + self.NAMES, self.GROUPS, indent=4, pointer=4, ticked={0, 4})] + self.assertEqual([len(l) for l in plain], [len(l) for l in marked]) + self.assertIn("› 5✓ theme-dracula", "\n".join(marked)) + self.assertTrue(marked[1].lstrip().startswith("1✓ compact-tabs")) + + def test_down_runs_on_into_the_next_group(self): + self.assertEqual(self.pick(["down"] * 3 + ["space", "enter"])[0], "4") + self.assertEqual(self.pick(["up", "space", "enter"])[0], "12") + + def test_left_and_right_stay_inside_a_group(self): + # → from the last tweak stays put rather than dropping into the schemes. + self.assertEqual(self.pick(["down", "down", "right", "space", "enter"])[0], "3") + start, _, rows = style.group_spans(self.NAMES, self.GROUPS, indent=4)[1] + self.assertEqual(start, 3) + answer, _ = self.pick(["down"] * 3 + ["right", "space", "enter"]) + self.assertEqual(answer, str(start + rows + 1)) + answer, _ = self.pick(["down"] * 3 + ["left", "space", "enter"]) + self.assertEqual(answer, "4") + + def test_typing_a_number_points_into_either_group(self): + answer, out = self.pick(["1", "0", "enter"]) + self.assertEqual(answer, "10") + lines = screen(out) + self.assertIn(" Colour schemes", lines) + self.assertTrue(any("10✓ theme-rose-pine" in line for line in lines)) + + class FallbackTests(unittest.TestCase): """Anything short of a real, wide-enough terminal gets the typed prompt.""" diff --git a/tests/test_sheet_groups.py b/tests/test_sheet_groups.py new file mode 100644 index 0000000..677f448 --- /dev/null +++ b/tests/test_sheet_groups.py @@ -0,0 +1,195 @@ +"""The stylesheet menu shows colour schemes apart from tweaks, when a theme has both.""" + +import io +import os +import re +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +from fxcss import cli, inventory, sheets, style + + +class SchemeNameTests(unittest.TestCase): + def test_scheme_names(self): + for name in ("theme-nord", "theme_ayu.css", "theme-material-blue", "nord", "gruvbox.css", + "cascade-mocha", "cascade-rose-pine", "one-dark", "tokyo-night", + "Dracula", "catppuccin-latte"): + self.assertTrue(inventory.is_colour_scheme(name), name) + + def test_tweak_names(self): + for name in ("compact-tabs", "tabs-swapclose", "noidentity", "increase-contrast", + "material-icons", "hide-sidebar", "nordic-spacing", "darker"): + self.assertFalse(inventory.is_colour_scheme(name), name) + + +class SheetGroupTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp(prefix="fxcss-groups-")) + self.addCleanup(lambda: __import__("shutil").rmtree(self.root, ignore_errors=True)) + + def sheets(self, *stems, palette=()): + """Sheets with distinct selectors, so no pair measures as alternatives; + those in `palette` instead all set the same :root colours.""" + paths = [] + for n, stem in enumerate(stems): + path = self.root / f"{stem}.css" + if stem in palette: + path.write_text(f":root {{ --bg: #{n * 40:02x}2030; --fg: #e0e0{n * 40:02x} }}\n") + else: + path.write_text(f"#part-{n} {{ margin: {n}px }}\n") + paths.append(path) + return paths + + def test_both_kinds_are_grouped_tweaks_first(self): + found = self.sheets("compact-tabs", "theme-dracula", "noidentity", "theme-nord") + shown, groups = cli._sheet_groups(found) + self.assertEqual([p.stem for p in shown], + ["compact-tabs", "noidentity", "theme-dracula", "theme-nord"]) + self.assertEqual(groups, [("Tweaks", 2), ("Colour schemes", 2)]) + + def test_schemes_that_all_clash_say_pick_one(self): + found = self.sheets("compact-tabs", "theme-dracula", "theme-nord", "theme-ayu", + palette={"theme-dracula", "theme-nord", "theme-ayu"}) + _, groups = cli._sheet_groups(found) + self.assertEqual(groups[1], (f"Colour schemes {style.DOT} pick one", 3)) + + def test_schemes_that_compose_do_not(self): + # Two of the three clash; the third adds to either, so "pick one" + # would be wrong about it. + found = self.sheets("compact-tabs", "theme-dracula", "theme-nord", "theme-ayu", + palette={"theme-dracula", "theme-nord"}) + self.assertEqual(cli._sheet_groups(found)[1][1], ("Colour schemes", 3)) + + def test_one_kind_keeps_the_single_list_in_its_order(self): + for stems in (("tabs-swapclose", "compact-tabs"), ("theme-nord", "theme-ayu")): + found = self.sheets(*stems) + self.assertEqual(cli._sheet_groups(found), (found, None)) + + def choose(self, found, *answers): + out = io.StringIO() + replies = iter(answers) + + def typed(prompt=""): + reply = next(replies) + print(prompt + reply) + return reply + with mock.patch.dict(os.environ, {"FXCSS_PLAIN_PROMPTS": "1"}), \ + mock.patch("builtins.input", typed), redirect_stdout(out): + style.set_colour(False) + try: + chosen = cli._choose_sheets(found, True) + finally: + style.set_colour(None) + return chosen, out.getvalue() + + def test_typed_numbers_follow_the_menu_and_the_answer_keeps_the_theme_order(self): + found = self.sheets("compact-tabs", "theme-dracula", "noidentity") + # Shown as 1 compact-tabs, 2 noidentity, 3 theme-dracula. + chosen, text = self.choose(found, "3,2") + self.assertEqual([p.stem for p in chosen], ["theme-dracula", "noidentity"]) + self.assertIn(" Tweaks", text) + self.assertIn(" Colour schemes", text) + self.assertIn("3. theme-dracula", text) + self.assertIn("--with theme-dracula,noidentity", text) + + def test_a_clash_asks_which_to_keep_and_enter_keeps_the_last(self): + found = self.sheets("compact-tabs", "theme-dracula", "theme-nord", + palette={"theme-dracula", "theme-nord"}) + chosen, text = self.choose(found, "1,2,3", "") + self.assertEqual([p.stem for p in chosen], ["compact-tabs", "theme-nord"]) + self.assertIn("Keep which colour scheme?", text) + self.assertIn("both set the same 2 colours; only one can take effect", text) + self.assertIn("Which one? 1-2 [2]", text) + self.assertNotIn("Pick one of each pair", text) + self.assertIn("--with compact-tabs,theme-nord skips this question", text) + + def test_the_kept_sheet_is_the_one_picked(self): + found = self.sheets("compact-tabs", "theme-dracula", "theme-nord", + palette={"theme-dracula", "theme-nord"}) + chosen, text = self.choose(found, "2,3", "1") + self.assertEqual([p.stem for p in chosen], ["theme-dracula"]) + self.assertIn("--with theme-dracula skips this question", text) + + def test_each_choice_shows_its_hex_colours(self): + found = self.sheets("compact-tabs", "theme-dracula", "theme-nord", + palette={"theme-dracula", "theme-nord"}) + _, text = self.choose(found, "2,3", "") + rows = [l for l in text.splitlines() if ". theme-" in l and "#" in l] + self.assertEqual(len(rows), 2) + for row in rows: + self.assertEqual(len(re.findall(r"#[0-9a-f]{6}\b", row)), 2, row) + self.assertTrue(rows[1].endswith(" (Enter)")) + # (Enter) sits just after the colours, not out at the terminal's edge. + self.assertLess(len(rows[1]), 60) + + def test_three_that_all_clash_are_one_question(self): + found = self.sheets("compact-tabs", "theme-ayu", "theme-dracula", "theme-nord", + palette={"theme-ayu", "theme-dracula", "theme-nord"}) + chosen, text = self.choose(found, "2,3,4", "2") + self.assertEqual([p.stem for p in chosen], ["theme-dracula"]) + self.assertEqual(text.count("Keep which"), 1) + self.assertIn("all set the same 2 colours", text) + + def test_a_chain_asks_only_about_what_clashes(self): + # a clashes with b and b with c, but a and c compose: keeping a + # settles it, keeping b leaves b against c to ask about next. + (self.root / "theme-a.css").write_text(":root { --x: #111111; --y: #222222 }\n") + (self.root / "theme-b.css").write_text( + ":root { --x: #333333; --y: #444444; --z: #555555; --w: #666666 }\n") + (self.root / "theme-c.css").write_text(":root { --z: #777777; --w: #888888 }\n") + (self.root / "compact.css").write_text("#tabs { margin: 0 }\n") + found = [self.root / f"{n}.css" for n in ("compact", "theme-a", "theme-b", "theme-c")] + chosen, text = self.choose(found, "2,3,4", "1") + self.assertEqual([p.stem for p in chosen], ["theme-a", "theme-c"]) + self.assertEqual(text.count("Keep which"), 1) + chosen, text = self.choose(found, "2,3,4", "2", "1") + self.assertEqual([p.stem for p in chosen], ["theme-b"]) + self.assertEqual(text.count("Keep which"), 2) + + def test_a_theme_with_one_kind_shows_no_titles(self): + _, text = self.choose(self.sheets("compact-tabs", "noidentity"), "") + self.assertNotIn("Tweaks", text) + self.assertNotIn("Colour schemes", text) + + +class PreviewTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp(prefix="fxcss-preview-")) + self.addCleanup(lambda: __import__("shutil").rmtree(self.root, ignore_errors=True)) + style.set_colour(True) + self.addCleanup(style.set_colour, None) + + def test_swatch_uses_what_the_terminal_has(self): + with mock.patch.dict(os.environ, {"COLORTERM": "truecolor", "TERM": "xterm"}): + self.assertIn("\033[38;2;136;192;208m", style.swatch("#88c0d0")) + with mock.patch.dict(os.environ, {"COLORTERM": "", "TERM": "xterm-256color"}): + self.assertIn("\033[38;5;", style.swatch("#88c0d0")) + with mock.patch.dict(os.environ, {"COLORTERM": "", "TERM": "xterm"}): + self.assertEqual(style.swatch("#88c0d0"), "") + style.set_colour(False) + with mock.patch.dict(os.environ, {"COLORTERM": "truecolor"}): + self.assertEqual(style.swatch("#88c0d0"), "") + + def test_nearest_256(self): + self.assertEqual(style._nearest_256((255, 0, 0)), 196) + self.assertEqual(style._nearest_256((0x1e, 0x1e, 0x2e)), 235) # a grey + self.assertEqual(style.rgb("#abc"), (170, 187, 204)) + self.assertEqual(style.rgb("#11223344"), (17, 34, 51)) + + def test_palette_prefers_distinct_colours(self): + a, b = self.root / "a.css", self.root / "b.css" + a.write_text(":root { --bg: #1e1e2e; --bg2: #181825; --bg3: #11111b; --bg4: #1e1e2e;" + " --fg: #cdd6f4; --accent: #cba6f7 !important; --mix: var(--bg) }\n") + b.write_text(":root { --bg: #2e3440; --bg2: #3b4252; --bg3: #434c5e; --bg4: #2e3440;" + " --fg: #eceff4; --accent: #88c0d0; --mix: var(--fg) }\n") + colours = sheets.palettes([a, b]) + self.assertEqual(colours[a], ["#1e1e2e", "#cdd6f4", "#cba6f7"]) + self.assertEqual(colours[b][:1], ["#2e3440"]) + self.assertIn("#eceff4", colours[b]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_units.py b/tests/test_units.py index b210a00..796690c 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -3521,14 +3521,16 @@ def install(self, answers, *options): self.assertEqual(list(profile.iterdir()), [profile / "prefs.js"]) return result, manifest, errors.getvalue() - def test_conflicting_menu_answers_never_install_without_force(self): + def test_conflicting_menu_answers_install_one_without_force(self): + # The menu asks which of the pair to keep; Enter, EOF or an answer it + # cannot use keeps the one that loads last, which would have won. for answers in (["1,2", ""], ["1,2", EOFError()], - ["1,2", "1,2", "1,2"], ["1,2,99", ""]): + ["1,2", "1,2"], ["1,2,99", "", ""]): with self.subTest(answers=answers): result, manifest, error = self.install(answers) - self.assertEqual(result, 2) - self.assertIsNone(manifest) - self.assertIn("Pick one, or pass --force", error) + self.assertEqual(result, 0) + self.assertEqual(manifest["sheets"], ["b"]) + self.assertNotIn("Pick one, or pass --force", error) def test_corrected_menu_selection_installs(self): result, manifest, _ = self.install(["1,2", "1"])