Skip to content

Commit 6074208

Browse files
Merge pull request #66 from RtlZeroMemory/create-textstyle-completeness
TextStyle completeness: add strikethrough/overline/blink with merge+encoding hardening
2 parents 4b67a23 + 101c9aa commit 6074208

20 files changed

Lines changed: 1154 additions & 36 deletions

File tree

docs/guide/styling.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ Rezi styling is designed to be:
66
- **deterministic**: the same inputs produce the same frames
77
- **composable**: styles inherit through containers
88

9+
## Text attributes
10+
11+
`TextStyle` supports these boolean text attributes:
12+
13+
- `bold`
14+
- `dim`
15+
- `italic`
16+
- `underline`
17+
- `inverse`
18+
- `strikethrough`
19+
- `overline`
20+
- `blink`
21+
22+
New attribute SGR target mappings:
23+
24+
- `strikethrough` -> SGR `9`
25+
- `overline` -> SGR `53`
26+
- `blink` -> SGR `5`
27+
28+
These codes are the terminal mapping used by the backend emitter. Drawlist encoding carries all three attrs, and backend emission now supports `strikethrough`, `overline`, and `blink` end-to-end (terminal rendering still depends on terminal support).
29+
930
## Inline styles
1031

1132
Most visual widgets accept a `style` prop:
@@ -57,6 +78,7 @@ Style is merged from parent → child:
5778

5879
- containers pass their resolved style to children
5980
- leaf widgets merge their own `style` on top
81+
- boolean attrs use tri-state semantics: `undefined` inherits, `false` disables, `true` enables
6082

6183
Example:
6284

@@ -87,5 +109,6 @@ ui.text(state.connected ? "Online" : "Offline", {
87109
- [Theme](../styling/theme.md) - Theme structure and built-ins
88110
- [Icons](../styling/icons.md) - Icon registry and fallback rules
89111
- [Focus styles](../styling/focus-styles.md) - Focus and disabled visuals
112+
- [Text style internals](text-style-internals.md) - Drawlist bit layout and merge/cache internals
90113

91114
Next: [Performance](performance.md).

docs/guide/text-style-internals.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Text Style Internals
2+
3+
This page documents renderer-side text style behavior that is stable and test-covered.
4+
5+
## Merge Semantics (Tri-State)
6+
7+
Each boolean text attribute is merged independently with tri-state semantics:
8+
9+
- `undefined`: inherit from base style
10+
- `false`: explicitly off
11+
- `true`: explicitly on
12+
13+
This applies to all 8 boolean attrs:
14+
15+
- `bold`, `dim`, `italic`, `underline`, `inverse`, `strikethrough`, `overline`, `blink`
16+
17+
## Drawlist Attr Encoding (8-bit Layout)
18+
19+
Drawlist style encoding packs boolean attrs into the low 8 bits of `style.attrs` (`u32`):
20+
21+
- bit `0`: `bold`
22+
- bit `1`: `italic`
23+
- bit `2`: `underline`
24+
- bit `3`: `inverse`
25+
- bit `4`: `dim`
26+
- bit `5`: `strikethrough`
27+
- bit `6`: `overline`
28+
- bit `7`: `blink`
29+
30+
Equivalent mask expression:
31+
32+
```text
33+
attrs =
34+
(bold ? 1<<0 : 0) |
35+
(italic ? 1<<1 : 0) |
36+
(underline ? 1<<2 : 0) |
37+
(inverse ? 1<<3 : 0) |
38+
(dim ? 1<<4 : 0) |
39+
(strikethrough ? 1<<5 : 0) |
40+
(overline ? 1<<6 : 0) |
41+
(blink ? 1<<7 : 0)
42+
```
43+
44+
The mapping is identical for `drawText(...)` and `drawTextRun(...)` segment styles.
45+
46+
## Terminal SGR Mapping
47+
48+
Terminal target SGR codes for the new attrs are:
49+
50+
- `strikethrough` -> `9`
51+
- `overline` -> `53`
52+
- `blink` -> `5`
53+
54+
Drawlist encoding carries these bits in `style.attrs`, and backend emission supports all three attrs (`strikethrough`, `overline`, `blink`) end-to-end. Terminal-visible behavior still depends on terminal support.
55+
56+
## Renderer Merge Cache (Fast Path)
57+
58+
`mergeTextStyle(base, override)` has a fast path when:
59+
60+
- `base === DEFAULT_BASE_STYLE`
61+
- `override.fg` and `override.bg` are both `undefined`
62+
63+
In that case, the renderer computes a compact key from per-attr tri-state values and uses direct array indexing into `BASE_BOOL_STYLE_CACHE`.
64+
65+
Tri-state encoding per attr:
66+
67+
- `0` = `undefined` (inherit)
68+
- `1` = `false`
69+
- `2` = `true`
70+
71+
Key layout (2 bits per attr, 8 attrs total):
72+
73+
```text
74+
key =
75+
b
76+
| (d << 2)
77+
| (i << 4)
78+
| (u << 6)
79+
| (inv<< 8)
80+
| (s << 10)
81+
| (o << 12)
82+
| (bl << 14)
83+
```
84+
85+
Cache sizing rationale:
86+
87+
- 8 attrs * 2 bits = 16-bit key space
88+
- cache length is `65536` (`2^16`), so every possible key index is valid
89+
- tri-state value set uses only `0..2` per 2-bit slot, so current reachable combinations are a subset (`3^8 = 6561`), with room for full direct addressing
90+
91+
Why this is used:
92+
93+
- avoids per-merge object churn for common default-base boolean-only style merges
94+
- avoids hash-map overhead by using a fixed-size array lookup
95+
- keeps object identity stable for repeated equivalent style inputs on the hot path

packages/core/src/app/__tests__/partialDrawlistEmission.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ function styleKey(style: TextStyle | undefined): string {
141141
style.italic ? "i1" : "i0",
142142
style.underline ? "u1" : "u0",
143143
style.inverse ? "v1" : "v0",
144+
style.strikethrough ? "s1" : "s0",
145+
style.overline ? "o1" : "o0",
146+
style.blink ? "k1" : "k0",
144147
`fg:${colorKey(style.fg)}`,
145148
`bg:${colorKey(style.bg)}`,
146149
].join("|");
@@ -623,6 +626,228 @@ describe("WidgetRenderer incremental drawlist emission", () => {
623626
);
624627
});
625628

629+
test("strikethrough-only style change in commit path matches full render", () => {
630+
const viewport: Viewport = { cols: 32, rows: 6 };
631+
const backend = createNoopBackend();
632+
633+
const viewFn = (s: Readonly<{ strike: boolean }>) =>
634+
ui.column({}, [
635+
ui.text("Header"),
636+
ui.text("strike-target", { style: { strikethrough: s.strike } }),
637+
ui.text("Footer"),
638+
]);
639+
640+
const partialBuilder = new RecordingBuilder();
641+
const partialRenderer = new WidgetRenderer<{ strike: boolean }>({
642+
backend,
643+
builder: partialBuilder,
644+
requestRender: () => {},
645+
});
646+
647+
const fullBuilder = new RecordingBuilder();
648+
const fullRenderer = new WidgetRenderer<{ strike: boolean }>({
649+
backend,
650+
builder: fullBuilder,
651+
requestRender: () => {},
652+
});
653+
654+
const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
655+
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;
656+
657+
const opsA = submitOps(
658+
partialRenderer,
659+
partialBuilder,
660+
viewFn,
661+
{ strike: false },
662+
viewport,
663+
planBootstrap,
664+
);
665+
const framebufferA = applyOps(createFramebuffer(viewport), opsA);
666+
667+
const opsExpected = submitOps(
668+
fullRenderer,
669+
fullBuilder,
670+
viewFn,
671+
{ strike: true },
672+
viewport,
673+
planBootstrap,
674+
);
675+
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);
676+
677+
const opsPartial = submitOps(
678+
partialRenderer,
679+
partialBuilder,
680+
viewFn,
681+
{ strike: true },
682+
viewport,
683+
planUpdate,
684+
);
685+
const framebufferPartial = applyOps(framebufferA, opsPartial);
686+
687+
assertFramebuffersEqual(framebufferPartial, framebufferExpected);
688+
assert.equal(
689+
opsPartial.some((op) => op.kind === "clearTo"),
690+
false,
691+
);
692+
assert.equal(
693+
opsPartial.some(
694+
(op) =>
695+
op.kind === "drawText" &&
696+
op.text === "strike-target" &&
697+
(op.style?.strikethrough ?? false) === true,
698+
),
699+
true,
700+
);
701+
});
702+
703+
test("overline-only style change in commit path matches full render", () => {
704+
const viewport: Viewport = { cols: 32, rows: 6 };
705+
const backend = createNoopBackend();
706+
707+
const viewFn = (s: Readonly<{ overline: boolean }>) =>
708+
ui.column({}, [
709+
ui.text("Header"),
710+
ui.text("overline-target", { style: { overline: s.overline } }),
711+
ui.text("Footer"),
712+
]);
713+
714+
const partialBuilder = new RecordingBuilder();
715+
const partialRenderer = new WidgetRenderer<{ overline: boolean }>({
716+
backend,
717+
builder: partialBuilder,
718+
requestRender: () => {},
719+
});
720+
721+
const fullBuilder = new RecordingBuilder();
722+
const fullRenderer = new WidgetRenderer<{ overline: boolean }>({
723+
backend,
724+
builder: fullBuilder,
725+
requestRender: () => {},
726+
});
727+
728+
const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
729+
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;
730+
731+
const opsA = submitOps(
732+
partialRenderer,
733+
partialBuilder,
734+
viewFn,
735+
{ overline: false },
736+
viewport,
737+
planBootstrap,
738+
);
739+
const framebufferA = applyOps(createFramebuffer(viewport), opsA);
740+
741+
const opsExpected = submitOps(
742+
fullRenderer,
743+
fullBuilder,
744+
viewFn,
745+
{ overline: true },
746+
viewport,
747+
planBootstrap,
748+
);
749+
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);
750+
751+
const opsPartial = submitOps(
752+
partialRenderer,
753+
partialBuilder,
754+
viewFn,
755+
{ overline: true },
756+
viewport,
757+
planUpdate,
758+
);
759+
const framebufferPartial = applyOps(framebufferA, opsPartial);
760+
761+
assertFramebuffersEqual(framebufferPartial, framebufferExpected);
762+
assert.equal(
763+
opsPartial.some((op) => op.kind === "clearTo"),
764+
false,
765+
);
766+
assert.equal(
767+
opsPartial.some(
768+
(op) =>
769+
op.kind === "drawText" &&
770+
op.text === "overline-target" &&
771+
(op.style?.overline ?? false) === true,
772+
),
773+
true,
774+
);
775+
});
776+
777+
test("blink-only style change in commit path matches full render", () => {
778+
const viewport: Viewport = { cols: 32, rows: 6 };
779+
const backend = createNoopBackend();
780+
781+
const viewFn = (s: Readonly<{ blink: boolean }>) =>
782+
ui.column({}, [
783+
ui.text("Header"),
784+
ui.text("blink-target", { style: { blink: s.blink } }),
785+
ui.text("Footer"),
786+
]);
787+
788+
const partialBuilder = new RecordingBuilder();
789+
const partialRenderer = new WidgetRenderer<{ blink: boolean }>({
790+
backend,
791+
builder: partialBuilder,
792+
requestRender: () => {},
793+
});
794+
795+
const fullBuilder = new RecordingBuilder();
796+
const fullRenderer = new WidgetRenderer<{ blink: boolean }>({
797+
backend,
798+
builder: fullBuilder,
799+
requestRender: () => {},
800+
});
801+
802+
const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
803+
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;
804+
805+
const opsA = submitOps(
806+
partialRenderer,
807+
partialBuilder,
808+
viewFn,
809+
{ blink: false },
810+
viewport,
811+
planBootstrap,
812+
);
813+
const framebufferA = applyOps(createFramebuffer(viewport), opsA);
814+
815+
const opsExpected = submitOps(
816+
fullRenderer,
817+
fullBuilder,
818+
viewFn,
819+
{ blink: true },
820+
viewport,
821+
planBootstrap,
822+
);
823+
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);
824+
825+
const opsPartial = submitOps(
826+
partialRenderer,
827+
partialBuilder,
828+
viewFn,
829+
{ blink: true },
830+
viewport,
831+
planUpdate,
832+
);
833+
const framebufferPartial = applyOps(framebufferA, opsPartial);
834+
835+
assertFramebuffersEqual(framebufferPartial, framebufferExpected);
836+
assert.equal(
837+
opsPartial.some((op) => op.kind === "clearTo"),
838+
false,
839+
);
840+
assert.equal(
841+
opsPartial.some(
842+
(op) =>
843+
op.kind === "drawText" &&
844+
op.text === "blink-target" &&
845+
(op.style?.blink ?? false) === true,
846+
),
847+
true,
848+
);
849+
});
850+
626851
test("incremental v2 cursor is emitted even when focused input is outside damage rect", () => {
627852
const viewport: Viewport = { cols: 40, rows: 8 };
628853
const backend = createNoopBackend();

packages/core/src/drawlist/__tests__/builder.alignment.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ function expectOk(result: DrawlistBuildResult): Uint8Array {
7272
return result.bytes;
7373
}
7474

75-
function readStringSpan(dv: DataView, stringsSpanOffset: number, index: number): { off: number; len: number } {
75+
function readStringSpan(
76+
dv: DataView,
77+
stringsSpanOffset: number,
78+
index: number,
79+
): { off: number; len: number } {
7680
const spanOff = stringsSpanOffset + index * SPAN_SIZE;
7781
return {
7882
off: dv.getUint32(spanOff, true),

0 commit comments

Comments
 (0)