mirror of
				https://github.com/SikongJueluo/cc-utils.git
				synced 2025-11-04 19:27:50 +08:00 
			
		
		
		
	add color style and polish input field
This commit is contained in:
		@@ -19,3 +19,6 @@ build-example:
 | 
			
		||||
 | 
			
		||||
sync:
 | 
			
		||||
    rsync --delete -r "./build/" "{{ sync-path }}"
 | 
			
		||||
 | 
			
		||||
lint:
 | 
			
		||||
    pnpm dlx eslint src/**/*.ts --fix
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -24,13 +24,13 @@ const Counter = () => {
 | 
			
		||||
  const [count, setCount] = createSignal(0);
 | 
			
		||||
 | 
			
		||||
  return div(
 | 
			
		||||
    { class: "flex flex-col" },
 | 
			
		||||
    { class: "flex flex-col bg-blue" },
 | 
			
		||||
    h3("Counter Example"),
 | 
			
		||||
    label({}, () => `Count: ${count()}`),
 | 
			
		||||
    label({ class: "text-yellow" }, () => `Count: ${count()}`),
 | 
			
		||||
    div(
 | 
			
		||||
      { class: "flex flex-row" },
 | 
			
		||||
      button({ onClick: () => setCount(count() - 1) }, "-"),
 | 
			
		||||
      button({ onClick: () => setCount(count() + 1) }, "+"),
 | 
			
		||||
      button({ onClick: () => setCount(count() - 1), class: "text-red" }, "-"),
 | 
			
		||||
      button({ onClick: () => setCount(count() + 1), class: "text-green" }, "+"),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -67,7 +67,7 @@ const TodosApp = () => {
 | 
			
		||||
        onInput: setNewTitle,
 | 
			
		||||
        placeholder: "Enter new todo",
 | 
			
		||||
      }),
 | 
			
		||||
      button({ onClick: addTodo }, "Add"),
 | 
			
		||||
      button({ onClick: addTodo, class: "bg-green text-white" }, "Add"),
 | 
			
		||||
    ),
 | 
			
		||||
    For({ each: todos, class: "flex flex-col" }, (todo, index) =>
 | 
			
		||||
      div(
 | 
			
		||||
@@ -79,10 +79,15 @@ const TodosApp = () => {
 | 
			
		||||
            setTodos(index(), "completed", checked);
 | 
			
		||||
          },
 | 
			
		||||
        }),
 | 
			
		||||
        label({ class: "ml-1" }, () => todo.title),
 | 
			
		||||
        label(
 | 
			
		||||
          { 
 | 
			
		||||
            class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white"
 | 
			
		||||
          }, 
 | 
			
		||||
          () => todo.title
 | 
			
		||||
        ),
 | 
			
		||||
        button(
 | 
			
		||||
          {
 | 
			
		||||
            class: "ml-1",
 | 
			
		||||
            class: "ml-1 bg-red text-white",
 | 
			
		||||
            onClick: () => {
 | 
			
		||||
              setTodos((t) => removeIndex(t, index()));
 | 
			
		||||
            },
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user