@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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Наблюдаемость для инспектора/e2e: текст, курсор, выделение, плейсхолдер.
|
|
42
|
+
* Под маской (`maskChar`) отдаётся ровно то, что на экране, — секрет наружу
|
|
43
|
+
* не уходит; `cursorOffset` при этом тоже смещение в маске.
|
|
44
|
+
*/
|
|
33
45
|
inspectState() {
|
|
46
|
+
const view = this.displayView();
|
|
34
47
|
return {
|
|
35
|
-
value:
|
|
36
|
-
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
31
|
+
"@tuidom/core": "0.3.0"
|
|
32
32
|
}
|
|
33
33
|
}
|