@tuidom/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,17 +3,66 @@
3
3
  * Must be negative so it's never confused with a packed RGB value (0x000000–0xFFFFFF).
4
4
  */
5
5
  export declare const DEFAULT_COLOR = -1;
6
+ /**
7
+ * Полностью прозрачный цвет (`#RRGGBB00`, CSS `transparent`): при наложении
8
+ * ничего не меняет, а как собственный bg элемента — не красит. Сентинел, а не
9
+ * упаковка с альфой 0: старший байт 0 занят непрозрачными 24-битными числами.
10
+ */
11
+ export declare const TRANSPARENT_COLOR = -2;
6
12
  /** Pack three 8-bit channels into a single 24-bit integer. */
7
13
  export declare function packRgb(r: number, g: number, b: number): number;
14
+ /**
15
+ * Упаковывает цвет с альфой (0..255). Непрозрачный (`a === 255`) даёт обычное
16
+ * 24-битное число — то же, что {@link packRgb}; `a === 0` — {@link TRANSPARENT_COLOR};
17
+ * промежуточная альфа кладётся в старший байт (`0xAARRGGBB`, беззнаковое
18
+ * значение > 0xFFFFFF). Так весь существующий код с 24-битными литералами
19
+ * остаётся валидным без миграции, а полупрозрачность — отличима одним сравнением.
20
+ */
21
+ export declare function packRgba(r: number, g: number, b: number, a: number): number;
8
22
  /** Extract the red channel (bits 16–23). */
9
23
  export declare function unpackR(color: number): number;
10
24
  /** Extract the green channel (bits 8–15). */
11
25
  export declare function unpackG(color: number): number;
12
26
  /** Extract the blue channel (bits 0–7). */
13
27
  export declare function unpackB(color: number): number;
28
+ /**
29
+ * Альфа цвета: 255 у 24-битных чисел и сентинелов (`DEFAULT_COLOR` — цвет
30
+ * терминала, он непрозрачен), 0 у {@link TRANSPARENT_COLOR}, иначе старший байт.
31
+ */
32
+ export declare function unpackA(color: number): number;
33
+ /** true для полупрозрачного цвета (альфа 1..254) — того, что требует композитинга с подложкой. */
34
+ export declare function isTranslucent(color: number): boolean;
35
+ /**
36
+ * true, если число — легальное значение цвета: 24-битный RGB, упаковка с
37
+ * альфой, {@link DEFAULT_COLOR} или {@link TRANSPARENT_COLOR}. Сентинелы
38
+ * каскада (`INHERITED_*`) и мусор — false.
39
+ */
40
+ export declare function isColorValue(color: number): boolean;
14
41
  /**
15
42
  * Накладывает полупрозрачный `fg` на непрозрачную подложку `bg` и возвращает
16
43
  * непрозрачный результат: терминал альфы не умеет, поэтому композитинг
17
44
  * выполняется заранее (`alpha` — доля 0..1).
18
45
  */
19
46
  export declare function blendRgb(fg: number, bg: number, alpha: number): number;
47
+ /**
48
+ * Композитинг «`color` поверх `under`» (source-over): результат всегда
49
+ * непрозрачен или `DEFAULT_COLOR`. Непрозрачный `color` (и `DEFAULT_COLOR`)
50
+ * возвращается как есть — путь нулевой стоимости, одно сравнение;
51
+ * {@link TRANSPARENT_COLOR} оставляет подложку. Полупрозрачный цвет смешивается
52
+ * с `under` по альфе; если подложка — `DEFAULT_COLOR` (цвет терминала, нам
53
+ * неизвестен), смешивать не с чем — альфа отбрасывается, цвет ложится непрозрачным.
54
+ */
55
+ export declare function compositeOver(color: number, under: number): number;
56
+ /**
57
+ * Разбирает CSS-подобный hex: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`
58
+ * (решётка необязательна, регистр любой) в упакованный цвет (см.
59
+ * {@link packRgba}). Некорректная строка — throw: цвет темы с опечаткой
60
+ * должен падать на разборе, а не красить в чёрный.
61
+ */
62
+ export declare function parseHexColor(hex: string): number;
63
+ /**
64
+ * Обратное к {@link parseHexColor}: `#rrggbb` для непрозрачного, `#rrggbbaa`
65
+ * с альфой, `#00000000` для {@link TRANSPARENT_COLOR}. `DEFAULT_COLOR` hex не
66
+ * имеет — throw.
67
+ */
68
+ export declare function formatHexColor(color: number): string;
@@ -3,10 +3,34 @@
3
3
  * Must be negative so it's never confused with a packed RGB value (0x000000–0xFFFFFF).
4
4
  */
5
5
  export const DEFAULT_COLOR = -1;
6
+ /**
7
+ * Полностью прозрачный цвет (`#RRGGBB00`, CSS `transparent`): при наложении
8
+ * ничего не меняет, а как собственный bg элемента — не красит. Сентинел, а не
9
+ * упаковка с альфой 0: старший байт 0 занят непрозрачными 24-битными числами.
10
+ */
11
+ export const TRANSPARENT_COLOR = -2;
12
+ /** Максимальное упакованное значение с альфой (`0xFE_FFFFFF`); всё выше — не цвет. */
13
+ const MAX_PACKED_COLOR = 0xfeffffff;
14
+ /** Множитель старшего байта — альфа хранится через умножение, а не `<<`, чтобы не уйти в отрицательные. */
15
+ const ALPHA_UNIT = 0x1000000;
6
16
  /** Pack three 8-bit channels into a single 24-bit integer. */
7
17
  export function packRgb(r, g, b) {
8
18
  return (r << 16) | (g << 8) | b;
9
19
  }
20
+ /**
21
+ * Упаковывает цвет с альфой (0..255). Непрозрачный (`a === 255`) даёт обычное
22
+ * 24-битное число — то же, что {@link packRgb}; `a === 0` — {@link TRANSPARENT_COLOR};
23
+ * промежуточная альфа кладётся в старший байт (`0xAARRGGBB`, беззнаковое
24
+ * значение > 0xFFFFFF). Так весь существующий код с 24-битными литералами
25
+ * остаётся валидным без миграции, а полупрозрачность — отличима одним сравнением.
26
+ */
27
+ export function packRgba(r, g, b, a) {
28
+ if (a >= 0xff)
29
+ return packRgb(r, g, b);
30
+ if (a <= 0)
31
+ return TRANSPARENT_COLOR;
32
+ return a * ALPHA_UNIT + packRgb(r, g, b);
33
+ }
10
34
  /** Extract the red channel (bits 16–23). */
11
35
  export function unpackR(color) {
12
36
  return (color >> 16) & 0xff;
@@ -19,6 +43,31 @@ export function unpackG(color) {
19
43
  export function unpackB(color) {
20
44
  return color & 0xff;
21
45
  }
46
+ /**
47
+ * Альфа цвета: 255 у 24-битных чисел и сентинелов (`DEFAULT_COLOR` — цвет
48
+ * терминала, он непрозрачен), 0 у {@link TRANSPARENT_COLOR}, иначе старший байт.
49
+ */
50
+ export function unpackA(color) {
51
+ if (color === TRANSPARENT_COLOR)
52
+ return 0;
53
+ if (color <= 0xffffff)
54
+ return 0xff;
55
+ return color >>> 24;
56
+ }
57
+ /** true для полупрозрачного цвета (альфа 1..254) — того, что требует композитинга с подложкой. */
58
+ export function isTranslucent(color) {
59
+ return color > 0xffffff;
60
+ }
61
+ /**
62
+ * true, если число — легальное значение цвета: 24-битный RGB, упаковка с
63
+ * альфой, {@link DEFAULT_COLOR} или {@link TRANSPARENT_COLOR}. Сентинелы
64
+ * каскада (`INHERITED_*`) и мусор — false.
65
+ */
66
+ export function isColorValue(color) {
67
+ return (color === DEFAULT_COLOR ||
68
+ color === TRANSPARENT_COLOR ||
69
+ (Number.isInteger(color) && color >= 0 && color <= MAX_PACKED_COLOR));
70
+ }
22
71
  /**
23
72
  * Накладывает полупрозрачный `fg` на непрозрачную подложку `bg` и возвращает
24
73
  * непрозрачный результат: терминал альфы не умеет, поэтому композитинг
@@ -28,3 +77,55 @@ export function blendRgb(fg, bg, alpha) {
28
77
  const mix = (f, b) => Math.round(f * alpha + b * (1 - alpha));
29
78
  return packRgb(mix(unpackR(fg), unpackR(bg)), mix(unpackG(fg), unpackG(bg)), mix(unpackB(fg), unpackB(bg)));
30
79
  }
80
+ /**
81
+ * Композитинг «`color` поверх `under`» (source-over): результат всегда
82
+ * непрозрачен или `DEFAULT_COLOR`. Непрозрачный `color` (и `DEFAULT_COLOR`)
83
+ * возвращается как есть — путь нулевой стоимости, одно сравнение;
84
+ * {@link TRANSPARENT_COLOR} оставляет подложку. Полупрозрачный цвет смешивается
85
+ * с `under` по альфе; если подложка — `DEFAULT_COLOR` (цвет терминала, нам
86
+ * неизвестен), смешивать не с чем — альфа отбрасывается, цвет ложится непрозрачным.
87
+ */
88
+ export function compositeOver(color, under) {
89
+ if (color <= 0xffffff)
90
+ return color === TRANSPARENT_COLOR ? under : color;
91
+ const rgb = color & 0xffffff;
92
+ if (under < 0)
93
+ return rgb;
94
+ return blendRgb(rgb, under, (color >>> 24) / 0xff);
95
+ }
96
+ /**
97
+ * Разбирает CSS-подобный hex: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`
98
+ * (решётка необязательна, регистр любой) в упакованный цвет (см.
99
+ * {@link packRgba}). Некорректная строка — throw: цвет темы с опечаткой
100
+ * должен падать на разборе, а не красить в чёрный.
101
+ */
102
+ export function parseHexColor(hex) {
103
+ const digits = hex.startsWith("#") ? hex.slice(1) : hex;
104
+ const short = digits.length === 3 || digits.length === 4;
105
+ if (!(short || digits.length === 6 || digits.length === 8) || !/^[0-9a-fA-F]+$/.test(digits)) {
106
+ throw new Error(`parseHexColor: некорректный hex-цвет "${hex}"`);
107
+ }
108
+ const channel = (i) => {
109
+ if (short) {
110
+ const d = parseInt(digits[i], 16);
111
+ return d * 16 + d;
112
+ }
113
+ return parseInt(digits.slice(i * 2, i * 2 + 2), 16);
114
+ };
115
+ const hasAlpha = digits.length === 4 || digits.length === 8;
116
+ return packRgba(channel(0), channel(1), channel(2), hasAlpha ? channel(3) : 0xff);
117
+ }
118
+ /**
119
+ * Обратное к {@link parseHexColor}: `#rrggbb` для непрозрачного, `#rrggbbaa`
120
+ * с альфой, `#00000000` для {@link TRANSPARENT_COLOR}. `DEFAULT_COLOR` hex не
121
+ * имеет — throw.
122
+ */
123
+ export function formatHexColor(color) {
124
+ if (color === DEFAULT_COLOR)
125
+ throw new Error("formatHexColor: DEFAULT_COLOR не имеет hex-представления");
126
+ if (color === TRANSPARENT_COLOR)
127
+ return "#00000000";
128
+ const hex2 = (n) => n.toString(16).padStart(2, "0");
129
+ const rgb = `#${hex2(unpackR(color))}${hex2(unpackG(color))}${hex2(unpackB(color))}`;
130
+ return isTranslucent(color) ? `${rgb}${hex2(unpackA(color))}` : rgb;
131
+ }
@@ -41,6 +41,13 @@ export interface OverlaySessionOptions {
41
41
  * (владелец обработает Escape сам — например закроет только верхнее подменю).
42
42
  */
43
43
  shouldCloseOnEscape?: () => boolean;
44
+ /**
45
+ * Рисовать тень под оверлеем (`TUIElement.shadow`, цвет `widget.shadow`) —
46
+ * TUI-аналог box-shadow попапов VS Code. Выключено по умолчанию у всех
47
+ * сессий, включая меню tuidom: включает хост. Не задано — остаётся
48
+ * `element.shadow` как выставил владелец.
49
+ */
50
+ shadow?: boolean;
44
51
  }
45
52
  export interface OverlaySessionHandle {
46
53
  readonly element: TUIElement;
@@ -1,4 +1,4 @@
1
- import { BoxConstraints, Offset, Point, Rect, Size } from "../common/geometryPromitives.js";
1
+ import { BoxConstraints, Point, Size } from "../common/geometryPromitives.js";
2
2
  import { RenderContext, TUIElement } from "./tuiElement.js";
3
3
  export class OverlayLayer extends TUIElement {
4
4
  items = [];
@@ -42,6 +42,8 @@ export class OverlayLayer extends TUIElement {
42
42
  createSession(element, position, options) {
43
43
  this.disposeSessionByElement(element);
44
44
  const initialVisible = options.visible ?? false;
45
+ if (options.shadow !== undefined)
46
+ element.shadow = options.shadow;
45
47
  this.addItem(element, position, false);
46
48
  const session = {
47
49
  element,
@@ -201,15 +203,9 @@ export class OverlayLayer extends TUIElement {
201
203
  continue;
202
204
  // Позиция — из localPosition (её выставил layoutChild), не из
203
205
  // item.position: после layout авторитетна геометрия элемента (Н5).
204
- // Клип по границам ребёнка — инвариант отрисовки (Н2): нарисованное
205
- // не выходит за layoutSize, хит-зона совпадает с видимым.
206
- const child = item.element;
207
- const childOffset = new Offset(child.localPosition.dx, child.localPosition.dy);
208
- const clip = new Rect(child.globalPosition, child.layoutSize);
209
- const childContext = context.withOffset(childOffset).withClip(clip);
210
- if (childContext.clipRect.isEmpty)
211
- continue;
212
- child.render(childContext);
206
+ // Клип по границам ребёнка и тень за ним — общий renderChild (Н2):
207
+ // нарисованное не выходит за layoutSize, хит-зона совпадает с видимым.
208
+ this.renderChild(context, item.element);
213
209
  }
214
210
  }
215
211
  openSession(session) {
@@ -72,6 +72,7 @@ export declare const STYLE_TOKEN_DEFAULTS: {
72
72
  "editorSuggestWidget.selectedForeground": number;
73
73
  "editorSuggestWidget.iconForeground": number;
74
74
  "editorSuggestWidget.detailForeground": number;
75
+ "widget.shadow": number;
75
76
  "editorWidget.foreground": number;
76
77
  "editorWidget.background": number;
77
78
  "editorWidget.border": number;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_COLOR, packRgb } from "../../common/colorUtils.js";
1
+ import { DEFAULT_COLOR, packRgb, packRgba } from "../../common/colorUtils.js";
2
2
  /**
3
3
  * Дефолтные значения цветовых токенов tuidom — единственное место, где у
4
4
  * виджетов tuidom/ui есть RGB-литералы. Правило: токен, на который ссылается
@@ -85,6 +85,10 @@ export const STYLE_TOKEN_DEFAULTS = {
85
85
  "editorSuggestWidget.selectedForeground": packRgb(255, 255, 255),
86
86
  "editorSuggestWidget.iconForeground": packRgb(130, 170, 255),
87
87
  "editorSuggestWidget.detailForeground": packRgb(120, 120, 130),
88
+ // ── Оверлеи ──
89
+ // Тень попапа (`TUIElement.shadow`): полупрозрачный чёрный, композитится с
90
+ // тем, что под ним. Значение — дефолт VS Code для тёмных тем (`#00000059`).
91
+ "widget.shadow": packRgba(0, 0, 0, 0x59),
88
92
  // ── Виджеты редактора (диалоги, find) ──
89
93
  "editorWidget.foreground": packRgb(204, 204, 204),
90
94
  "editorWidget.background": packRgb(37, 37, 38),
@@ -72,4 +72,10 @@ export declare function mergeStyleVariants(style: TUIStyle, isActive: (selector:
72
72
  fg: StyleColor | undefined;
73
73
  bg: StyleColor | undefined;
74
74
  };
75
+ /**
76
+ * Резолв базовых fg/bg без when-вариантов и var-scope (утилита для тестов и
77
+ * простых потребителей; ядро идёт через `TUIElement.performStyleResolution`,
78
+ * та же модель): цвет с альфой композитится с унаследованным bg, fg — с
79
+ * итоговым bg, наружу уходят непрозрачные значения.
80
+ */
75
81
  export declare function resolveStyle(style: TUIStyle, inherited: ResolvedTUIStyle): ResolvedTUIStyle;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_COLOR } from "../../common/colorUtils.js";
1
+ import { compositeOver, DEFAULT_COLOR } from "../../common/colorUtils.js";
2
2
  import { ROOT_VAR_SCOPE } from "./styleTokens.js";
3
3
  // ─── Inherited Color Sentinels ───
4
4
  // Sentinel values that resolve to the inherited fg/bg from the parent.
@@ -114,8 +114,16 @@ export function mergeStyleVariants(style, isActive) {
114
114
  }
115
115
  return { fg, bg };
116
116
  }
117
+ /**
118
+ * Резолв базовых fg/bg без when-вариантов и var-scope (утилита для тестов и
119
+ * простых потребителей; ядро идёт через `TUIElement.performStyleResolution`,
120
+ * та же модель): цвет с альфой композитится с унаследованным bg, fg — с
121
+ * итоговым bg, наружу уходят непрозрачные значения.
122
+ */
117
123
  export function resolveStyle(style, inherited) {
118
- const fg = style.fg !== undefined ? resolveStyleColor(style.fg, inherited.fg, inherited.bg) : inherited.fg;
119
- const bg = style.bg !== undefined ? resolveStyleColor(style.bg, inherited.fg, inherited.bg) : inherited.bg;
124
+ const ownBg = style.bg !== undefined ? resolveStyleColor(style.bg, inherited.fg, inherited.bg) : inherited.bg;
125
+ const bg = compositeOver(ownBg, inherited.bg);
126
+ const ownFg = style.fg !== undefined ? resolveStyleColor(style.fg, inherited.fg, inherited.bg) : inherited.fg;
127
+ const fg = compositeOver(ownFg, bg);
120
128
  return { fg, bg };
121
129
  }
@@ -143,6 +143,8 @@ export declare class TUIElement {
143
143
  private subtreeStyleDirty;
144
144
  private appliedFgValue;
145
145
  private appliedBgValue;
146
+ /** Красит ли элемент собственный фон: bg задан своим стилем и не TRANSPARENT_COLOR. */
147
+ private paintsOwnBackgroundValue;
146
148
  private styleStatesSet;
147
149
  private styleVarsValue;
148
150
  private varScopeRef;
@@ -182,6 +184,25 @@ export declare class TUIElement {
182
184
  get hidden(): boolean;
183
185
  set hidden(value: boolean);
184
186
  private hiddenValue;
187
+ /**
188
+ * Тень оверлея — TUI-аналог `box-shadow` VS Code: колонка справа и строка
189
+ * снизу от элемента (сдвиг 1×1) затемняются цветом `widget.shadow`
190
+ * (с альфой, композитится с тем, что уже нарисовано). Рисует не сам
191
+ * элемент, а его родитель после него ({@link renderChildren},
192
+ * `OverlayLayer.render`): клип ребёнка не выпускает его за собственный
193
+ * rect (Н2), а тень лежит снаружи. Damage-обход учитывает её через
194
+ * {@link paintOutset}. Отдаёт форму оверлею там, где тема рисует рамку
195
+ * цветом фона (Catppuccin: `menu.border` = фон меню).
196
+ *
197
+ * Рисуют её канонические циклы — {@link renderChildren} и
198
+ * `OverlayLayer.render` (оба через {@link renderChild}). Контейнеры со своим
199
+ * циклом (`SizedBoxElement`, `ScrollViewport`, строки списка) ребёнка
200
+ * растягивают на весь свой rect — тени там некуда лечь, она осталась бы за
201
+ * клипом самого контейнера.
202
+ */
203
+ get shadow(): boolean;
204
+ set shadow(value: boolean);
205
+ private shadowValue;
185
206
  /** Прикрепляет ребёнка в конец списка (снимая с прежнего родителя). */
186
207
  protected appendChild(child: TUIElement): void;
187
208
  /** Прикрепляет ребёнка на позицию index (снимая с прежнего родителя). */
@@ -283,8 +304,9 @@ export declare class TUIElement {
283
304
  * предков и дефолтов tuidom (STYLE_TOKEN_DEFAULTS). Обычное место — корень:
284
305
  * хост транслирует сюда палитру темы одним вызовом (hot-swap = повторный
285
306
  * вызов). Таблица заменяется целиком, null — снимает. Значения — только
286
- * конкретные числа (packed RGB | DEFAULT_COLOR); сентинелы INHERITED_*
287
- * нелегальны.
307
+ * конкретные числа (packed RGB, в том числе с альфой — `packRgba`/
308
+ * `parseHexColor`, | DEFAULT_COLOR | TRANSPARENT_COLOR); сентинелы
309
+ * INHERITED_* нелегальны.
288
310
  */
289
311
  setStyleVars(vars: Readonly<Record<string, number>> | null): void;
290
312
  /**
@@ -360,6 +382,11 @@ export declare class TUIElement {
360
382
  * запустило бы lazy-layout с мусорными constraints).
361
383
  */
362
384
  collectDamage(sink: DamageList, parentOrigin: Point): void;
385
+ /**
386
+ * На сколько ячеек вправо и вниз родитель рисует за границей элемента
387
+ * (тень, см. {@link shadow}). Damage-rect элемента расширяется на столько же.
388
+ */
389
+ protected get paintOutset(): number;
363
390
  /** Обход детей damage-сбора — seam для контейнеров с нестандартной структурой. */
364
391
  protected collectChildrenDamage(sink: DamageList, origin: Point): void;
365
392
  /**
@@ -503,6 +530,10 @@ export declare class TUIElement {
503
530
  * флагов стиля: собственный фон — это новая поверхность, и атрибуты того,
504
531
  * что нарисовано под ней раньше в этом кадре (подчёркивание диагностики под
505
532
  * попапом, жирный токен под диалогом), на ней проступать не должны.
533
+ *
534
+ * Полупрозрачный собственный bg уже скомпозичен каскадом с унаследованным
535
+ * (см. {@link performStyleResolution}); `TRANSPARENT_COLOR` (`#RRGGBB00`)
536
+ * считается «фона нет» — как CSS `transparent`, элемент ничего не красит.
506
537
  */
507
538
  protected paintOwnBackground(context: RenderContext): void;
508
539
  /** true, если фон задан собственным стилем (см. {@link paintOwnBackground}). */
@@ -522,6 +553,32 @@ export declare class TUIElement {
522
553
  * самый цикл, который раньше был скопирован в десяток контейнеров.
523
554
  */
524
555
  protected renderChildren(context: RenderContext): void;
556
+ /**
557
+ * Отрисовка одного ребёнка в контексте родителя: сдвиг на localPosition,
558
+ * клип по границам ребёнка, затем его тень (см. {@link shadow}) — уже в
559
+ * контексте родителя, потому что она лежит за клипом ребёнка. Тень не
560
+ * зависит от того, попал ли сам ребёнок в клип: damage-rect может задеть
561
+ * только полосу тени (перерисовался виджет под ней), и тогда ребёнок
562
+ * пропускается, а тень обязана лечь заново.
563
+ */
564
+ protected renderChild(context: RenderContext, child: TUIElement): void;
565
+ /**
566
+ * Тень ребёнка (см. {@link shadow}) в контексте РОДИТЕЛЯ: колонка справа
567
+ * (x = right, строки top+1…bottom) и строка снизу (y = bottom, колонки
568
+ * left+1…right) — классический сдвиг 1×1. Ячейка затемняется цветом
569
+ * `widget.shadow` из var-scope ребёнка: bg композитит грид, fg — здесь
570
+ * (грид кладёт fg с альфой на bg, а не на прежний fg); терминальный
571
+ * DEFAULT_COLOR у fg не трогаем — смешивать не с чем.
572
+ *
573
+ * Широкий глиф (CJK, эмодзи) наполовину не красится: голова тянет цвета в
574
+ * продолжение (`Grid.updateCell`), поэтому глиф под кромкой тени
575
+ * затемняется целиком — тень на этой строке шире на колонку. Продолжение
576
+ * в полосе пропускается: его голова либо сама в полосе (уже покрашена —
577
+ * иначе двойная тень), либо внутри ребёнка (его глиф у правой кромки).
578
+ * Единственное исключение — голова в пропущенном нижнем левом углу: такой
579
+ * глиф красится целиком через голову.
580
+ */
581
+ protected paintChildShadow(context: RenderContext, child: TUIElement): void;
525
582
  /**
526
583
  * Финальный шаблон хит-теста — НЕ переопределять (кастомизация — через
527
584
  * {@link hitTestChildren}/{@link hitTestSelf}): скрытое не кликается,
@@ -1,4 +1,4 @@
1
- import { DEFAULT_COLOR } from "../common/colorUtils.js";
1
+ import { compositeOver, isColorValue, TRANSPARENT_COLOR } from "../common/colorUtils.js";
2
2
  import { DisplayLine } from "../common/displayLine.js";
3
3
  import { BoxConstraints, Offset, Point, Rect, Size } from "../common/geometryPromitives.js";
4
4
  import { StyleFlags } from "../common/styleFlags.js";
@@ -217,6 +217,8 @@ export class TUIElement {
217
217
  // вопрос «задан ли цвет собственным стилем» (заливка фона, инспектор).
218
218
  appliedFgValue;
219
219
  appliedBgValue;
220
+ /** Красит ли элемент собственный фон: bg задан своим стилем и не TRANSPARENT_COLOR. */
221
+ paintsOwnBackgroundValue = false;
220
222
  // Активные состояния (hover/focus ведёт ядро, прочие — виджеты). Lazy:
221
223
  // у подавляющего большинства элементов состояний нет.
222
224
  styleStatesSet = null;
@@ -310,6 +312,32 @@ export class TUIElement {
310
312
  this.markDirty();
311
313
  }
312
314
  hiddenValue = false;
315
+ /**
316
+ * Тень оверлея — TUI-аналог `box-shadow` VS Code: колонка справа и строка
317
+ * снизу от элемента (сдвиг 1×1) затемняются цветом `widget.shadow`
318
+ * (с альфой, композитится с тем, что уже нарисовано). Рисует не сам
319
+ * элемент, а его родитель после него ({@link renderChildren},
320
+ * `OverlayLayer.render`): клип ребёнка не выпускает его за собственный
321
+ * rect (Н2), а тень лежит снаружи. Damage-обход учитывает её через
322
+ * {@link paintOutset}. Отдаёт форму оверлею там, где тема рисует рамку
323
+ * цветом фона (Catppuccin: `menu.border` = фон меню).
324
+ *
325
+ * Рисуют её канонические циклы — {@link renderChildren} и
326
+ * `OverlayLayer.render` (оба через {@link renderChild}). Контейнеры со своим
327
+ * циклом (`SizedBoxElement`, `ScrollViewport`, строки списка) ребёнка
328
+ * растягивают на весь свой rect — тени там некуда лечь, она осталась бы за
329
+ * клипом самого контейнера.
330
+ */
331
+ get shadow() {
332
+ return this.shadowValue;
333
+ }
334
+ set shadow(value) {
335
+ if (this.shadowValue === value)
336
+ return;
337
+ this.shadowValue = value;
338
+ this.markDirty();
339
+ }
340
+ shadowValue = false;
313
341
  /** Прикрепляет ребёнка в конец списка (снимая с прежнего родителя). */
314
342
  appendChild(child) {
315
343
  this.insertChild(this.childrenList.length, child);
@@ -571,13 +599,19 @@ export class TUIElement {
571
599
  const vars = this.styleVarsValue !== null ? extendVarScope(context.vars, this.styleVarsValue) : context.vars;
572
600
  this.varScopeRef = vars;
573
601
  const describe = () => this.describeForStyleError();
574
- const fg = applied.fg !== undefined
575
- ? resolveStyleColor(applied.fg, context.fg, context.bg, vars, describe)
576
- : context.fg;
577
- const bg = applied.bg !== undefined
602
+ // Цвет с альфой композитится уже здесь, с унаследованным bg: в
603
+ // resolvedStyle и детям уходят непрозрачные значения, иначе заливка
604
+ // фона и текст с тем же bg наложились бы на ячейку дважды.
605
+ const ownBg = applied.bg !== undefined
578
606
  ? resolveStyleColor(applied.bg, context.fg, context.bg, vars, describe)
579
607
  : context.bg;
608
+ const bg = compositeOver(ownBg, context.bg);
609
+ const ownFg = applied.fg !== undefined
610
+ ? resolveStyleColor(applied.fg, context.fg, context.bg, vars, describe)
611
+ : context.fg;
612
+ const fg = compositeOver(ownFg, bg);
580
613
  this.resolvedStyleValue = { fg, bg };
614
+ this.paintsOwnBackgroundValue = applied.bg !== undefined && ownBg !== TRANSPARENT_COLOR;
581
615
  this.childStyleContext = this.buildChildStyleContext(context);
582
616
  }
583
617
  this.isStyleDirty = false;
@@ -636,15 +670,16 @@ export class TUIElement {
636
670
  * предков и дефолтов tuidom (STYLE_TOKEN_DEFAULTS). Обычное место — корень:
637
671
  * хост транслирует сюда палитру темы одним вызовом (hot-swap = повторный
638
672
  * вызов). Таблица заменяется целиком, null — снимает. Значения — только
639
- * конкретные числа (packed RGB | DEFAULT_COLOR); сентинелы INHERITED_*
640
- * нелегальны.
673
+ * конкретные числа (packed RGB, в том числе с альфой — `packRgba`/
674
+ * `parseHexColor`, | DEFAULT_COLOR | TRANSPARENT_COLOR); сентинелы
675
+ * INHERITED_* нелегальны.
641
676
  */
642
677
  setStyleVars(vars) {
643
678
  if (vars === this.styleVarsValue)
644
679
  return;
645
680
  if (vars !== null) {
646
681
  for (const key of Object.keys(vars)) {
647
- if (vars[key] < DEFAULT_COLOR) {
682
+ if (!isColorValue(vars[key])) {
648
683
  throw new Error(`${this.describeForStyleError()}.setStyleVars: токен "${key}" содержит сентинел/некорректное значение ${vars[key]} — таблицы принимают только конкретные цвета`);
649
684
  }
650
685
  }
@@ -804,7 +839,11 @@ export class TUIElement {
804
839
  this.hasPaintDirtyDescendant = false;
805
840
  return;
806
841
  }
807
- const rect = new Rect(new Point(parentOrigin.x + this.localPosition.dx, parentOrigin.y + this.localPosition.dy), this.allocatedSize);
842
+ // Rect для damage — с outset'ом: тень родитель рисует за границей
843
+ // allocatedSize, и её ячейки тоже должны перерисоваться при переезде,
844
+ // скрытии и paint-dirty.
845
+ const outset = this.paintOutset;
846
+ const rect = new Rect(new Point(parentOrigin.x + this.localPosition.dx, parentOrigin.y + this.localPosition.dy), new Size(this.allocatedSize.width + outset, this.allocatedSize.height + outset));
808
847
  const old = this.lastPaintedRect;
809
848
  // Ещё не рисовался — тоже moved. Optional chain здесь не подходит: после
810
849
  // первого `old?.x` тип уже сужен, и остальные `old?.` стали бы лишними.
@@ -830,6 +869,13 @@ export class TUIElement {
830
869
  if (descend)
831
870
  this.collectChildrenDamage(sink, rect.origin);
832
871
  }
872
+ /**
873
+ * На сколько ячеек вправо и вниз родитель рисует за границей элемента
874
+ * (тень, см. {@link shadow}). Damage-rect элемента расширяется на столько же.
875
+ */
876
+ get paintOutset() {
877
+ return this.shadowValue ? 1 : 0;
878
+ }
833
879
  /** Обход детей damage-сбора — seam для контейнеров с нестандартной структурой. */
834
880
  collectChildrenDamage(sink, origin) {
835
881
  for (const child of this.childrenList)
@@ -1101,9 +1147,13 @@ export class TUIElement {
1101
1147
  * флагов стиля: собственный фон — это новая поверхность, и атрибуты того,
1102
1148
  * что нарисовано под ней раньше в этом кадре (подчёркивание диагностики под
1103
1149
  * попапом, жирный токен под диалогом), на ней проступать не должны.
1150
+ *
1151
+ * Полупрозрачный собственный bg уже скомпозичен каскадом с унаследованным
1152
+ * (см. {@link performStyleResolution}); `TRANSPARENT_COLOR` (`#RRGGBB00`)
1153
+ * считается «фона нет» — как CSS `transparent`, элемент ничего не красит.
1104
1154
  */
1105
1155
  paintOwnBackground(context) {
1106
- if (this.appliedBgValue === undefined)
1156
+ if (!this.paintsOwnBackgroundValue)
1107
1157
  return;
1108
1158
  const { fg, bg } = this.resolvedStyleValue;
1109
1159
  const { width, height } = this.layoutSize;
@@ -1115,7 +1165,7 @@ export class TUIElement {
1115
1165
  }
1116
1166
  /** true, если фон задан собственным стилем (см. {@link paintOwnBackground}). */
1117
1167
  get hasOwnBackground() {
1118
- return this.appliedBgValue !== undefined;
1168
+ return this.paintsOwnBackgroundValue;
1119
1169
  }
1120
1170
  /**
1121
1171
  * Сырые цвета после when-merge, до резолва токенов/сентинелов: что элемент
@@ -1134,16 +1184,75 @@ export class TUIElement {
1134
1184
  for (const child of this.getChildren()) {
1135
1185
  if (child.hidden)
1136
1186
  continue;
1137
- const offset = new Offset(child.localPosition.dx, child.localPosition.dy);
1138
- const clip = new Rect(child.globalPosition, child.layoutSize);
1139
- const childContext = context.withOffset(offset).withClip(clip);
1140
- // Пустой клип — ребёнок целиком вне отрисовываемой области: пропуск
1141
- // всего поддерева, включая side-эффекты его render.
1142
- if (childContext.clipRect.isEmpty)
1143
- continue;
1144
- child.render(childContext);
1187
+ this.renderChild(context, child);
1145
1188
  }
1146
1189
  }
1190
+ /**
1191
+ * Отрисовка одного ребёнка в контексте родителя: сдвиг на localPosition,
1192
+ * клип по границам ребёнка, затем его тень (см. {@link shadow}) — уже в
1193
+ * контексте родителя, потому что она лежит за клипом ребёнка. Тень не
1194
+ * зависит от того, попал ли сам ребёнок в клип: damage-rect может задеть
1195
+ * только полосу тени (перерисовался виджет под ней), и тогда ребёнок
1196
+ * пропускается, а тень обязана лечь заново.
1197
+ */
1198
+ renderChild(context, child) {
1199
+ const offset = new Offset(child.localPosition.dx, child.localPosition.dy);
1200
+ const clip = new Rect(child.globalPosition, child.layoutSize);
1201
+ const childContext = context.withOffset(offset).withClip(clip);
1202
+ // Пустой клип — ребёнок целиком вне отрисовываемой области: пропуск
1203
+ // всего поддерева, включая side-эффекты его render.
1204
+ if (!childContext.clipRect.isEmpty)
1205
+ child.render(childContext);
1206
+ if (child.shadow)
1207
+ this.paintChildShadow(context, child);
1208
+ }
1209
+ /**
1210
+ * Тень ребёнка (см. {@link shadow}) в контексте РОДИТЕЛЯ: колонка справа
1211
+ * (x = right, строки top+1…bottom) и строка снизу (y = bottom, колонки
1212
+ * left+1…right) — классический сдвиг 1×1. Ячейка затемняется цветом
1213
+ * `widget.shadow` из var-scope ребёнка: bg композитит грид, fg — здесь
1214
+ * (грид кладёт fg с альфой на bg, а не на прежний fg); терминальный
1215
+ * DEFAULT_COLOR у fg не трогаем — смешивать не с чем.
1216
+ *
1217
+ * Широкий глиф (CJK, эмодзи) наполовину не красится: голова тянет цвета в
1218
+ * продолжение (`Grid.updateCell`), поэтому глиф под кромкой тени
1219
+ * затемняется целиком — тень на этой строке шире на колонку. Продолжение
1220
+ * в полосе пропускается: его голова либо сама в полосе (уже покрашена —
1221
+ * иначе двойная тень), либо внутри ребёнка (его глиф у правой кромки).
1222
+ * Единственное исключение — голова в пропущенном нижнем левом углу: такой
1223
+ * глиф красится целиком через голову.
1224
+ */
1225
+ paintChildShadow(context, child) {
1226
+ const color = child.styleVar("widget.shadow");
1227
+ if (color === TRANSPARENT_COLOR)
1228
+ return;
1229
+ const left = child.localPosition.dx;
1230
+ const top = child.localPosition.dy;
1231
+ const right = left + child.layoutSize.width;
1232
+ const bottom = top + child.layoutSize.height;
1233
+ const shadeCell = (x, y, cell) => {
1234
+ context.setCell(x, y, { bg: color, fg: cell.fg < 0 ? cell.fg : compositeOver(color, cell.fg) });
1235
+ };
1236
+ const shade = (x, y, wholeGlyph) => {
1237
+ const cell = context.getCell(x, y);
1238
+ if (cell === null)
1239
+ return;
1240
+ if (cell.width !== 0) {
1241
+ shadeCell(x, y, cell);
1242
+ return;
1243
+ }
1244
+ if (!wholeGlyph)
1245
+ return;
1246
+ const head = context.getCell(x - 1, y);
1247
+ if (head === null)
1248
+ return;
1249
+ shadeCell(x - 1, y, head);
1250
+ };
1251
+ for (let y = top + 1; y <= bottom; y++)
1252
+ shade(right, y, false);
1253
+ for (let x = left + 1; x < right; x++)
1254
+ shade(x, bottom, x === left + 1);
1255
+ }
1147
1256
  // ─── Hit-testing ───
1148
1257
  //
1149
1258
  // Правило системы (Н6): рендер и Tab обходят детей ВПЕРЁД, хит-тест — тем
@@ -15,6 +15,12 @@ export interface CellPatch {
15
15
  }
16
16
  /**
17
17
  * 2D grid of terminal cells backed by a flat array for cache-friendly access.
18
+ *
19
+ * Ячейки хранят только непрозрачные цвета (или DEFAULT_COLOR): цвет с альфой,
20
+ * пришедший в {@link setCell}/{@link updateCell}, композитится с текущим
21
+ * содержимым ячейки на месте (см. `compositeOver`). Так порядок отрисовки —
22
+ * родитель, дети, оверлеи, патчи подсветок — становится порядком наложения
23
+ * слоёв, как в браузере, а бэкенды/снапшоты/SVG видят уже готовые цвета.
18
24
  */
19
25
  export declare class Grid {
20
26
  readonly size: Size;
@@ -1,9 +1,15 @@
1
- import { DEFAULT_COLOR } from "../common/colorUtils.js";
1
+ import { compositeOver, DEFAULT_COLOR } from "../common/colorUtils.js";
2
2
  import { Point, Rect, Size } from "../common/geometryPromitives.js";
3
3
  import { StyleFlags } from "../common/styleFlags.js";
4
4
  import { Cell } from "./cell.js";
5
5
  /**
6
6
  * 2D grid of terminal cells backed by a flat array for cache-friendly access.
7
+ *
8
+ * Ячейки хранят только непрозрачные цвета (или DEFAULT_COLOR): цвет с альфой,
9
+ * пришедший в {@link setCell}/{@link updateCell}, композитится с текущим
10
+ * содержимым ячейки на месте (см. `compositeOver`). Так порядок отрисовки —
11
+ * родитель, дети, оверлеи, патчи подсветок — становится порядком наложения
12
+ * слоёв, как в браузере, а бэкенды/снапшоты/SVG видят уже готовые цвета.
7
13
  */
8
14
  export class Grid {
9
15
  size;
@@ -55,8 +61,8 @@ export class Grid {
55
61
  /* v8 ignore stop */
56
62
  }
57
63
  cell.char = char;
58
- cell.fg = fg;
59
- cell.bg = bg;
64
+ cell.bg = compositeOver(bg, cell.bg);
65
+ cell.fg = compositeOver(fg, cell.bg);
60
66
  cell.style = style;
61
67
  cell.width = width;
62
68
  // For wide chars, set up the continuation cell
@@ -73,8 +79,8 @@ export class Grid {
73
79
  /* v8 ignore stop */
74
80
  }
75
81
  cont.char = "";
76
- cont.fg = fg;
77
- cont.bg = bg;
82
+ cont.fg = cell.fg;
83
+ cont.bg = cell.bg;
78
84
  cont.style = style;
79
85
  cont.width = 0;
80
86
  }
@@ -108,10 +114,13 @@ export class Grid {
108
114
  }
109
115
  if (patch.char !== undefined)
110
116
  cell.char = patch.char;
111
- if (patch.fg !== undefined)
112
- cell.fg = patch.fg;
117
+ // Композитинг в порядке отрисовки: полупрозрачный bg ложится на то, что
118
+ // уже лежит в ячейке в этом кадре, полупрозрачный fg — на итоговый bg.
119
+ // Непрозрачные цвета проходят как раньше (compositeOver — одно сравнение).
113
120
  if (patch.bg !== undefined)
114
- cell.bg = patch.bg;
121
+ cell.bg = compositeOver(patch.bg, cell.bg);
122
+ if (patch.fg !== undefined)
123
+ cell.fg = compositeOver(patch.fg, cell.bg);
115
124
  if (patch.style !== undefined)
116
125
  cell.style = patch.style;
117
126
  if (patch.width !== undefined)
@@ -132,10 +141,11 @@ export class Grid {
132
141
  }
133
142
  cont.char = "";
134
143
  cont.width = 0;
144
+ // Продолжение — та же ячейка: берёт уже скомпозиченные цвета головы.
135
145
  if (patch.fg !== undefined)
136
- cont.fg = patch.fg;
146
+ cont.fg = cell.fg;
137
147
  if (patch.bg !== undefined)
138
- cont.bg = patch.bg;
148
+ cont.bg = cell.bg;
139
149
  if (patch.style !== undefined)
140
150
  cont.style = patch.style;
141
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuidom/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Experimental terminal UI DOM — core: element tree, flex layout, grid rendering, input parsing. API is unstable.",
6
6
  "license": "MIT",