@tuidom/core 0.4.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.
@@ -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),
@@ -184,6 +184,25 @@ export declare class TUIElement {
184
184
  get hidden(): boolean;
185
185
  set hidden(value: boolean);
186
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;
187
206
  /** Прикрепляет ребёнка в конец списка (снимая с прежнего родителя). */
188
207
  protected appendChild(child: TUIElement): void;
189
208
  /** Прикрепляет ребёнка на позицию index (снимая с прежнего родителя). */
@@ -363,6 +382,11 @@ export declare class TUIElement {
363
382
  * запустило бы lazy-layout с мусорными constraints).
364
383
  */
365
384
  collectDamage(sink: DamageList, parentOrigin: Point): void;
385
+ /**
386
+ * На сколько ячеек вправо и вниз родитель рисует за границей элемента
387
+ * (тень, см. {@link shadow}). Damage-rect элемента расширяется на столько же.
388
+ */
389
+ protected get paintOutset(): number;
366
390
  /** Обход детей damage-сбора — seam для контейнеров с нестандартной структурой. */
367
391
  protected collectChildrenDamage(sink: DamageList, origin: Point): void;
368
392
  /**
@@ -529,6 +553,32 @@ export declare class TUIElement {
529
553
  * самый цикл, который раньше был скопирован в десяток контейнеров.
530
554
  */
531
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;
532
582
  /**
533
583
  * Финальный шаблон хит-теста — НЕ переопределять (кастомизация — через
534
584
  * {@link hitTestChildren}/{@link hitTestSelf}): скрытое не кликается,
@@ -312,6 +312,32 @@ export class TUIElement {
312
312
  this.markDirty();
313
313
  }
314
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;
315
341
  /** Прикрепляет ребёнка в конец списка (снимая с прежнего родителя). */
316
342
  appendChild(child) {
317
343
  this.insertChild(this.childrenList.length, child);
@@ -813,7 +839,11 @@ export class TUIElement {
813
839
  this.hasPaintDirtyDescendant = false;
814
840
  return;
815
841
  }
816
- 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));
817
847
  const old = this.lastPaintedRect;
818
848
  // Ещё не рисовался — тоже moved. Optional chain здесь не подходит: после
819
849
  // первого `old?.x` тип уже сужен, и остальные `old?.` стали бы лишними.
@@ -839,6 +869,13 @@ export class TUIElement {
839
869
  if (descend)
840
870
  this.collectChildrenDamage(sink, rect.origin);
841
871
  }
872
+ /**
873
+ * На сколько ячеек вправо и вниз родитель рисует за границей элемента
874
+ * (тень, см. {@link shadow}). Damage-rect элемента расширяется на столько же.
875
+ */
876
+ get paintOutset() {
877
+ return this.shadowValue ? 1 : 0;
878
+ }
842
879
  /** Обход детей damage-сбора — seam для контейнеров с нестандартной структурой. */
843
880
  collectChildrenDamage(sink, origin) {
844
881
  for (const child of this.childrenList)
@@ -1147,16 +1184,75 @@ export class TUIElement {
1147
1184
  for (const child of this.getChildren()) {
1148
1185
  if (child.hidden)
1149
1186
  continue;
1150
- const offset = new Offset(child.localPosition.dx, child.localPosition.dy);
1151
- const clip = new Rect(child.globalPosition, child.layoutSize);
1152
- const childContext = context.withOffset(offset).withClip(clip);
1153
- // Пустой клип — ребёнок целиком вне отрисовываемой области: пропуск
1154
- // всего поддерева, включая side-эффекты его render.
1155
- if (childContext.clipRect.isEmpty)
1156
- continue;
1157
- child.render(childContext);
1187
+ this.renderChild(context, child);
1158
1188
  }
1159
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
+ }
1160
1256
  // ─── Hit-testing ───
1161
1257
  //
1162
1258
  // Правило системы (Н6): рендер и Tab обходят детей ВПЕРЁД, хит-тест — тем
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuidom/core",
3
- "version": "0.4.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",