@teammates/consolonia 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 +48 -0
- package/dist/__tests__/ansi.test.d.ts +1 -0
- package/dist/__tests__/ansi.test.js +520 -0
- package/dist/__tests__/chat-view.test.d.ts +4 -0
- package/dist/__tests__/chat-view.test.js +480 -0
- package/dist/__tests__/drawing.test.d.ts +4 -0
- package/dist/__tests__/drawing.test.js +426 -0
- package/dist/__tests__/input.test.d.ts +5 -0
- package/dist/__tests__/input.test.js +911 -0
- package/dist/__tests__/layout.test.d.ts +4 -0
- package/dist/__tests__/layout.test.js +689 -0
- package/dist/__tests__/pixel.test.d.ts +1 -0
- package/dist/__tests__/pixel.test.js +674 -0
- package/dist/__tests__/render.test.d.ts +1 -0
- package/dist/__tests__/render.test.js +400 -0
- package/dist/__tests__/styled.test.d.ts +4 -0
- package/dist/__tests__/styled.test.js +149 -0
- package/dist/__tests__/widgets.test.d.ts +5 -0
- package/dist/__tests__/widgets.test.js +924 -0
- package/dist/ansi/esc.d.ts +61 -0
- package/dist/ansi/esc.js +85 -0
- package/dist/ansi/output.d.ts +66 -0
- package/dist/ansi/output.js +192 -0
- package/dist/ansi/strip.d.ts +16 -0
- package/dist/ansi/strip.js +74 -0
- package/dist/app.d.ts +68 -0
- package/dist/app.js +297 -0
- package/dist/drawing/clip.d.ts +23 -0
- package/dist/drawing/clip.js +67 -0
- package/dist/drawing/context.d.ts +77 -0
- package/dist/drawing/context.js +275 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +63 -0
- package/dist/input/escape-matcher.d.ts +27 -0
- package/dist/input/escape-matcher.js +253 -0
- package/dist/input/events.d.ts +49 -0
- package/dist/input/events.js +17 -0
- package/dist/input/index.d.ts +15 -0
- package/dist/input/index.js +14 -0
- package/dist/input/matcher.d.ts +23 -0
- package/dist/input/matcher.js +14 -0
- package/dist/input/mouse-matcher.d.ts +27 -0
- package/dist/input/mouse-matcher.js +142 -0
- package/dist/input/paste-matcher.d.ts +23 -0
- package/dist/input/paste-matcher.js +104 -0
- package/dist/input/processor.d.ts +51 -0
- package/dist/input/processor.js +145 -0
- package/dist/input/raw-mode.d.ts +13 -0
- package/dist/input/raw-mode.js +24 -0
- package/dist/input/text-matcher.d.ts +14 -0
- package/dist/input/text-matcher.js +32 -0
- package/dist/layout/box.d.ts +33 -0
- package/dist/layout/box.js +92 -0
- package/dist/layout/column.d.ts +21 -0
- package/dist/layout/column.js +90 -0
- package/dist/layout/control.d.ts +73 -0
- package/dist/layout/control.js +215 -0
- package/dist/layout/row.d.ts +21 -0
- package/dist/layout/row.js +95 -0
- package/dist/layout/stack.d.ts +18 -0
- package/dist/layout/stack.js +64 -0
- package/dist/layout/types.d.ts +27 -0
- package/dist/layout/types.js +4 -0
- package/dist/pixel/background.d.ts +16 -0
- package/dist/pixel/background.js +16 -0
- package/dist/pixel/box-pattern.d.ts +38 -0
- package/dist/pixel/box-pattern.js +57 -0
- package/dist/pixel/buffer.d.ts +25 -0
- package/dist/pixel/buffer.js +51 -0
- package/dist/pixel/color.d.ts +48 -0
- package/dist/pixel/color.js +92 -0
- package/dist/pixel/foreground.d.ts +31 -0
- package/dist/pixel/foreground.js +64 -0
- package/dist/pixel/pixel.d.ts +21 -0
- package/dist/pixel/pixel.js +38 -0
- package/dist/pixel/symbol.d.ts +38 -0
- package/dist/pixel/symbol.js +192 -0
- package/dist/render/regions.d.ts +54 -0
- package/dist/render/regions.js +102 -0
- package/dist/render/render-target.d.ts +42 -0
- package/dist/render/render-target.js +118 -0
- package/dist/styled.d.ts +113 -0
- package/dist/styled.js +176 -0
- package/dist/widgets/border.d.ts +34 -0
- package/dist/widgets/border.js +121 -0
- package/dist/widgets/chat-view.d.ts +239 -0
- package/dist/widgets/chat-view.js +993 -0
- package/dist/widgets/interview.d.ts +87 -0
- package/dist/widgets/interview.js +187 -0
- package/dist/widgets/markdown.d.ts +87 -0
- package/dist/widgets/markdown.js +611 -0
- package/dist/widgets/panel.d.ts +19 -0
- package/dist/widgets/panel.js +35 -0
- package/dist/widgets/scroll-view.d.ts +43 -0
- package/dist/widgets/scroll-view.js +182 -0
- package/dist/widgets/styled-text.d.ts +38 -0
- package/dist/widgets/styled-text.js +183 -0
- package/dist/widgets/syntax.d.ts +37 -0
- package/dist/widgets/syntax.js +670 -0
- package/dist/widgets/text-input.d.ts +121 -0
- package/dist/widgets/text-input.js +618 -0
- package/dist/widgets/text.d.ts +34 -0
- package/dist/widgets/text.js +168 -0
- package/package.json +45 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches printable text characters — anything with char code >= 32
|
|
3
|
+
* that is not DEL (0x7f) and not ESC (0x1b).
|
|
4
|
+
*
|
|
5
|
+
* Emits a KeyEvent for each printable character.
|
|
6
|
+
*/
|
|
7
|
+
import { type InputEvent } from "./events.js";
|
|
8
|
+
import { type IMatcher, MatchResult } from "./matcher.js";
|
|
9
|
+
export declare class TextMatcher implements IMatcher {
|
|
10
|
+
private result;
|
|
11
|
+
append(char: string): MatchResult;
|
|
12
|
+
flush(): InputEvent | null;
|
|
13
|
+
reset(): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches printable text characters — anything with char code >= 32
|
|
3
|
+
* that is not DEL (0x7f) and not ESC (0x1b).
|
|
4
|
+
*
|
|
5
|
+
* Emits a KeyEvent for each printable character.
|
|
6
|
+
*/
|
|
7
|
+
import { keyEvent } from "./events.js";
|
|
8
|
+
import { MatchResult } from "./matcher.js";
|
|
9
|
+
export class TextMatcher {
|
|
10
|
+
result = null;
|
|
11
|
+
append(char) {
|
|
12
|
+
const code = char.charCodeAt(0);
|
|
13
|
+
// Printable: code >= 32, not DEL (127), not ESC (27).
|
|
14
|
+
// Also accept high-Unicode characters (surrogate pairs, emoji, etc.)
|
|
15
|
+
if (code >= 32 && code !== 127 && code !== 27) {
|
|
16
|
+
const isUpper = code >= 65 && code <= 90;
|
|
17
|
+
const key = char === " " ? "space" : char;
|
|
18
|
+
const charValue = char;
|
|
19
|
+
this.result = keyEvent(key, charValue, isUpper, false, false);
|
|
20
|
+
return MatchResult.Complete;
|
|
21
|
+
}
|
|
22
|
+
return MatchResult.NoMatch;
|
|
23
|
+
}
|
|
24
|
+
flush() {
|
|
25
|
+
const ev = this.result;
|
|
26
|
+
this.result = null;
|
|
27
|
+
return ev;
|
|
28
|
+
}
|
|
29
|
+
reset() {
|
|
30
|
+
this.result = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box container — a single-child wrapper with padding.
|
|
3
|
+
*
|
|
4
|
+
* The Box adds padding around its child during measure and arrange,
|
|
5
|
+
* but produces no visual output of its own.
|
|
6
|
+
*/
|
|
7
|
+
import type { DrawingContext } from "../drawing/context.js";
|
|
8
|
+
import { Control } from "./control.js";
|
|
9
|
+
import type { Constraint, Rect, Size } from "./types.js";
|
|
10
|
+
export interface BoxOptions {
|
|
11
|
+
child?: Control;
|
|
12
|
+
paddingTop?: number;
|
|
13
|
+
paddingRight?: number;
|
|
14
|
+
paddingBottom?: number;
|
|
15
|
+
paddingLeft?: number;
|
|
16
|
+
/** Shorthand: sets all four sides when the individual values are not given. */
|
|
17
|
+
padding?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class Box extends Control {
|
|
20
|
+
paddingTop: number;
|
|
21
|
+
paddingRight: number;
|
|
22
|
+
paddingBottom: number;
|
|
23
|
+
paddingLeft: number;
|
|
24
|
+
constructor(options?: BoxOptions);
|
|
25
|
+
/** The single child, or null. */
|
|
26
|
+
get child(): Control | null;
|
|
27
|
+
set child(ctrl: Control | null);
|
|
28
|
+
private get hPad();
|
|
29
|
+
private get vPad();
|
|
30
|
+
measure(constraint: Constraint): Size;
|
|
31
|
+
arrange(rect: Rect): void;
|
|
32
|
+
render(ctx: DrawingContext): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box container — a single-child wrapper with padding.
|
|
3
|
+
*
|
|
4
|
+
* The Box adds padding around its child during measure and arrange,
|
|
5
|
+
* but produces no visual output of its own.
|
|
6
|
+
*/
|
|
7
|
+
import { Control, clampSize } from "./control.js";
|
|
8
|
+
export class Box extends Control {
|
|
9
|
+
paddingTop;
|
|
10
|
+
paddingRight;
|
|
11
|
+
paddingBottom;
|
|
12
|
+
paddingLeft;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
super();
|
|
15
|
+
const p = options.padding ?? 0;
|
|
16
|
+
this.paddingTop = options.paddingTop ?? p;
|
|
17
|
+
this.paddingRight = options.paddingRight ?? p;
|
|
18
|
+
this.paddingBottom = options.paddingBottom ?? p;
|
|
19
|
+
this.paddingLeft = options.paddingLeft ?? p;
|
|
20
|
+
if (options.child) {
|
|
21
|
+
this.addChild(options.child);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The single child, or null. */
|
|
25
|
+
get child() {
|
|
26
|
+
return this.children.length > 0 ? this.children[0] : null;
|
|
27
|
+
}
|
|
28
|
+
set child(ctrl) {
|
|
29
|
+
// Remove existing child
|
|
30
|
+
while (this.children.length > 0) {
|
|
31
|
+
this.removeChild(this.children[0]);
|
|
32
|
+
}
|
|
33
|
+
if (ctrl) {
|
|
34
|
+
this.addChild(ctrl);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ── Horizontal / vertical padding totals ──────────────────────────
|
|
38
|
+
get hPad() {
|
|
39
|
+
return this.paddingLeft + this.paddingRight;
|
|
40
|
+
}
|
|
41
|
+
get vPad() {
|
|
42
|
+
return this.paddingTop + this.paddingBottom;
|
|
43
|
+
}
|
|
44
|
+
// ── Layout ────────────────────────────────────────────────────────
|
|
45
|
+
measure(constraint) {
|
|
46
|
+
const innerConstraint = {
|
|
47
|
+
minWidth: Math.max(0, constraint.minWidth - this.hPad),
|
|
48
|
+
minHeight: Math.max(0, constraint.minHeight - this.vPad),
|
|
49
|
+
maxWidth: Math.max(0, constraint.maxWidth - this.hPad),
|
|
50
|
+
maxHeight: Math.max(0, constraint.maxHeight - this.vPad),
|
|
51
|
+
};
|
|
52
|
+
let childSize = { width: 0, height: 0 };
|
|
53
|
+
if (this.child?.visible) {
|
|
54
|
+
childSize = this.child.measure(innerConstraint);
|
|
55
|
+
}
|
|
56
|
+
const size = clampSize({
|
|
57
|
+
width: childSize.width + this.hPad,
|
|
58
|
+
height: childSize.height + this.vPad,
|
|
59
|
+
}, constraint);
|
|
60
|
+
this.desiredSize = size;
|
|
61
|
+
return size;
|
|
62
|
+
}
|
|
63
|
+
arrange(rect) {
|
|
64
|
+
this.bounds = rect;
|
|
65
|
+
if (this.child?.visible) {
|
|
66
|
+
const innerRect = {
|
|
67
|
+
x: this.paddingLeft,
|
|
68
|
+
y: this.paddingTop,
|
|
69
|
+
width: Math.max(0, rect.width - this.hPad),
|
|
70
|
+
height: Math.max(0, rect.height - this.vPad),
|
|
71
|
+
};
|
|
72
|
+
this.child.arrange(innerRect);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ── Render ────────────────────────────────────────────────────────
|
|
76
|
+
render(ctx) {
|
|
77
|
+
if (this.child?.visible) {
|
|
78
|
+
const child = this.child;
|
|
79
|
+
ctx.pushClip({
|
|
80
|
+
x: child.bounds.x,
|
|
81
|
+
y: child.bounds.y,
|
|
82
|
+
width: child.bounds.width,
|
|
83
|
+
height: child.bounds.height,
|
|
84
|
+
});
|
|
85
|
+
ctx.pushTranslate(child.bounds.x, child.bounds.y);
|
|
86
|
+
child.render(ctx);
|
|
87
|
+
child.dirty = false;
|
|
88
|
+
ctx.popTranslate();
|
|
89
|
+
ctx.popClip();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column — vertical layout container.
|
|
3
|
+
*
|
|
4
|
+
* Children are laid out top to bottom. If total desired heights exceed
|
|
5
|
+
* the available height, children are proportionally scaled down.
|
|
6
|
+
*/
|
|
7
|
+
import type { DrawingContext } from "../drawing/context.js";
|
|
8
|
+
import { Control } from "./control.js";
|
|
9
|
+
import type { Constraint, Rect, Size } from "./types.js";
|
|
10
|
+
export interface ColumnOptions {
|
|
11
|
+
children?: Control[];
|
|
12
|
+
/** Spacing in rows between adjacent children (default 0). */
|
|
13
|
+
gap?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class Column extends Control {
|
|
16
|
+
gap: number;
|
|
17
|
+
constructor(options?: ColumnOptions);
|
|
18
|
+
measure(constraint: Constraint): Size;
|
|
19
|
+
arrange(rect: Rect): void;
|
|
20
|
+
render(ctx: DrawingContext): void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column — vertical layout container.
|
|
3
|
+
*
|
|
4
|
+
* Children are laid out top to bottom. If total desired heights exceed
|
|
5
|
+
* the available height, children are proportionally scaled down.
|
|
6
|
+
*/
|
|
7
|
+
import { Control, clampSize } from "./control.js";
|
|
8
|
+
export class Column extends Control {
|
|
9
|
+
gap;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.gap = options.gap ?? 0;
|
|
13
|
+
if (options.children) {
|
|
14
|
+
for (const child of options.children) {
|
|
15
|
+
this.addChild(child);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// ── Layout ────────────────────────────────────────────────────────
|
|
20
|
+
measure(constraint) {
|
|
21
|
+
const visible = this.children.filter((c) => c.visible);
|
|
22
|
+
if (visible.length === 0) {
|
|
23
|
+
const size = clampSize({ width: 0, height: 0 }, constraint);
|
|
24
|
+
this.desiredSize = size;
|
|
25
|
+
return size;
|
|
26
|
+
}
|
|
27
|
+
const totalGap = this.gap * (visible.length - 1);
|
|
28
|
+
let maxWidth = 0;
|
|
29
|
+
let totalHeight = 0;
|
|
30
|
+
for (const child of visible) {
|
|
31
|
+
const childConstraint = {
|
|
32
|
+
minWidth: constraint.minWidth,
|
|
33
|
+
minHeight: 0,
|
|
34
|
+
maxWidth: constraint.maxWidth,
|
|
35
|
+
maxHeight: Math.max(0, constraint.maxHeight - totalGap),
|
|
36
|
+
};
|
|
37
|
+
const childSize = child.measure(childConstraint);
|
|
38
|
+
maxWidth = Math.max(maxWidth, childSize.width);
|
|
39
|
+
totalHeight += childSize.height;
|
|
40
|
+
}
|
|
41
|
+
const size = clampSize({ width: maxWidth, height: totalHeight + totalGap }, constraint);
|
|
42
|
+
this.desiredSize = size;
|
|
43
|
+
return size;
|
|
44
|
+
}
|
|
45
|
+
arrange(rect) {
|
|
46
|
+
this.bounds = rect;
|
|
47
|
+
const visible = this.children.filter((c) => c.visible);
|
|
48
|
+
if (visible.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
const totalGap = this.gap * (visible.length - 1);
|
|
51
|
+
const availableHeight = Math.max(0, rect.height - totalGap);
|
|
52
|
+
const totalDesired = visible.reduce((s, c) => s + c.desiredSize.height, 0);
|
|
53
|
+
let y = 0;
|
|
54
|
+
for (let i = 0; i < visible.length; i++) {
|
|
55
|
+
const child = visible[i];
|
|
56
|
+
let childHeight;
|
|
57
|
+
if (totalDesired <= availableHeight || totalDesired === 0) {
|
|
58
|
+
childHeight = child.desiredSize.height;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
childHeight = Math.floor((child.desiredSize.height / totalDesired) * availableHeight);
|
|
62
|
+
}
|
|
63
|
+
child.arrange({
|
|
64
|
+
x: 0,
|
|
65
|
+
y,
|
|
66
|
+
width: rect.width,
|
|
67
|
+
height: childHeight,
|
|
68
|
+
});
|
|
69
|
+
y += childHeight + this.gap;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── Render ────────────────────────────────────────────────────────
|
|
73
|
+
render(ctx) {
|
|
74
|
+
for (const child of this.children) {
|
|
75
|
+
if (!child.visible)
|
|
76
|
+
continue;
|
|
77
|
+
ctx.pushClip({
|
|
78
|
+
x: child.bounds.x,
|
|
79
|
+
y: child.bounds.y,
|
|
80
|
+
width: child.bounds.width,
|
|
81
|
+
height: child.bounds.height,
|
|
82
|
+
});
|
|
83
|
+
ctx.pushTranslate(child.bounds.x, child.bounds.y);
|
|
84
|
+
child.render(ctx);
|
|
85
|
+
child.dirty = false;
|
|
86
|
+
ctx.popTranslate();
|
|
87
|
+
ctx.popClip();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Control class — the root of the widget tree.
|
|
3
|
+
*
|
|
4
|
+
* Every UI element extends Control. It provides:
|
|
5
|
+
* - Parent/child tree management
|
|
6
|
+
* - Measure/arrange layout protocol
|
|
7
|
+
* - Abstract render() for drawing
|
|
8
|
+
* - Input event routing with bubbling
|
|
9
|
+
* - Focus management (tab-cycle through focusable descendants)
|
|
10
|
+
* - Dirty tracking with upward propagation
|
|
11
|
+
* - Lightweight inline event emitter (no Node.js dependency)
|
|
12
|
+
*/
|
|
13
|
+
import type { DrawingContext } from "../drawing/context.js";
|
|
14
|
+
import type { InputEvent } from "../input/events.js";
|
|
15
|
+
import type { Constraint, Rect, Size } from "./types.js";
|
|
16
|
+
/** Clamp a measured size to the constraint bounds. */
|
|
17
|
+
declare function clampSize(size: Size, c: Constraint): Size;
|
|
18
|
+
/** Collect all focusable controls in depth-first order. */
|
|
19
|
+
declare function collectFocusable(root: Control): Control[];
|
|
20
|
+
export declare abstract class Control {
|
|
21
|
+
parent: Control | null;
|
|
22
|
+
children: Control[];
|
|
23
|
+
desiredSize: Size;
|
|
24
|
+
bounds: Rect;
|
|
25
|
+
focusable: boolean;
|
|
26
|
+
focused: boolean;
|
|
27
|
+
visible: boolean;
|
|
28
|
+
dirty: boolean;
|
|
29
|
+
private _listeners;
|
|
30
|
+
/**
|
|
31
|
+
* Measure: determine desired size given constraints.
|
|
32
|
+
* Override in subclasses. Default returns (0,0) clamped to constraints.
|
|
33
|
+
*/
|
|
34
|
+
measure(constraint: Constraint): Size;
|
|
35
|
+
/**
|
|
36
|
+
* Arrange: position this control within the given rect.
|
|
37
|
+
* Override in subclasses. Default sets this.bounds = rect.
|
|
38
|
+
*/
|
|
39
|
+
arrange(rect: Rect): void;
|
|
40
|
+
/**
|
|
41
|
+
* Render this control using the drawing context.
|
|
42
|
+
* The context's coordinate system is already translated so (0,0) is
|
|
43
|
+
* this control's top-left corner. Override in subclasses.
|
|
44
|
+
*/
|
|
45
|
+
abstract render(ctx: DrawingContext): void;
|
|
46
|
+
/**
|
|
47
|
+
* Handle an input event. Return true if consumed.
|
|
48
|
+
*
|
|
49
|
+
* Default behaviour:
|
|
50
|
+
* 1. If this is a Tab key event, cycle focus and consume.
|
|
51
|
+
* 2. Route to the focused child (depth-first) — if it consumes, return true.
|
|
52
|
+
* 3. Otherwise return false so the event bubbles up.
|
|
53
|
+
*/
|
|
54
|
+
handleInput(event: InputEvent): boolean;
|
|
55
|
+
/** Check whether any descendant of the given control has focus. */
|
|
56
|
+
private _hasFocusedDescendant;
|
|
57
|
+
/** Called when this control gains focus. */
|
|
58
|
+
onFocus(): void;
|
|
59
|
+
/** Called when this control loses focus. */
|
|
60
|
+
onBlur(): void;
|
|
61
|
+
/** Move focus to the next focusable control in depth-first order. */
|
|
62
|
+
focusNext(): void;
|
|
63
|
+
/** Move focus to the previous focusable control in depth-first order. */
|
|
64
|
+
focusPrev(): void;
|
|
65
|
+
addChild(child: Control): void;
|
|
66
|
+
removeChild(child: Control): void;
|
|
67
|
+
/** Mark this control as needing re-render. Propagates up to the root. */
|
|
68
|
+
invalidate(): void;
|
|
69
|
+
on(event: string, handler: (...args: any[]) => void): void;
|
|
70
|
+
off(event: string, handler: (...args: any[]) => void): void;
|
|
71
|
+
emit(event: string, ...args: any[]): void;
|
|
72
|
+
}
|
|
73
|
+
export { clampSize, collectFocusable };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Control class — the root of the widget tree.
|
|
3
|
+
*
|
|
4
|
+
* Every UI element extends Control. It provides:
|
|
5
|
+
* - Parent/child tree management
|
|
6
|
+
* - Measure/arrange layout protocol
|
|
7
|
+
* - Abstract render() for drawing
|
|
8
|
+
* - Input event routing with bubbling
|
|
9
|
+
* - Focus management (tab-cycle through focusable descendants)
|
|
10
|
+
* - Dirty tracking with upward propagation
|
|
11
|
+
* - Lightweight inline event emitter (no Node.js dependency)
|
|
12
|
+
*/
|
|
13
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
14
|
+
/** Clamp a measured size to the constraint bounds. */
|
|
15
|
+
function clampSize(size, c) {
|
|
16
|
+
return {
|
|
17
|
+
width: Math.max(c.minWidth, Math.min(size.width, c.maxWidth)),
|
|
18
|
+
height: Math.max(c.minHeight, Math.min(size.height, c.maxHeight)),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Collect all focusable controls in depth-first order. */
|
|
22
|
+
function collectFocusable(root) {
|
|
23
|
+
const result = [];
|
|
24
|
+
const walk = (ctrl) => {
|
|
25
|
+
if (!ctrl.visible)
|
|
26
|
+
return;
|
|
27
|
+
if (ctrl.focusable)
|
|
28
|
+
result.push(ctrl);
|
|
29
|
+
for (const child of ctrl.children) {
|
|
30
|
+
walk(child);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
walk(root);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Walk up to the root of the tree. */
|
|
37
|
+
function root(ctrl) {
|
|
38
|
+
let c = ctrl;
|
|
39
|
+
while (c.parent)
|
|
40
|
+
c = c.parent;
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
// ── Control ─────────────────────────────────────────────────────────
|
|
44
|
+
export class Control {
|
|
45
|
+
// --- Tree ---
|
|
46
|
+
parent = null;
|
|
47
|
+
children = [];
|
|
48
|
+
// --- Layout state ---
|
|
49
|
+
desiredSize = { width: 0, height: 0 };
|
|
50
|
+
bounds = { x: 0, y: 0, width: 0, height: 0 };
|
|
51
|
+
// --- Focus ---
|
|
52
|
+
focusable = false;
|
|
53
|
+
focused = false;
|
|
54
|
+
// --- Visibility ---
|
|
55
|
+
visible = true;
|
|
56
|
+
// --- Dirty tracking ---
|
|
57
|
+
dirty = true;
|
|
58
|
+
// --- Event emitter storage ---
|
|
59
|
+
_listeners = new Map();
|
|
60
|
+
// ── Layout lifecycle ──────────────────────────────────────────────
|
|
61
|
+
/**
|
|
62
|
+
* Measure: determine desired size given constraints.
|
|
63
|
+
* Override in subclasses. Default returns (0,0) clamped to constraints.
|
|
64
|
+
*/
|
|
65
|
+
measure(constraint) {
|
|
66
|
+
const size = clampSize({ width: 0, height: 0 }, constraint);
|
|
67
|
+
this.desiredSize = size;
|
|
68
|
+
return size;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Arrange: position this control within the given rect.
|
|
72
|
+
* Override in subclasses. Default sets this.bounds = rect.
|
|
73
|
+
*/
|
|
74
|
+
arrange(rect) {
|
|
75
|
+
this.bounds = rect;
|
|
76
|
+
}
|
|
77
|
+
// ── Event handling ────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Handle an input event. Return true if consumed.
|
|
80
|
+
*
|
|
81
|
+
* Default behaviour:
|
|
82
|
+
* 1. If this is a Tab key event, cycle focus and consume.
|
|
83
|
+
* 2. Route to the focused child (depth-first) — if it consumes, return true.
|
|
84
|
+
* 3. Otherwise return false so the event bubbles up.
|
|
85
|
+
*/
|
|
86
|
+
handleInput(event) {
|
|
87
|
+
// Tab focus cycling at root or any level
|
|
88
|
+
if (event.type === "key") {
|
|
89
|
+
const ke = event.event;
|
|
90
|
+
if (ke.key === "tab" || ke.key === "Tab") {
|
|
91
|
+
if (ke.shift) {
|
|
92
|
+
this.focusPrev();
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.focusNext();
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Route to focused child
|
|
101
|
+
for (const child of this.children) {
|
|
102
|
+
if (!child.visible)
|
|
103
|
+
continue;
|
|
104
|
+
if (child.focused || this._hasFocusedDescendant(child)) {
|
|
105
|
+
if (child.handleInput(event))
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/** Check whether any descendant of the given control has focus. */
|
|
112
|
+
_hasFocusedDescendant(ctrl) {
|
|
113
|
+
for (const child of ctrl.children) {
|
|
114
|
+
if (child.focused)
|
|
115
|
+
return true;
|
|
116
|
+
if (this._hasFocusedDescendant(child))
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
/** Called when this control gains focus. */
|
|
122
|
+
onFocus() {
|
|
123
|
+
this.focused = true;
|
|
124
|
+
this.invalidate();
|
|
125
|
+
this.emit("focus");
|
|
126
|
+
}
|
|
127
|
+
/** Called when this control loses focus. */
|
|
128
|
+
onBlur() {
|
|
129
|
+
this.focused = false;
|
|
130
|
+
this.invalidate();
|
|
131
|
+
this.emit("blur");
|
|
132
|
+
}
|
|
133
|
+
// ── Focus management ──────────────────────────────────────────────
|
|
134
|
+
/** Move focus to the next focusable control in depth-first order. */
|
|
135
|
+
focusNext() {
|
|
136
|
+
const r = root(this);
|
|
137
|
+
const list = collectFocusable(r);
|
|
138
|
+
if (list.length === 0)
|
|
139
|
+
return;
|
|
140
|
+
const currentIndex = list.findIndex((c) => c.focused);
|
|
141
|
+
// Blur current
|
|
142
|
+
if (currentIndex >= 0)
|
|
143
|
+
list[currentIndex].onBlur();
|
|
144
|
+
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % list.length;
|
|
145
|
+
list[nextIndex].onFocus();
|
|
146
|
+
}
|
|
147
|
+
/** Move focus to the previous focusable control in depth-first order. */
|
|
148
|
+
focusPrev() {
|
|
149
|
+
const r = root(this);
|
|
150
|
+
const list = collectFocusable(r);
|
|
151
|
+
if (list.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
const currentIndex = list.findIndex((c) => c.focused);
|
|
154
|
+
if (currentIndex >= 0)
|
|
155
|
+
list[currentIndex].onBlur();
|
|
156
|
+
const prevIndex = currentIndex <= 0 ? list.length - 1 : currentIndex - 1;
|
|
157
|
+
list[prevIndex].onFocus();
|
|
158
|
+
}
|
|
159
|
+
// ── Children management ───────────────────────────────────────────
|
|
160
|
+
addChild(child) {
|
|
161
|
+
if (child.parent) {
|
|
162
|
+
child.parent.removeChild(child);
|
|
163
|
+
}
|
|
164
|
+
child.parent = this;
|
|
165
|
+
this.children.push(child);
|
|
166
|
+
this.invalidate();
|
|
167
|
+
}
|
|
168
|
+
removeChild(child) {
|
|
169
|
+
const idx = this.children.indexOf(child);
|
|
170
|
+
if (idx >= 0) {
|
|
171
|
+
this.children.splice(idx, 1);
|
|
172
|
+
child.parent = null;
|
|
173
|
+
this.invalidate();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ── Dirty tracking ───────────────────────────────────────────────
|
|
177
|
+
/** Mark this control as needing re-render. Propagates up to the root. */
|
|
178
|
+
invalidate() {
|
|
179
|
+
if (this.dirty)
|
|
180
|
+
return; // already dirty, no need to propagate again
|
|
181
|
+
this.dirty = true;
|
|
182
|
+
if (this.parent) {
|
|
183
|
+
this.parent.invalidate();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ── Inline event emitter ──────────────────────────────────────────
|
|
187
|
+
on(event, handler) {
|
|
188
|
+
let handlers = this._listeners.get(event);
|
|
189
|
+
if (!handlers) {
|
|
190
|
+
handlers = [];
|
|
191
|
+
this._listeners.set(event, handlers);
|
|
192
|
+
}
|
|
193
|
+
handlers.push(handler);
|
|
194
|
+
}
|
|
195
|
+
off(event, handler) {
|
|
196
|
+
const handlers = this._listeners.get(event);
|
|
197
|
+
if (!handlers)
|
|
198
|
+
return;
|
|
199
|
+
const idx = handlers.indexOf(handler);
|
|
200
|
+
if (idx >= 0)
|
|
201
|
+
handlers.splice(idx, 1);
|
|
202
|
+
if (handlers.length === 0)
|
|
203
|
+
this._listeners.delete(event);
|
|
204
|
+
}
|
|
205
|
+
emit(event, ...args) {
|
|
206
|
+
const handlers = this._listeners.get(event);
|
|
207
|
+
if (!handlers)
|
|
208
|
+
return;
|
|
209
|
+
// Iterate over a copy so handlers can safely remove themselves
|
|
210
|
+
for (const h of [...handlers]) {
|
|
211
|
+
h(...args);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export { clampSize, collectFocusable };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row — horizontal layout container.
|
|
3
|
+
*
|
|
4
|
+
* Children are laid out left to right. If total desired widths exceed
|
|
5
|
+
* the available width, children are proportionally scaled down.
|
|
6
|
+
*/
|
|
7
|
+
import type { DrawingContext } from "../drawing/context.js";
|
|
8
|
+
import { Control } from "./control.js";
|
|
9
|
+
import type { Constraint, Rect, Size } from "./types.js";
|
|
10
|
+
export interface RowOptions {
|
|
11
|
+
children?: Control[];
|
|
12
|
+
/** Spacing in columns between adjacent children (default 0). */
|
|
13
|
+
gap?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class Row extends Control {
|
|
16
|
+
gap: number;
|
|
17
|
+
constructor(options?: RowOptions);
|
|
18
|
+
measure(constraint: Constraint): Size;
|
|
19
|
+
arrange(rect: Rect): void;
|
|
20
|
+
render(ctx: DrawingContext): void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row — horizontal layout container.
|
|
3
|
+
*
|
|
4
|
+
* Children are laid out left to right. If total desired widths exceed
|
|
5
|
+
* the available width, children are proportionally scaled down.
|
|
6
|
+
*/
|
|
7
|
+
import { Control, clampSize } from "./control.js";
|
|
8
|
+
export class Row extends Control {
|
|
9
|
+
gap;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.gap = options.gap ?? 0;
|
|
13
|
+
if (options.children) {
|
|
14
|
+
for (const child of options.children) {
|
|
15
|
+
this.addChild(child);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// ── Layout ────────────────────────────────────────────────────────
|
|
20
|
+
measure(constraint) {
|
|
21
|
+
const visible = this.children.filter((c) => c.visible);
|
|
22
|
+
if (visible.length === 0) {
|
|
23
|
+
const size = clampSize({ width: 0, height: 0 }, constraint);
|
|
24
|
+
this.desiredSize = size;
|
|
25
|
+
return size;
|
|
26
|
+
}
|
|
27
|
+
const totalGap = this.gap * (visible.length - 1);
|
|
28
|
+
let totalWidth = 0;
|
|
29
|
+
let maxHeight = 0;
|
|
30
|
+
for (const child of visible) {
|
|
31
|
+
// Each child is measured with the full available height but
|
|
32
|
+
// unconstrained width (up to remaining space).
|
|
33
|
+
const childConstraint = {
|
|
34
|
+
minWidth: 0,
|
|
35
|
+
minHeight: constraint.minHeight,
|
|
36
|
+
maxWidth: Math.max(0, constraint.maxWidth - totalGap),
|
|
37
|
+
maxHeight: constraint.maxHeight,
|
|
38
|
+
};
|
|
39
|
+
const childSize = child.measure(childConstraint);
|
|
40
|
+
totalWidth += childSize.width;
|
|
41
|
+
maxHeight = Math.max(maxHeight, childSize.height);
|
|
42
|
+
}
|
|
43
|
+
const size = clampSize({ width: totalWidth + totalGap, height: maxHeight }, constraint);
|
|
44
|
+
this.desiredSize = size;
|
|
45
|
+
return size;
|
|
46
|
+
}
|
|
47
|
+
arrange(rect) {
|
|
48
|
+
this.bounds = rect;
|
|
49
|
+
const visible = this.children.filter((c) => c.visible);
|
|
50
|
+
if (visible.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
const totalGap = this.gap * (visible.length - 1);
|
|
53
|
+
const availableWidth = Math.max(0, rect.width - totalGap);
|
|
54
|
+
// Sum of children's desired widths
|
|
55
|
+
const totalDesired = visible.reduce((s, c) => s + c.desiredSize.width, 0);
|
|
56
|
+
let x = 0;
|
|
57
|
+
for (let i = 0; i < visible.length; i++) {
|
|
58
|
+
const child = visible[i];
|
|
59
|
+
let childWidth;
|
|
60
|
+
if (totalDesired <= availableWidth || totalDesired === 0) {
|
|
61
|
+
// Enough space — give each child its desired width
|
|
62
|
+
childWidth = child.desiredSize.width;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Proportionally scale down
|
|
66
|
+
childWidth = Math.floor((child.desiredSize.width / totalDesired) * availableWidth);
|
|
67
|
+
}
|
|
68
|
+
child.arrange({
|
|
69
|
+
x,
|
|
70
|
+
y: 0,
|
|
71
|
+
width: childWidth,
|
|
72
|
+
height: rect.height,
|
|
73
|
+
});
|
|
74
|
+
x += childWidth + this.gap;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ── Render ────────────────────────────────────────────────────────
|
|
78
|
+
render(ctx) {
|
|
79
|
+
for (const child of this.children) {
|
|
80
|
+
if (!child.visible)
|
|
81
|
+
continue;
|
|
82
|
+
ctx.pushClip({
|
|
83
|
+
x: child.bounds.x,
|
|
84
|
+
y: child.bounds.y,
|
|
85
|
+
width: child.bounds.width,
|
|
86
|
+
height: child.bounds.height,
|
|
87
|
+
});
|
|
88
|
+
ctx.pushTranslate(child.bounds.x, child.bounds.y);
|
|
89
|
+
child.render(ctx);
|
|
90
|
+
child.dirty = false;
|
|
91
|
+
ctx.popTranslate();
|
|
92
|
+
ctx.popClip();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|