@tuidom/elements 0.2.1 → 0.3.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.
@@ -69,7 +69,7 @@ export class EditorPartElement extends TUIElement {
69
69
  */
70
70
  setViews(views, weights) {
71
71
  this.views = [...views];
72
- const raw = weights !== undefined && weights.length === views.length ? [...weights] : [];
72
+ const raw = weights?.length === views.length ? [...weights] : [];
73
73
  this.weightsValue = this.normalizeWeights(raw);
74
74
  this.maximizedIndexValue = null;
75
75
  this.rebuildSashes();
@@ -18,11 +18,33 @@ export declare class InputElement extends TUIElement {
18
18
  readonly inputState: InputState;
19
19
  placeholder: string | undefined;
20
20
  showBorder: boolean;
21
+ /**
22
+ * Маска поля пароля: каждый графемный кластер значения рисуется этой
23
+ * строкой, сам текст на экран не попадает и не отдаётся инспектору.
24
+ * `undefined` — обычное поле. Модель (`inputState`) хранит настоящий текст:
25
+ * маска — это только представление, редактирование и `onChange` работают
26
+ * по-прежнему с реальным значением.
27
+ */
28
+ maskChar: string | undefined;
21
29
  onChange: ((value: string) => void) | undefined;
22
30
  private scrollX;
23
31
  constructor(inputState?: InputState);
24
- /** Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер. */
32
+ /**
33
+ * Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер.
34
+ * Под маской (`maskChar`) отдаётся ровно то, что на экране, — секрет наружу
35
+ * не уходит; `cursorOffset` при этом тоже смещение в маске.
36
+ */
25
37
  inspectState(): Record<string, unknown>;
38
+ /**
39
+ * Что рисуем и где в нарисованном стоят курсор и выделение.
40
+ *
41
+ * Без маски — сам текст и его же смещения. С маской — по одному
42
+ * {@link maskChar} на графемный кластер, а смещения пересчитаны в этот новый
43
+ * текст: курсор за N-й графемой стоит за N-й маской. Смещения считаем через
44
+ * длину самой маски, а не «одна графема = один символ», — маской может быть
45
+ * и многокодовый глиф.
46
+ */
47
+ private displayView;
26
48
  getMinIntrinsicWidth(_height: number): number;
27
49
  getMinIntrinsicHeight(_width: number): number;
28
50
  getMaxIntrinsicHeight(_width: number): number;
@@ -19,6 +19,14 @@ export class InputElement extends TUIElement {
19
19
  inputState;
20
20
  placeholder = undefined;
21
21
  showBorder = false;
22
+ /**
23
+ * Маска поля пароля: каждый графемный кластер значения рисуется этой
24
+ * строкой, сам текст на экран не попадает и не отдаётся инспектору.
25
+ * `undefined` — обычное поле. Модель (`inputState`) хранит настоящий текст:
26
+ * маска — это только представление, редактирование и `onChange` работают
27
+ * по-прежнему с реальным значением.
28
+ */
29
+ maskChar = undefined;
22
30
  onChange = undefined;
23
31
  scrollX = 0;
24
32
  constructor(inputState) {
@@ -29,15 +37,49 @@ export class InputElement extends TUIElement {
29
37
  // ядро ставит focus-состояние и перерезолвит стиль (render дёргается).
30
38
  this.style = { fg: "input.foreground", bg: "input.background" };
31
39
  }
32
- /** Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер. */
40
+ /**
41
+ * Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер.
42
+ * Под маской (`maskChar`) отдаётся ровно то, что на экране, — секрет наружу
43
+ * не уходит; `cursorOffset` при этом тоже смещение в маске.
44
+ */
33
45
  inspectState() {
46
+ const view = this.displayView();
34
47
  return {
35
- value: this.inputState.value,
36
- cursorOffset: this.inputState.cursorOffset,
48
+ value: view.text,
49
+ cursorOffset: view.cursorOffset,
37
50
  hasSelection: this.inputState.hasSelection,
38
51
  showsPlaceholder: this.inputState.value.length === 0 && this.placeholder !== undefined,
39
52
  };
40
53
  }
54
+ /**
55
+ * Что рисуем и где в нарисованном стоят курсор и выделение.
56
+ *
57
+ * Без маски — сам текст и его же смещения. С маской — по одному
58
+ * {@link maskChar} на графемный кластер, а смещения пересчитаны в этот новый
59
+ * текст: курсор за N-й графемой стоит за N-й маской. Смещения считаем через
60
+ * длину самой маски, а не «одна графема = один символ», — маской может быть
61
+ * и многокодовый глиф.
62
+ */
63
+ displayView() {
64
+ const state = this.inputState;
65
+ const mask = this.maskChar;
66
+ if (mask === undefined) {
67
+ return {
68
+ text: state.text,
69
+ cursorOffset: state.cursorOffset,
70
+ selectionStart: state.selectionStart,
71
+ selectionEnd: state.selectionEnd,
72
+ };
73
+ }
74
+ const slots = new DisplayLine(state.text).slots;
75
+ const toMask = (offset) => slots.filter((slot) => slot.offset < offset).length * mask.length;
76
+ return {
77
+ text: mask.repeat(slots.length),
78
+ cursorOffset: toMask(state.cursorOffset),
79
+ selectionStart: toMask(state.selectionStart),
80
+ selectionEnd: toMask(state.selectionEnd),
81
+ };
82
+ }
41
83
  // ─── Layout ─────────────────────────────────────────────────────────────
42
84
  getMinIntrinsicWidth(_height) {
43
85
  return this.showBorder ? 5 : 3;
@@ -57,8 +99,11 @@ export class InputElement extends TUIElement {
57
99
  render(context) {
58
100
  const w = this.layoutSize.width;
59
101
  const h = this.layoutSize.height;
60
- const text = this.inputState.text;
61
- const cursorOffset = this.inputState.cursorOffset;
102
+ // Рисуем ПРЕДСТАВЛЕНИЕ значения: без маски это сам текст, под маской —
103
+ // её символы со смещениями, пересчитанными в них.
104
+ const view = this.displayView();
105
+ const text = view.text;
106
+ const cursorOffset = view.cursorOffset;
62
107
  const focused = this.isFocused;
63
108
  const contentXStart = this.showBorder ? 1 : 0;
64
109
  const contentY = this.showBorder ? 1 : 0;
@@ -94,7 +139,7 @@ export class InputElement extends TUIElement {
94
139
  });
95
140
  }
96
141
  else if (this.inputState.hasSelection) {
97
- this.renderTextWithSelection(textContext, dl, text, contentXStart, contentY);
142
+ this.renderTextWithSelection(textContext, dl, text, view, contentXStart, contentY);
98
143
  }
99
144
  else {
100
145
  textContext.drawText(contentXStart - this.scrollX, contentY, text, { fg, bg });
@@ -109,9 +154,11 @@ export class InputElement extends TUIElement {
109
154
  /* v8 ignore stop */
110
155
  }
111
156
  }
112
- renderTextWithSelection(context, dl, text, contentXStart, contentY) {
113
- const selStart = this.inputState.selectionStart;
114
- const selEnd = this.inputState.selectionEnd;
157
+ renderTextWithSelection(context, dl, text, view, contentXStart, contentY) {
158
+ // Границы выделения берём из представления: под маской они смещения в
159
+ // ней, а `text`/`dl` — тоже она.
160
+ const selStart = view.selectionStart;
161
+ const selEnd = view.selectionEnd;
115
162
  const before = text.slice(0, selStart);
116
163
  const selected = text.slice(selStart, selEnd);
117
164
  const after = text.slice(selEnd);
@@ -519,8 +519,9 @@ export class ListViewElement extends ScrollableElement {
519
519
  this.hasCollapsibleRows = hasCollapsible;
520
520
  // Курсор возвращается на прежнюю строку по id; если она пропала (скрыта,
521
521
  // свёрнута под родителя) — на ближайший оставшийся индекс, не прыгая наверх.
522
- if (this.pendingCursorId !== null && this.visibleIndexById.has(this.pendingCursorId)) {
523
- this.cursorIndex = this.visibleIndexById.get(this.pendingCursorId);
522
+ const pendingIndex = this.pendingCursorId !== null ? this.visibleIndexById.get(this.pendingCursorId) : undefined;
523
+ if (pendingIndex !== undefined) {
524
+ this.cursorIndex = pendingIndex;
524
525
  }
525
526
  else if (out.length === 0) {
526
527
  this.cursorIndex = 0;
@@ -584,8 +585,10 @@ export class ListViewElement extends ScrollableElement {
584
585
  * тогда новый последний ребёнок строки встаёт в самый хвост проекции.
585
586
  */
586
587
  isProjectionTail(id) {
587
- // Вызывается при видимом родителе — проекция материализована и непуста.
588
- let cursor = this.visibleRows[this.visibleRows.length - 1];
588
+ // Вызывается при видимом родителе — проекция материализована и непуста,
589
+ // так что ensureProjection здесь просто отдаёт готовый массив.
590
+ const rows = this.ensureProjection();
591
+ let cursor = rows[rows.length - 1];
589
592
  while (cursor) {
590
593
  if (cursor.id === id)
591
594
  return true;
@@ -794,7 +797,7 @@ export class ListViewElement extends ScrollableElement {
794
797
  for (let i = 0; i < count; i++) {
795
798
  const idx = (start + i) % count;
796
799
  const label = rows[idx].label;
797
- if (label !== undefined && label.toLowerCase().startsWith(prefix)) {
800
+ if (label?.toLowerCase().startsWith(prefix) === true) {
798
801
  this.setSelectedIndex(idx);
799
802
  return;
800
803
  }
@@ -802,7 +805,7 @@ export class ListViewElement extends ScrollableElement {
802
805
  }
803
806
  handleExpandOrMoveToChild() {
804
807
  const rows = this.ensureProjection();
805
- const row = rows[this.cursorIndex];
808
+ const row = rows.at(this.cursorIndex);
806
809
  if (!row || !this.rowHasChildren(row.id))
807
810
  return;
808
811
  if (this.collapsedIds.has(row.id)) {
@@ -815,7 +818,7 @@ export class ListViewElement extends ScrollableElement {
815
818
  }
816
819
  handleCollapseOrMoveToParent() {
817
820
  const rows = this.ensureProjection();
818
- const row = rows[this.cursorIndex];
821
+ const row = rows.at(this.cursorIndex);
819
822
  if (!row)
820
823
  return;
821
824
  if (this.rowHasChildren(row.id) && !this.collapsedIds.has(row.id)) {
@@ -832,14 +835,14 @@ export class ListViewElement extends ScrollableElement {
832
835
  }
833
836
  activateCursorRow() {
834
837
  const rows = this.ensureProjection();
835
- const row = rows[this.cursorIndex];
838
+ const row = rows.at(this.cursorIndex);
836
839
  if (!row)
837
840
  return;
838
841
  this.onActivate?.(row.element);
839
842
  }
840
843
  toggleCursorRow() {
841
844
  const rows = this.ensureProjection();
842
- const row = rows[this.cursorIndex];
845
+ const row = rows.at(this.cursorIndex);
843
846
  if (!row || !this.rowHasChildren(row.id))
844
847
  return;
845
848
  this.toggleCollapsed(row.id);
@@ -189,7 +189,7 @@ export class PopupMenuElement extends TUIElement {
189
189
  return true;
190
190
  current = current.getParent();
191
191
  }
192
- return this.child !== null && this.child.menu.ownsElement(target);
192
+ return this.child?.menu.ownsElement(target) === true;
193
193
  }
194
194
  /** Каскадно закрывает всю цепочку открытых подменю. */
195
195
  closeSubmenus() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuidom/elements",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Experimental terminal UI DOM — widgets: lists, trees, menus, inputs, quick pick. API is unstable.",
6
6
  "license": "MIT",
@@ -28,6 +28,6 @@
28
28
  "./*": "./dist/*.js"
29
29
  },
30
30
  "dependencies": {
31
- "@tuidom/core": "0.2.1"
31
+ "@tuidom/core": "0.3.0"
32
32
  }
33
33
  }