From 305ac4f063f201426e390588602ebe64c156d485 Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Sat, 18 Jul 2026 02:40:29 -0300 Subject: [PATCH] fix: render template titles as FormattedText, not raw ANSI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit questionary.select prints string titles literally, so SGR escapes showed as ^[[1;94m…. Use prompt_toolkit style tokens plus SearchableFormattedText so type-to-filter keeps working. Co-authored-by: Cursor --- docs/UIUX_BRANDING_HANDOFF.md | 11 +- .../src/create_awesome_python_app/catalog.py | 44 ++++---- .../create_awesome_python_app/prompt_style.py | 100 +++++++++++++----- .../tests/test_catalog_resolve.py | 24 +++-- 4 files changed, 121 insertions(+), 58 deletions(-) diff --git a/docs/UIUX_BRANDING_HANDOFF.md b/docs/UIUX_BRANDING_HANDOFF.md index 524cfb8..2e7e813 100644 --- a/docs/UIUX_BRANDING_HANDOFF.md +++ b/docs/UIUX_BRANDING_HANDOFF.md @@ -112,14 +112,11 @@ From `cpa.config.json` or catalog `customOptions`: ### Category badges Interactive template choices use a fixed-width badge from `short_category_label()` -with bright bold ANSI colors (`prompt_style.color_category`) so they stay readable -on dark terminals. Titles may include ANSI because the picker is -`questionary.select(..., use_search_filter=True)` — **not** autocomplete (which -HTML-parses choice text and breaks on ANSI). +styled with prompt_toolkit FormattedText tokens (`prompt_style.template_title_tokens`). +Raw ANSI in string titles is avoided — `select()` prints those escapes literally +(`^[[1;94m…`). Titles are `SearchableFormattedText` so `use_search_filter` still works. -Respects `NO_COLOR`. `--list-templates` uses Rich tables for color. - -UX: ↑↓ browse the full catalog, type to filter, Enter to pick (CNA-parity discovery). +Respects `NO_COLOR` (plain string titles). `--list-templates` uses Rich tables. ### Rich semantic color usage diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py index ad0169e..080e42c 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py @@ -16,7 +16,10 @@ from rich.table import Table from create_awesome_python_app import __version__ -from create_awesome_python_app.prompt_style import bold, color_category, dim +from create_awesome_python_app.prompt_style import ( + custom_template_title, + template_title_tokens, +) console = Console(stderr=True) @@ -27,7 +30,7 @@ class TemplateChoice: """Searchable interactive template choice.""" - title: str + title: Any value: str search: str @@ -236,35 +239,40 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]: category_name = categories.get(category_slug, category_slug) badge = short_category_label(category_name).ljust(10)[:10] slug = str(template.get("slug", "")) - labels = template.get("labels", []) - label_suffix = "" - if isinstance(labels, list) and labels: - label_suffix = dim(" · " + ", ".join(str(label) for label in labels[:3])) + labels_raw = template.get("labels", []) + labels = ( + [str(label) for label in labels_raw[:3]] + if isinstance(labels_raw, list) + else [] + ) description = str(template.get("description", "")).strip() - # Keep slug + short description in the title so select(use_search_filter) - # can match them (filter scans Choice.title only). - description_suffix = dim(f" — {description}") if description else "" name = str(template.get("name", slug)) - # ANSI is OK here: questionary.select renders titles as terminal text. - # Do not pass these titles to autocomplete (HTML match highlighting). - title = ( - f"{color_category(category_slug, badge)} " - f"{bold(name)} ({slug})" - f"{label_suffix}{description_suffix}" + search = _search_text(template, category_name) + # FormattedText tokens (not raw ANSI): select() prints str titles + # literally, which showed ^[[1;94m… in terminals. + title = template_title_tokens( + category_slug=category_slug, + badge=badge, + name=name, + slug=slug, + labels=labels, + description=description, + search=search, ) choices.append( TemplateChoice( title=title, value=template_url, - search=_search_text(template, category_name), + search=search, ) ) + custom_search = "custom own template url github file" choices.append( TemplateChoice( - title=" " * 12 + dim("Use my own template URL"), + title=custom_template_title(custom_search), value=CUSTOM_TEMPLATE_SENTINEL, - search="custom own template url github file", + search=custom_search, ) ) return choices diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py b/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py index c3806a1..e91b2d3 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from typing import Any from questionary import Style @@ -25,37 +26,88 @@ } ) +# Bright prompt_toolkit style strings (not raw ANSI — select() prints str titles +# literally, so escapes show as ^[[...m unless titles are FormattedText tokens). +_CATEGORY_STYLES = ( + "fg:#facc15 bold", # yellow + "fg:#4ade80 bold", # green + "fg:#22d3ee bold", # cyan + "fg:#e879f9 bold", # magenta + "fg:#60a5fa bold", # blue +) + + +class SearchableFormattedText(list): + """FormattedText tokens with ``.lower()`` for questionary search filter. + + ``select(use_search_filter=True)`` does ``needle in choice.title.lower()``. + A plain token list has no ``.lower()``; this keeps filter working while + titles render as styled FormattedText. + """ + + def __init__(self, tokens: list[tuple[str, str]], search: str) -> None: + super().__init__(tokens) + self._search = search + + def lower(self) -> str: + return self._search.lower() + def colors_enabled() -> bool: return not os.environ.get("NO_COLOR") -def ansi(code: str, text: str) -> str: - """Wrap *text* in an ANSI SGR sequence when colors are enabled.""" - if not colors_enabled(): - return text - return f"\033[{code}m{text}\033[0m" - - -# Bold bright ANSI — readable on dark terminals; select() renders these safely -# (unlike autocomplete, which HTML-parses choice text). -_CATEGORY_PALETTE = ( - "1;93", # bright yellow - "1;92", # bright green - "1;96", # bright cyan - "1;95", # bright magenta - "1;94", # bright blue -) - +def category_style(slug: str) -> str: + idx = sum(ord(char) for char in slug) % len(_CATEGORY_STYLES) + return _CATEGORY_STYLES[idx] -def color_category(slug: str, label: str) -> str: - idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE) - return ansi(_CATEGORY_PALETTE[idx], label) +def plain_title_text(title: Any) -> str: + """Join FormattedText token text (or return a plain string title).""" + if isinstance(title, list): + return "".join(str(token[1]) for token in title) + return str(title) -def bold(text: str) -> str: - return ansi("1", text) +def template_title_tokens( + *, + category_slug: str, + badge: str, + name: str, + slug: str, + labels: list[str], + description: str, + search: str, +) -> SearchableFormattedText | str: + """Build a select-safe title: FormattedText when colors on, else plain str.""" + label_suffix = "" + if labels: + label_suffix = " · " + ", ".join(labels[:3]) + description_suffix = f" — {description}" if description else "" + plain = f"{badge} {name} ({slug}){label_suffix}{description_suffix}" -def dim(text: str) -> str: - return ansi("2", text) + if not colors_enabled(): + return plain + + tokens: list[tuple[str, str]] = [ + (category_style(category_slug), badge), + ("", " "), + ("bold", name), + ("class:instruction", f" ({slug})"), + ] + if label_suffix: + tokens.append(("class:instruction", label_suffix)) + if description_suffix: + tokens.append(("fg:#94a3b8", description_suffix)) + return SearchableFormattedText(tokens, search=search or plain) + + +def custom_template_title(search: str) -> SearchableFormattedText | str: + label = "Use my own template URL" + plain = " " * 12 + label + if not colors_enabled(): + return plain + return SearchableFormattedText( + [("", " " * 12), ("italic fg:#94a3b8", label)], + search=search or plain, + ) diff --git a/packages/create-awesome-python-app/tests/test_catalog_resolve.py b/packages/create-awesome-python-app/tests/test_catalog_resolve.py index f9e73d6..46e7b73 100644 --- a/packages/create-awesome-python-app/tests/test_catalog_resolve.py +++ b/packages/create-awesome-python-app/tests/test_catalog_resolve.py @@ -17,6 +17,10 @@ short_category_label, validate_extension_compatibility, ) +from create_awesome_python_app.prompt_style import ( + SearchableFormattedText, + plain_title_text, +) SAMPLE_CATALOG = { "templates": [ @@ -99,19 +103,18 @@ def test_build_template_choices_are_searchable() -> None: choices = build_template_choices(catalog) first = choices[0] assert first.value == "file:///templates/fastapi" - assert "FastAPI Starter" in first.title - assert "OpenAPI" in first.title - assert "uv" in first.title + title_text = plain_title_text(first.title) + assert "FastAPI Starter" in title_text + assert "OpenAPI" in title_text + assert "uv" in title_text assert "openapi" in first.search assert "backend" in first.search assert "uv" in first.search assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL -def test_template_choice_titles_include_bright_category_ansi( - monkeypatch, -) -> None: - """select() can render ANSI; badges use bright bold codes for contrast.""" +def test_template_choice_titles_use_formatted_text(monkeypatch) -> None: + """select() needs FormattedText tokens; raw ANSI shows as ^[[…m.""" monkeypatch.delenv("NO_COLOR", raising=False) catalog = { "categories": [ @@ -127,8 +130,10 @@ def test_template_choice_titles_include_bright_category_ansi( ], } title = build_template_choices(catalog)[0].title - assert "\033[" in title - assert "FastAPI Starter" in title + assert isinstance(title, SearchableFormattedText) + assert "\033" not in plain_title_text(title) + assert "FastAPI Starter" in plain_title_text(title) + assert "fastapi" in title.lower() def test_template_choice_titles_respect_no_color(monkeypatch) -> None: @@ -147,6 +152,7 @@ def test_template_choice_titles_respect_no_color(monkeypatch) -> None: ], } title = build_template_choices(catalog)[0].title + assert isinstance(title, str) assert "\033" not in title assert "FastAPI Starter" in title