Make the Ratatui catalogue easier to learn and adopt

Visitors can start from a runnable editable app, choose among six tasks covering all 17 collections, and read usage guidance for every collection. Composer, workbar, ocean and whale entries provide complete public drawing functions exported from compiled examples at kit 4d5b05b. Gallery fixtures remain available in a separate source tab with helper code.

Search recognizes common names such as themes, ombre/ombré, loading, fish and jellyfish without broad family false matches. Filtered selections stay in the result set, empty searches hide old previews, and moving away from motion restores all static profiles and widths. English/Chinese learning copy uses the existing locale system. Theme detection stays outside the draw callback.

Validation: npm test passed 1173 tests (101 package, 19 SDK, 378 extension, 675 web), 7 extension tests skipped,0 failed. Final web tests after UI polish: 675/675. npm run check:web passed facts, latest-release, docs/captures, tokens, lint, TypeScript and production build; an existing facts-lib.mjs unused-import warning remains. Focused learning/catalogue tests 10/10. Browser QA confirmed public-code copy, filtering/motion recovery, no stale empty preview and Chinese mobile layout with no page overflow. Kit recipes 4/4 and PTY 8/8; kit CI and Gallery passed and PR 18 merged. No deployment performed.
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-10-02 22:13:57 -07:00
1 parent e7de150f36
commit cf03333740
11 files changed
+520 -57

No files matched your search

+3 -33
View File
@@ -1,23 +1,12 @@
import Link from "next/link";
import { PageHeader, Section } from "@/components/page-header";
import { PageHeader } from "@/components/page-header";
import { RatatuiExplorer } from "@/components/ratatui/explorer";
import { RatatuiQuickstart } from "@/components/ratatui/quickstart";
import { getRatatuiCopy } from "@/lib/content/ratatui";
import { buildPageMetadata } from "@/lib/page-meta";
import { readCatalogue } from "@/lib/ratatui/catalogue";
const REPOSITORY = "https://github.com/Hmbown/codewhale-ratatui";
const INSTALL = `[dependencies]
codewhale-ratatui = { git = "https://github.com/Hmbown/codewhale-ratatui" }
ratatui = "0.30.2"`;
const STARTER = `use codewhale_ratatui::{NativeComposer, Paint, Theme};
use ratatui::Frame;
fn draw(frame: &mut Frame) {
let theme = Theme::detect().tui();
let composer = NativeComposer::new("Review the changes")
.focused(true);
frame.render_widget(composer.themed(&theme), frame.area());
}`;
export const revalidate = 3600;
@@ -49,27 +38,8 @@ export default async function RatatuiPage({ params }: { params: Promise<{ locale
</>
}
/>
<RatatuiQuickstart copy={copy} />
<RatatuiExplorer catalogue={catalogue} locale={locale} copy={copy} />
<div className="page-body">
<Section id="ratatui-install" title={copy.installationTitle} scope={copy.installationDescription} layout="split">
<div className="stack">
<pre tabIndex={0} className="code-block" dir="ltr">{INSTALL}</pre>
<p className="section-scope">{copy.rustVersion} · {copy.license}</p>
<p>{copy.usageDescription}</p>
<pre tabIndex={0} className="code-block" dir="ltr">{STARTER}</pre>
<div className="actions">
<Link href={`${REPOSITORY}/blob/main/VIEWS.md`} className="section-link">{copy.viewGuide} →</Link>
<Link href={`${REPOSITORY}/blob/main/QUALITY.md`} className="section-link">{copy.quality} →</Link>
</div>
<p className="section-scope">{copy.tryLocally}</p>
<pre tabIndex={0} className="code-block" dir="ltr">{"git clone https://github.com/Hmbown/codewhale-ratatui\ncd codewhale-ratatui\ncargo run --example gallery"}</pre>
<div className="actions">
<Link href={`${REPOSITORY}/actions`} className="section-link">{copy.checks} →</Link>
<Link href={`${REPOSITORY}/releases`} className="section-link">{copy.releaseHistory} →</Link>
</div>
</div>
</Section>
</div>
</div>
);
}
+13 -1
View File
@@ -10,6 +10,18 @@
.rat-explorer input { caret-color: var(--accent); }
.rat-explorer svg { width: 1rem; height: 1rem; flex-shrink: 0; }
.rat-explorer a { text-underline-offset: .2em; }
.rat-task-picker { grid-column: 1 / -1; display: grid; gap: 1rem; padding-block: 1.25rem; border-bottom: 1px solid var(--line); }
.rat-task-picker h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 .25rem; }
.rat-task-picker p { margin: 0; color: var(--muted); font-size: .875rem; line-height: 1.55; max-width: 70ch; }
.rat-task-options { display: flex; flex-wrap: wrap; gap: .5rem; }
.rat-task-options .rat-button[aria-pressed=true] { background: var(--panel); color: var(--accent); border-color: var(--accent); }
.rat-task-options .rat-button { min-height: 2.75rem; }
.rat-usage-panel > h3 { font-size: 1.15rem; font-weight: 600; margin-bottom: .5rem; }
.rat-usage-panel > p { font-size: .9rem; line-height: 1.6; max-width: 70ch; }
.rat-usage-panel .rat-code-heading { margin-top: 1.25rem; }
.rat-usage-links { display: flex; gap: .75rem 1.5rem; flex-wrap: wrap; margin-block: 1.25rem; }
.rat-empty-study { display: grid; align-content: start; justify-items: start; gap: 1rem; min-height: 20rem; }
.rat-empty-study p { color: var(--muted); max-width: 65ch; line-height: 1.6; }
.rat-browser { padding: 1.5rem 1.25rem 0 0; min-width: 0; border-inline-end: 1px solid var(--line); }
.rat-search { display: block; }
.rat-search > span, .rat-field, .rat-family-field { font-size: .8rem; font-weight: 500; }
@@ -33,7 +45,7 @@
.rat-study h2 { font-size: 1.5rem; font-weight: 600; line-height: 1.2; margin: 0; text-wrap: balance; }
.rat-link { display: inline-flex; gap: .4rem; align-items: center; font-size: .8rem; white-space: nowrap; }
.rat-description { font-size: .9rem; color: var(--muted); line-height: 1.5; max-width: 68ch; margin-block: .75rem 1.25rem; }
.rat-study-tabs { display: flex; align-items: stretch; gap: 1.25rem; border-bottom: 1px solid var(--line); }
.rat-study-tabs { display: flex; align-items: stretch; flex-wrap: wrap; gap: .25rem 1.25rem; border-bottom: 1px solid var(--line); }
.rat-study-tabs > button { background: none; border: 0; color: var(--muted); padding: .65rem 0; font-size: .875rem; min-height: 2.75rem; border-bottom: 2px solid transparent; }
.rat-study-tabs > button[aria-pressed=true] { color: var(--text); border-bottom-color: var(--accent); }
.rat-pager { margin-inline-start: auto; display: flex; gap: .25rem; }
+43 -17
View File
@@ -4,7 +4,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Icon } from "@/components/icon";
import type { RatatuiCopy } from "@/lib/content/ratatui";
import { pickText } from "@/lib/i18n/dictionaries";
import { motionId, previewAssetPath, searchEntries, type Catalogue, type EntryPreview } from "@/lib/ratatui/catalogue";
import { TASKS, filterByTask, getGuidance } from "@/lib/ratatui/learning";
import { getRecipe, recipeSourceUrl, learningGuideUrl } from "@/lib/ratatui/recipes";
import "./explorer.css";
type MotionTrack = {
@@ -20,7 +23,7 @@ type MotionTrack = {
const REPOSITORY = "https://github.com/Hmbown/codewhale-ratatui";
function CopyButton({ text, copy }: { text: string; copy: RatatuiCopy }) {
export function CopyButton({ text, copy }: { text: string; copy: RatatuiCopy }) {
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
useEffect(() => {
if (status === "idle") return;
@@ -88,7 +91,7 @@ function MotionPlayer({ id, copy, fit }: { id: string; copy: RatatuiCopy; fit: b
if (error) return <div className="rat-load" role="alert"><p>{copy.previewError}</p><button className="rat-button" onClick={() => setAttempt((value) => value + 1)}>{copy.retry}</button></div>;
if (!track) return <div className="rat-load" role="status">{copy.loading}</div>;
return <div className="rat-motion">
<div className="rat-motion-intro"><p>{track.title}</p><span>{track.profile} · {copy.frameCount.replace("{count}", String(track.frames.length))}</span></div>
<div className="rat-motion-intro"><p>{copy.relatedMotion}: {track.title}</p><span>{track.profile} · {copy.frameCount.replace("{count}", String(track.frames.length))}</span></div>
<TerminalImage svg={track.frames[frame]} label={`${track.title} · ${copy.frame} ${frame + 1}`} fit={fit} width={track.width} height={track.height} />
<div className="rat-motion-controls">
<button type="button" className="rat-button rat-button-primary" onClick={() => setPlaying(!playing)} aria-pressed={playing}>{playing ? copy.pause : copy.play}</button>
@@ -106,21 +109,25 @@ export function RatatuiExplorer({ catalogue, locale, copy, initialEntry }: { cat
const [selected, setSelected] = useState(first.name);
const [query, setQuery] = useState("");
const [family, setFamily] = useState("all");
const [task, setTask] = useState("all");
const [browserExpanded, setBrowserExpanded] = useState(true);
const [mobile, setMobile] = useState(false);
const [profile, setProfile] = useState("dark-truecolor");
const [size, setSize] = useState("native");
const [fit, setFit] = useState(true);
const [tab, setTab] = useState<"preview" | "code" | "motion">("preview");
const [tab, setTab] = useState<"preview" | "use" | "code" | "motion">("preview");
const [data, setData] = useState<EntryPreview | null>(null);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const searchRef = useRef<HTMLInputElement>(null);
const entry = catalogue.entries.find((item) => item.name === selected) ?? first;
const filtered = useMemo(() => searchEntries(filterByTask(catalogue.entries, task), query, family), [catalogue.entries, query, family, task]);
const entry = filtered.find((item) => item.name === selected) ?? filtered[0] ?? first;
const collection = catalogue.families.find((item) => item.id === entry.family);
const filtered = useMemo(() => searchEntries(catalogue.entries, query, family), [catalogue.entries, query, family]);
const guidance = getGuidance(entry, locale);
const recipe = getRecipe(guidance.recipeId);
const animation = motionId(entry, profile);
const currentIndex = filtered.findIndex((item) => item.name === selected);
const activeTab = tab === "motion" && !animation ? "preview" : tab;
const currentIndex = filtered.findIndex((item) => item.name === entry.name);
const svg = data?.previews[profile]?.[size];
const label = copy.terminalPreviewLabel.replace("{name}", entry.title);
const sourceUrl = entry.source.url;
@@ -153,38 +160,57 @@ export function RatatuiExplorer({ catalogue, locale, copy, initialEntry }: { cat
return () => media.removeEventListener("change", update);
}, []);
const choose = (name: string) => { setSelected(name); setTab("preview"); if (mobile) setBrowserExpanded(false); };
const example = data ? [data.fixture.imports, data.fixture.code].filter(Boolean).join("\n\n") : "";
const helpers = data?.fixture.helpers?.map((helper) => helper.code).join("\n\n") ?? "";
const example = data ? [data.fixture.imports, data.fixture.code, helpers].filter(Boolean).join("\n\n") : "";
const clearFilters = () => { setQuery(""); setFamily("all"); setTask("all"); };
return <section className="rat-explorer" aria-label={copy.title}>
<div className="rat-task-picker">
<div><h2>{copy.tasksTitle}</h2><p>{copy.tasksDescription}</p></div>
<div className="rat-task-options" role="group" aria-label={copy.tasksTitle}>
<button type="button" className="rat-button" aria-pressed={task === "all"} onClick={clearFilters}>{copy.allComponents}</button>
{TASKS.map((item) => <button key={item.id} type="button" className="rat-button" aria-pressed={task === item.id} onClick={() => {
setTask(item.id); setFamily("all"); setQuery("");
choose(item.recommended.find((name) => catalogue.entries.some((candidate) => candidate.name === name)) ?? first.name);
}}>{pickText(item.label, locale)}</button>)}
</div>
{task !== "all" && <p className="rat-task-description">{pickText(TASKS.find((item) => item.id === task)!.description, locale)}</p>}
</div>
<aside className="rat-browser" aria-label={copy.componentNavLabel}>
<label className="rat-search" htmlFor="rat-search"><span>{copy.searchLabel}</span><div><Icon name="search" /><input id="rat-search" ref={searchRef} type="search" value={query} placeholder={copy.searchPlaceholder} onChange={(event) => { setQuery(event.target.value); if (mobile) setBrowserExpanded(true); }} onKeyDown={(event) => { if (event.key === "Escape") setQuery(""); }} /><kbd>/</kbd></div></label>
<label className="rat-family-field" htmlFor="rat-family">{copy.families}<select id="rat-family" value={family} onChange={(event) => { setFamily(event.target.value); if (mobile) setBrowserExpanded(true); }}><option value="all">{copy.allComponents}</option>{catalogue.families.map((item) => <option key={item.id} value={item.id}>{item.title} ({item.count})</option>)}</select></label>
<label className="rat-family-field" htmlFor="rat-family">{copy.families}<select id="rat-family" value={family} onChange={(event) => { setFamily(event.target.value); setTask("all"); if (mobile) setBrowserExpanded(true); }}><option value="all">{copy.allComponents}</option>{catalogue.families.map((item) => <option key={item.id} value={item.id}>{item.title} ({item.count})</option>)}</select></label>
<p className="rat-count" role="status">{copy.visibleCount.replace("{visible}", String(filtered.length)).replace("{total}", String(catalogue.entries.length))}</p>
<details className="rat-browser-results" open={browserExpanded} onToggle={(event) => setBrowserExpanded(event.currentTarget.open)}><summary>{copy.allComponents}<Icon name="chevron-down" /></summary>
<nav className="rat-entry-list" aria-label={copy.resultsLabel}>
{catalogue.families.map((item) => {
const entries = filtered.filter((candidate) => candidate.family === item.id);
return entries.length ? <div className="rat-family" key={item.id}><h3>{item.title}</h3>{entries.map((candidate) => <button type="button" key={candidate.name} className="rat-entry" aria-current={candidate.name === selected ? "true" : undefined} onClick={() => choose(candidate.name)}><span>{candidate.title}</span><span className="rat-entry-width">{candidate.width}</span></button>)}</div> : null;
return entries.length ? <div className="rat-family" key={item.id}><h3>{item.title}</h3>{entries.map((candidate) => <button type="button" key={candidate.name} className="rat-entry" aria-current={candidate.name === entry.name ? "true" : undefined} onClick={() => choose(candidate.name)}><span>{candidate.title}</span><span className="rat-entry-width">{candidate.width}</span></button>)}</div> : null;
})}
{filtered.length === 0 && <div className="rat-empty"><h3>{copy.emptyTitle}</h3><p>{copy.emptyDescription}</p><button className="rat-button" onClick={() => { setQuery(""); setFamily("all"); }}>{copy.clearSearch}</button></div>}
</nav></details>
</aside>
<div className="rat-study">
{filtered.length === 0 ? <div className="rat-study rat-empty-study"><h2>{copy.emptyTitle}</h2><p>{copy.emptyDescription}</p><button className="rat-button" onClick={clearFilters}>{copy.clearSearch}</button></div> : <div className="rat-study">
<div className="rat-study-heading"><div><p>{collection?.title}</p><h2>{entry.title}</h2></div><Link href={`/${locale}/ratatui/${entry.name}`} className="rat-link" title={copy.share}>{copy.share}<Icon name="external" /></Link></div>
<p className="rat-description">{entry.description}</p>
<div className="rat-study-tabs" role="group" aria-label={copy.preview}>
<button type="button" aria-pressed={tab === "preview"} onClick={() => setTab("preview")}>{copy.preview}</button>
<button type="button" aria-pressed={tab === "code"} onClick={() => setTab("code")}>{copy.code}</button>
{animation && <button type="button" aria-pressed={tab === "motion"} onClick={() => { setTab("motion"); if (profile !== "light-truecolor") setProfile("dark-truecolor"); }}>{copy.motion}</button>}
<button type="button" aria-pressed={activeTab === "preview"} onClick={() => setTab("preview")}>{copy.preview}</button>
<button type="button" aria-pressed={activeTab === "use"} onClick={() => setTab("use")}>{copy.use}</button>
<button type="button" aria-pressed={activeTab === "code"} onClick={() => setTab("code")}>{copy.code}</button>
{animation && <button type="button" aria-pressed={activeTab === "motion"} onClick={() => { setTab("motion"); if (profile !== "light-truecolor") setProfile("dark-truecolor"); }}>{copy.motion}</button>}
<div className="rat-pager"><button type="button" aria-label={copy.previous} disabled={currentIndex <= 0} onClick={() => choose(filtered[currentIndex - 1].name)}><Icon name="arrow-right" className="rat-prev-icon" /></button><button type="button" aria-label={copy.next} disabled={currentIndex < 0 || currentIndex >= filtered.length - 1} onClick={() => choose(filtered[currentIndex + 1].name)}><Icon name="arrow-right" /></button></div>
</div>
{tab !== "code" && <div className="rat-preview-controls">{(tab !== "motion" || !["whale", "habitat"].includes(animation ?? "")) && <label className="rat-field">{copy.profile}<select aria-label={copy.profile} value={profile} onChange={(event) => setProfile(event.target.value)}>{catalogue.profiles.filter((item) => tab !== "motion" || ["dark-truecolor", "light-truecolor"].includes(item.id)).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}</select></label>}{tab === "preview" && <label className="rat-field">{copy.size}<select aria-label={copy.size} value={size} onChange={(event) => setSize(event.target.value)}>{catalogue.sizes.map((item) => <option key={item.id} value={item.id}>{item.id === "native" ? copy.nativeSize : copy.columns.replace("{count}", item.id)}</option>)}</select></label>}<button type="button" className="rat-button rat-scale" onClick={() => setFit(!fit)} aria-pressed={!fit}>{fit ? copy.actualSize : copy.fit}</button></div>}
{tab === "motion" && animation ? <MotionPlayer key={animation} id={animation} copy={copy} fit={fit} /> : tab === "code" ? <div className="rat-code-panel"><div className="rat-code-heading"><h3>{copy.fixtureTitle}</h3><CopyButton text={example} copy={copy} /></div>{data ? <pre tabIndex={0} dir="ltr"><code>{example}</code></pre> : <p role="status">{error ? copy.previewError : copy.loading}</p>}<p className="rat-note">{copy.fixtureNote} <a href={sourceUrl}>{copy.openSource}<Icon name="external" /></a></p></div> : <>
{(activeTab === "preview" || activeTab === "motion") && <div className="rat-preview-controls">{(activeTab !== "motion" || !["whale", "habitat"].includes(animation ?? "")) && <label className="rat-field">{copy.profile}<select aria-label={copy.profile} value={profile} onChange={(event) => setProfile(event.target.value)}>{catalogue.profiles.filter((item) => activeTab !== "motion" || ["dark-truecolor", "light-truecolor"].includes(item.id)).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}</select></label>}{activeTab === "preview" && <label className="rat-field">{copy.size}<select aria-label={copy.size} value={size} onChange={(event) => setSize(event.target.value)}>{catalogue.sizes.map((item) => <option key={item.id} value={item.id}>{item.id === "native" ? copy.nativeSize : copy.columns.replace("{count}", item.id)}</option>)}</select></label>}<button type="button" className="rat-button rat-scale" onClick={() => setFit(!fit)} aria-pressed={!fit}>{fit ? copy.actualSize : copy.fit}</button></div>}
{activeTab === "use" ? <div className="rat-code-panel rat-usage-panel">
<h3>{guidance.title}</h3><p>{guidance.description}</p><p className="rat-note">{guidance.hostNote}</p>
{recipe && <><div className="rat-code-heading"><h4>{copy.use}</h4><CopyButton text={recipe.code} copy={copy} /></div><pre tabIndex={0} dir="ltr"><code>{recipe.code}</code></pre><p className="rat-note">{copy.recipeNote} <a href={recipeSourceUrl(recipe.line)}>{copy.openSource}<Icon name="external" /></a></p></>}
<div className="rat-usage-links"><a className="rat-link" href={learningGuideUrl(guidance.guideAnchor)}>{copy.guide}<Icon name="external" /></a><Link className="rat-link" href={`/${locale}/ratatui#ratatui-install`}>{copy.gettingStarted}<Icon name="arrow-right" /></Link></div>
<p className="rat-note">{copy.runExample}</p><pre tabIndex={0} dir="ltr"><code>{`cargo run --locked --example ${recipe ? "recipes" : guidance.example.replace(/^examples\//, "").replace(/\.rs$/, "")}`}</code></pre>{recipe && <p className="rat-note">{copy.recipeControls}</p>}
</div> :
activeTab === "motion" && animation ? <MotionPlayer key={animation} id={animation} copy={copy} fit={fit} /> : activeTab === "code" ? <div className="rat-code-panel"><div className="rat-code-heading"><h3>{copy.fixtureTitle}</h3><CopyButton text={example} copy={copy} /></div>{data ? <pre tabIndex={0} dir="ltr"><code>{example}</code></pre> : <div className="rat-load" role={error ? "alert" : "status"}><p>{error ? copy.previewError : copy.loading}</p>{error && <button className="rat-button" onClick={() => setAttempt((value) => value + 1)}>{copy.retry}</button>}</div>}<p className="rat-note">{copy.fixtureNote} <a href={sourceUrl}>{copy.openSource}<Icon name="external" /></a></p></div> : <>
{error ? <div className="rat-load" role="alert"><p>{copy.previewError}</p><button className="rat-button" type="button" onClick={() => setAttempt((value) => value + 1)}>{copy.retry}</button></div> : svg ? <TerminalImage svg={svg} label={label} fit={fit} width={columns} height={entry.height} /> : <div className="rat-load" role="status">{copy.previewLoading}</div>}
<div className="rat-preview-meta"><span>{columns} × {entry.height}</span><span>{profile}</span>{svg && <a className="rat-link" href={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} download={`${entry.name}.${profile}.${size}.svg`}>{copy.downloadSvg}<Icon name="external" /></a>}</div>
<p className="rat-note">{copy.renderedPreviewNote}</p>
</>}
<div className="rat-api"><h3>{copy.api}</h3><div>{entry.api.map((symbol) => <a key={symbol} href={sourceUrl}><code>{symbol}</code></a>)}</div><p>{copy.hostNote}</p></div>
<div className="rat-project"><h3>{copy.projectStatus}</h3><div className="rat-project-links"><a href={`${REPOSITORY}/commit/${catalogue.source.revision}`}>{copy.sourceRevision}<code>{catalogue.source.revision.slice(0, 7)}</code></a><a href={`${REPOSITORY}/actions/workflows/ci.yml?query=branch%3Amain`}>{copy.buildChecks}<Icon name="external" /></a><a href={`${REPOSITORY}/actions/workflows/gallery.yml?query=branch%3Amain`}>{copy.galleryChecks}<Icon name="external" /></a><a href={`${REPOSITORY}/releases`}>{copy.releaseHistory}<Icon name="external" /></a></div></div>
</div>
</div>}
</section>;
}
+23
View File
@@ -0,0 +1,23 @@
.rat-quickstart { width: var(--container); margin: 0 auto 2rem; padding: 1.5rem; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1.25rem 2rem; border-radius: var(--radius-surface); background: var(--surface); color: var(--text); scroll-margin-top: 6rem; }
.rat-start-intro h2 { font-size: 1.15rem; font-weight: 600; margin: 0 0 .5rem; }
.rat-quickstart p { font-size: .875rem; line-height: 1.6; color: var(--muted); max-width: 70ch; margin: 0; }
.rat-start-links { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; margin-top: 1rem; font-size: .8rem; }
.rat-quickstart a { color: var(--accent); text-underline-offset: .2em; }
.rat-quickstart a:hover { text-decoration: underline; }
.rat-start-command .rat-copy { display: flex; justify-content: flex-end; margin-bottom: .5rem; }
.rat-start-command > p { font-size: .75rem; margin-top: .75rem; }
.rat-quickstart pre { min-width: 0; max-width: 100%; overflow: auto; padding: 1rem; border-radius: var(--radius-control); background: var(--bg); font-size: .8rem; line-height: 1.6; margin: 0; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--bg); }
.rat-quickstart pre::selection, .rat-quickstart code::selection { background: var(--accent); color: var(--on-brand); }
.rat-quickstart :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.rat-existing-app { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 1rem; min-width: 0; }
.rat-existing-app summary { font-weight: 600; font-size: .875rem; cursor: pointer; min-height: 2.75rem; display: list-item; align-content: center; }
.rat-existing-body { padding-top: 1rem; display: grid; gap: 1rem; min-width: 0; }
.rat-existing-body .rat-copy { justify-self: end; }
.rat-host-flow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.5rem; border-top: 1px solid var(--line); padding-top: 1.25rem; margin-top: .5rem; }
.rat-host-flow dt { font-weight: 600; font-size: .875rem; margin-bottom: .35rem; }
.rat-host-flow dd { margin: 0; color: var(--muted); font-size: .85rem; line-height: 1.6; }
@media (max-width: 760px) {
.rat-quickstart { grid-template-columns: minmax(0, 1fr); padding: 1.1rem; gap: 1rem; }
.rat-host-flow { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.rat-start-command .rat-copy { justify-content: flex-start; }
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import type { RatatuiCopy } from "@/lib/content/ratatui";
import { CopyButton } from "./explorer";
import { INSTALL, STARTER_SOURCE, TRY_STARTER, getRecipe, learningGuideUrl } from "@/lib/ratatui/recipes";
import "./quickstart.css";
export function RatatuiQuickstart({ copy }: { copy: RatatuiCopy }) {
const composer = getRecipe("composer");
return <section id="ratatui-install" className="rat-quickstart" aria-labelledby="rat-start-title">
<div className="rat-start-intro">
<h2 id="rat-start-title">{copy.firstAppTitle}</h2>
<p>{copy.firstAppDescription}</p>
<div className="rat-start-links">
<a href={STARTER_SOURCE}>{copy.starterSource} →</a>
<a href={learningGuideUrl()}>{copy.guide} →</a>
</div>
</div>
<div className="rat-start-command">
<CopyButton text={TRY_STARTER} copy={copy} />
<pre tabIndex={0} dir="ltr"><code>{TRY_STARTER}</code></pre>
<p>{copy.rustVersion} · {copy.license}</p>
</div>
<details className="rat-existing-app">
<summary>{copy.existingApp}</summary>
<div className="rat-existing-body">
<p>{copy.installationDescription}</p>
<CopyButton text={INSTALL} copy={copy} />
<pre tabIndex={0} dir="ltr"><code>{INSTALL}</code></pre>
<p>{copy.themeNote}</p>
<pre tabIndex={0} dir="ltr"><code>{"let theme = codewhale_ratatui::Theme::detect().tui();"}</code></pre>
{composer && <><CopyButton text={composer.code} copy={copy} /><pre tabIndex={0} dir="ltr"><code>{composer.code}</code></pre></>}
<dl className="rat-host-flow">
<div><dt>{copy.stateTitle}</dt><dd>{copy.stateDescription}</dd></div>
<div><dt>{copy.paintTitle}</dt><dd>{copy.paintDescription}</dd></div>
<div><dt>{copy.actionsTitle}</dt><dd>{copy.actionsDescription}</dd></div>
</dl>
</div>
</details>
</section>;
}
+41 -3
View File
@@ -79,6 +79,25 @@ export interface RatatuiCopy {
componentNavLabel: string;
websiteNote: string;
reducedMotionNote: string;
tasksTitle: string;
tasksDescription: string;
use: string;
guide: string;
runExample: string;
relatedMotion: string;
recipeNote: string;
recipeControls: string;
firstAppTitle: string;
firstAppDescription: string;
existingApp: string;
themeNote: string;
starterSource: string;
stateTitle: string;
stateDescription: string;
paintTitle: string;
paintDescription: string;
actionsTitle: string;
actionsDescription: string;
}
export const RATATUI_COPY: Record<keyof RatatuiCopy, LocalizedText> = {
@@ -98,7 +117,7 @@ export const RATATUI_COPY: Record<keyof RatatuiCopy, LocalizedText> = {
families: { en: "Collections", zh: "组件集" },
componentCount: { en: "{count} previews", zh: "{count} 个预览" },
preview: { en: "Preview", zh: "预览" },
code: { en: "Rust source", zh: "Rust 源码" },
code: { en: "Gallery source", zh: "展示源码" },
api: { en: "Components used", zh: "使用的组件" },
source: { en: "Source", zh: "源码" },
profile: { en: "Terminal profile", zh: "终端配置" },
@@ -128,14 +147,14 @@ export const RATATUI_COPY: Record<keyof RatatuiCopy, LocalizedText> = {
zh: "使用单个组件,或组合成完整工作区。你的应用管理状态、时钟与操作;组件库负责渲染界面。",
},
installationTitle: { en: "Start building", zh: "开始构建" },
installationDescription: { en: "Add the library to your Cargo.toml. Your app chooses the terminal backend.", zh: "将组件库添加到 Cargo.toml。终端后端由你的应用选择。" },
installationDescription: { en: "Add the library to your Cargo.toml. Use your existing backend, or enable Crossterm for a new app.", zh: "将组件库添加到 Cargo.toml。可使用现有终端后端,或为新应用启用 Crossterm。" },
tryLocally: { en: "Try the interactive gallery", zh: "试用交互式展示" },
repository: { en: "GitHub repository", zh: "GitHub 仓库" },
viewGuide: { en: "Terminal view guide", zh: "终端视图指南" },
quality: { en: "Quality and compatibility", zh: "质量与兼容性" },
releaseHistory: { en: "Release history", zh: "发布历史" },
checks: { en: "Latest checks", zh: "最新检查" },
fixtureNote: { en: "This is the exact gallery source, with example state and helper calls. Open the source file for the full recipe.", zh: "这是展示中实际使用的源码,包含示例状态和辅助函数调用。请打开源文件查看完整示例。" },
fixtureNote: { en: "This source reproduces the gallery preview, including its example data and internal helpers. For public API code you can reuse, choose Use in your app.", zh: "此源码用于重现展示预览,包含示例数据和内部辅助函数。如需复用公开 API 代码,请选择“用于你的应用”。" },
motionNote: { en: "Animation uses rendered frames. Your app supplies the clock.", zh: "动画使用实际渲染的帧。时钟由你的应用提供。" },
rustVersion: { en: "Rust 1.89+", zh: "Rust 1.89+" },
license: { en: "MIT license", zh: "MIT 许可证" },
@@ -170,6 +189,25 @@ export const RATATUI_COPY: Record<keyof RatatuiCopy, LocalizedText> = {
componentNavLabel: { en: "Ratatui component collections", zh: "Ratatui 组件集" },
websiteNote: { en: "Part of Codewhale. Built for your Ratatui app.", zh: "来自 Codewhale,用于你的 Ratatui 应用。" },
reducedMotionNote: { en: "Playback is paused for reduced motion. You can step through the frames or choose Play.", zh: "已按减少动态效果的偏好暂停播放。你可以逐帧查看或选择播放。" },
tasksTitle: { en: "What are you building?", zh: "你想构建什么?" },
tasksDescription: { en: "Choose a starting point, or browse every component below.", zh: "选择一个起点,或浏览下方的全部组件。" },
use: { en: "Use in your app", zh: "用于你的应用" },
guide: { en: "Read the getting-started guide", zh: "阅读入门指南" },
runExample: { en: "Run from the cloned library", zh: "在克隆的组件库中运行" },
relatedMotion: { en: "Related animation demo", zh: "相关动画示例" },
recipeNote: { en: "A public API starting point from the runnable recipes example. Pass your app’s theme and state; adapt the data and layout to your app.", zh: "这是可运行示例中的公开 API 起点。传入应用的主题与状态,并按需调整数据和布局。" },
recipeControls: { en: "F1 composer · F2 workbar · F3 water · F4 whale · F5 animated whale. Press m in F5 to compare motion policies; Esc closes.", zh: "F1 输入框 · F2 工作栏 · F3 海洋背景 · F4 鲸鱼 · F5 动态鲸鱼。在 F5 中按 m 比较动画策略;Esc 退出。" },
firstAppTitle: { en: "Start with a working app", zh: "从可运行的应用开始" },
firstAppDescription: { en: "Type a message, press Enter to echo it, and Esc to exit. The starter connects editable input, the native composer and workbar in one small app.", zh: "输入消息,按 Enter 显示消息,按 Esc 退出。入门示例在一个小应用中连接可编辑输入、原生输入框与工作栏。" },
existingApp: { en: "Add to an existing Ratatui app", zh: "添加到现有 Ratatui 应用" },
themeNote: { en: "Detect the theme once when your app starts, then pass it to your draw function. Theme::detect().tui() selects the native terminal palette.", zh: "启动时检测一次主题,再传入绘制函数。Theme::detect().tui() 会选择原生终端配色。" },
starterSource: { en: "See the complete starter", zh: "查看完整入门示例" },
stateTitle: { en: "Keep the state", zh: "保存状态" },
stateDescription: { en: "Your app stores drafts, selection, work and a persistent whale Stage.", zh: "应用保存草稿、选中项、工作数据及持续使用的鲸鱼 Stage。" },
paintTitle: { en: "Render the view", zh: "渲染视图" },
paintDescription: { en: "Components paint the facts you supply into your Ratatui frame.", zh: "组件将你提供的数据绘制到 Ratatui 帧中。" },
actionsTitle: { en: "Handle the action", zh: "处理操作" },
actionsDescription: { en: "Route input outcomes to your app. Supply elapsed time when motion needs a redraw.", zh: "将输入结果交给应用处理;动画需要重绘时,提供经过的时间。" },
};
/** Use the site's deterministic locale selection rather than page-local copy forks. */
+4 -3
View File
@@ -1,4 +1,5 @@
import catalogue from "@/public/ratatui/catalogue.json";
import { discoveryText, normalizeSearch } from "./learning";
export interface CatalogueEntry {
name: string;
@@ -26,7 +27,7 @@ export interface EntryPreview {
width: number;
height: number;
previews: Record<string, Record<string, string>>;
fixture: { code: string; imports: string; source: unknown };
fixture: { code: string; imports: string; helpers?: { name: string; code: string; line: number }[]; source: unknown };
}
/** Bundled at build time: no filesystem or remote repository dependency in the Worker. */
@@ -35,10 +36,10 @@ export function readCatalogue(): Catalogue {
}
export function searchEntries(entries: readonly CatalogueEntry[], query: string, family = "all") {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
const terms = normalizeSearch(query).split(/\s+/).filter(Boolean);
return entries.filter((entry) => {
if (family !== "all" && entry.family !== family) return false;
const text = [entry.name, entry.title, entry.description, ...entry.api].join(" ").toLocaleLowerCase();
const text = normalizeSearch([entry.name, entry.title, ...entry.api, discoveryText(entry)].join(" "));
return terms.every((term) => text.includes(term));
});
}
+82
View File
@@ -0,0 +1,82 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readCatalogue } from "./catalogue";
import { FAMILY_GUIDANCE, TASKS, discoveryText, filterByTask, getGuidance, normalizeSearch } from "./learning";
const catalogue = readCatalogue();
const names = new Map(catalogue.entries.map((entry) => [entry.name, entry]));
const matching = (query: string) => {
const terms = normalizeSearch(query).trim().split(/\s+/).filter(Boolean);
return catalogue.entries.filter((entry) => terms.every((term) => discoveryText(entry).includes(term)))
.map((entry) => entry.name);
};
describe("Ratatui learning routes", () => {
it("gives every generated collection exactly one intent and an existing example", () => {
const families = TASKS.flatMap((task) => task.families);
expect(new Set(families).size).toBe(families.length);
expect([...families].sort()).toEqual(catalogue.families.map((family) => family.id).sort());
expect(Object.keys(FAMILY_GUIDANCE).sort()).toEqual([...families].sort());
for (const guidance of Object.values(FAMILY_GUIDANCE)) {
expect(guidance.purpose.en.length).toBeGreaterThan(0);
expect(guidance.purpose.zh.length).toBeGreaterThan(0);
expect(guidance.responsibility.en.length).toBeGreaterThan(0);
expect(guidance.responsibility.zh.length).toBeGreaterThan(0);
expect(existsSync(join(process.cwd(), "..", "vendor", "codewhale-ratatui", guidance.example)), guidance.example).toBe(true);
}
});
it("recommends available entries belonging to each task and keeps gallery order", () => {
for (const task of TASKS) {
const filtered = filterByTask(catalogue.entries, task.id);
expect(filtered).toEqual(catalogue.entries.filter((entry) => task.families.includes(entry.family)));
for (const name of task.recommended) {
expect(names.has(name), name).toBe(true);
expect(task.families).toContain(names.get(name)!.family);
}
}
expect(filterByTask(catalogue.entries, "all")).toEqual(catalogue.entries);
expect(filterByTask(catalogue.entries, "unknown")).toEqual(catalogue.entries);
});
it("finds themes, ombré and loading by common words without matching unrelated widgets", () => {
expect(matching("themes")).toContain("tui-theme-underwater");
expect(matching("ombré")).toEqual(matching("ombre"));
expect(matching("ombré")).toContain("ocean-column");
expect(matching("loading")).toContain("spinner");
expect(matching("loading")).not.toContain("toasts");
expect(matching("progress")).toContain("workflow-progress-live");
expect(matching("progress")).not.toContain("receipt-table");
expect(matching("progress")).not.toContain("artifact");
});
it("distinguishes fish, jellyfish, fields and search while retaining public API lookup", () => {
expect(matching("jelly")).toContain("jellyfish");
expect(matching("jelly")).not.toContain("fish-school");
expect(matching("fish")).toContain("fish-school");
expect(matching("forms")).toContain("form");
expect(matching("forms")).not.toContain("text-input-typed");
expect(matching("search")).toContain("picker-query");
expect(matching("search")).not.toContain("whale-rest");
expect(matching("NativeComposer")).toContain("native-composer");
expect(matching("水母")).toContain("jellyfish");
});
it("offers public recipes only for matching component APIs and uses English locale fallback", () => {
const expected = {
"native-composer": "composer", "workbar-tasks": "workbar", "ocean-column": "ocean",
"whale-busy": "whale", "whale-actions": "animated-whale",
};
for (const [name, recipe] of Object.entries(expected)) {
expect(getGuidance(names.get(name)!, "en").recipeId).toBe(recipe);
}
for (const name of ["native-composer-rich-selection", "showcase-work", "atmosphere-ocean", "fish-school"]) {
expect(getGuidance(names.get(name)!, "en").recipeId).toBeUndefined();
}
const whale = names.get("whale-busy")!;
expect(getGuidance(whale, "fr")).toEqual(getGuidance(whale, "en"));
expect(getGuidance(whale, "zh").hostNote).toContain("WhaleState");
expect(getGuidance(whale, "zh").description).not.toBe(getGuidance(whale, "en").description);
});
});
+216
View File
@@ -0,0 +1,216 @@
import type { LocalizedText } from "@/lib/content/vocabulary";
import { pickText } from "@/lib/i18n/dictionaries";
import type { CatalogueEntry } from "./catalogue";
export interface LearningTask {
id: string;
label: LocalizedText;
description: LocalizedText;
families: string[];
recommended: string[];
}
/** Intent routes sit beside the generated catalogue so exports remain reproducible. */
export const TASKS: LearningTask[] = [
{
id: "build-app", label: { en: "Build a terminal app", zh: "构建终端应用" },
description: { en: "Start with the native shell, composer and workbar, then arrange your own workspace.", zh: "从原生界面框架、输入框与工作栏开始,再组合自己的工作区。" },
families: ["studio", "native-views", "native-chrome", "workbar", "scenes"],
recommended: ["native-composer", "workbar-tasks", "showcase-work"],
},
{
id: "input", label: { en: "Accept input", zh: "接收输入" },
description: { en: "Add editable fields, search, choices, forms and keyboard navigation.", zh: "添加可编辑字段、搜索、选择、表单与键盘导航。" },
families: ["input", "chrome"], recommended: ["text-input-typed", "picker-query", "form"],
},
{
id: "conversations", label: { en: "Show conversations", zh: "展示对话" },
description: { en: "Render messages, code, attached context, queued input and agents.", zh: "渲染消息、代码、附加上下文、排队输入与智能体。" },
families: ["transcript", "components"], recommended: ["message", "transcript-code", "pending-queued"],
},
{
id: "review", label: { en: "Review work", zh: "审查工作" },
description: { en: "Explain changes, request a decision and present results supplied by your app.", zh: "说明变更、请求决定,并展示应用提供的结果。" },
families: ["display"], recommended: ["diff", "approval-command", "receipt-table"],
},
{
id: "style", label: { en: "Style your terminal", zh: "设计终端风格" },
description: { en: "Choose a Codewhale theme, ocean depth and readable terminal details.", zh: "选择 Codewhale 主题、海洋深度与易读的终端细节。" },
families: ["tui-palettes", "water", "foundation"], recommended: ["tui-theme-underwater", "ocean-column", "depth"],
},
{
id: "motion", label: { en: "Add motion and life", zh: "添加动画与海洋生物" },
description: { en: "Show progress with spinners, or add the whale, fish and jellyfish.", zh: "用加载动画展示进度,或添加鲸鱼、鱼群与水母。" },
families: ["motion", "habitat", "whales", "whale-actions"], recommended: ["spinner", "whale-busy", "fish-school"],
},
];
export interface FamilyGuidance {
purpose: LocalizedText;
responsibility: LocalizedText;
example: string;
}
/** Teach composition without making the gallery's example state an application authority. */
export const FAMILY_GUIDANCE: Record<string, FamilyGuidance> = {
studio: {
purpose: { en: "See how the native composer, conversation and workbar fit together.", zh: "了解原生输入框、对话与工作栏如何组合。" },
responsibility: { en: "Adapt the layout to your data; your app handles input and session state.", zh: "根据自己的数据调整布局;输入与会话状态由应用处理。" },
example: "examples/showcase.rs",
},
"native-views": {
purpose: { en: "Build session lists, settings and pickers from Codewhale's terminal parts.", zh: "用 Codewhale 终端组件构建会话列表、设置与选择器。" },
responsibility: { en: "Supply rows and selection, then handle the resulting actions in your app.", zh: "提供行与选择状态,并在应用中处理相应操作。" },
example: "examples/gallery.rs",
},
"native-chrome": {
purpose: { en: "Compose the native input area, permission words, workflow progress and metrics.", zh: "组合原生输入区域、权限文字、工作流进度与指标。" },
responsibility: { en: "Own the editable draft, cursor and reported state; painting does not submit a message.", zh: "管理可编辑草稿、光标与状态;渲染不会发送消息。" },
example: "examples/starter.rs",
},
workbar: {
purpose: { en: "Show Tasks, Fleet, Jobs, Files, Notes, Context, Git or Cost in a native workbar.", zh: "在原生工作栏中展示任务、Fleet、作业、文件、笔记、上下文、Git 或费用。" },
responsibility: { en: "Supply stable row IDs and panel facts, and connect selection to your own actions.", zh: "提供稳定的行 ID 与面板数据,并将选择连接到自己的操作。" },
example: "examples/starter.rs",
},
scenes: {
purpose: { en: "Explore optional workspace, review, fleet and habitat compositions.", zh: "探索可选的工作区、审查、Fleet 与海洋生境组合。" },
responsibility: { en: "Reuse the parts you need; these scenes contain example data rather than live sessions.", zh: "复用所需组件;这些场景包含示例数据,而非实时会话。" },
example: "examples/gallery.rs",
},
input: {
purpose: { en: "Edit text, validate fields and choose from searchable lists.", zh: "编辑文本、验证字段,并从可搜索列表中选择。" },
responsibility: { en: "Keep input state and validation in your app, and handle submitted or cancelled outcomes.", zh: "在应用中管理输入状态与验证,并处理提交或取消结果。" },
example: "examples/starter.rs",
},
chrome: {
purpose: { en: "Add headings, tabs, toggles, settings details and keyboard hints.", zh: "添加标题、标签页、开关、设置详情与键盘提示。" },
responsibility: { en: "Update selected values and dispatch keys; the controls display your current state.", zh: "更新选定值并分发按键;控件展示应用当前的状态。" },
example: "examples/gallery.rs",
},
transcript: {
purpose: { en: "Display structured prose, code, links, attachments and queued instructions.", zh: "展示结构化正文、代码、链接、附件与排队指令。" },
responsibility: { en: "Supply parsed content and queue state; your app owns scrolling, links and clipboard actions.", zh: "提供解析后的内容与队列状态;应用负责滚动、链接与剪贴板操作。" },
example: "examples/gallery.rs",
},
components: {
purpose: { en: "Combine messages, tool and agent cards, attention queues and workspace framing.", zh: "组合消息、工具与智能体卡片、待处理队列及工作区框架。" },
responsibility: { en: "Supply conversation and agent facts, and keep execution and priority decisions in your app.", zh: "提供对话与智能体数据;执行与优先级决策由应用负责。" },
example: "examples/gallery.rs",
},
display: {
purpose: { en: "Present diffs, approvals, artifacts, review decisions and run results.", zh: "展示差异、审批、产物、审查决定与运行结果。" },
responsibility: { en: "Supply the changes and results, and handle approval choices in your app.", zh: "提供变更与结果,并在应用中处理审批选择。" },
example: "examples/gallery.rs",
},
"tui-palettes": {
purpose: { en: "Compare the sixteen native Codewhale themes and their backgrounds and semantic inks.", zh: "比较十六种原生 Codewhale 主题及其背景与语义颜色。" },
responsibility: { en: "Choose a palette and terminal capabilities; use semantic roles for readable states.", zh: "选择调色板与终端能力,并用语义角色保证状态易读。" },
example: "examples/showcase.rs",
},
water: {
purpose: { en: "Apply native ocean depth or an optional ombré treatment to rendered components.", zh: "为已渲染组件添加原生海洋深度或可选渐变效果。" },
responsibility: { en: "Paint components first, then apply the background pass with your viewport and motion policy.", zh: "先渲染组件,再根据视口与动画策略应用背景效果。" },
example: "examples/starter.rs",
},
foundation: {
purpose: { en: "Use shared surfaces, depth, dividers, icons, status marks and key hints.", zh: "使用共享表面、层次、分隔线、图标、状态标记与按键提示。" },
responsibility: { en: "Choose meaningful labels and layout bounds, and retain words alongside status color.", zh: "选择清晰标签与布局边界,并在状态颜色旁保留文字。" },
example: "examples/gallery.rs",
},
motion: {
purpose: { en: "Show running work, verification, notifications and calm state transitions.", zh: "展示进行中的工作、验证、通知与平静的状态过渡。" },
responsibility: { en: "Supply elapsed time and completion status; choose Full, Reduced or Still motion and schedule redraws.", zh: "提供经过时间与完成状态;选择 Full、Reduced 或 Still 动画并安排重绘。" },
example: "examples/motion.rs",
},
habitat: {
purpose: { en: "Render fish schools, jellyfish and bubbles together or individually.", zh: "组合或单独渲染鱼群、水母与气泡。" },
responsibility: { en: "Supply the clock, density and motion preference; the creatures render into your chosen area.", zh: "提供时钟、密度与动画偏好;海洋生物渲染到选定区域。" },
example: "examples/habitat.rs",
},
whales: {
purpose: { en: "Give session state a whale: resting, busy, needing attention, done or with a pod.", zh: "用鲸鱼表现会话状态:休息、忙碌、需要关注、完成或带有鲸群。" },
responsibility: { en: "Map your app's state to WhaleState; use the compact or words-only fallback when space is tight.", zh: "将应用状态映射到 WhaleState;空间不足时使用紧凑或纯文字样式。" },
example: "examples/showcase.rs",
},
"whale-actions": {
purpose: { en: "Explore the whale's full action vocabulary and related animated performances.", zh: "探索鲸鱼的完整动作语言与相关动画表现。" },
responsibility: { en: "For animation, keep a whale_motion Stage and supply inputs and time from your host.", zh: "播放动画时保留 whale_motion Stage,并由应用提供输入与时间。" },
example: "examples/showcase.rs",
},
};
export function normalizeSearch(value: string): string {
return value.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase();
}
/** Name-scoped aliases avoid the repeated family descriptions matching unrelated widgets. */
const ALIASES: [RegExp, string][] = [
[/^(tui-theme-|view-theme$)/, "themes palette colors colours 主题 调色板"],
[/^(atmosphere-|ocean-)/, "ombre ombres gradient background underwater 渐变 背景 海洋"],
[/^(spinner$|verification-pending$|motion-working$)/, "loading busy waiting 加载 等待"],
[/^verification-/, "verification verified checks 验证 检查"],
[/^(workflow-progress-|count-bars$)/, "progress completion 进度 完成"],
[/^workflow-tree/, "tasks hierarchy tree 任务 层级"],
[/^(text-input-|native-composer|composer$)/, "input edit editable typing 输入 编辑"],
[/^form$/, "forms fields validation 表单 字段 验证"],
[/^(picker-|mode-picker$|status-picker$|view-commands$|view-models)/, "search filter select choices 搜索 筛选 选择"],
[/^native-composer-rich-search$/, "search find 搜索 查找"],
[/^list/, "selection rows scroll 选择 列表 滚动"],
[/^fish-school$/, "fish fishes swimming 鱼 鱼群"],
[/^jellyfish$/, "jelly jellyfish 水母"],
[/^bubble-field$/, "bubbles 气泡"],
[/^habitat-(scene|ascii|reduced)$/, "fish jellyfish bubbles aquarium 鱼 水母 气泡"],
[/^whale-/, "whales mascot character 鲸鱼 角色"],
[/^whale-needs$/, "attention waiting needs input 关注 等待 输入"],
[/^workbar-/, "dock panel 工作栏 面板"],
[/^(transcript-|message$)/, "conversation messages prose 对话 消息"],
[/^pending-/, "queue queued attachments steering 队列 附件"],
[/^diff/, "changes patch review 差异 变更 审查"],
[/^approval-/, "permission decision approve 审批 权限"],
[/^receipt-/, "results cost usage 结果 费用 用量"],
[/^toasts/, "notifications feedback 通知 反馈"],
];
export function discoveryText(entry: CatalogueEntry): string {
const aliases = ALIASES.filter(([pattern]) => pattern.test(entry.name)).map(([, text]) => text);
return normalizeSearch([entry.name, entry.title, ...entry.api, ...aliases].join(" "));
}
export function getGuidance(entry: CatalogueEntry, locale: string): {
title: string;
description: string;
hostNote: string;
example: string;
recipeId?: string;
guideAnchor?: string;
} {
const guidance = FAMILY_GUIDANCE[entry.family];
let recipeId: string | undefined;
if (entry.family === "native-chrome" && entry.api.includes("NativeComposer")) recipeId = "composer";
if (entry.family === "workbar" && entry.api.includes("Workbar")) recipeId = "workbar";
if (entry.family === "water" && entry.api.includes("OceanColumn")) recipeId = "ocean";
if (entry.family === "whales" && entry.api.includes("Whale")) recipeId = "whale";
if (entry.name === "whale-actions") recipeId = "animated-whale";
return {
title: entry.title,
description: guidance ? pickText(guidance.purpose, locale) : entry.description,
hostNote: guidance ? pickText(guidance.responsibility, locale) : pickText({
en: "Your app supplies state and handles input; components render into your terminal.",
zh: "应用提供状态并处理输入;组件渲染到你的终端。",
}, locale),
example: guidance?.example ?? "examples/gallery.rs",
guideAnchor: ["motion", "habitat", "whales", "whale-actions"].includes(entry.family)
? "bring-the-whale-and-water-to-life"
: ["tui-palettes", "water", "foundation"].includes(entry.family)
? "use-the-native-colors-and-background"
: ["native-chrome", "input", "chrome", "workbar"].includes(entry.family)
? "connect-input-to-your-state" : "choose-the-parts-you-need",
...(recipeId ? { recipeId } : {}),
};
}
export function filterByTask(entries: readonly CatalogueEntry[], taskId: string): CatalogueEntry[] {
const task = TASKS.find((candidate) => candidate.id === taskId);
return task ? entries.filter((entry) => task.families.includes(entry.family)) : [...entries];
}
+26
View File
@@ -0,0 +1,26 @@
{
"sourceRevision": "4d5b05ba0709eb850e7415929b51c4db083d1241",
"sourceSha256": "4e8b679ac8ad746adf984bd94d18e21336b4310a9fb948c27da1e7a7eadf667f",
"recipes": {
"composer": {
"code": "pub fn draw_composer(\n area: ratatui::layout::Rect,\n buffer: &mut ratatui::buffer::Buffer,\n theme: &codewhale_ratatui::Theme,\n draft: &codewhale_ratatui::TextInputState,\n) -> Option<ratatui::layout::Position> {\n use codewhale_ratatui::{NativeComposer, Paint};\n let area = area.intersection(buffer.area);\n if area.is_empty() {\n return None;\n }\n let composer = NativeComposer::new(draft.text())\n .focused(true)\n .cursor(draft.cursor())\n .can_submit(!draft.is_empty())\n .placeholder(\"Type a line, then press Enter\");\n let height = composer.desired_height(area.width, area.height);\n let slot = ratatui::layout::Rect::new(area.x, area.bottom() - height, area.width, height);\n composer.paint(slot, buffer, theme);\n composer.cursor_position(slot)\n}\n",
"line": 10
},
"workbar": {
"code": "pub fn draw_workbar(\n area: ratatui::layout::Rect,\n buffer: &mut ratatui::buffer::Buffer,\n theme: &codewhale_ratatui::Theme,\n) {\n use codewhale_ratatui::{Paint, Workbar, WorkbarPanel, WorkbarRow};\n // Replace these example rows with your application's current tasks.\n Workbar::new(\n WorkbarPanel::Tasks,\n vec![\n WorkbarRow::new(\"read\", \"Read the starter example\").mark(\"✓\"),\n WorkbarRow::new(\"build\", \"Add a component to your app\").mark(\"●\"),\n ],\n )\n .goal(\"Your first Codewhale component\")\n .paint(area, buffer, theme);\n}\n",
"line": 34
},
"ocean": {
"code": "pub fn draw_ocean(\n area: ratatui::layout::Rect,\n buffer: &mut ratatui::buffer::Buffer,\n theme: &codewhale_ratatui::Theme,\n) {\n use codewhale_ratatui::{\n Message, MotionMode, NativeComposer, OceanColumn, Paint, PostureBar, TerminalShell,\n TuiGround,\n };\n use std::time::Duration;\n let area = area.intersection(buffer.area);\n if area.is_empty() {\n return;\n }\n let composer = NativeComposer::new(\"\").placeholder(\"One continuous water column\");\n let shell = TerminalShell::new(composer.desired_height(area.width, area.height));\n let slots = shell.areas(area);\n shell.paint(area, buffer, theme);\n Message::native(\"Paint content first; finish ordinary grounds with the water.\").paint(\n slots.conversation,\n buffer,\n theme,\n );\n composer.paint(slots.composer, buffer, theme);\n PostureBar::new(\"ask\").paint(slots.posture, buffer, theme);\n let water = OceanColumn::new(Duration::ZERO, MotionMode::Still).viewport(area);\n water.apply(area, buffer, theme);\n if let Some(ground) = theme.tui_ground(TuiGround::Composer).bg {\n water.apply_matching(slots.composer, buffer, theme, ground);\n }\n}\n",
"line": 54
},
"whale": {
"code": "pub fn draw_whale(\n area: ratatui::layout::Rect,\n buffer: &mut ratatui::buffer::Buffer,\n theme: &codewhale_ratatui::Theme,\n) {\n use codewhale_ratatui::{Paint, Whale, WhaleState};\n // A still pose needs no clock. Narrow and ASCII viewports retain the words.\n Whale::new(WhaleState::Rest)\n .words(\"Ready when you are\")\n .paint(area, buffer, theme);\n}\n",
"line": 88
},
"animated-whale": {
"code": "pub fn draw_animated_whale(\n area: ratatui::layout::Rect,\n buffer: &mut ratatui::buffer::Buffer,\n theme: &codewhale_ratatui::Theme,\n stage: &mut codewhale_ratatui::whale_motion::Stage,\n now: std::time::Instant,\n) {\n use codewhale_ratatui::whale_motion::colored_braille;\n use codewhale_ratatui::{Whale, WhaleState, detect::Appearance};\n // Keep this Stage in the host. Observe its inputs before drawing this frame.\n let area = area.intersection(buffer.area);\n stage.set_visible(area.width >= 16 && area.height >= 9 && !theme.ascii());\n stage.advance(now);\n let grid = colored_braille(\n stage.director(),\n usize::from(area.width.min(32)),\n usize::from(area.height.saturating_sub(1).min(16)),\n theme.caps().appearance != Appearance::Light,\n );\n // The host reports Working; this example has no agent or provider connection.\n grid.paint(&Whale::new(WhaleState::Busy), area, buffer, theme);\n}\n",
"line": 102
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import recipes from "./recipes.generated.json";
const REPOSITORY = "https://github.com/Hmbown/codewhale-ratatui";
/** Exact public functions exported from the library's compiled recipes example. */
export function getRecipe(id?: string): { code: string; line: number } | null {
if (!id || !Object.prototype.hasOwnProperty.call(recipes.recipes, id)) return null;
return recipes.recipes[id as keyof typeof recipes.recipes];
}
export function recipeSourceUrl(line: number) {
return `${REPOSITORY}/blob/${recipes.sourceRevision}/examples/recipes.rs#L${line}`;
}
export function learningGuideUrl(anchor?: string) {
return `${REPOSITORY}/blob/${recipes.sourceRevision}/GETTING-STARTED.md${anchor ? `#${anchor}` : ""}`;
}
export const STARTER_SOURCE = `${REPOSITORY}/blob/${recipes.sourceRevision}/examples/starter.rs`;
export const RECIPE_SOURCE = `${REPOSITORY}/blob/${recipes.sourceRevision}/examples/recipes.rs`;
export const INSTALL = `[dependencies]
codewhale-ratatui = { git = "${REPOSITORY}", rev = "${recipes.sourceRevision}" }
ratatui = { version = "0.30.2", default-features = false, features = ["std", "crossterm_0_29"] }
crossterm = "0.29"`;
export const TRY_STARTER = `git clone ${REPOSITORY}
cd codewhale-ratatui
cargo run --locked --example starter`;