@valyrianjs/terminal 0.1.1 → 0.2.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.
- package/README.md +105 -55
- package/dist/ansi.d.ts +20 -4
- package/dist/ansi.d.ts.map +1 -1
- package/dist/ansi.js +171 -47
- package/dist/ansi.js.map +1 -1
- package/dist/editor-state.d.ts +22 -0
- package/dist/editor-state.d.ts.map +1 -0
- package/dist/editor-state.js +110 -0
- package/dist/editor-state.js.map +1 -0
- package/dist/events.d.ts +1 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +15 -38
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/keymap.d.ts +7 -0
- package/dist/keymap.d.ts.map +1 -0
- package/dist/keymap.js +133 -0
- package/dist/keymap.js.map +1 -0
- package/dist/layout.d.ts +10 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +97 -7
- package/dist/layout.js.map +1 -1
- package/dist/mouse.d.ts +1 -0
- package/dist/mouse.d.ts.map +1 -1
- package/dist/mouse.js +24 -1
- package/dist/mouse.js.map +1 -1
- package/dist/output-writer.d.ts +9 -0
- package/dist/output-writer.d.ts.map +1 -0
- package/dist/output-writer.js +79 -0
- package/dist/output-writer.js.map +1 -0
- package/dist/paste.d.ts +7 -0
- package/dist/paste.d.ts.map +1 -0
- package/dist/paste.js +18 -0
- package/dist/paste.js.map +1 -0
- package/dist/primitives.d.ts +15 -3
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +9 -1
- package/dist/primitives.js.map +1 -1
- package/dist/render.d.ts +9 -4
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +923 -68
- package/dist/render.js.map +1 -1
- package/dist/runtime.d.ts +29 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +209 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +24 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +858 -199
- package/dist/session.js.map +1 -1
- package/dist/stream-log.d.ts +40 -0
- package/dist/stream-log.d.ts.map +1 -0
- package/dist/stream-log.js +73 -0
- package/dist/stream-log.js.map +1 -0
- package/dist/text.d.ts +3 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +19 -0
- package/dist/text.js.map +1 -0
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +254 -0
- package/dist/theme.js.map +1 -0
- package/dist/tree.d.ts +2 -0
- package/dist/tree.d.ts.map +1 -1
- package/dist/tree.js +42 -1
- package/dist/tree.js.map +1 -1
- package/dist/types.d.ts +203 -24
- package/dist/types.d.ts.map +1 -1
- package/docs/api-reference.md +313 -142
- package/docs/assets/quick-note.svg +13 -0
- package/docs/cookbook.md +296 -201
- package/docs/core-concepts.md +143 -55
- package/docs/getting-started.md +209 -90
- package/docs/interaction-model.md +98 -54
- package/docs/primitive-gallery.md +370 -0
- package/docs/session-runtime.md +131 -362
- package/docs/valyrian-modules.md +3196 -0
- package/llms-full.txt +5377 -0
- package/package.json +21 -8
- package/src/ansi.ts +269 -0
- package/src/clipboard.ts +76 -0
- package/src/editor-state.ts +162 -0
- package/src/events.ts +163 -0
- package/src/index.ts +95 -0
- package/src/keymap.ts +151 -0
- package/src/layout.ts +282 -0
- package/src/mouse.ts +68 -0
- package/src/output-writer.ts +93 -0
- package/src/paste.ts +23 -0
- package/src/primitives.ts +55 -0
- package/src/render.ts +1204 -0
- package/src/runtime.ts +267 -0
- package/src/scheduler.ts +33 -0
- package/src/session.ts +1408 -0
- package/src/stream-log.ts +96 -0
- package/src/text.ts +20 -0
- package/src/theme.ts +263 -0
- package/src/tree.ts +169 -0
- package/src/types.ts +541 -0
- package/tsconfig.json +4 -7
- package/docs/local-demo.md +0 -28
package/src/events.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { InputInteractionState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function clamp(value: number, min: number, max: number) {
|
|
4
|
+
return Math.max(min, Math.min(max, value));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeInputState(state: InputInteractionState | undefined, valueLength: number): InputInteractionState {
|
|
8
|
+
const cursor = clamp(Number(state?.cursor ?? valueLength), 0, valueLength);
|
|
9
|
+
const anchor = clamp(Number(state?.anchor ?? cursor), 0, valueLength);
|
|
10
|
+
return { cursor, anchor };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasSelection(state: InputInteractionState) {
|
|
14
|
+
return state.cursor !== state.anchor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getSelectionRange(state: InputInteractionState) {
|
|
18
|
+
return {
|
|
19
|
+
start: Math.min(state.cursor, state.anchor),
|
|
20
|
+
end: Math.max(state.cursor, state.anchor)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function moveTo(state: InputInteractionState, valueLength: number, nextCursor: number, extendSelection = false) {
|
|
25
|
+
const cursor = clamp(nextCursor, 0, valueLength);
|
|
26
|
+
return extendSelection ? { cursor, anchor: state.anchor } : { cursor, anchor: cursor };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function moveCursorLeft(state: InputInteractionState, valueLength: number, extendSelection = false) {
|
|
30
|
+
if (!extendSelection && hasSelection(state)) {
|
|
31
|
+
const { start } = getSelectionRange(state);
|
|
32
|
+
return moveTo(state, valueLength, start, false);
|
|
33
|
+
}
|
|
34
|
+
return moveTo(state, valueLength, state.cursor - 1, extendSelection);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function moveCursorRight(state: InputInteractionState, valueLength: number, extendSelection = false) {
|
|
38
|
+
if (!extendSelection && hasSelection(state)) {
|
|
39
|
+
const { end } = getSelectionRange(state);
|
|
40
|
+
return moveTo(state, valueLength, end, false);
|
|
41
|
+
}
|
|
42
|
+
return moveTo(state, valueLength, state.cursor + 1, extendSelection);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function moveCursorHome(state: InputInteractionState, valueLength: number, extendSelection = false) {
|
|
46
|
+
return moveTo(state, valueLength, 0, extendSelection);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function moveCursorEnd(state: InputInteractionState, valueLength: number, extendSelection = false) {
|
|
50
|
+
return moveTo(state, valueLength, valueLength, extendSelection);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function moveCursorWordLeft(value: string, state: InputInteractionState, extendSelection = false) {
|
|
54
|
+
if (!extendSelection && hasSelection(state)) {
|
|
55
|
+
const { start } = getSelectionRange(state);
|
|
56
|
+
return moveTo(state, value.length, start, false);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let cursor = state.cursor;
|
|
60
|
+
while (cursor > 0 && /\s/.test(value[cursor - 1])) {
|
|
61
|
+
cursor -= 1;
|
|
62
|
+
}
|
|
63
|
+
while (cursor > 0 && !/\s/.test(value[cursor - 1])) {
|
|
64
|
+
cursor -= 1;
|
|
65
|
+
}
|
|
66
|
+
return moveTo(state, value.length, cursor, extendSelection);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function moveCursorWordRight(value: string, state: InputInteractionState, extendSelection = false) {
|
|
70
|
+
if (!extendSelection && hasSelection(state)) {
|
|
71
|
+
const { end } = getSelectionRange(state);
|
|
72
|
+
return moveTo(state, value.length, end, false);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let cursor = state.cursor;
|
|
76
|
+
while (cursor < value.length && /\s/.test(value[cursor])) {
|
|
77
|
+
cursor += 1;
|
|
78
|
+
}
|
|
79
|
+
while (cursor < value.length && !/\s/.test(value[cursor])) {
|
|
80
|
+
cursor += 1;
|
|
81
|
+
}
|
|
82
|
+
return moveTo(state, value.length, cursor, extendSelection);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function selectAll(value: string) {
|
|
86
|
+
return { cursor: value.length, anchor: 0 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function copySelection(value: string, state: InputInteractionState) {
|
|
90
|
+
if (!hasSelection(state)) {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
const { start, end } = getSelectionRange(state);
|
|
94
|
+
return value.slice(start, end);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function replaceRange(value: string, start: number, end: number, insert: string) {
|
|
98
|
+
const nextValue = `${value.slice(0, start)}${insert}${value.slice(end)}`;
|
|
99
|
+
const nextCursor = start + insert.length;
|
|
100
|
+
return { value: nextValue, state: { cursor: nextCursor, anchor: nextCursor } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function insertText(value: string, state: InputInteractionState, text: string) {
|
|
104
|
+
const { start, end } = getSelectionRange(state);
|
|
105
|
+
return replaceRange(value, start, end, text);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function removeBackward(value: string, state: InputInteractionState) {
|
|
109
|
+
if (hasSelection(state)) {
|
|
110
|
+
const { start, end } = getSelectionRange(state);
|
|
111
|
+
return replaceRange(value, start, end, "");
|
|
112
|
+
}
|
|
113
|
+
if (state.cursor === 0) {
|
|
114
|
+
return { value, state };
|
|
115
|
+
}
|
|
116
|
+
return replaceRange(value, state.cursor - 1, state.cursor, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function removeForward(value: string, state: InputInteractionState) {
|
|
120
|
+
if (hasSelection(state)) {
|
|
121
|
+
const { start, end } = getSelectionRange(state);
|
|
122
|
+
return replaceRange(value, start, end, "");
|
|
123
|
+
}
|
|
124
|
+
if (state.cursor >= value.length) {
|
|
125
|
+
return { value, state };
|
|
126
|
+
}
|
|
127
|
+
return replaceRange(value, state.cursor, state.cursor + 1, "");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseTerminalKey(chunk: string | Uint8Array) {
|
|
131
|
+
const value = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
132
|
+
if (value === "\r" || value === "\n") return "ENTER";
|
|
133
|
+
if (value === "\t") return "TAB";
|
|
134
|
+
if (value === "\u001b") return "ESCAPE";
|
|
135
|
+
if (value === "\u001b[27~") return "ESCAPE";
|
|
136
|
+
if (value === "\u001b[13;2u" || value === "\u001b[27;2;13~" || value === "\u001b[13;2~") return "SHIFT_ENTER";
|
|
137
|
+
const kittyEnter = /^\u001b\[13;(\d+)u$/.exec(value);
|
|
138
|
+
if (kittyEnter) {
|
|
139
|
+
const modifier = Number(kittyEnter[1]);
|
|
140
|
+
if (Number.isInteger(modifier)) {
|
|
141
|
+
return ((modifier - 1) & 1) === 1 ? "SHIFT_ENTER" : "ENTER";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (value === "\u001b[Z") return "SHIFT_TAB";
|
|
145
|
+
if (value === "\u001b[A") return "UP";
|
|
146
|
+
if (value === "\u001b[B") return "DOWN";
|
|
147
|
+
if (value === "\u001b[C") return "RIGHT";
|
|
148
|
+
if (value === "\u001b[D") return "LEFT";
|
|
149
|
+
if (value === "\u001b[1;2C") return "SHIFT_RIGHT";
|
|
150
|
+
if (value === "\u001b[1;2D") return "SHIFT_LEFT";
|
|
151
|
+
if (value === "\u001b[1;3C" || value === "\u001bf") return "ALT_RIGHT";
|
|
152
|
+
if (value === "\u001b[1;3D" || value === "\u001bb") return "ALT_LEFT";
|
|
153
|
+
if (value === "\u001b[3~") return "DELETE";
|
|
154
|
+
if (value === "\u0008" || value === "\u007f") return "BACKSPACE";
|
|
155
|
+
if (value === "\u001b[H") return "HOME";
|
|
156
|
+
if (value === "\u001b[F") return "END";
|
|
157
|
+
if (value === "\u0001") return "CTRL_A";
|
|
158
|
+
if (value === "\u0003") return "CTRL_C";
|
|
159
|
+
if (value === "\u000b") return "CTRL_K";
|
|
160
|
+
if (value === "\u0016") return "CTRL_V";
|
|
161
|
+
if (value === "\u0018") return "CTRL_X";
|
|
162
|
+
return value;
|
|
163
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
TerminalBoxProps,
|
|
3
|
+
TerminalButtonPressEventPayload,
|
|
4
|
+
TerminalButtonProps,
|
|
5
|
+
TerminalChangeEventPayload,
|
|
6
|
+
TerminalCommand,
|
|
7
|
+
TerminalCommandContext,
|
|
8
|
+
TerminalCommandId,
|
|
9
|
+
TerminalCommandScope,
|
|
10
|
+
InputInteractionState,
|
|
11
|
+
TerminalClipboardAdapter,
|
|
12
|
+
TerminalCaptureEventPayload,
|
|
13
|
+
TerminalDirectiveProps,
|
|
14
|
+
TerminalElementNode,
|
|
15
|
+
TerminalEditorCancelEventPayload,
|
|
16
|
+
TerminalEditorChangeEventPayload,
|
|
17
|
+
TerminalEditorProps,
|
|
18
|
+
TerminalEditorSubmitEventPayload,
|
|
19
|
+
TerminalFrame,
|
|
20
|
+
TerminalHitbox,
|
|
21
|
+
TerminalInputChangeEventPayload,
|
|
22
|
+
TerminalInputContextPressEventPayload,
|
|
23
|
+
TerminalInputProps,
|
|
24
|
+
TerminalInputSubmitEventPayload,
|
|
25
|
+
TerminalKeyBinding,
|
|
26
|
+
TerminalKeyBindingCondition,
|
|
27
|
+
TerminalKeymapOptions,
|
|
28
|
+
TerminalListPointerEventPayload,
|
|
29
|
+
TerminalListChangeEventPayload,
|
|
30
|
+
TerminalListProps,
|
|
31
|
+
TerminalListPressEventPayload,
|
|
32
|
+
TerminalLayoutProps,
|
|
33
|
+
TerminalLogViewEntry,
|
|
34
|
+
TerminalLogViewProps,
|
|
35
|
+
TerminalMountOptions,
|
|
36
|
+
TerminalMouseEventType,
|
|
37
|
+
TerminalNode,
|
|
38
|
+
TerminalOverlayProps,
|
|
39
|
+
TerminalOutputStream,
|
|
40
|
+
TerminalPaneProps,
|
|
41
|
+
TerminalPointerCoordinates,
|
|
42
|
+
TerminalPointerCaptureProps,
|
|
43
|
+
TerminalPressEventPayload,
|
|
44
|
+
TerminalPointerSource,
|
|
45
|
+
TerminalRowPointerEventPayload,
|
|
46
|
+
TerminalRowPointerEventPayloadBase,
|
|
47
|
+
TerminalRowProps,
|
|
48
|
+
TerminalScreenProps,
|
|
49
|
+
TerminalScrollPointerEventPayload,
|
|
50
|
+
TerminalScrollContextPressEventPayload,
|
|
51
|
+
TerminalScrollViewProps,
|
|
52
|
+
TerminalSemanticStyleKind,
|
|
53
|
+
TerminalSession,
|
|
54
|
+
TerminalSize,
|
|
55
|
+
TerminalSplitBreakpoint,
|
|
56
|
+
TerminalSplitProps,
|
|
57
|
+
TerminalSplitSize,
|
|
58
|
+
TerminalStyleSpan,
|
|
59
|
+
TerminalStyleToken,
|
|
60
|
+
TerminalStyleDefinition,
|
|
61
|
+
TerminalStyleProps,
|
|
62
|
+
TerminalStyleReference,
|
|
63
|
+
TerminalStyleTree,
|
|
64
|
+
TerminalStyleValue,
|
|
65
|
+
TerminalStateStyles,
|
|
66
|
+
TerminalVisualState,
|
|
67
|
+
TerminalColor,
|
|
68
|
+
TerminalSpacing,
|
|
69
|
+
TerminalBorder,
|
|
70
|
+
TerminalBorderStyle,
|
|
71
|
+
TerminalTableProps,
|
|
72
|
+
TerminalTdProps,
|
|
73
|
+
TerminalTheme,
|
|
74
|
+
TerminalTextProps,
|
|
75
|
+
TerminalValueEventPayloadBase,
|
|
76
|
+
TerminalFocusableProps,
|
|
77
|
+
TerminalEventPayloadBase,
|
|
78
|
+
TerminalFocusScopeProps,
|
|
79
|
+
TerminalFixedProps,
|
|
80
|
+
TerminalViewProps,
|
|
81
|
+
TerminalTextNode
|
|
82
|
+
} from "./types.js";
|
|
83
|
+
|
|
84
|
+
export { Box, Button, Editor, Fixed, FocusScope, Input, List, LogView, Overlay, Pane, Row, Screen, ScrollView, Split, Table, Td, Text, View } from "./primitives.js";
|
|
85
|
+
export {
|
|
86
|
+
createResolvedTerminalKeymap,
|
|
87
|
+
createTerminalKeyBindings,
|
|
88
|
+
defaultTerminalKeyBindings,
|
|
89
|
+
resolveTerminalKeyBinding,
|
|
90
|
+
validateTerminalKeyBindings
|
|
91
|
+
} from "./keymap.js";
|
|
92
|
+
export { defaultTerminalTheme, highContrastTerminalTheme, mergeTerminalTheme, resolveTerminalStyle, resolveTerminalStyleToken } from "./theme.js";
|
|
93
|
+
export { renderTerminal } from "./render.js";
|
|
94
|
+
export type { TerminalRenderContext } from "./render.js";
|
|
95
|
+
export { mountTerminal } from "./session.js";
|
package/src/keymap.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { TerminalCommand, TerminalCommandContext, TerminalKeyBinding } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const defaultTerminalKeyBindings: TerminalKeyBinding[] = [
|
|
4
|
+
{ key: "TAB", command: { id: "focus.next" }, scope: "focus" },
|
|
5
|
+
{ key: "SHIFT_TAB", command: { id: "focus.prev" }, scope: "focus" },
|
|
6
|
+
{ key: "ENTER", command: { id: "input.submit" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
7
|
+
{ key: "LEFT", command: { id: "input.cursorLeft" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
8
|
+
{ key: "RIGHT", command: { id: "input.cursorRight" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
9
|
+
{ key: "SHIFT_LEFT", command: { id: "input.selectLeft" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
10
|
+
{ key: "SHIFT_RIGHT", command: { id: "input.selectRight" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
11
|
+
{ key: "ALT_LEFT", command: { id: "input.wordLeft" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
12
|
+
{ key: "ALT_RIGHT", command: { id: "input.wordRight" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
13
|
+
{ key: "BACKSPACE", command: { id: "input.backspace" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
14
|
+
{ key: "DELETE", command: { id: "input.delete" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
15
|
+
{ key: "HOME", command: { id: "input.home" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
16
|
+
{ key: "END", command: { id: "input.end" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
17
|
+
{ key: "CTRL_A", command: { id: "input.selectAll" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
18
|
+
{ key: "CTRL_C", command: { id: "input.copy" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
19
|
+
{ key: "CTRL_V", command: { id: "input.paste" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
20
|
+
{ key: "CTRL_X", command: { id: "input.cut" }, scope: "input", when: { focusedTag: "terminal-input" } },
|
|
21
|
+
{ key: "ENTER", command: { id: "editor.submit" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
22
|
+
{ key: "ESCAPE", command: { id: "editor.cancel" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
23
|
+
{ key: "SHIFT_ENTER", command: { id: "editor.newline" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
24
|
+
{ key: "BACKSPACE", command: { id: "editor.backspace" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
25
|
+
{ key: "DELETE", command: { id: "editor.delete" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
26
|
+
{ key: "LEFT", command: { id: "editor.cursorLeft" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
27
|
+
{ key: "RIGHT", command: { id: "editor.cursorRight" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
28
|
+
{ key: "UP", command: { id: "editor.cursorUp" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
29
|
+
{ key: "DOWN", command: { id: "editor.cursorDown" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
30
|
+
{ key: "HOME", command: { id: "editor.home" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
31
|
+
{ key: "END", command: { id: "editor.end" }, scope: "editor", when: { focusedTag: "terminal-editor" } },
|
|
32
|
+
{ key: "ENTER", command: { id: "button.press" }, scope: "button", when: { focusedTag: "terminal-button" } },
|
|
33
|
+
{ key: "SPACE", command: { id: "button.press" }, scope: "button", when: { focusedTag: "terminal-button" } },
|
|
34
|
+
{ key: "UP", command: { id: "list.prev" }, scope: "list", when: { focusedTag: "terminal-list" } },
|
|
35
|
+
{ key: "LEFT", command: { id: "list.prev" }, scope: "list", when: { focusedTag: "terminal-list" } },
|
|
36
|
+
{ key: "DOWN", command: { id: "list.next" }, scope: "list", when: { focusedTag: "terminal-list" } },
|
|
37
|
+
{ key: "RIGHT", command: { id: "list.next" }, scope: "list", when: { focusedTag: "terminal-list" } },
|
|
38
|
+
{ key: "ENTER", command: { id: "list.press" }, scope: "list", when: { focusedTag: "terminal-list" } },
|
|
39
|
+
{ key: "UP", command: { id: "scroll.up" }, scope: "scroll", when: { focusedTag: "terminal-scroll" } },
|
|
40
|
+
{ key: "DOWN", command: { id: "scroll.down" }, scope: "scroll", when: { focusedTag: "terminal-scroll" } }
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function validateTerminalKeyBindings(bindings: readonly TerminalKeyBinding[]) {
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
|
|
46
|
+
for (const binding of bindings) {
|
|
47
|
+
const signature = bindingSignature(binding);
|
|
48
|
+
if (seen.has(signature)) {
|
|
49
|
+
throw new RangeError(`Duplicate terminal key binding for ${signature}`);
|
|
50
|
+
}
|
|
51
|
+
seen.add(signature);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createResolvedTerminalKeymap(customBindings: readonly TerminalKeyBinding[] = []) {
|
|
56
|
+
validateTerminalKeyBindings(defaultTerminalKeyBindings);
|
|
57
|
+
validateTerminalKeyBindings(customBindings);
|
|
58
|
+
|
|
59
|
+
const customSignatures = new Set(customBindings.map(bindingSignature));
|
|
60
|
+
const defaults = defaultTerminalKeyBindings.filter((binding) => !customSignatures.has(bindingSignature(binding)));
|
|
61
|
+
return [...customBindings, ...defaults];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const createTerminalKeyBindings = createResolvedTerminalKeymap;
|
|
65
|
+
|
|
66
|
+
export function resolveTerminalKeyBinding(
|
|
67
|
+
bindings: readonly TerminalKeyBinding[],
|
|
68
|
+
context: TerminalCommandContext
|
|
69
|
+
): TerminalCommand | undefined {
|
|
70
|
+
let bestBinding: TerminalKeyBinding | undefined;
|
|
71
|
+
let bestScore = -1;
|
|
72
|
+
|
|
73
|
+
for (const binding of bindings) {
|
|
74
|
+
if (!matchesBinding(binding, context)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const score = bindingTier(binding) + bindingSpecificity(binding);
|
|
79
|
+
if (score > bestScore) {
|
|
80
|
+
bestBinding = binding;
|
|
81
|
+
bestScore = score;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (bestBinding) {
|
|
86
|
+
return bestBinding.command;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (context.focusedTag === "terminal-input" && context.key.length === 1) {
|
|
90
|
+
return { id: "input.insertText", text: context.key };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (context.focusedTag === "terminal-editor" && context.key.includes("\n")) {
|
|
94
|
+
return { id: "editor.pasteText", text: context.key };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function matchesBinding(binding: TerminalKeyBinding, context: TerminalCommandContext) {
|
|
101
|
+
if (binding.key !== context.key) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (!matchesScope(binding, context)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (binding.when?.focusedId !== undefined && binding.when.focusedId !== context.focusedId) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (binding.when?.focusedTag !== undefined && binding.when.focusedTag !== context.focusedTag) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function bindingSpecificity(binding: TerminalKeyBinding) {
|
|
117
|
+
let score = 0;
|
|
118
|
+
if (binding.scope && binding.scope !== "global" && binding.scope !== "focus") score += 1;
|
|
119
|
+
if (binding.when?.focusedTag !== undefined) score += 1;
|
|
120
|
+
if (binding.when?.focusedId !== undefined) score += 2;
|
|
121
|
+
return score;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function matchesScope(binding: TerminalKeyBinding, context: TerminalCommandContext) {
|
|
125
|
+
switch (binding.scope) {
|
|
126
|
+
case undefined:
|
|
127
|
+
case "global":
|
|
128
|
+
case "focus":
|
|
129
|
+
return true;
|
|
130
|
+
case "input":
|
|
131
|
+
return context.focusedTag === "terminal-input";
|
|
132
|
+
case "editor":
|
|
133
|
+
return context.focusedTag === "terminal-editor";
|
|
134
|
+
case "button":
|
|
135
|
+
return context.focusedTag === "terminal-button";
|
|
136
|
+
case "list":
|
|
137
|
+
return context.focusedTag === "terminal-list";
|
|
138
|
+
case "scroll":
|
|
139
|
+
return context.focusedTag === "terminal-scroll";
|
|
140
|
+
default:
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function bindingTier(binding: TerminalKeyBinding) {
|
|
146
|
+
return defaultTerminalKeyBindings.includes(binding) ? 0 : 100;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function bindingSignature(binding: TerminalKeyBinding) {
|
|
150
|
+
return [binding.key, binding.scope ?? "global", binding.when?.focusedId ?? "", binding.when?.focusedTag ?? ""].join("\u0000");
|
|
151
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { CursorPosition, TerminalFrame, TerminalHitbox, TerminalStyleSpan } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function repeat(char: string, count: number) {
|
|
4
|
+
return char.repeat(Math.max(0, count));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createFrame(lines: string[], hitboxes: TerminalHitbox[] = [], cursor: CursorPosition | null = null, spans: TerminalStyleSpan[] = []): TerminalFrame {
|
|
8
|
+
return { lines: lines.length ? lines : [""], hitboxes, cursor, spans };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getFrameWidth(frame: TerminalFrame) {
|
|
12
|
+
return frame.lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getFrameHeight(frame: TerminalFrame) {
|
|
16
|
+
return frame.lines.length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shiftFrame(frame: TerminalFrame, dx: number, dy: number): TerminalFrame {
|
|
20
|
+
return {
|
|
21
|
+
lines: frame.lines,
|
|
22
|
+
hitboxes: frame.hitboxes.map((box) => ({
|
|
23
|
+
...box,
|
|
24
|
+
x1: box.x1 + dx,
|
|
25
|
+
x2: box.x2 + dx,
|
|
26
|
+
y1: box.y1 + dy,
|
|
27
|
+
y2: box.y2 + dy,
|
|
28
|
+
textStartX: typeof box.textStartX === "number" ? box.textStartX + dx : undefined
|
|
29
|
+
})),
|
|
30
|
+
cursor: frame.cursor ? { x: frame.cursor.x + dx, y: frame.cursor.y + dy } : null,
|
|
31
|
+
spans: frame.spans.map((span) => ({ ...span, x1: span.x1 + dx, x2: span.x2 + dx, y: span.y + dy }))
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeLines(frame: TerminalFrame, width: number, height: number) {
|
|
36
|
+
const lines = frame.lines.map((line) => line.padEnd(width, " "));
|
|
37
|
+
while (lines.length < height) {
|
|
38
|
+
lines.push(repeat(" ", width));
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mergeVertical(frames: TerminalFrame[], options: { gap?: number } = {}): TerminalFrame {
|
|
44
|
+
const filtered = frames.filter(Boolean);
|
|
45
|
+
if (!filtered.length) {
|
|
46
|
+
return createFrame([""]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const gap = Math.max(0, Number(options.gap || 0));
|
|
50
|
+
const width = filtered.reduce((max, frame) => Math.max(max, getFrameWidth(frame)), 0);
|
|
51
|
+
const lines: string[] = [];
|
|
52
|
+
const hitboxes: TerminalHitbox[] = [];
|
|
53
|
+
const spans: TerminalStyleSpan[] = [];
|
|
54
|
+
let cursor: CursorPosition | null = null;
|
|
55
|
+
let rowOffset = 0;
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < filtered.length; i += 1) {
|
|
58
|
+
const frame = filtered[i];
|
|
59
|
+
const normalized = normalizeLines(frame, width, getFrameHeight(frame));
|
|
60
|
+
lines.push(...normalized);
|
|
61
|
+
const shifted = shiftFrame(frame, 0, rowOffset);
|
|
62
|
+
hitboxes.push(...shifted.hitboxes);
|
|
63
|
+
spans.push(...shifted.spans);
|
|
64
|
+
if (!cursor && frame.cursor) {
|
|
65
|
+
cursor = { x: frame.cursor.x, y: frame.cursor.y + rowOffset };
|
|
66
|
+
}
|
|
67
|
+
rowOffset += normalized.length;
|
|
68
|
+
if (i < filtered.length - 1 && gap > 0) {
|
|
69
|
+
for (let j = 0; j < gap; j += 1) {
|
|
70
|
+
lines.push(repeat(" ", width));
|
|
71
|
+
}
|
|
72
|
+
rowOffset += gap;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return createFrame(lines, hitboxes, cursor, spans);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function mergeHorizontal(frames: TerminalFrame[], options: { gap?: number } = {}): TerminalFrame {
|
|
80
|
+
const filtered = frames.filter(Boolean);
|
|
81
|
+
if (!filtered.length) {
|
|
82
|
+
return createFrame([""]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const gap = Math.max(0, Number(options.gap || 0));
|
|
86
|
+
const widths = filtered.map(getFrameWidth);
|
|
87
|
+
const height = filtered.reduce((max, frame) => Math.max(max, getFrameHeight(frame)), 0);
|
|
88
|
+
const normalizedFrames = filtered.map((frame, index) => ({
|
|
89
|
+
frame,
|
|
90
|
+
width: widths[index],
|
|
91
|
+
lines: normalizeLines(frame, widths[index], height)
|
|
92
|
+
}));
|
|
93
|
+
const gapText = repeat(" ", gap);
|
|
94
|
+
const lines = new Array<string>(height).fill("");
|
|
95
|
+
const hitboxes: TerminalHitbox[] = [];
|
|
96
|
+
const spans: TerminalStyleSpan[] = [];
|
|
97
|
+
let cursor: CursorPosition | null = null;
|
|
98
|
+
let xOffset = 0;
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index < normalizedFrames.length; index += 1) {
|
|
101
|
+
const part = normalizedFrames[index];
|
|
102
|
+
for (let row = 0; row < height; row += 1) {
|
|
103
|
+
lines[row] += part.lines[row];
|
|
104
|
+
if (index < normalizedFrames.length - 1) {
|
|
105
|
+
lines[row] += gapText;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const shifted = shiftFrame(part.frame, xOffset, 0);
|
|
109
|
+
hitboxes.push(...shifted.hitboxes);
|
|
110
|
+
spans.push(...shifted.spans);
|
|
111
|
+
if (!cursor && part.frame.cursor) {
|
|
112
|
+
cursor = { x: part.frame.cursor.x + xOffset, y: part.frame.cursor.y };
|
|
113
|
+
}
|
|
114
|
+
xOffset += part.width + (index < normalizedFrames.length - 1 ? gap : 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return createFrame(lines, hitboxes, cursor, spans);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function padFrame(frame: TerminalFrame, padding: number) {
|
|
121
|
+
const amount = Math.max(0, Number(padding || 0));
|
|
122
|
+
if (!amount) {
|
|
123
|
+
return frame;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const width = getFrameWidth(frame);
|
|
127
|
+
const middle = frame.lines.map((line) => `${repeat(" ", amount)}${line.padEnd(width, " ")}${repeat(" ", amount)}`);
|
|
128
|
+
const blank = repeat(" ", width + amount * 2);
|
|
129
|
+
const lines = [...new Array<string>(amount).fill(blank), ...middle, ...new Array<string>(amount).fill(blank)];
|
|
130
|
+
const shifted = shiftFrame(frame, amount, amount);
|
|
131
|
+
return createFrame(lines, shifted.hitboxes, frame.cursor ? { x: frame.cursor.x + amount, y: frame.cursor.y + amount } : null, shifted.spans);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function fitFrame(frame: TerminalFrame, width?: number, height?: number) {
|
|
135
|
+
const nextWidth = Math.max(getFrameWidth(frame), Number(width || 0));
|
|
136
|
+
const nextHeight = Math.max(getFrameHeight(frame), Number(height || 0));
|
|
137
|
+
if (nextWidth === getFrameWidth(frame) && nextHeight === getFrameHeight(frame)) {
|
|
138
|
+
return frame;
|
|
139
|
+
}
|
|
140
|
+
return createFrame(normalizeLines(frame, nextWidth, nextHeight), frame.hitboxes, frame.cursor, frame.spans);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function constraintSize(value: number | undefined, fallback: number) {
|
|
144
|
+
if (typeof value === "undefined") {
|
|
145
|
+
return fallback;
|
|
146
|
+
}
|
|
147
|
+
const size = Number(value);
|
|
148
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
149
|
+
throw new RangeError("Frame constraints must be greater than zero");
|
|
150
|
+
}
|
|
151
|
+
return size;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function overlaps(start: number, end: number, min: number, max: number) {
|
|
155
|
+
return end >= min && start <= max;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function clamp(value: number, min: number, max: number) {
|
|
159
|
+
return Math.min(max, Math.max(min, value));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function constrainFrame(frame: TerminalFrame, options: { width?: number; height?: number } = {}): TerminalFrame {
|
|
163
|
+
const width = constraintSize(options.width, getFrameWidth(frame));
|
|
164
|
+
const height = constraintSize(options.height, getFrameHeight(frame));
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
|
|
167
|
+
for (let row = 0; row < height; row += 1) {
|
|
168
|
+
lines.push((frame.lines[row] || "").slice(0, width).padEnd(width, " "));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const hitboxes = frame.hitboxes
|
|
172
|
+
.filter((box) => width > 0 && height > 0 && overlaps(box.x1, box.x2, 1, width) && overlaps(box.y1, box.y2, 1, height))
|
|
173
|
+
.map((box) => ({
|
|
174
|
+
...box,
|
|
175
|
+
x1: clamp(box.x1, 1, width),
|
|
176
|
+
x2: clamp(box.x2, 1, width),
|
|
177
|
+
y1: clamp(box.y1, 1, height),
|
|
178
|
+
y2: clamp(box.y2, 1, height),
|
|
179
|
+
...(typeof box.textStartX === "number" ? { textStartX: clamp(box.textStartX, 1, width) } : {})
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
const cursor = frame.cursor && frame.cursor.x >= 1 && frame.cursor.x <= width && frame.cursor.y >= 1 && frame.cursor.y <= height
|
|
183
|
+
? { x: frame.cursor.x, y: frame.cursor.y }
|
|
184
|
+
: null;
|
|
185
|
+
|
|
186
|
+
const spanRightEdge = width + 1;
|
|
187
|
+
const spans = frame.spans.flatMap((span) => {
|
|
188
|
+
if (width <= 0 || span.y < 1 || span.y > height || span.x2 <= 1 || span.x1 >= spanRightEdge) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const clippedX1 = clamp(span.x1, 1, spanRightEdge);
|
|
193
|
+
const clippedX2 = clamp(span.x2, 1, spanRightEdge);
|
|
194
|
+
if (clippedX1 >= clippedX2) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return [{ ...span, x1: clippedX1, x2: clippedX2 }];
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return createFrame(lines, hitboxes, cursor, spans);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function overlayMetric(value: number, name: string) {
|
|
205
|
+
const metric = Number(value);
|
|
206
|
+
if (!Number.isFinite(metric) || !Number.isInteger(metric) || metric <= 0) {
|
|
207
|
+
throw new RangeError(`${name} must be a positive finite integer`);
|
|
208
|
+
}
|
|
209
|
+
return metric;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function nextPointerLayer(hitboxes: TerminalHitbox[]) {
|
|
213
|
+
let layer = 0;
|
|
214
|
+
for (const box of hitboxes) {
|
|
215
|
+
layer = Math.max(layer, box.pointerLayer ?? 0);
|
|
216
|
+
}
|
|
217
|
+
return layer + 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, options: { x: number; y: number; width: number; height: number }): TerminalFrame {
|
|
221
|
+
const x = overlayMetric(options.x, "Overlay x");
|
|
222
|
+
const y = overlayMetric(options.y, "Overlay y");
|
|
223
|
+
const width = overlayMetric(options.width, "Overlay width");
|
|
224
|
+
const height = overlayMetric(options.height, "Overlay height");
|
|
225
|
+
const constrainedOverlay = constrainFrame(overlay, { width, height });
|
|
226
|
+
const lines = base.lines.slice();
|
|
227
|
+
|
|
228
|
+
for (let row = 0; row < constrainedOverlay.lines.length; row += 1) {
|
|
229
|
+
const baseRow = y + row - 1;
|
|
230
|
+
if (baseRow < 0 || baseRow >= lines.length) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const baseLine = lines[baseRow];
|
|
235
|
+
const start = x - 1;
|
|
236
|
+
if (start >= baseLine.length) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const visibleWidth = Math.min(constrainedOverlay.lines[row].length, baseLine.length - start);
|
|
241
|
+
if (visibleWidth <= 0) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
lines[baseRow] = `${baseLine.slice(0, start)}${constrainedOverlay.lines[row].slice(0, visibleWidth)}${baseLine.slice(start + visibleWidth)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const shiftedOverlay = shiftFrame(constrainedOverlay, x - 1, y - 1);
|
|
249
|
+
const visibleOverlay = constrainFrame(shiftedOverlay, { width: Math.max(1, getFrameWidth(base)), height: Math.max(1, getFrameHeight(base)) });
|
|
250
|
+
const overlayLayer = nextPointerLayer(base.hitboxes);
|
|
251
|
+
const overlayHitboxes = visibleOverlay.hitboxes.map((box) => ({
|
|
252
|
+
...box,
|
|
253
|
+
pointerLayer: overlayLayer + (box.pointerLayer ?? 0)
|
|
254
|
+
}));
|
|
255
|
+
const cursor = visibleOverlay.cursor || base.cursor;
|
|
256
|
+
|
|
257
|
+
return createFrame(lines, [...overlayHitboxes, ...base.hitboxes], cursor, [...base.spans, ...visibleOverlay.spans]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function cropFrame(frame: TerminalFrame, offset: number, height: number) {
|
|
261
|
+
const start = Math.max(0, Number(offset || 0));
|
|
262
|
+
const size = Math.max(0, Number(height || 0));
|
|
263
|
+
const lines = frame.lines.slice(start, start + size);
|
|
264
|
+
const hitboxes = frame.hitboxes
|
|
265
|
+
.filter((box) => box.y2 > start && box.y1 <= start + size)
|
|
266
|
+
.map((box) => ({
|
|
267
|
+
...box,
|
|
268
|
+
y1: Math.max(1, box.y1 - start),
|
|
269
|
+
y2: Math.max(1, box.y2 - start)
|
|
270
|
+
}));
|
|
271
|
+
const spans = frame.spans
|
|
272
|
+
.filter((span) => span.y > start && span.y <= start + size)
|
|
273
|
+
.map((span) => ({
|
|
274
|
+
...span,
|
|
275
|
+
y: span.y - start
|
|
276
|
+
}));
|
|
277
|
+
const cursor = frame.cursor && frame.cursor.y > start && frame.cursor.y <= start + size
|
|
278
|
+
? { x: frame.cursor.x, y: frame.cursor.y - start }
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
return createFrame(lines.length ? lines : [""], hitboxes, cursor, spans);
|
|
282
|
+
}
|