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
11 changes: 4 additions & 7 deletions docs/UIUX_BRANDING_HANDOFF.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)

Expand All@@ -27,7 +30,7 @@
class TemplateChoice:
"""Searchable interactive template choice."""

title: str
title: Any
value: str
search: str

Expand DownExpand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
from __future__ import annotations

import os
from typing import Any

from questionary import Style

Expand All@@ -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,
)
24 changes: 15 additions & 9 deletions packages/create-awesome-python-app/tests/test_catalog_resolve.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand DownExpand Up@@ -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": [
Expand All@@ -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:
Expand All@@ -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

Expand Down
Loading