Skip to content

Commit b0c8183

Browse files
committed
feat: a second label on the top rule, beside the title
`rightTitle`, on any bordered box and on `Panel`: the short thing that belongs next to a heading rather than under it - a count, a state, the shortcut that opens it. Optional, and most panels will not have one. It is painted **before** the title and the title is then given the width it leaves, which settles two things at once. The two labels cannot land on the same cells whatever `titleAlign` says - one drawn over the other reads as a corrupted frame rather than as a layout mistake - and when the rule is too narrow it is the *title* that truncates. That is the right way round: the right label is a count or a state, and half a count says nothing, where a shortened heading still reads. ┌ A very long pane… 12 ┐ A borderless panel has no rule to write on, so both labels share the heading row, with the title flexing and truncating around them for the same reason. This also unpicks `meta`, which claimed to be exactly this - "right-aligned text in the title row" - and was not. On a bordered panel it went into the *bottom* rule; only on a borderless one did it sit beside the title. One prop meaning two places depending on the value of another. `meta` keeps what it does and now says so, and `rightTitle` is the title row in both.
1 parent 1a72545 commit b0c8183

8 files changed

Lines changed: 132 additions & 10 deletions

File tree

docs/components/base-props.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Props every node accepts. Style arrives three ways on purpose: the full `style`
117117
| `children` | `unknown` | |
118118
| `title` | `string` | Header text drawn into the top border. Needs a border to land on. |
119119
| `titleAlign` | `'left' \| 'center' \| 'right'` | |
120+
| `rightTitle` | `string` | A second label on the top border, hard against the right. For the short thing that belongs beside a heading rather than under it - a count, a shortcut, a state. It takes its space first and `title` gets what is left, so the two never collide and the title is the one that truncates. |
120121
| `footer` | `string` | Footer text drawn into the bottom border. |
121122
| `footerAlign` | `'left' \| 'center' \| 'right'` | |
122123
| `scrollTop` | `number` | Scroll offset in cells, when overflow is 'scroll'. |

docs/components/layout/column.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { Column } from '@textui/widgets';
2626
| `children` | `unknown` | | |
2727
| `title` | `string` | | Header text drawn into the top border. Needs a border to land on. |
2828
| `titleAlign` | `'left' \| 'center' \| 'right'` | | |
29+
| `rightTitle` | `string` | | A second label on the top border, hard against the right. For the short thing that belongs beside a heading rather than under it - a count, a shortcut, a state. It takes its space first and `title` gets what is left, so the two never collide and the title is the one that truncates. |
2930
| `footer` | `string` | | Footer text drawn into the bottom border. |
3031
| `footerAlign` | `'left' \| 'center' \| 'right'` | | |
3132
| `scrollTop` | `number` | | Scroll offset in cells, when overflow is 'scroll'. |

docs/components/layout/panel.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import { Panel } from '@textui/widgets';
2626
| `subtitle` | `string` | | |
2727
| `tone` | `StyleColor` | | Accent colour for the title. |
2828
| `border` | `BorderSpec` | | Overrides the theme's default border. `'none'` gives an airy panel. |
29-
| `meta` | `string` | | Right-aligned text in the title row. Counts, hints, shortcuts. |
29+
| `rightTitle` | `string` | | Right-aligned text beside the title, on the same row. The short thing that belongs next to a heading rather than under it: a count, a state, the shortcut that opens it. Optional, and most panels do not have one. |
30+
| `meta` | `string` | | Right-aligned text on the *bottom* rule, where there is one. Not the title row - which is what this said for a long time while doing something else. A bordered panel put it in the footer and a borderless one put it beside the title, so the same prop meant two places depending on a different prop. `rightTitle` is the title row, in both; this is the bottom, and on a borderless panel there is no bottom to put it on. |
3031

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

docs/components/primitives/box.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ The container. Flex layout, background, border, title and footer - and the only
2424
| `children` | `unknown` | | |
2525
| `title` | `string` | | Header text drawn into the top border. Needs a border to land on. |
2626
| `titleAlign` | `'left' \| 'center' \| 'right'` | | |
27+
| `rightTitle` | `string` | | A second label on the top border, hard against the right. For the short thing that belongs beside a heading rather than under it - a count, a shortcut, a state. It takes its space first and `title` gets what is left, so the two never collide and the title is the one that truncates. |
2728
| `footer` | `string` | | Footer text drawn into the bottom border. |
2829
| `footerAlign` | `'left' \| 'center' \| 'right'` | | |
2930
| `scrollTop` | `number` | | Scroll offset in cells, when overflow is 'scroll'. |

packages/core/src/jsx/intrinsics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ export interface BoxProps extends BaseProps {
5858
/** Header text drawn into the top border. Needs a border to land on. */
5959
title?: string;
6060
titleAlign?: 'left' | 'center' | 'right';
61+
/**
62+
* A second label on the top border, hard against the right.
63+
*
64+
* For the short thing that belongs beside a heading rather than under it - a
65+
* count, a shortcut, a state. It takes its space first and `title` gets what
66+
* is left, so the two never collide and the title is the one that truncates.
67+
*/
68+
rightTitle?: string;
6169
/** Footer text drawn into the bottom border. */
6270
footer?: string;
6371
footerAlign?: 'left' | 'center' | 'right';

packages/core/src/runtime/paint.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,10 +558,31 @@ function paintBorder(
558558
const inner = w - 2;
559559
if (inner <= 0) return;
560560

561+
/*
562+
* A second label on the top rule, hard against the right.
563+
*
564+
* Painted *before* the title, and the title is then given the width it
565+
* leaves, so the two cannot land on the same cells whatever `titleAlign`
566+
* says. That order also decides which one loses when there is not enough
567+
* room: the right label is the short one - a count, a shortcut, a state -
568+
* and the title is the one that can be truncated and still be read.
569+
*/
570+
const rightTitle = instance.props.rightTitle;
571+
let reserved = 0;
572+
if (sides.top && typeof rightTitle === 'string' && rightTitle !== '') {
573+
const label = ` ${rightTitle} `;
574+
reserved = Math.min(inner, stringWidth(sanitize(label)));
575+
paintBorderLabel(
576+
surface, x, y, inner, label, 'right',
577+
{ ...style, fg: colorOf(packStyleColor('muted', env.theme)), attrs: visual.attrs },
578+
env.theme.glyphs.ellipsis,
579+
);
580+
}
581+
561582
const title = instance.props.title;
562-
if (sides.top && typeof title === 'string' && title !== '') {
583+
if (sides.top && typeof title === 'string' && title !== '' && inner - reserved > 0) {
563584
paintBorderLabel(
564-
surface, x, y, inner, ` ${title} `,
585+
surface, x, y, inner - reserved, ` ${title} `,
565586
(instance.props.titleAlign as 'left' | 'center' | 'right') ?? 'left',
566587
{ ...style, fg: colorOf(visual.fg === COLOR_DEFAULT ? packStyleColor('text', env.theme) : visual.fg), attrs: visual.attrs },
567588
env.theme.glyphs.ellipsis,

packages/widgets/src/layout/panel.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,23 @@ export interface PanelProps extends BoxProps {
99
tone?: StyleColor;
1010
/** Overrides the theme's default border. `'none'` gives an airy panel. */
1111
border?: BorderSpec;
12-
/** Right-aligned text in the title row. Counts, hints, shortcuts. */
12+
/**
13+
* Right-aligned text beside the title, on the same row.
14+
*
15+
* The short thing that belongs next to a heading rather than under it: a
16+
* count, a state, the shortcut that opens it. Optional, and most panels do
17+
* not have one.
18+
*/
19+
rightTitle?: string;
20+
/**
21+
* Right-aligned text on the *bottom* rule, where there is one.
22+
*
23+
* Not the title row - which is what this said for a long time while doing
24+
* something else. A bordered panel put it in the footer and a borderless one
25+
* put it beside the title, so the same prop meant two places depending on a
26+
* different prop. `rightTitle` is the title row, in both; this is the
27+
* bottom, and on a borderless panel there is no bottom to put it on.
28+
*/
1329
meta?: string;
1430
}
1531

@@ -22,7 +38,7 @@ export interface PanelProps extends BoxProps {
2238
*/
2339
export const Panel = defineComponent<PanelProps>('Panel', (props) => {
2440
const theme = useTheme();
25-
const { title, subtitle, tone, meta, children, ...rest } = props;
41+
const { title, subtitle, tone, meta, rightTitle, children, ...rest } = props;
2642
const border = props.border ?? theme.border;
2743
const borderless = border === 'none' || (typeof border === 'object' && border.style === 'none');
2844

@@ -32,13 +48,14 @@ export const Panel = defineComponent<PanelProps>('Panel', (props) => {
3248
const fill = { alignSelf: 'stretch' as const };
3349

3450
if (!borderless) {
35-
// `meta` goes into the bottom rule when there is one. The alternative was
36-
// a prop that silently did nothing on any panel with a border, which is
37-
// most of them.
51+
// Two rules, two labels: `rightTitle` on the top one beside the title, and
52+
// `meta` on the bottom one. The border painter gives the right label its
53+
// width first, so a long title truncates rather than running into it.
3854
return h('box', {
3955
role: 'region',
4056
border,
4157
title,
58+
...(rightTitle !== undefined ? { rightTitle } : {}),
4259
footer: meta,
4360
footerAlign: 'right',
4461
...fill,
@@ -52,8 +69,10 @@ export const Panel = defineComponent<PanelProps>('Panel', (props) => {
5269
return h('box', { role: 'region', direction: 'column', ...fill, ...rest, border: 'none' },
5370
title
5471
? h('box', { direction: 'row', gap: 1 },
55-
h('text', { content: title, bold: true, fg: tone ?? 'text' }),
56-
meta ? h('spacer', { flex: 1 }) : null,
72+
h('text', { content: title, bold: true, fg: tone ?? 'text', flex: 1, truncate: 'end' }),
73+
// No border to write on, so the same two labels share the heading
74+
// row - `rightTitle` first, because it is the one that must survive.
75+
rightTitle ? h('text', { content: rightTitle, fg: 'muted' }) : null,
5776
meta ? h('text', { content: meta, fg: 'muted' }) : null)
5877
: null,
5978
subtitle ? h('text', { content: subtitle, fg: 'muted' }) : null,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { h, renderToString } from '@textui/core';
3+
import { Panel } from '../src/layout/index.js';
4+
import { CATALOG } from '../src/index.js';
5+
6+
/*
7+
* A panel can carry a short label beside its heading - a count, a state, the
8+
* shortcut that opens it - and it goes on the top rule, hard against the right.
9+
*
10+
* `meta` was documented as exactly that and did something else: on a bordered
11+
* panel it went into the *bottom* rule, and only on a borderless one did it sit
12+
* beside the title. One prop, two places, depending on another prop.
13+
*/
14+
describe('a panel with a label beside its title', () => {
15+
const draw = (props: Record<string, unknown>, width: number): string[] =>
16+
renderToString(h(Panel, props, h('text', { content: 'body' })), { width, components: CATALOG })
17+
.split('\n').map((line) => line.trimEnd()).filter((line) => line !== '');
18+
19+
// Two widths, because the whole question is what happens when the two
20+
// labels want more room than the rule has.
21+
for (const width of [40, 24]) {
22+
it(`puts it on the top rule at ${width} columns`, () => {
23+
const [top] = draw({ title: 'Controls', rightTitle: '3 items' }, width);
24+
expect(top).toContain('Controls');
25+
expect(top).toContain('3 items');
26+
// Hard against the right: the last cell is the corner, and the label is
27+
// the thing before it.
28+
expect(top?.trimEnd().endsWith('3 items ┐')).toBe(true);
29+
});
30+
}
31+
32+
it('truncates the title rather than the label', () => {
33+
// The right label takes its width first. It is the short one - a count or
34+
// a state - and a half-written count says nothing, where a truncated
35+
// heading still reads.
36+
const [top] = draw({ title: 'A very long panel heading indeed', rightTitle: '12' }, 24);
37+
expect(top).toContain('12 ┐');
38+
expect(top).toContain('…');
39+
expect(top).not.toContain('heading indeed');
40+
});
41+
42+
it('never lets the two labels land on the same cells', () => {
43+
// The failure this is guarding is one drawn over the other, which reads as
44+
// a corrupted frame rather than as a layout mistake.
45+
for (const width of [40, 24, 16]) {
46+
const [top = ''] = draw({ title: 'Wide enough heading', rightTitle: 'state' }, width);
47+
expect(top.length).toBe(width);
48+
// One '…' at most: two would mean both were truncated into each other.
49+
expect((top.match(//g) ?? []).length).toBeLessThanOrEqual(1);
50+
}
51+
});
52+
53+
it('stands alone, with no title beside it', () => {
54+
const [top = ''] = draw({ rightTitle: 'ctrl+p' }, 30);
55+
expect(top).toContain('ctrl+p ┐');
56+
});
57+
58+
it('is a different place from meta, which is the bottom rule', () => {
59+
const rows = draw({ title: 'Both', rightTitle: 'live', meta: 'f1 help' }, 34);
60+
expect(rows[0]).toContain('live');
61+
expect(rows[0]).not.toContain('f1 help');
62+
expect(rows[rows.length - 1]).toContain('f1 help');
63+
});
64+
65+
it('shares the heading row when there is no border to write on', () => {
66+
const rows = draw({ title: 'Airy', rightTitle: '7', border: 'none' }, 30);
67+
expect(rows[0]).toContain('Airy');
68+
expect(rows[0]?.trimEnd().endsWith('7')).toBe(true);
69+
});
70+
});

0 commit comments

Comments
 (0)