mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-05 03:37:50 +08:00
add color style and polish input field
This commit is contained in:
@@ -17,6 +17,16 @@ export interface LayoutProps {
|
||||
alignItems?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* Style properties for colors and appearance
|
||||
*/
|
||||
export interface StyleProps {
|
||||
/** Text color */
|
||||
textColor?: number;
|
||||
/** Background color */
|
||||
backgroundColor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed layout result after flexbox calculation
|
||||
*/
|
||||
@@ -74,6 +84,9 @@ export class UIObject {
|
||||
/** Layout properties parsed from class string */
|
||||
layoutProps: LayoutProps;
|
||||
|
||||
/** Style properties parsed from class string */
|
||||
styleProps: StyleProps;
|
||||
|
||||
/** Whether this component is currently mounted */
|
||||
mounted: boolean;
|
||||
|
||||
@@ -85,6 +98,9 @@ export class UIObject {
|
||||
|
||||
/** Event handlers */
|
||||
handlers: Record<string, ((...args: unknown[]) => void) | undefined>;
|
||||
|
||||
/** For input text components - cursor position */
|
||||
cursorPos?: number;
|
||||
|
||||
constructor(
|
||||
type: UIObjectType,
|
||||
@@ -95,21 +111,56 @@ export class UIObject {
|
||||
this.props = props;
|
||||
this.children = children;
|
||||
this.layoutProps = {};
|
||||
this.styleProps = {};
|
||||
this.mounted = false;
|
||||
this.cleanupFns = [];
|
||||
this.handlers = {};
|
||||
|
||||
// Parse layout from class prop
|
||||
this.parseLayout();
|
||||
// Parse layout and styles from class prop
|
||||
this.parseClassNames();
|
||||
|
||||
// Extract event handlers
|
||||
this.extractHandlers();
|
||||
|
||||
// Initialize cursor position for text inputs
|
||||
if (type === "input" && props.type !== "checkbox") {
|
||||
this.cursorPos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSS-like class string into layout properties
|
||||
* Map color name to ComputerCraft colors API value
|
||||
*
|
||||
* @param colorName - The color name from class (e.g., "white", "red")
|
||||
* @returns The color value from colors API, or undefined if invalid
|
||||
*/
|
||||
private parseLayout(): void {
|
||||
private parseColor(colorName: string): number | undefined {
|
||||
const colorMap: Record<string, number> = {
|
||||
"white": colors.white,
|
||||
"orange": colors.orange,
|
||||
"magenta": colors.magenta,
|
||||
"lightBlue": colors.lightBlue,
|
||||
"yellow": colors.yellow,
|
||||
"lime": colors.lime,
|
||||
"pink": colors.pink,
|
||||
"gray": colors.gray,
|
||||
"lightGray": colors.lightGray,
|
||||
"cyan": colors.cyan,
|
||||
"purple": colors.purple,
|
||||
"blue": colors.blue,
|
||||
"brown": colors.brown,
|
||||
"green": colors.green,
|
||||
"red": colors.red,
|
||||
"black": colors.black,
|
||||
};
|
||||
|
||||
return colorMap[colorName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSS-like class string into layout and style properties
|
||||
*/
|
||||
private parseClassNames(): void {
|
||||
const className = this.props.class as string | undefined;
|
||||
if (className === undefined) return;
|
||||
|
||||
@@ -142,6 +193,24 @@ export class UIObject {
|
||||
} else if (cls === "items-end") {
|
||||
this.layoutProps.alignItems = "end";
|
||||
}
|
||||
|
||||
// Text color (text-<color>)
|
||||
else if (cls.startsWith("text-")) {
|
||||
const colorName = cls.substring(5); // Remove "text-" prefix
|
||||
const color = this.parseColor(colorName);
|
||||
if (color !== undefined) {
|
||||
this.styleProps.textColor = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Background color (bg-<color>)
|
||||
else if (cls.startsWith("bg-")) {
|
||||
const colorName = cls.substring(3); // Remove "bg-" prefix
|
||||
const color = this.parseColor(colorName);
|
||||
if (color !== undefined) {
|
||||
this.styleProps.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
|
||||
@@ -19,6 +19,9 @@ export class Application {
|
||||
private termWidth: number;
|
||||
private termHeight: number;
|
||||
private logger: CCLog;
|
||||
private cursorBlinkState = false;
|
||||
private lastBlinkTime = 0;
|
||||
private readonly BLINK_INTERVAL = 0.5; // seconds
|
||||
|
||||
constructor() {
|
||||
const [width, height] = term.getSize();
|
||||
@@ -73,6 +76,7 @@ export class Application {
|
||||
parallel.waitForAll(
|
||||
() => this.renderLoop(),
|
||||
() => this.eventLoop(),
|
||||
() => this.timerLoop(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,10 +125,33 @@ export class Application {
|
||||
|
||||
// Render the tree
|
||||
this.logger.debug("renderFrame: Rendering tree.");
|
||||
renderTree(this.root, this.focusedNode);
|
||||
renderTree(this.root, this.focusedNode, this.cursorBlinkState);
|
||||
this.logger.debug("renderFrame: Finished rendering tree.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer loop - handles cursor blinking
|
||||
*/
|
||||
private timerLoop(): void {
|
||||
while (this.running) {
|
||||
const currentTime = os.clock();
|
||||
if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) {
|
||||
this.lastBlinkTime = currentTime;
|
||||
this.cursorBlinkState = !this.cursorBlinkState;
|
||||
|
||||
// Only trigger render if we have a focused text input
|
||||
if (
|
||||
this.focusedNode !== undefined &&
|
||||
this.focusedNode.type === "input" &&
|
||||
this.focusedNode.props.type !== "checkbox"
|
||||
) {
|
||||
this.needsRender = true;
|
||||
}
|
||||
}
|
||||
os.sleep(0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event loop - handles user input
|
||||
*/
|
||||
@@ -186,6 +213,61 @@ export class Application {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
|
||||
// Handle text input key events
|
||||
const type = this.focusedNode.props.type as string | undefined;
|
||||
if (type !== "checkbox") {
|
||||
this.handleTextInputKey(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events for text input
|
||||
*/
|
||||
private handleTextInputKey(key: number): void {
|
||||
if (this.focusedNode === undefined) return;
|
||||
|
||||
const valueProp = this.focusedNode.props.value;
|
||||
const onInputProp = this.focusedNode.props.onInput;
|
||||
|
||||
if (
|
||||
typeof valueProp !== "function" ||
|
||||
typeof onInputProp !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = (valueProp as () => string)();
|
||||
const cursorPos = this.focusedNode.cursorPos ?? 0;
|
||||
|
||||
if (key === keys.left) {
|
||||
// Move cursor left
|
||||
this.focusedNode.cursorPos = math.max(0, cursorPos - 1);
|
||||
this.needsRender = true;
|
||||
} else if (key === keys.right) {
|
||||
// Move cursor right
|
||||
this.focusedNode.cursorPos = math.min(currentValue.length, cursorPos + 1);
|
||||
this.needsRender = true;
|
||||
} else if (key === keys.backspace) {
|
||||
// Delete character before cursor
|
||||
if (cursorPos > 0) {
|
||||
const newValue =
|
||||
currentValue.substring(0, cursorPos - 1) +
|
||||
currentValue.substring(cursorPos);
|
||||
(onInputProp as (v: string) => void)(newValue);
|
||||
this.focusedNode.cursorPos = cursorPos - 1;
|
||||
this.needsRender = true;
|
||||
}
|
||||
} else if (key === keys.delete) {
|
||||
// Delete character after cursor
|
||||
if (cursorPos < currentValue.length) {
|
||||
const newValue =
|
||||
currentValue.substring(0, cursorPos) +
|
||||
currentValue.substring(cursorPos + 1);
|
||||
(onInputProp as (v: string) => void)(newValue);
|
||||
this.needsRender = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +278,7 @@ export class Application {
|
||||
if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
|
||||
const type = this.focusedNode.props.type as string | undefined;
|
||||
if (type !== "checkbox") {
|
||||
// Add character to text input
|
||||
// Insert character at cursor position
|
||||
const onInputProp = this.focusedNode.props.onInput;
|
||||
const valueProp = this.focusedNode.props.value;
|
||||
|
||||
@@ -205,7 +287,13 @@ export class Application {
|
||||
typeof valueProp === "function"
|
||||
) {
|
||||
const currentValue = (valueProp as () => string)();
|
||||
(onInputProp as (v: string) => void)(currentValue + char);
|
||||
const cursorPos = this.focusedNode.cursorPos ?? 0;
|
||||
const newValue =
|
||||
currentValue.substring(0, cursorPos) +
|
||||
char +
|
||||
currentValue.substring(cursorPos);
|
||||
(onInputProp as (v: string) => void)(newValue);
|
||||
this.focusedNode.cursorPos = cursorPos + 1;
|
||||
this.needsRender = true;
|
||||
}
|
||||
}
|
||||
@@ -229,6 +317,15 @@ export class Application {
|
||||
// Set focus
|
||||
this.focusedNode = clicked;
|
||||
|
||||
// Initialize cursor position for text inputs on focus
|
||||
if (clicked.type === "input" && clicked.props.type !== "checkbox") {
|
||||
const valueProp = clicked.props.value;
|
||||
if (typeof valueProp === "function") {
|
||||
const currentValue = (valueProp as () => string)();
|
||||
clicked.cursorPos = currentValue.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger click handler
|
||||
if (clicked.type === "button") {
|
||||
const onClick = clicked.handlers.onClick;
|
||||
@@ -256,6 +353,8 @@ export class Application {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.needsRender = true;
|
||||
} else {
|
||||
this.logger.debug("handleMouseClick: No node found at click position.");
|
||||
}
|
||||
|
||||
@@ -15,35 +15,6 @@
|
||||
|
||||
为了直观地展示目标 API,这里将 SolidJS 的 "Simple Todos" 示例与我们期望的 ccTUI 实现进行对比。
|
||||
|
||||
**SolidJS (Web) 版本:**
|
||||
```typescript
|
||||
// ... imports
|
||||
const App = () => {
|
||||
const [newTitle, setTitle] = createSignal("");
|
||||
const [todos, setTodos] = createLocalStore<TodoItem[]>("todo list", []);
|
||||
// ... logic
|
||||
return (
|
||||
<>
|
||||
<h3>Simple Todos Example</h3>
|
||||
<form onSubmit={addTodo}>
|
||||
<input /* ...props */ />
|
||||
<button>+</button>
|
||||
</form>
|
||||
<For each={todos}>
|
||||
{(todo, i) => (
|
||||
<div>
|
||||
<input type="checkbox" /* ...props */ />
|
||||
<input type="text" /* ...props */ />
|
||||
<button /* ...props */>x</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
);
|
||||
};
|
||||
render(App, document.getElementById("app")!);
|
||||
```
|
||||
|
||||
**ccTUI (ComputerCraft) 目标版本:**
|
||||
```typescript
|
||||
// ... imports
|
||||
@@ -96,27 +67,31 @@ render(App);
|
||||
|
||||
## 1. 基础组件 API
|
||||
|
||||
组件是返回 `UIObject` 的函数。第一个参数是 `props` 对象,后续参数是子组件。
|
||||
组件是返回 `UIObject` 的函数。所有组件的 `props` 对象都继承自一个基础接口,获得了通用能力。
|
||||
|
||||
- **`MouseClickEvent`**: `{ button: number, x: number, y: number }`
|
||||
- 描述一次鼠标点击事件的对象。
|
||||
|
||||
- **`BaseProps`**:
|
||||
- `{ class?: string, onMouseClick?: (event: MouseClickEvent) => void }`
|
||||
- 所有组件 `props` 都包含这两个可选属性,用于样式和通用的点击事件处理。
|
||||
|
||||
### 容器与文本
|
||||
- **`div(props: DivProps, ...children: UIObject[]): UIObject`**
|
||||
- 通用容器组件,用于包裹其他组件并应用布局样式。
|
||||
- `DivProps`: `{ class?: string }` - `class` 属性用于指定布局,详见“布局系统”。
|
||||
- **`div(props: BaseProps, ...children: UIObject[]): UIObject`**
|
||||
- 通用容器组件,用于包裹其他组件并应用布局与样式。
|
||||
|
||||
- **`label(props: LabelProps, text: string | Signal<string>): UIObject`**
|
||||
- **`label(props: BaseProps, text: string | Signal<string>): UIObject`**
|
||||
- 静态或动态文本标签。
|
||||
- `LabelProps`: `{ class?: string }`
|
||||
|
||||
- **`h1`, `h2`, `h3`(text): UIObject**
|
||||
- 预设样式的标题标签,本质是 `label` 的封装。
|
||||
|
||||
### 交互组件
|
||||
- **`button(props: ButtonProps, text: string): UIObject`**
|
||||
- **`button(props: { onClick?: () => void } & BaseProps, text: string): UIObject`**
|
||||
- 可点击的按钮。
|
||||
- `ButtonProps`: `{ onClick?: () => void, class?: string }`
|
||||
- 按钮会在被点击时调用 `onClick` 回调。
|
||||
- `onClick` 是一个为方便使用的回调,在鼠标左键点击时触发。
|
||||
|
||||
- **`input(props: InputProps): UIObject`**
|
||||
- **`input(props: InputProps & BaseProps): UIObject`**
|
||||
- 文本或复选框输入。
|
||||
- `InputProps`:
|
||||
- `type?: "text" | "checkbox"` (默认为 "text")
|
||||
@@ -125,12 +100,25 @@ render(App);
|
||||
- `checked?: Signal<boolean>`: (用于 checkbox) 选中状态的 Signal。
|
||||
- `onChange?: (checked: boolean) => void`: (用于 checkbox) 状态变化时的回调。
|
||||
- `placeholder?: string`
|
||||
- `class?: string`
|
||||
|
||||
- **`form(props: FormProps, ...children: UIObject[]): UIObject`**
|
||||
- **运行时行为 (Text Type)**:
|
||||
- **焦点获取 (Focus)**: 当用户点击组件时,它将获得焦点。
|
||||
- `placeholder` 文本会被清除。
|
||||
- 在当前的输入位置会出现一个闪烁的光标(例如 `|`)。
|
||||
- **焦点丢失 (Blur)**: 当用户点击其他组件时,它将失去焦点。
|
||||
- 闪烁的光标消失。
|
||||
- 如果此时输入框内容为空,`placeholder` 文本将重新显示。
|
||||
- **光标移动**:
|
||||
- `left_arrow` 键: 光标向左移动一个字符位置。
|
||||
- `right_arrow` 键: 光标向右移动一个字符位置。
|
||||
- **文本编辑**:
|
||||
- `backspace` 键: 删除光标前的一个字符。
|
||||
- `delete` 键: 删除光标后的一个字符。
|
||||
- 其他字符按键: 在光标位置插入对应字符。
|
||||
|
||||
- **`form(props: { onSubmit?: () => void } & BaseProps, ...children: UIObject[]): UIObject`**
|
||||
- 表单容器,主要用于组织输入组件。
|
||||
- `FormProps`: `{ onSubmit?: () => void, class?: string }`
|
||||
- 在表单内按回车键(或点击提交按钮,如果未来实现)会触发 `onSubmit`。
|
||||
- 在表单内按回车键会触发 `onSubmit`。
|
||||
|
||||
---
|
||||
|
||||
@@ -148,41 +136,6 @@ render(App);
|
||||
- `fallback?: UIObject`: 当 `when` 返回 `false` 时要渲染的组件。
|
||||
- `child`: 当 `when` 返回 `true` 时要渲染的组件。
|
||||
|
||||
**SolidJS 示例:**
|
||||
```typescript
|
||||
import { createSignal, Show } from "solid-js";
|
||||
|
||||
function App() {
|
||||
const [loggedIn, setLoggedIn] = createSignal(false);
|
||||
const toggle = () => setLoggedIn(!loggedIn());
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={loggedIn()}
|
||||
fallback={<button onClick={toggle}>Log In</button>}
|
||||
>
|
||||
<button onClick={toggle}>Log Out</button>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**ccTUI 目标版本:**
|
||||
```typescript
|
||||
const App = () => {
|
||||
const [loggedIn, setLoggedIn] = createSignal(false);
|
||||
const toggle = () => setLoggedIn(!loggedIn());
|
||||
|
||||
return Show(
|
||||
{
|
||||
when: loggedIn, // 直接传递 Signal 的 getter
|
||||
fallback: button({ onClick: toggle }, "Log In"),
|
||||
},
|
||||
button({ onClick: toggle }, "Log Out")
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 布局系统 (Flexbox)
|
||||
@@ -206,34 +159,24 @@ render(App);
|
||||
- **`items-center`**: 交叉轴的中点对齐。
|
||||
- **`items-end`**: 交叉轴的终点对齐。
|
||||
|
||||
### 示例
|
||||
```typescript
|
||||
// 一个垂直居中的登录框
|
||||
div({ class: "flex flex-col justify-center items-center" },
|
||||
label("Username"),
|
||||
input({}),
|
||||
label("Password"),
|
||||
input({}),
|
||||
button("Login")
|
||||
)
|
||||
```
|
||||
### Color & Styling
|
||||
|
||||
### 实现要点
|
||||
渲染引擎在计算布局时:
|
||||
1. 解析 `class` 字符串,转换为布局属性(如 `flexDirection`, `justifyContent`)。
|
||||
2. 实现一个简化的 Flexbox 算法,该算法能根据容器尺寸、子元素尺寸和布局属性,为每个子元素计算出正确的 `(x, y)` 坐标和 `(width, height)`。
|
||||
3. 在 `draw` 阶段,将计算出的区域传递给子组件进行绘制。
|
||||
除了布局,`class` 属性也用于控制组件的颜色。这借鉴了 TailwindCSS 的思想。
|
||||
|
||||
- **文本颜色**: `text-<color>`
|
||||
- **背景颜色**: `bg-<color>`
|
||||
|
||||
#### 可用颜色
|
||||
|
||||
颜色名称直接映射自 `tweaked.cc` 的 `colors` API: `white`, `orange`, `magenta`, `lightBlue`, `yellow`, `lime`, `pink`, `gray`, `lightGray`, `cyan`, `purple`, `blue`, `brown`, `green`, `red`, `black`.
|
||||
|
||||
---
|
||||
|
||||
## 4. 响应式系统 (Reactivity System)
|
||||
|
||||
框架的核心是其细粒度的响应式系统。该系统由 Signal 和 Effect 组成,其设计深受 SolidJS 启发。理解这两者是构建动态UI的关键。
|
||||
框架的核心是其细粒度的响应式系统。该系统由 Signal 和 Effect 组成。
|
||||
|
||||
### `createSignal`: 响应式的基本单元
|
||||
|
||||
Signal 是一个包含值的“盒子”,当它的值发生变化时,它可以通知所有正在“监听”它的代码。
|
||||
|
||||
- **`createSignal<T>(initialValue: T): [() => T, (newValue: T) => void]`**
|
||||
- 它接收一个初始值,并返回一个包含两个函数的数组:一个 `getter` 和一个 `setter`。
|
||||
- **Getter** (`() => T`): 一个无参数的函数,调用它会返回 Signal 的当前值。**重要的是,在特定上下文(如组件渲染或 Effect 中)调用 getter 会自动将该上下文注册为监听者。**
|
||||
@@ -256,9 +199,6 @@ Signal 是一个包含值的“盒子”,当它的值发生变化时,它可
|
||||
```
|
||||
|
||||
### `createEffect`: 响应 Signal 的变化
|
||||
|
||||
Effect 用于将响应式系统与外部世界(如日志、计时器、手动API调用)连接起来。它是一个自动跟踪其依赖(即它内部读取的 Signal)并重新执行的函数。
|
||||
|
||||
- **`createEffect(fn: () => void): void`**
|
||||
- 它接收一个函数 `fn` 并立即执行一次。
|
||||
- 框架会监视 `fn` 在执行期间读取了哪些 Signal (调用了哪些 getter)。
|
||||
@@ -293,7 +233,6 @@ Effect 用于将响应式系统与外部世界(如日志、计时器、手动A
|
||||
```
|
||||
|
||||
### 复杂状态管理
|
||||
|
||||
- **`createStore<T extends object>(initialValue: T): [T, (updater: ...) => void]`**
|
||||
- 用于响应式地管理对象和数组。与 `createSignal` 管理单个值不同,`createStore` 允许你独立地更新对象或数组的特定部分,并只触发关心这些部分的更新。其 API 应参考 SolidJS 的 `createStore`。
|
||||
|
||||
@@ -371,6 +310,8 @@ Effect 用于将响应式系统与外部世界(如日志、计时器、手动A
|
||||
- `flex-row`, `flex-col` - 设置 flex 方向
|
||||
- `justify-start`, `justify-center`, `justify-end`, `justify-between` - 主轴对齐
|
||||
- `items-start`, `items-center`, `items-end` - 交叉轴对齐
|
||||
- `text-<color>` - 文本颜色(支持所有 CC 颜色)
|
||||
- `bg-<color>` - 背景颜色(支持所有 CC 颜色)
|
||||
|
||||
#### 渲染器 (renderer.ts)
|
||||
- ✅ 将 UI 树渲染到 ComputerCraft 终端
|
||||
@@ -384,17 +325,6 @@ Effect 用于将响应式系统与外部世界(如日志、计时器、手动A
|
||||
- ✅ 自动焦点管理
|
||||
- ✅ 响应式重渲染
|
||||
|
||||
### 📋 API 导出 (index.ts)
|
||||
- ✅ 所有新 API 已正确导出
|
||||
- ✅ 保留旧 API 以实现向后兼容
|
||||
|
||||
### 🎯 示例代码
|
||||
- ✅ `main.new.ts` - 简单的计数器示例,演示响应式系统的基本用法
|
||||
|
||||
### 🔄 向后兼容性
|
||||
- ✅ 旧的类组件系统(Signal, UIComponent, Button 等)仍然可用
|
||||
- ✅ 旧的示例代码 `main.ts` 不受影响
|
||||
|
||||
---
|
||||
|
||||
## 9. 使用指南
|
||||
@@ -433,5 +363,8 @@ pnpm tstl -p ./tsconfig.tuiExample.json
|
||||
|
||||
```bash
|
||||
# 运行 ESLint 检查
|
||||
pnpm dlx eslint src/lib/ccTUI/reactivity.ts
|
||||
pnpm dlx eslint src/**/*.ts
|
||||
|
||||
# OR
|
||||
just lint
|
||||
```
|
||||
|
||||
@@ -50,6 +50,7 @@ export { Application, render } from "./application";
|
||||
export {
|
||||
UIObject,
|
||||
type LayoutProps,
|
||||
type StyleProps,
|
||||
type ComputedLayout,
|
||||
type BaseProps,
|
||||
} from "./UIObject";
|
||||
|
||||
@@ -33,8 +33,9 @@ function getTextContent(node: UIObject): string {
|
||||
*
|
||||
* @param node - The node to draw
|
||||
* @param focused - Whether this node has focus
|
||||
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
|
||||
*/
|
||||
function drawNode(node: UIObject, focused: boolean): void {
|
||||
function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): void {
|
||||
if (!node.layout) return;
|
||||
|
||||
const { x, y, width } = node.layout;
|
||||
@@ -43,6 +44,10 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
const [origX, origY] = term.getCursorPos();
|
||||
|
||||
try {
|
||||
// Default colors that can be overridden by styleProps
|
||||
let textColor = node.styleProps.textColor;
|
||||
const bgColor = node.styleProps.backgroundColor;
|
||||
|
||||
switch (node.type) {
|
||||
case "label":
|
||||
case "h1":
|
||||
@@ -50,17 +55,21 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
case "h3": {
|
||||
const text = getTextContent(node);
|
||||
|
||||
// Set colors based on heading level
|
||||
if (node.type === "h1") {
|
||||
term.setTextColor(colors.yellow);
|
||||
} else if (node.type === "h2") {
|
||||
term.setTextColor(colors.orange);
|
||||
} else if (node.type === "h3") {
|
||||
term.setTextColor(colors.lightGray);
|
||||
} else {
|
||||
term.setTextColor(colors.white);
|
||||
// Set colors based on heading level (if not overridden by styleProps)
|
||||
if (textColor === undefined) {
|
||||
if (node.type === "h1") {
|
||||
textColor = colors.yellow;
|
||||
} else if (node.type === "h2") {
|
||||
textColor = colors.orange;
|
||||
} else if (node.type === "h3") {
|
||||
textColor = colors.lightGray;
|
||||
} else {
|
||||
textColor = colors.white;
|
||||
}
|
||||
}
|
||||
term.setBackgroundColor(colors.black);
|
||||
|
||||
term.setTextColor(textColor);
|
||||
term.setBackgroundColor(bgColor ?? colors.black);
|
||||
|
||||
term.setCursorPos(x, y);
|
||||
term.write(text.substring(0, width));
|
||||
@@ -70,13 +79,13 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
case "button": {
|
||||
const text = getTextContent(node);
|
||||
|
||||
// Set colors based on focus
|
||||
// Set colors based on focus (if not overridden by styleProps)
|
||||
if (focused) {
|
||||
term.setTextColor(colors.black);
|
||||
term.setBackgroundColor(colors.yellow);
|
||||
term.setTextColor(textColor ?? colors.black);
|
||||
term.setBackgroundColor(bgColor ?? colors.yellow);
|
||||
} else {
|
||||
term.setTextColor(colors.white);
|
||||
term.setBackgroundColor(colors.gray);
|
||||
term.setTextColor(textColor ?? colors.white);
|
||||
term.setBackgroundColor(bgColor ?? colors.gray);
|
||||
}
|
||||
|
||||
term.setCursorPos(x, y);
|
||||
@@ -96,11 +105,11 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
}
|
||||
|
||||
if (focused) {
|
||||
term.setTextColor(colors.black);
|
||||
term.setBackgroundColor(colors.white);
|
||||
term.setTextColor(textColor ?? colors.black);
|
||||
term.setBackgroundColor(bgColor ?? colors.white);
|
||||
} else {
|
||||
term.setTextColor(colors.white);
|
||||
term.setBackgroundColor(colors.black);
|
||||
term.setTextColor(textColor ?? colors.white);
|
||||
term.setBackgroundColor(bgColor ?? colors.black);
|
||||
}
|
||||
|
||||
term.setCursorPos(x, y);
|
||||
@@ -112,33 +121,71 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
if (typeof valueProp === "function") {
|
||||
value = (valueProp as Accessor<string>)();
|
||||
}
|
||||
|
||||
|
||||
const placeholder = node.props.placeholder as string | undefined;
|
||||
const cursorPos = node.cursorPos ?? 0;
|
||||
let displayText = value;
|
||||
|
||||
if (value === "" && placeholder !== undefined) {
|
||||
let currentTextColor = textColor;
|
||||
let showPlaceholder = false;
|
||||
|
||||
const focusedBgColor = bgColor ?? colors.white;
|
||||
const unfocusedBgColor = bgColor ?? colors.black;
|
||||
|
||||
if (value === "" && placeholder !== undefined && !focused) {
|
||||
displayText = placeholder;
|
||||
term.setTextColor(colors.gray);
|
||||
showPlaceholder = true;
|
||||
currentTextColor = currentTextColor ?? colors.gray;
|
||||
} else if (focused) {
|
||||
term.setTextColor(colors.black);
|
||||
currentTextColor = currentTextColor ?? colors.black;
|
||||
} else {
|
||||
term.setTextColor(colors.white);
|
||||
currentTextColor = currentTextColor ?? colors.white;
|
||||
}
|
||||
|
||||
if (focused) {
|
||||
term.setBackgroundColor(colors.white);
|
||||
} else {
|
||||
term.setBackgroundColor(colors.black);
|
||||
}
|
||||
|
||||
|
||||
// Set background and clear the input area, creating a 1-character padding on the left
|
||||
term.setBackgroundColor(focused ? focusedBgColor : unfocusedBgColor);
|
||||
term.setCursorPos(x, y);
|
||||
// Pad or truncate to fit width
|
||||
if (displayText.length > width) {
|
||||
displayText = displayText.substring(0, width);
|
||||
} else {
|
||||
displayText = displayText.padEnd(width, " ");
|
||||
term.write(" ".repeat(width));
|
||||
|
||||
term.setTextColor(currentTextColor);
|
||||
term.setCursorPos(x + 1, y); // Position cursor for text after padding
|
||||
|
||||
const renderWidth = width - 1;
|
||||
let textToRender = displayText;
|
||||
|
||||
// Truncate text if it's too long for the padded area
|
||||
if (textToRender.length > renderWidth) {
|
||||
textToRender = textToRender.substring(0, renderWidth);
|
||||
}
|
||||
|
||||
if (focused && !showPlaceholder && cursorBlinkState) {
|
||||
// Draw text with a block cursor by inverting colors at the cursor position
|
||||
for (let i = 0; i < textToRender.length; i++) {
|
||||
const char = textToRender.substring(i, i + 1);
|
||||
if (i === cursorPos) {
|
||||
// Invert colors for cursor
|
||||
term.setBackgroundColor(currentTextColor);
|
||||
term.setTextColor(focusedBgColor);
|
||||
term.write(char);
|
||||
// Restore colors
|
||||
term.setBackgroundColor(focusedBgColor);
|
||||
term.setTextColor(currentTextColor);
|
||||
} else {
|
||||
term.write(char);
|
||||
}
|
||||
}
|
||||
// Draw cursor at the end of the text if applicable
|
||||
if (cursorPos === textToRender.length && cursorPos < renderWidth) {
|
||||
term.setBackgroundColor(currentTextColor);
|
||||
term.setTextColor(focusedBgColor);
|
||||
term.write(" ");
|
||||
// Restore colors
|
||||
term.setBackgroundColor(focusedBgColor);
|
||||
term.setTextColor(currentTextColor);
|
||||
}
|
||||
} else {
|
||||
// Not focused or no cursor, just write the text
|
||||
term.write(textToRender);
|
||||
}
|
||||
term.write(displayText);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -147,7 +194,16 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
case "form":
|
||||
case "for":
|
||||
case "show": {
|
||||
// Container elements don't draw themselves, just their children
|
||||
// Container elements may have background colors
|
||||
if (bgColor !== undefined && node.layout !== undefined) {
|
||||
const { x: divX, y: divY, width: divWidth, height: divHeight } = node.layout;
|
||||
term.setBackgroundColor(bgColor);
|
||||
// Fill the background area
|
||||
for (let row = 0; row < divHeight; row++) {
|
||||
term.setCursorPos(divX, divY + row);
|
||||
term.write(string.rep(" ", divWidth));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -158,8 +214,8 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
? (node.textContent)()
|
||||
: node.textContent;
|
||||
|
||||
term.setTextColor(colors.white);
|
||||
term.setBackgroundColor(colors.black);
|
||||
term.setTextColor(textColor ?? colors.white);
|
||||
term.setBackgroundColor(bgColor ?? colors.black);
|
||||
term.setCursorPos(x, y);
|
||||
term.write(text.substring(0, width));
|
||||
}
|
||||
@@ -177,15 +233,16 @@ function drawNode(node: UIObject, focused: boolean): void {
|
||||
*
|
||||
* @param node - The root node to render
|
||||
* @param focusedNode - The currently focused node (if any)
|
||||
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
|
||||
*/
|
||||
export function render(node: UIObject, focusedNode?: UIObject): void {
|
||||
export function render(node: UIObject, focusedNode?: UIObject, cursorBlinkState = false): void {
|
||||
// Draw this node
|
||||
const isFocused = node === focusedNode;
|
||||
drawNode(node, isFocused);
|
||||
drawNode(node, isFocused, cursorBlinkState);
|
||||
|
||||
// Recursively draw children
|
||||
for (const child of node.children) {
|
||||
render(child, focusedNode);
|
||||
render(child, focusedNode, cursorBlinkState);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user