Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
170 changes: 139 additions & 31 deletions fxcss/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -1258,30 +1259,132 @@ 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 "
f"{{ …: {example['b']} }}")
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)
Expand All @@ -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"))
Expand All @@ -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


Expand Down Expand Up @@ -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, "
Expand Down
28 changes: 28 additions & 0 deletions fxcss/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
24 changes: 15 additions & 9 deletions fxcss/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,17 +245,21 @@ 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
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.

`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)

Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading