Skip to content

Commit 60baecc

Browse files
committed
ColorText: a colour per cell, and the ink example
A `text` takes one colour for the whole run, which is right for nearly everything and no answer at all where the colour *is* the content - a banner, a ramp across a title, a palette walked down a block of ascii art. `ColorText` is that case: it paints on a canvas and asks an `ink` for a colour per cell. An ink is spelled three ways. A ramp between stops, across the columns, down the lines or corner to corner. A palette walked in runs, so `every: [4, 3]` is four cells of one colour and three of the next. Or a function handed the cell, which is the escape hatch - and the one that cannot be written in a JSON screen, the same trade canvas makes with draw. Two things the building of it decided: * the cell carries a `col` and an `index`, because they stop being the same number the moment the text is not plain ascii, and the walk advances by grapheme width - colour by index and paint at col, and a gradient shears through the first wide character it meets; * `alignBlock`, because textAlign centres every line over its own middle. That is right for prose and it shears a picture: five rows of block letters do not have equal widths once the trailing spaces are gone, so each row lands somewhere slightly different and the letters lean. A block that wraps asks for no width and takes what it is given; one that does not is as wide as its widest line and says so. Reporting the unwrapped width in both cases pushed every sibling off the row. The examples/ink example is the demonstration and the test. A field, a list of inks, a font, and a panel that scrolls - and a switch that swaps the block letters for three paragraphs of prose, because the component has no idea which it is looking at. Its fonts are one hand-drawn table with both cases on one baseline, plus three transforms of it: wide, slant and shadow. A bitmap font is a grid of characters, and a grid of characters can be sheared, doubled or duplicated by ten lines of code. ScrollView takes a `focusId`. It was the only focusable control without one, so the example had no way to name the panel it scrolls - and the test that scrolls it was passing for the wrong reason until it did.
1 parent cf1f942 commit 60baecc

23 files changed

Lines changed: 2005 additions & 5 deletions

File tree

docs/components/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Every node also accepts `role`, `label`, `focusable`, `onKey`, `onClick`, `disab
3131

3232
<!-- props:start -->
3333

34-
~96 components, and growing.
34+
~97 components, and growing.
3535

3636
### The four primitives
3737

@@ -65,6 +65,7 @@ Every node also accepts `role`, `label`, `focusable`, `onKey`, `onClick`, `disab
6565
| [`Badge`](display/badge.md) | Small status marker; carries a glyph as well as a colour. |
6666
| [`StatusDot`](display/status-dot.md) | The shared status vocabulary: up, degraded, down. |
6767
| [`Card`](display/card.md) | Bordered block with a title. |
68+
| [`ColorText`](display/color-text.md) | Multiline text coloured cell by cell - a ramp, a palette per line, or a function. |
6869
| [`Marquee`](display/marquee.md) | Text too long for its box, read by sliding it while it has the cursor. |
6970

7071
### Data

docs/components/display.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ What shows a value: a label, a table of them, or a shape drawn from a series.
1111

1212
## Display
1313

14-
`Heading` `Label` `Badge` `StatusDot` `Card` `KeyValue` `Timeline`
14+
`Heading` `Label` `Badge` `StatusDot` `Card` `ColorText` `Marquee` `KeyValue` `Timeline`
1515

1616
`StatusDot` is the shared status vocabulary. A status is a glyph *and* a colour, because a 16-colour session, a colourblind reader and a piped log all lose the colour and keep the glyph.
1717

18+
`ColorText` is the exception that proves that rule: it colours a block of text cell by cell - a ramp, a palette walked in runs, or a function handed each cell - and everything it does is decoration. A banner, a title, ascii art. What a reader has to know still has to be in the words, because the ramp is the first thing an ssh session flattens.
19+
1820
## Data
1921

2022
`List` `Table` `Tree` `Pagination` `LogViewer` `CodeViewer` `MarkdownView` `Feed`
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
title: ColorText
3+
parent: Display and data
4+
grand_parent: Components
5+
---
6+
7+
# ColorText
8+
{: .no_toc }
9+
10+
Multiline text coloured cell by cell - a ramp, a palette per line, or a function.
11+
12+
<!-- docs:setup
13+
declare const banner: string;
14+
-->
15+
16+
```tsx
17+
import { ColorText } from '@textui/widgets';
18+
19+
<ColorText ink={{ gradient: ['cyan', 'magenta'] }} content={banner} alignBlock />
20+
```
21+
22+
## Props
23+
24+
<!-- props:start -->
25+
| Prop | Type | Default | |
26+
| --- | --- | --- | --- |
27+
| `ink` | `Ink` | | Left unset, this is an ordinary block of text. |
28+
| `alignBlock` | `boolean` | | Align the block as one thing, rather than each line on its own. `textAlign` centres every line over its own middle, which is right for prose and shears a picture: five rows of block letters do not have equal widths once the trailing spaces are gone, so each row lands somewhere slightly different and the letters lean. Under this, the whole block is placed once and the lines keep their offsets from each other. Off by default, because that is what `text` does and the two are supposed to mean the same thing by the same prop. |
29+
30+
Plus everything on [`TextProps`](../base-props.md).
31+
<!-- props:end -->
32+
33+
A `text` takes one colour for the whole run, which is right for nearly
34+
everything and no answer at all where the colour *is* the content - a banner, a
35+
ramp across a title, a palette walked down a block of ascii art. Everything else
36+
about this is a `text`: `wrap`, `truncate`, `textAlign` and the style keys
37+
all mean here what they mean there.
38+
39+
`ink` is spelled three ways, and the first two are data:
40+
41+
<!-- docs:local
42+
import { ColorText } from '@textui/widgets';
43+
import type { Color } from '@textui/core';
44+
declare const text: string;
45+
declare const palette: Color[];
46+
-->
47+
48+
```tsx
49+
<box direction="column">
50+
<ColorText ink={{ gradient: ['#ff5f6d', '#ffc371'], axis: 'y' }} content={text} />
51+
<ColorText ink={['danger', 'warning', 'success']} content={text} />
52+
<ColorText ink={{ cycle: palette, every: [4, 3] }} content={text} />
53+
<ColorText ink={(cell) => (cell.index % 2 ? 'muted' : 'accent')} content={text} />
54+
</box>
55+
```
56+
57+
A **ramp** runs across the columns, down the lines, or corner to corner. It is
58+
measured against the widest line of the block, so the rows of a banner share one
59+
gradient and the colours line up down it; `per: 'line'` restarts it on each
60+
line, which is what ragged prose wants.
61+
62+
A **cycle** walks a palette. An array on its own is the short spelling of one
63+
colour per line. `every` is how much of the text each colour takes - a number,
64+
or a repeating pattern of runs, so `[4, 3]` is four cells then three. The count
65+
restarts on each line, which is what keeps the bands vertical; `continuous`
66+
carries it over the line breaks and leans them into a diagonal. `unit` picks
67+
what advances the colour: `cell` (the default, and the one that keeps a block
68+
aligned), `grapheme`, `letter`, `word` or `line`.
69+
70+
A **function** is handed each cell and answers with a colour, a whole
71+
`CellStyle`, or nothing - and nothing means "leave this one alone", which is
72+
what makes an ink that colours only the vowels two lines long. The cell carries
73+
a `col` *and* an `index` because they stop being the same number the moment
74+
the text is not plain ascii: colour by `index` and paint at `col`, or a
75+
gradient shears through the first wide character it meets.
76+
77+
Only the data forms survive being written in a JSON screen. A function prop
78+
cannot be serialized - the same trade [`canvas`](../primitives/canvas.md)
79+
makes with `draw`, and for the same reason: this paints on one.
80+
81+
Two consequences of that canvas are worth knowing. **Inherited colour stops
82+
here**: a cell the ink declines takes this component's own `fg`, not the one a
83+
parent row would have handed a `text`, so a `ColorText` inside something that
84+
recolours its children when selected has to be told. And **a block that wraps
85+
asks for no width** - it fills what it is given, the way anything that wraps
86+
must; a block that does not wrap is as wide as its widest line and says so.
87+
88+
`alignBlock` is for pictures. `textAlign` centres every line over its own
89+
middle, which is right for prose and shears a banner, because five rows of block
90+
letters do not have equal widths once the trailing spaces are gone. Under
91+
`alignBlock` the whole block is placed once and the lines keep their offsets
92+
from each other.
93+
94+
The colour is decoration and never the message. A 16-colour session flattens a
95+
six-stop ramp into a couple of bands and a piped log loses all of it, so
96+
anything the reader has to know has to be in the words.
97+
98+
## See also
99+
100+
- [text](../primitives/text.md) - one colour, and the right answer nearly always
101+
- [canvas](../primitives/canvas.md) - the primitive underneath, for cells that are not text
102+
- [Themes](../../themes/tokens.md) - the tokens an ink can name

docs/components/layout/scroll-view.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { ScrollView } from '@textui/widgets';
2727
| `scrollbar` | `boolean` | `true` | Draw a scrollbar on the right when the content overflows. |
2828
| `focusable` | `boolean` | `true` | A tab stop, so the keys that scroll it can reach it. On by default: a viewport had the arrow handlers all along and registered nothing, so unless the caller happened to make it focusable itself the only way to scroll was the wheel - which is to say, on a keyboard, not at all. Turn it off for a view that scrolls inside something already focused. |
2929
| `autoFocus` | `boolean` | | |
30+
| `focusId` | `string` | | A stable focus id, so a command - or the screen that owns this - can put the reader here by name. Without one the id comes from the instance, which nothing outside the render can know: "scroll the preview" has nothing to name and the key that would do it cannot be written. Every other focusable control takes one; this was the exception, and there was no reason for it. |
3031

3132
Plus everything on [`BoxProps`](../base-props.md).
3233
<!-- props:end -->

examples/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Bigger programs, bundled by esbuild because they are many files rather than
5454
because textui needs it.
5555

5656
```bash
57-
pnpm example todo # or arcade, chat, surfaces
57+
pnpm example todo # or arcade, chat, surfaces, ink
5858
```
5959

6060
| | |
@@ -63,4 +63,5 @@ pnpm example todo # or arcade, chat, surfaces
6363
| [`arcade`](arcade) | Frame loops, canvas painting, input timing |
6464
| [`chat`](chat) | `Feed`, `TextArea`, markdown, and a fake host |
6565
| [`surfaces`](surfaces) | An application with no shell, arranging its own chrome |
66+
| [`ink`](ink) | `ColorText`: a colour per cell, over a banner and over prose |
6667
| [`showcase`](showcase) | The catalog on one screen, as a row that wraps. Writes its own screenshot |

examples/ink/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.dev
2+
dist

examples/ink/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# ink
2+
3+
```bash
4+
pnpm example ink
5+
pnpm example ink -- --colors 4 # what an ssh session makes of it
6+
pnpm example ink -- --unicode ascii # and a terminal with no block glyph
7+
```
8+
9+
Type in the field - enter starts a second line - pick an ink and a font, and
10+
watch the same component colour a banner or a paragraph. The panel scrolls, so
11+
a tall font or three paragraphs of prose is not a clipped screen.
12+
13+
## What it is for
14+
15+
`ColorText` colours a block of text cell by cell. The example is a banner
16+
because that is where per-cell colour is worth anything, but **the component
17+
has no idea what a banner is** - `ctrl+p` swaps the block letters for ordinary
18+
prose and every ink in the list still applies, unchanged. The fonts are
19+
[`src/fonts.ts`](src/fonts.ts), which is application data and deliberately not
20+
something the library ships.
21+
22+
## The three ways to write an ink
23+
24+
The list runs top to bottom through all of them, and
25+
[`src/inks.ts`](src/inks.ts) is where they are written.
26+
27+
**A ramp.** `{ gradient: ['#ff5f6d', '#ffc371'] }`, with an `axis` of `x`, `y`
28+
or `xy`. The ramp is measured against the widest line of the block by default,
29+
so five rows of block letters share one gradient and the colours line up down
30+
the block; `per: 'line'` restarts it on each line instead, which is what
31+
ragged prose wants.
32+
33+
**A palette.** An array is one colour per line. `{ cycle, every: [4, 3] }` is
34+
four cells of one colour, three of the next, and round again - and the run
35+
restarts on each line, which is what keeps the bands vertical. `continuous`
36+
carries the count over the line breaks and leans them into a diagonal.
37+
`unit` picks what advances the colour: cells, graphemes, letters, words, lines.
38+
39+
**A function.** `(cell, ctx) => colour`. The cell carries `char`, `col`,
40+
`line`, `index`, `offset`, `width`, `height` and `blockWidth` - a column *and*
41+
an index, because they stop being the same number the moment the text is not
42+
plain ascii. Returning nothing leaves that cell the component's own colour,
43+
which is what makes `vowels` two lines long.
44+
45+
The first two are data and would survive being written in a JSON screen. The
46+
third would not, and that is the trade: it is the same one `canvas` makes with
47+
`draw`.
48+
49+
## The fonts are one table and three transforms
50+
51+
`block` is hand-drawn: five rows, one cell to a stroke, and **both cases on one
52+
baseline** - lowercase sits on the bottom four rows and the ascenders reach up
53+
into the fifth, so a cap is visibly taller than an x. Nothing descends below
54+
the baseline, because five rows is not enough to put a tail under a `g` and
55+
keep the line spacing honest.
56+
57+
The other three are that table, transformed:
58+
59+
| | |
60+
|---|---|
61+
| `wide` | every column drawn twice |
62+
| `slant` | sheared half a column per row - an italic |
63+
| `shadow` | the letter, and a copy one cell down and right in a second glyph |
64+
65+
Which is the other thing worth showing: a bitmap font is a grid of characters,
66+
and a grid of characters can be sheared, doubled or duplicated by ten lines of
67+
code. `shadow` has one subtlety - the shadow is kept only *outside* the letter,
68+
found by a flood fill from the border, because a shadow that fell into the
69+
counter of an `A` turned a five-row capital into a smudge.
70+
71+
## What the flags are for
72+
73+
Nothing in here is allowed to depend on the colour, and the flags are how that
74+
gets checked rather than asserted. `--colors 4` reduces every 24-bit ink to the
75+
sixteen the terminal has, and a six-stop spectrum comes out as a few bands.
76+
`--unicode ascii` takes away the full block, and the banner is drawn in `#`
77+
with a `-` shadow, because the fill glyphs came from the theme rather than from
78+
the font.

examples/ink/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@textui/example-ink",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "Text coloured cell by cell: ramps, palettes, and an ink you write yourself",
7+
"scripts": {
8+
"build": "tsc -p tsconfig.json",
9+
"typecheck": "tsc -p tsconfig.json --noEmit",
10+
"test": "vitest run"
11+
},
12+
"dependencies": {
13+
"@textui/core": "workspace:*",
14+
"@textui/terminal": "workspace:*",
15+
"@textui/widgets": "workspace:*"
16+
},
17+
"devDependencies": {
18+
"@textui/testing": "workspace:*"
19+
}
20+
}

0 commit comments

Comments
 (0)