Skip to content

Commit 305ac4f

Browse files
fix: render template titles as FormattedText, not raw ANSI
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 <cursoragent@cursor.com>
1 parent 950fb7a commit 305ac4f

4 files changed

Lines changed: 121 additions & 58 deletions

File tree

docs/UIUX_BRANDING_HANDOFF.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,11 @@ From `cpa.config.json` or catalog `customOptions`:
112112
### Category badges
113113

114114
Interactive template choices use a fixed-width badge from `short_category_label()`
115-
with bright bold ANSI colors (`prompt_style.color_category`) so they stay readable
116-
on dark terminals. Titles may include ANSI because the picker is
117-
`questionary.select(..., use_search_filter=True)`**not** autocomplete (which
118-
HTML-parses choice text and breaks on ANSI).
115+
styled with prompt_toolkit FormattedText tokens (`prompt_style.template_title_tokens`).
116+
Raw ANSI in string titles is avoided — `select()` prints those escapes literally
117+
(`^[[1;94m…`). Titles are `SearchableFormattedText` so `use_search_filter` still works.
119118

120-
Respects `NO_COLOR`. `--list-templates` uses Rich tables for color.
121-
122-
UX: ↑↓ browse the full catalog, type to filter, Enter to pick (CNA-parity discovery).
119+
Respects `NO_COLOR` (plain string titles). `--list-templates` uses Rich tables.
123120

124121
### Rich semantic color usage
125122

packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
from rich.table import Table
1717

1818
from create_awesome_python_app import __version__
19-
from create_awesome_python_app.prompt_style import bold, color_category, dim
19+
from create_awesome_python_app.prompt_style import (
20+
custom_template_title,
21+
template_title_tokens,
22+
)
2023

2124
console = Console(stderr=True)
2225

@@ -27,7 +30,7 @@
2730
class TemplateChoice:
2831
"""Searchable interactive template choice."""
2932

30-
title: str
33+
title: Any
3134
value: str
3235
search: str
3336

@@ -236,35 +239,40 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
236239
category_name = categories.get(category_slug, category_slug)
237240
badge = short_category_label(category_name).ljust(10)[:10]
238241
slug = str(template.get("slug", ""))
239-
labels = template.get("labels", [])
240-
label_suffix = ""
241-
if isinstance(labels, list) and labels:
242-
label_suffix = dim(" · " + ", ".join(str(label) for label in labels[:3]))
242+
labels_raw = template.get("labels", [])
243+
labels = (
244+
[str(label) for label in labels_raw[:3]]
245+
if isinstance(labels_raw, list)
246+
else []
247+
)
243248
description = str(template.get("description", "")).strip()
244-
# Keep slug + short description in the title so select(use_search_filter)
245-
# can match them (filter scans Choice.title only).
246-
description_suffix = dim(f" — {description}") if description else ""
247249
name = str(template.get("name", slug))
248-
# ANSI is OK here: questionary.select renders titles as terminal text.
249-
# Do not pass these titles to autocomplete (HTML match highlighting).
250-
title = (
251-
f"{color_category(category_slug, badge)} "
252-
f"{bold(name)} ({slug})"
253-
f"{label_suffix}{description_suffix}"
250+
search = _search_text(template, category_name)
251+
# FormattedText tokens (not raw ANSI): select() prints str titles
252+
# literally, which showed ^[[1;94m… in terminals.
253+
title = template_title_tokens(
254+
category_slug=category_slug,
255+
badge=badge,
256+
name=name,
257+
slug=slug,
258+
labels=labels,
259+
description=description,
260+
search=search,
254261
)
255262
choices.append(
256263
TemplateChoice(
257264
title=title,
258265
value=template_url,
259-
search=_search_text(template, category_name),
266+
search=search,
260267
)
261268
)
262269

270+
custom_search = "custom own template url github file"
263271
choices.append(
264272
TemplateChoice(
265-
title=" " * 12 + dim("Use my own template URL"),
273+
title=custom_template_title(custom_search),
266274
value=CUSTOM_TEMPLATE_SENTINEL,
267-
search="custom own template url github file",
275+
search=custom_search,
268276
)
269277
)
270278
return choices

packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import os
6+
from typing import Any
67

78
from questionary import Style
89

@@ -25,37 +26,88 @@
2526
}
2627
)
2728

29+
# Bright prompt_toolkit style strings (not raw ANSI — select() prints str titles
30+
# literally, so escapes show as ^[[...m unless titles are FormattedText tokens).
31+
_CATEGORY_STYLES = (
32+
"fg:#facc15 bold", # yellow
33+
"fg:#4ade80 bold", # green
34+
"fg:#22d3ee bold", # cyan
35+
"fg:#e879f9 bold", # magenta
36+
"fg:#60a5fa bold", # blue
37+
)
38+
39+
40+
class SearchableFormattedText(list):
41+
"""FormattedText tokens with ``.lower()`` for questionary search filter.
42+
43+
``select(use_search_filter=True)`` does ``needle in choice.title.lower()``.
44+
A plain token list has no ``.lower()``; this keeps filter working while
45+
titles render as styled FormattedText.
46+
"""
47+
48+
def __init__(self, tokens: list[tuple[str, str]], search: str) -> None:
49+
super().__init__(tokens)
50+
self._search = search
51+
52+
def lower(self) -> str:
53+
return self._search.lower()
54+
2855

2956
def colors_enabled() -> bool:
3057
return not os.environ.get("NO_COLOR")
3158

3259

33-
def ansi(code: str, text: str) -> str:
34-
"""Wrap *text* in an ANSI SGR sequence when colors are enabled."""
35-
if not colors_enabled():
36-
return text
37-
return f"\033[{code}m{text}\033[0m"
38-
39-
40-
# Bold bright ANSI — readable on dark terminals; select() renders these safely
41-
# (unlike autocomplete, which HTML-parses choice text).
42-
_CATEGORY_PALETTE = (
43-
"1;93", # bright yellow
44-
"1;92", # bright green
45-
"1;96", # bright cyan
46-
"1;95", # bright magenta
47-
"1;94", # bright blue
48-
)
49-
60+
def category_style(slug: str) -> str:
61+
idx = sum(ord(char) for char in slug) % len(_CATEGORY_STYLES)
62+
return _CATEGORY_STYLES[idx]
5063

51-
def color_category(slug: str, label: str) -> str:
52-
idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE)
53-
return ansi(_CATEGORY_PALETTE[idx], label)
5464

65+
def plain_title_text(title: Any) -> str:
66+
"""Join FormattedText token text (or return a plain string title)."""
67+
if isinstance(title, list):
68+
return "".join(str(token[1]) for token in title)
69+
return str(title)
5570

56-
def bold(text: str) -> str:
57-
return ansi("1", text)
5871

72+
def template_title_tokens(
73+
*,
74+
category_slug: str,
75+
badge: str,
76+
name: str,
77+
slug: str,
78+
labels: list[str],
79+
description: str,
80+
search: str,
81+
) -> SearchableFormattedText | str:
82+
"""Build a select-safe title: FormattedText when colors on, else plain str."""
83+
label_suffix = ""
84+
if labels:
85+
label_suffix = " · " + ", ".join(labels[:3])
86+
description_suffix = f" — {description}" if description else ""
87+
plain = f"{badge} {name} ({slug}){label_suffix}{description_suffix}"
5988

60-
def dim(text: str) -> str:
61-
return ansi("2", text)
89+
if not colors_enabled():
90+
return plain
91+
92+
tokens: list[tuple[str, str]] = [
93+
(category_style(category_slug), badge),
94+
("", " "),
95+
("bold", name),
96+
("class:instruction", f" ({slug})"),
97+
]
98+
if label_suffix:
99+
tokens.append(("class:instruction", label_suffix))
100+
if description_suffix:
101+
tokens.append(("fg:#94a3b8", description_suffix))
102+
return SearchableFormattedText(tokens, search=search or plain)
103+
104+
105+
def custom_template_title(search: str) -> SearchableFormattedText | str:
106+
label = "Use my own template URL"
107+
plain = " " * 12 + label
108+
if not colors_enabled():
109+
return plain
110+
return SearchableFormattedText(
111+
[("", " " * 12), ("italic fg:#94a3b8", label)],
112+
search=search or plain,
113+
)

packages/create-awesome-python-app/tests/test_catalog_resolve.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
short_category_label,
1818
validate_extension_compatibility,
1919
)
20+
from create_awesome_python_app.prompt_style import (
21+
SearchableFormattedText,
22+
plain_title_text,
23+
)
2024

2125
SAMPLE_CATALOG = {
2226
"templates": [
@@ -99,19 +103,18 @@ def test_build_template_choices_are_searchable() -> None:
99103
choices = build_template_choices(catalog)
100104
first = choices[0]
101105
assert first.value == "file:///templates/fastapi"
102-
assert "FastAPI Starter" in first.title
103-
assert "OpenAPI" in first.title
104-
assert "uv" in first.title
106+
title_text = plain_title_text(first.title)
107+
assert "FastAPI Starter" in title_text
108+
assert "OpenAPI" in title_text
109+
assert "uv" in title_text
105110
assert "openapi" in first.search
106111
assert "backend" in first.search
107112
assert "uv" in first.search
108113
assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL
109114

110115

111-
def test_template_choice_titles_include_bright_category_ansi(
112-
monkeypatch,
113-
) -> None:
114-
"""select() can render ANSI; badges use bright bold codes for contrast."""
116+
def test_template_choice_titles_use_formatted_text(monkeypatch) -> None:
117+
"""select() needs FormattedText tokens; raw ANSI shows as ^[[…m."""
115118
monkeypatch.delenv("NO_COLOR", raising=False)
116119
catalog = {
117120
"categories": [
@@ -127,8 +130,10 @@ def test_template_choice_titles_include_bright_category_ansi(
127130
],
128131
}
129132
title = build_template_choices(catalog)[0].title
130-
assert "\033[" in title
131-
assert "FastAPI Starter" in title
133+
assert isinstance(title, SearchableFormattedText)
134+
assert "\033" not in plain_title_text(title)
135+
assert "FastAPI Starter" in plain_title_text(title)
136+
assert "fastapi" in title.lower()
132137

133138

134139
def test_template_choice_titles_respect_no_color(monkeypatch) -> None:
@@ -147,6 +152,7 @@ def test_template_choice_titles_respect_no_color(monkeypatch) -> None:
147152
],
148153
}
149154
title = build_template_choices(catalog)[0].title
155+
assert isinstance(title, str)
150156
assert "\033" not in title
151157
assert "FastAPI Starter" in title
152158

0 commit comments

Comments
 (0)