组件参考

23 个脚本可写的内置元素的完整说明 —— 功能、参数、调用方法与可运行示例。所有示例都取自仓库 testdata/ 下实际通过验收的演示脚本。

0先读:五个共同约定

下面每个组件只说自己的差异,但这五条对所有组件都成立。先理解它们,后面的表格会好读很多。

① 导入方式

JSX 需要 h 在作用域里,所以每个 GUI 脚本都要从 gx/gfx 引入它; 响应式信号来自 gx/solid:

import { createSignal } from "gx/solid";
import { h, render } from "gx/gfx";

JSX 标签在编译期被降级成 h(tag, props, ...children) 调用,所以 h必须导入的 —— 哪怕你一次都没显式调用它。也可以用 h() 手写树,与 JSX 完全等价。

② 响应式:传函数就是绑定

任何 prop 或文本子节点,只要传函数,就会被包成 effect:函数体里读到的 signal 变化时, 这个值自动重新求值并标脏该区域。

<text>{"静态文本"}</text>                 // 固定值,永不更新
<text>{() => `count: ${count()}`}</text>   // 函数 → 响应式绑定

<rect width={200} />                      // 固定宽
<rect width={() => count() * 20} />       // 宽度跟随 count
依赖来自"函数体里读到了什么"

没有读取就没有订阅。把 count() 藏进 if (ctx.width > 0) 这类 运行时可能不进入的分支里,订阅就收不到 —— 表现为"数据变了界面不动"。

③ 受控组件:显示只看 prop,编辑只派发事件

所有输入类组件(input / textarea / select / slider / checkbox / radio / switch / progress / dialog)都不存自己的状态。 它们显示什么完全由 prop 决定,用户操作只派发回调。

// 正确:onInput 里把值写回 signal,显示才会变
<input value={() => name()} onInput={(e) => setName(e.value)} />

// 错误:不回写 → 敲键盘没反应(显示永远来自 value prop)
<input value={() => name()} onInput={(e) => console.log(e.value)} />

滑块不回写会"弹回原位",显示"拖不动";输入框不回写则"打字没反应"。这与 DOM 受控组件的语义一致。

④ 尺寸与颜色的写法

尺寸只接受整数像素,没有百分比、没有 min/max-width (滑块自己的 min/max 是取值范围,不是 CSS 尺寸)。 颜色支持命名色与 #rgb / #rgba / #rrggbb / #rrggbbaa / rgb() / rgba(),其中 alpha 可写 0~2550~1

属性作用于说明
width / height / margin所有元素整数像素;不写时由 intrinsicSize 按内容算
padding容器四边统一值,不可分边设置
gapcolumn / row子元素间距
background所有元素容器上是填充色;组件标签上是"强调色"语义(选中填充、进度前景、开关轨道)
border所有元素固定 1px 描边色,不可调宽度/圆角
color所有元素文本色,沿祖先链继承
fonttext像素字号
disabled所有元素沿祖先链继承;子树既不响应事件也不参与焦点切换,整体降饱和

⑤ 未知标签会被警告

h() 对标签名做白名单校验,集合外的标签会往 stderr 打印一次警告并渲染成普通盒子:

gfx: unknown tag "foo" (rendered as a plain box; see docs/gui-component-status.md)

这是刻意设计的 —— 没登记的组件此前会静默渲染成空白,最难排查。看到这行警告就说明标签名拼错了或用了未实现的组件。

通用盒子不布局子元素

column / row 的标签(包括未知标签、rect) 即使有了尺寸,子元素也全部叠在左上角。需要分组容器就老实用 <column><row>

分类 1

1布局容器

布局原语只有两个方向,加上滚动、分隔与占位,共 4 组。


<column> / <row>

稳定 布局原语

唯一的两个真布局容器。子元素按主轴依次排列(column 纵排、row 横排), 交叉轴可按 alignItems 对齐。尺寸未显式给出时按内容自适应。

Prop类型说明
gapnumber子元素间距(像素),缺省 0
paddingnumber四边统一内边距,缺省 0
alignItemsstringstretch(默认) / start / center / end
justifyContentstringstart(默认) / center / end / between
width / heightnumber不写则按内容算;作为根容器时不写则铺满窗口
background / border / color颜色容器自身可描底与描边

子元素可写 flexGrow(number)吃主轴的富余空间 —— 配合 <spacer flexGrow={1}/> 可以把两侧元素推到两端。注意 stretch 会让无固有尺寸的子元素占满交叉轴,而内置控件(按钮、输入框等)保持自己的内容尺寸。

<column gap={12} padding={16}>
  <text font={18}>标题</text>

  <!-- 横向排列,垂直居中 -->
  <row gap={8} alignItems="center">
    <rect width={10} height={10} background="#27ae60" />
    <text>一行内容</text>
  </row>

  <!-- 两端对齐:spacer 吃掉中间所有空余 -->
  <row>
    <rect width={80} height={24} background="#c0392b" />
    <spacer flexGrow={1} />
    <rect width={80} height={24} background="#27ae60" />
  </row>
</column>

<scroll>

稳定 纵向滚动

纵向滚动容器:内容超出视口时可滚轮滚动,右侧自动出现 8px 轨道 + 比例滑块。 绘制裁剪与命中裁剪共用同一个视口 —— 滚出视口的行既画不出来也点不中,不会出现"看不见却点得到"的幽灵点击。

Prop类型说明
width / heightnumberheight 不给时缺省 200;内容不足一屏则不出滚动条
onWheelfunction容器滚到边界后,滚轮事件才继续往外冒泡给脚本;回调收到 {deltaY}
子节点元素 / 数组数组子节点会逐个展开成兄弟节点,不需要手动 map
const rows = [];
for (let i = 0; i < 20; i++) {
  rows.push(
    h("rect", { height: 36, background: i % 2 ? "#eef2f7" : "#ffffff" },
      h("text", { font: 13 }, `row ${i}`))
  );
}

<scroll width={240} height={120} onWheel={(e) => log(`overscroll ${e.deltaY}`)}>
  {rows}
</scroll>

<separator>

稳定

1px 分隔线。横向时高度固定 1px、宽度由容器 stretch 拉满;加 vertical 后变成 1px 宽的竖线。

Prop类型说明
verticalbooleantrue 时画竖线;此时主轴(高度)需显式给 height
background颜色线条颜色
<text>上半区</text>
<separator />
<text>下半区</text>

<spacer>

稳定 零绘制

弹性占位:零固有尺寸、零绘制,专门用来吃主轴的富余空间。最常见的用法是"把两个元素推到两端"。

<row>
  <text>左</text>
  <spacer flexGrow={1} />
  <text>右</text>
</row>

<rect>

稳定 通用盒子

矩形色块,也是最通用的"盒子":填充 background、描边 border, 宽高常用来做色条、分隔块、仪表底色。点击区域、右键菜单等交互常直接挂在它上面。

Prop类型说明
width / heightnumber尺寸;缺省为 0(必须给,否则看不见)
background颜色填充色(支持 alpha)
border颜色固定 1px 描边
onClick / onMouseMove / onWheel / onContextMenu / onKeyDown …function通用盒子可挂任意事件,是"可点击面板"的标准写法
<rect width={80} height={24} background="#c0392b" />

{/* 宽度绑定信号 → 做进度色条 */}
<rect height={12} background="#c0392b" width={() => vol() * 1.6} />

{/* 带事件的面板 */}
<rect width={340} height={140} background="#e8eef7"
      onContextMenu={(e) => openContextMenu(e.x, e.y, items)} />
rect 不做子元素布局

它是通用盒子分支,子元素全部叠放在左上角。想在色块里排版,就用 <column> / <row> 当容器,把 <rect> 当纯色块用。

分类 2

2表单控件

全部是受控组件,显示只看 prop,交互只派发回调(见第 0 节约定 ③)。


<button>

稳定 受控外观

按钮。尺寸按内容自适应(文本 + 8px 左右内边距),文本垂直居中;自带悬停提亮与按压压暗反馈。

Prop类型说明
onClickfunction左键抬起时触发。回调没有参数
disabledboolean整体降饱和,点击被拦截,也不抢键盘焦点
background颜色强调色,缺省浅灰 #e8e8e8
border颜色缺省 1px #999
color颜色文字色,缺省深色
<button onClick={() => setCount(c => c + 1)}>默认按钮</button>

<button
  background="#1a5fb4"
  border="#1a5fb4"
  color="#ffffff"
  onClick={() => setCount(c => c + 1)}
>自定义配色</button>

<button disabled={true} onClick={() => setCount(c => c + 100)}>禁用按钮</button>

<checkbox> / <radio> / <switch>

稳定 纯受控

三个布尔开关控件。勾选框 18×18,开关 36×20(方形轨道 + 16×16 滑块),单选框是 1px 圆环 + 中心实心点。 三者都不自带任何状态 —— 勾上还是不勾,完全由 checked prop 决定。

Prop类型说明
checkedboolean / function是否选中。通常传函数绑定 signal
onClickfunction点击时触发,无参数;自己在回调里翻转 signal
disabledboolean点击被拦截
background颜色选中态填充色(强调色语义)
单选互斥要自己实现

内核里没有"radio group"。做法是让一组 radio 共享同一个 signal, 把 checked 写成"值相等"的比较即可,无需额外的分组容器。

const [agree, setAgree] = createSignal(false);
const [size, setSize] = createSignal("S");
const [notify, setNotify] = createSignal(true);

<row gap={8} alignItems="center">
  <checkbox checked={() => agree()} onClick={() => setAgree(v => !v)} />
  <text>{() => (agree() ? "Agreed" : "Not agreed")}</text>
</row>

<!-- 互斥靠"共享 signal + 比较值" -->
<row gap={14} alignItems="center">
  <row gap={4} alignItems="center">
    <radio checked={() => size() === "S"} onClick={() => setSize("S")} />
    <text>S</text>
  </row>
  <row gap={4} alignItems="center">
    <radio checked={() => size() === "M"} onClick={() => setSize("M")} />
    <text>M</text>
  </row>
</row>

<row gap={8} alignItems="center">
  <switch checked={() => notify()} onClick={() => setNotify(v => !v)} />
  <text>{() => (notify() ? "Notifications on" : "Notifications off")}</text>
</row>

<input>

稳定 IME:仅 Windows

单行文本输入。获焦时边框转蓝并出现闪烁竖线光标;点击框内任意位置可把光标落到最近的字符边界。 支持 Backspace / Delete / / / Home / End, 以及 Windows 上的中文输入法整批提交

Prop类型说明
valuestring / function显示内容,显示只看它
onInputfunction内容变化时派发,收到 {value}(string)。移动光标不派发
placeholderstring值为空时以灰字显示;此时光标停在最左,不被灰字挤走
onKeyDown / onKeyUpfunction未被消费的键冒泡上来,收到 {key, ctrl, shift, alt}
onFocus / onBlurfunction焦点进入 / 离开
widthnumber缺省 160(刻意不按内容算宽,也不参与交叉轴 stretch)
disabledboolean不可编辑、不参与焦点

键盘归属

输入框只消费编辑类按键;其余一律放行给上层,这样"输入框放在对话框里按 Esc 关掉"能照常工作。

按键归属
方向键 / Home / End输入框消费(移动光标),不冒泡
Backspace / Delete输入框消费(删字符)
Enter放行给上层 —— 常用来做"回车提交"
Escape / Tab / 功能键放行
Ctrl / Alt 的组合键放行,留给脚本或全局快捷键表
const [name, setName] = createSignal("");
const [enters, setEnters] = createSignal(0);

<input
  width={260}
  placeholder="Type your name"
  value={() => name()}
  onInput={(e) => setName(e.value)}
  onKeyDown={(e) => {
    if (e.key === "Enter") setEnters((n) => n + 1);
  }}
/>
光标闪烁依赖事件泵醒着

窗口既没有事件、也没有任何定时器时,事件泵会在等待里睡着,光标就冻住了。 需要持续闪烁的应用挂一个空转的 requestAnimationFrame 循环即可:

function tick() { requestAnimationFrame(tick); }
tick();

<textarea>

稳定 IME:仅 Windows

多行文本编辑。光标是二维的 {行, 列},内容超出可视高度时纵向滚动,并且滚动跟随光标 (在最后一行回车时不会把光标顶出框外)。

Prop类型说明
valuestring / function显示内容,受控
onInputfunction收到 {value};输入法一次提交只派发一次
rowsnumber可见行数,决定缺省高度
placeholderstring空值时的灰字提示
width / heightnumber显式尺寸
onKeyDownfunctionEnter(被编辑框消费了),但 Escape 会冒泡上来
disabledboolean不可编辑
与 <input> 唯一的按键差异

多行框里 Enter内容(插入换行),所以它消费 Enter; 单行框里 Enter 放行给上层。其余键序完全一致。

const [text, setText] = createSignal("");

<textarea
  rows={4}
  width={260}
  placeholder="Type here..."
  value={() => text()}
  onInput={(e) => setText(e.value)}
  onKeyDown={(e) => {
    if (e.key === "Escape") closeDialog();
  }}
/>

<select>

稳定 弹层

下拉框。点击展开选项弹层(自带逃逸裁剪,不会被 28px 的字段盒裁剪,也不会被后面的兄弟节点盖住), 支持键盘开合与移动高亮。

Prop类型说明
optionsarray字符串数组,或 {value, label} 对象数组
valuestring / function当前值,显示只看它
onChangefunction选中时派发,收到 {value}
placeholderstring无选中值时的灰字
widthnumber字段宽度,缺省按内容
disabledboolean不可展开

键盘操作:获焦后 Enter / Space 展开, / 移动高亮(环绕),Enter 选中, Esc 收起。

const CITIES = [
  { value: "sh", label: "Shanghai" },
  { value: "bj", label: "Beijing" },
  { value: "sz", label: "Shenzhen" },
];

const [city, setCity] = createSignal("sh");

<select
  width={220}
  options={CITIES}
  value={() => city()}
  onChange={(e) => setCity(e.value)}
/>

<!-- 也支持纯字符串数组 + placeholder -->
<select
  width={220}
  placeholder="Pick a fruit"
  options={["apple", "banana", "cherry"]}
  value={() => fruit()}
  onChange={(e) => setFruit(e.value)}
/>
点外部关闭会吞掉那一次点击

弹层展开时点击画面别处,这次点击只用来收起弹层,不会顺带按到下面的控件 —— 避免"关下拉框时误触发按钮"。


<slider>

稳定 拖动独占

滑块。拖动滑块或单击轨道任意位置跳值都会更新;拖动期间鼠标捕获由后端提供,拖出窗口仍然跟手。

Prop类型说明
valuenumber / function当前值,决定滑块位置
onInputfunction拖动/点击时派发,收到 {value} —— 是 number,不是字符串
min / maxnumber取值范围,缺省 0 / 100
stepnumber步长,缺省 1;step <= 0 表示连续取值
widthnumber缺省 160
disabledboolean拖不动,整体降饱和
const [vol, setVol] = createSignal(40);

h("slider", {
  width: 200, min: 0, max: 100, step: 5,
  value: () => vol(),
  onInput: (e) => setVol(e.value),     // e.value 直接当数字用,不用 parseFloat
}),

// 数值只会落在 0/2/4/6/8/10 上
h("slider", { width: 200, min: 0, max: 10, step: 2,
  value: () => zoom(), onInput: (e) => setZoom(e.value) }),

// 禁用态
h("slider", { width: 200, min: 0, max: 100, step: 5, value: 70, disabled: true })
分类 3

3内容展示


<text>

稳定

文本。它是文本块唯一载体 —— JSX 里的裸字符串子节点会变成隐式的 #text 节点, 而 #text 永远是单行的。想折行必须用 <text> 并开 wrap

Prop类型说明
fontnumber像素字号
color颜色文本色,沿祖先链继承
wrapbooleantrue 时按可用宽度贪心折行,高度按行数增长
ellipsisnumber限制行数上限,超出补 "…";隐含开启 wrap
width / heightnumber折行需要一个宽度约束(显式 width,或父容器交叉轴 stretch)
<text font={20}>{() => `count: ${count()}`}</text>

<!-- 折行:给宽度,或者放在 column 里靠 stretch -->
<text wrap width={260} font={12}>
  {() => `value = "${text()}"`}
</text>

<!-- 最多两行,超出显示省略号 -->
<text wrap ellipsis={2} width={200}>{longDescription}</text>
开了 wrap 的文本块默认参与 stretch

否则它在 column 里会按"未折行的整行宽"撑开并溢出容器。 不想被拉满就显式写 width。另外文本块高度是"算两遍"的(父容器定下盒宽后回头重算), 这是实现细节,你不需要管,但要知道它意味着"折行文本的高度依赖父容器给的宽度"。


<image>

稳定 同步解码

图片显示。用 Go 标准库解码 PNG / JPEG / GIF(不引入新依赖),挂载时同步解码并带 16 项 LRU 缓存。

Prop类型说明
srcstring文件路径,相对进程工作目录解析
width / heightnumber不给用图片自然尺寸;给了则最近邻缩放
disabledboolean罩一层半透明灰
<image src="testdata/image_demo.png" />                    {/* 自然尺寸 */}
<image src="testdata/image_demo.png" width={96} height={96} />  {/* 最近邻放大 */}
<image src="testdata/missing.png" width={96} height={48} />     {/* 失败:灰底 + 交叉线 */}

<progress>

稳定

进度条。value 取 0~1(自动钳位),缺省 200×8,轨道浅灰 + 前景绿。

Prop类型说明
valuenumber / function0~1,超出范围自动钳位
background颜色前景色(强调色语义),缺省 #27ae60
width / heightnumber缺省 200×8
// 用整数步进(0..10)而不是浮点累加,避免 0.30000000000000004 这类误差
const [step, setStep] = createSignal(0);
setInterval(() => setStep(s => (s >= 10 ? 0 : s + 1)), 400);

<progress value={() => step() / 10} />
<text>{() => `value: ${step() * 10}%`}</text>
分类 4

4反馈与弹层

这一类的标签自带弹层层级基线(overlayZBase),会自动逃逸祖先裁剪,不需要手动写 escapeClipping


<dialog>

稳定 模态遮罩

模态对话框:40% 黑遮罩铺满窗口 + 居中卡片。流内子节点就是卡片内容, 卡片之外的区域属于遮罩。遮罩存在时会吞掉其下所有点击。

Prop类型说明
openboolean / function受控显示开关;关闭时不绘制也不参与命中
onClosefunction点击遮罩 / 按 Esc 时派发;通常在里面把 open 置 false
子节点元素卡片内容(居中),点它自身不会误关
const [open, setOpen] = createSignal(false);

<button onClick={() => setOpen(true)}>Open dialog</button>

<dialog open={() => open()} onClose={() => setOpen(false)}>
  <column gap={8} padding={14}>
    <text font={16}>Confirm</text>
    <text>Click the mask or press Esc to close.</text>
    <button onClick={() => setOpen(false)}>Close</button>
  </column>
</dialog>
Esc 的关闭优先级

按一次 Esc 只关一层,优先级是:菜单 > 下拉框 > 对话框。 所以对话框里展开着的下拉框会先收起来,再按才轮到对话框。


<toast>

稳定 非模态

轻提示:固定在窗口右上角,非模态(它下面的内容照常可点)。挂在树上就显示,挂载/卸载完全由 JS 控制, 内核不管定时器。

Prop类型说明
messagestring提示文本
levelstringsuccess / warn / error / info,决定色条
const [showToast, setShowToast] = createSignal(false);

const notify = () => {
  setShowToast(true);
  setTimeout(() => setShowToast(false), 3000);   // 自动消失靠 JS 定时器
};

{() => (showToast() ? <toast message="Saved successfully" level="success" /> : null)}

gx/dialog 原生系统对话框

稳定 仅 Windows 原生 async

调起操作系统原生的消息框与文件选择框,而不是自绘弹层。三个 API 都返回 Promise。

API返回说明
alert(msg, title?)Promise<void>系统消息框,只有一个"确定"
confirm(msg, title?)Promise<boolean>确定 / 取消,返回用户选择
openFile({title, filter})Promise<string | null>系统"打开文件"对话框;取消返回 null,不是抛异常

filter{name, pattern} 数组, pattern 里多个通配符用 ; 分隔。

import { alert, confirm, openFile } from "gx/dialog";

// ⚠ 只支持 async function,不支持 async () => {}
h("button", {
  onClick: async function () {
    const yes = await confirm("Proceed with the operation?", "Please confirm");
    log("confirm -> " + (yes ? "yes" : "no"));
  }
}, "Confirm"),

h("button", {
  onClick: async function () {
    const path = await openFile({
      title: "Pick a file",
      filter: [
        { name: "Text files", pattern: "*.txt;*.md" },
        { name: "All files", pattern: "*.*" }
      ]
    });
    log(path === null ? "cancelled" : "picked " + path);
  }
}, "Open file")
语法陷阱:不支持 async () => {}

解析器只实现 async function。要给事件处理器写异步箭头函数,必须写成 async function () { ... }(匿名 async 函数表达式)。

分类 5

稳定 弹层

自绘菜单栏 + 下拉菜单 + 子菜单。支持互斥展开、点击外部关闭、键盘导航,菜单项可挂全局快捷键。

元素Prop说明
menubar普通容器:高度固定 26px,横向排列子项
子节点<menu> 标题与任意其它元素(如右侧的 status 文本)
menulabel菜单标题(顶级)或多个菜单项的容器(子菜单)
子节点<menuitem> / <separator>
menuitemlabel菜单项文本
shortcut快捷键文本,如 "Ctrl+S"。显示在右侧,并全应用生效(无需展开菜单)
disabled灰字,点了没反应
onClick点击触发;快捷键命中时收到 {x, y, shortcut}
<column>
  <menubar>
    <menu label="File">
      <menuitem label="New"  shortcut="Ctrl+N" onClick={() => say("New")} />
      <menuitem label="Open" shortcut="Ctrl+O" onClick={() => say("Open")} />
      <separator />
      <menuitem label="Save As" disabled={true} onClick={() => say("不该被触发")} />
    </menu>

    <menu label="View">
      <menuitem label="Zoom In" onClick={() => say("Zoom In")} />
      <!-- 子菜单:在 menuitem 里再嵌一个 menu -->
      <menuitem label="Theme">
        <menu>
          <menuitem label="Dark"  onClick={() => say("Dark")} />
          <menuitem label="Light" onClick={() => say("Light")} />
        </menu>
      </menuitem>
    </menu>

    <text>ready</text>    {/* menubar 是普通容器,右侧放什么都行 */}
  </menubar>

  <column gap={12} padding={16}>
    {/* 窗口主体内容 */}
  </column>
</column>

快捷键规则

快捷键表在 Go 侧(Pump 层)匹配 —— 因为 JS 看不到已被输入框消费的按键。规则:

键盘导航

焦点在菜单栏时: / 在标题间循环切换(换过去就展开), / Enter / Space 展开, Esc 收起当前层。


openContextMenu(x, y, items)

稳定 弹层 同步 API

在指定坐标就地弹出右键菜单。这是数据式 API,不是 contextMenu prop —— 理由见下方提示。

参数类型说明
x, ynumber弹出的屏幕坐标,通常来自 onContextMenue.x/e.y
itemsarray<menuitem> / <separator> 元素数组,与菜单栏里的写法完全一致
import { h, render, openContextMenu } from "gx/gfx";

<rect
  width={340}
  height={140}
  background="#e8eef7"
  onContextMenu={(e) =>
    openContextMenu(e.x, e.y, [
      <menuitem label="Copy"    shortcut="Ctrl+C" onClick={() => say("Copy")} />
      <menuitem label="Paste"   shortcut="Ctrl+V" onClick={() => say("Paste")} />
      <separator />
      <menuitem label="Inspect" onClick={() => say("Inspect")} />
    ])
  }
/>
为什么不做成 prop

JSX 元素是单次挂载的对象,一个节点只有一个 Parent 字段。 把同一个 <menu> 做成 prop 挂到多处会互相争抢宿主。做成"每次调用现构造一份"的数据式 API 就没有这个问题。

行为:靠近屏幕右下角时会自动向左上翻折保证整块可见;点选项或点外部关闭;在菜单上再点右键会被吞掉(不会误关)。

分类 6

6媒体与自绘


<canvas>

稳定 响应式自绘

自绘画布。在 onDraw(ctx) 里用 7 个原语直接落笔,坐标是画布局部坐标 (0,0 就是画布左上角),越界部分自动裁掉,不会溢出到界面其它地方。 在 onDraw 里读到的 signal 变化会自动触发重绘。

Prop类型说明
onDrawfunction绘制函数,收到 ctx。必须传函数本身
width / heightnumber缺省 200×120;canvas 不是容器,拿不到父容器交叉轴 stretch
background / border颜色画布盒自身的底色与描边
disabledboolean降饱和

ctx 提供的能力

原语签名说明
fillRect(x, y, w, h, color)实心矩形
strokeRect(x, y, w, h, color)空心矩形(1px 边框)
fillCircle(cx, cy, r, color)实心圆
strokeCircle(cx, cy, r, color)圆环
line(x1, y1, x2, y2, color)直线
drawText(text, x, y, size, color)文本
clear(color)整块填充
width / heightnumber(只读)画布尺寸
const [tick, setTick] = createSignal(0);
const DATA = [4, 7, 3, 8, 5, 9, 6, 2];

const chart = h("canvas", {
  width: 246, height: 110,
  onDraw: (ctx) => {
    // 读到 tick() → 订阅它,变化时自动重绘
    const cur = tick() % DATA.length;
    ctx.fillRect(0, 0, ctx.width, ctx.height, "#fafafa");
    ctx.line(0, 96, ctx.width - 1, 96, "#cccccc");
    ctx.drawText("bars " + DATA.length, 4, 2, 12, "#555555");
    for (let i = 0; i < DATA.length; i++) {
      const bh = DATA[i] * 8;
      const x = 6 + i * 30;
      ctx.fillRect(x, 96 - bh, 24, bh, i === cur ? "#c0392b" : "#7f8c8d");
    }
  },
});

setInterval(() => setTick(tick() + 1), 500);
三个最容易踩的坑

① 传函数本身:写 onDraw={draw()} 会在挂载时先调一次,再把返回值 (undefined)当回调 —— 现象是"什么都不画"且不报错。
② 依赖靠"读":必须在 onDraw 里读 signal 才会订阅。读普通变量不会产生依赖, 数据变了画布不动。
③ 画布不铺底:与 HTML canvas 一样是透明的,要底色就自己 ctx.fillRect 或挂 background

onDraw 每次依赖变化会执行两遍(一遍用空操作 ctx 收集依赖、一遍用真 ctx 落笔), 所以它必须是纯绘制函数 —— 别在里面改状态、别做耗时计算、也别把 signal 读取藏在 if (ctx.width > 0) 之后(依赖收集时尺寸可能还是 0,那条分支不跑就收不到订阅)。

分类 7

7横切能力与模块 API


响应式与列表渲染

稳定

来自 gx/solid 的三个 API,是"界面为什么会更新"的答案。

API签名说明
createSignal(initial) => [get, set]创建信号。调用 get() 读值(同时登记依赖),set(v)set(fn) 写值
createEffect(fn) => void立即跑一次 fn,并订阅其中读到的所有信号
createMemo(fn) => getter派生计算值,依赖变化时自动重算并缓存

函数子节点是列表渲染与条件渲染的统一入口:求值结果可以是元素、数组、标量或 null/false(渲染为空)。

const [items, setItems] = createSignal(["alpha", "beta"]);
const [tab, setTab] = createSignal(0);

// 列表渲染:函数返回数组,逐元素挂载
<column gap={6}>
  {() => items().map((t) => <text>{t}</text>)}
</column>

// 条件渲染:函数返回元素 或 null
{() => (tab() === 0 ? <text>面板 A</text> : <text>面板 B</text>)}

// 静态数组子节点也支持:直接写 {rows} 会逐个展开成兄弟节点
<scroll height={120}>{rows}</scroll>
v1 不做 diff / key

数组或条件变化时整组重建子树(旧子树的 effect 会被注销)。增删几行没问题, 但超长列表请配合 scroll 自行只挂可见区间,否则每次变化都在重建全部行。


事件与焦点

稳定

所有事件都是"沿祖先链找第一个处理器",找到就停 —— 不冒泡、无捕获、无 stopPropagation

事件回调参数说明
onClick左键抬起
onMouseMove{x, y}鼠标在节点内移动
onWheel{deltaY}向下滚为正(DOM 约定)。scroll 内先被容器消费
onContextMenu{x, y}右键抬起;常用来调 openContextMenu
onKeyDown / onKeyUp{key, ctrl, shift, alt}投给焦点节点,再沿祖先链上溯
onFocus / onBlur焦点进入 / 离开
onInput{value}输入类组件的编辑回调(string;slider 是 number)
onResize{width, height}窗口级事件:窗口尺寸变化时投给布局根,挂非根节点不触发。配合 signal 就是 useWindowSize 模式(见下)

useWindowSize:拖窗口自适应

// onResize 只认布局根;断点是脚本里的普通 memo,不烧进内核
const [win, setWin] = createSignal({ width: 520, height: 360 });
const wide = createMemo(() => win().width >= 480);

<window title="app" width={520} height={360}>
  <column onResize={(e) => setWin({ width: e.width, height: e.height })}>
    {() => (wide() ? <Sidebar /> : <text font={12}>(narrow)</text>)}
  </column>
</window>

焦点模型:点击任意节点即成为键盘焦点,焦点节点会画 1px 蓝色虚线框。 disabled 子树不响应任何事件、也不参与焦点切换。Tab 键遍历 v1 未实现,焦点只能靠鼠标点击切换。

<rect
  width={380} height={110} background="#eef3f8"
  onClick={() => setLast("click")}
  onMouseMove={(e) => setPos(`${e.x}, ${e.y}`)}
  onWheel={(e) => setLast(`wheel deltaY=${e.deltaY}`)}
  onContextMenu={(e) => setLast(`context menu at ${e.x}, ${e.y}`)}
  onKeyDown={(e) => setLast(`keydown ${e.key}`)}
>
  <text>click to focus, then move / scroll / right-click / type</text>
</rect>

层叠与定位

稳定 逃逸裁剪

控制谁盖在谁上面、以及元素脱离常规流。三组 prop 各管一件事。

Prop类型说明
zIndexnumber同层内的绘制与命中顺序,越大越靠上。用稳定排序,不改变子节点顺序
positionstring设成 "absolute" 即脱离常规流,此时 left / top 生效(相对父内容区)
left / topnumber绝对定位的偏移
escapeClippingboolean让该子树的绘制与命中溢出父盒,并收集到根层级最后绘制
absolute 不解除父盒裁剪

position="absolute" 只负责"脱离常规流",元素默认仍然被父盒裁掉。 要让弹层溢出父边界显示,必须显式加 escapeClipping。 内置弹层标签(dialog / toast / select 的弹出层 / 菜单) 已经自带这一能力,不用手写。

{/* 覆盖层:绝对定位于父内容区左上角 */}
<rect position="absolute" left={12} top={8} width={100} height={20} background="#f2c94c" />

{/* 自定义弹层:溢出父盒显示必须加 escapeClipping */}
<column position="absolute" top={30} escapeClipping={true}>
  <text>浮在外面</text>
</column>

过渡动画

稳定 非元素

动画不是某个组件,而是一条可以挂在任何节点上的横切能力:值变化时在给定时长内按 ease-out 平滑逼近。

Prop / API类型说明
transitionnumber 或对象transition={400} 是简写(对所有可动属性生效);transition={{width: 400, opacity: 350}} 按属性分别定时长
opacitynumber0~1,成组:父节点半透明 = 整棵子树一起淡
animate(node, prop, to, ms)命令式让某节点的某属性动到目标值,返回 cancel 函数
animate(from, to, ms, onUpdate, onDone)命令式不经过任何元素属性,自己拿插值(配合 setStatus 之类用)

声明式 transition 只认这五个数值属性: width / height / left / top / opacity

// 宽度过渡:切换时在 400ms 内平滑伸展/收缩
h("rect", {
  height: 18,
  background: "#2f80ed",
  transition: { width: 400 },
  width: () => (wide() ? 300 : 60)
}),

// 成组淡出:父节点半透明 = 整棵子树一起淡
h("row", {
  gap: 6, height: 28,
  transition: { opacity: 350 },
  opacity: () => (visible() ? 1 : 0.15)
},
  h("rect", { width: 24, height: 24, background: "#c0392b" }),
  h("text", { font: 12, width: 80, height: 24 }, "fading")
),

// 命令式:自己拿插值,onUpdate 每帧(~60fps)调用
h("button", {
  onClick: () => {
    animate(0, 100, 600,
      (v) => setStatus("progress " + Math.round(v) + "%"),
      () => setStatus("done"));
  }
}, "bounce")
三条容易误判的行为

① 首次赋值不做过渡(与 CSS 一致):元素刚挂上时不会"从 0 长出来",想要入场动画请用命令式 animate()
② 过渡期间布局读的是插值,所以兄弟节点会跟着让位 —— 不只是视觉在动。
③ 动画没结束前脚本读到的 prop 已经是终值:prop 是唯一真相,插值只活在渲染层。 想读"当前显示值"得自己在 signal 里维护。


gx/gfx 模块函数

稳定

除组件标签外,gx/gfx 还导出这些函数。

导出签名说明
h(tag, props, ...children)创建元素。JSX 编译后就是它,所以必须导入
render(vnode, config?)挂载窗口,返回窗口句柄。三种写法见下
requestAnimationFrame(fn)16ms 定时器实现;也用来让事件泵保持醒着(驱动光标闪烁)
clipboardReadText() => string同步读剪贴板;读不到返回空串,不抛异常
clipboardWriteText(text) => boolean同步写剪贴板;失败返回 false
animate过渡动画命令式补间
openContextMenu(x, y, items)弹出右键菜单

render 的三种形态

// ① JSX:窗口配置写在根元素属性上(最常用)
render(<window title="Counter" width={400} height={300}>...</window>);

// ② h() 手拼树:窗口配置作为第二个参数
render(h("column", { gap: 10 }, ...), { title: "Slider demo", width: 260, height: 260 });

// ③ 省略配置:用缺省 Gox 400x300
render(h("text", null, "hello"));

剪贴板(同步 API)

import { clipboardReadText, clipboardWriteText } from "gx/gfx";

const ok = clipboardWriteText(text());
const s  = clipboardReadText();        // 读不到是空串

两者都是同步的 —— 脚本与窗口在同一个 OS 线程,直接调原生 API 就是正确的线程,不需要 await。后端不支持时静默降级(写返回 false、读返回空串),不抛异常。


gx/storage 本地持久化

稳定

应用级 kv 存储:值经 JSON 序列化落盘到用户配置目录,下次启动还在。

API返回说明
setAppName(name)void指定应用名(数据目录的一段);推荐在任何 storage 调用前先调
appDataDir()string数据目录绝对路径(UserConfigDir/Gox/<appName>)
setStorage(key, value)void写入。value 只能是纯数据(对象/数组/标量);写穿透立即落盘
getStorage(key)value读回。不存在返回 undefined,不抛异常
removeStorage(key) / clearStorage()void删一个键 / 清空
getStorageInfo(){keys, currentSize, limit}键列表与字节数;limit 恒 -1(v1 不做配额)
import { setAppName, setStorage, getStorage } from "gx/storage";

setAppName("my-app");              // → %APPDATA%/Gox/my-app (Linux: ~/.config/Gox/my-app)
setStorage("theme", "dark");
setStorage("profile", { name: "gox", level: 3 });   // 对象也行,序列化后整存整取
const t = getStorage("theme");     // "dark"; 首次运行为 undefined
if (t === undefined) setStorage("theme", "light");

多窗口

稳定

render() 可以调用多次,每次都开一个独立窗口,返回句柄用于关闭。

句柄方法说明
close()关闭该窗口。内部经 Post 投回 GUI 线程,异步受理 —— 返回时可能还没真关
isClosed()查询是否已关闭
const makeCounter = (title) => {
  const [n, setN] = createSignal(0);
  let self = null;

  self = render(
    <window title={`Multi-window ${title}`} width={380} height={340}>
      <column gap={12} padding={16}>
        <text font={22}>{() => `${title} count = ${n()}`}</text>
        <button onClick={() => setN(n() + 1)}>+1</button>
        <button onClick={() => self.close()}>Close this window</button>
      </column>
    </window>
  );
  return self;
};

const a = makeCounter("A");   // 两个窗口各有独立元素树、焦点与快捷键表
const b = makeCounter("B");
分类 8

8全局限制与常见误区

语法层面的坑

现象原因与解法
async () => {} 报语法错 解析器只实现 async function。改用 async function () { ... }
多行 JSX 最后一个参数后的逗号报 no prefix parse function for RPAREN 解析器不支持调用参数列表的尾逗号。把最后那个 , 删掉
标签渲染成空白并打印 unknown tag 标签名拼错或用了未实现的组件。已实现的标签见本页各节标题

行为层面的坑

现象原因与解法
输入框打字没反应 / 滑块拖不动 受控组件没回写。在 onInput / onChange 里把值写回 signal
输入框光标不闪 事件泵睡着了。挂一个空转 requestAnimationFrame 循环
点按钮没反应(无 VM 嵌入场景) 纯 Go 嵌入时脚本闭包需要 VM 回调桥,测试要跑完整事件循环而不是裸调泵
折行文本高度不对 / 压住兄弟节点 折行需要宽度约束:写显式 width,或用父容器 stretch 给宽
带背景色的容器里子元素全叠在左上角 那不是 column/row,通用盒子不布局子元素
滚出视口的行看不见却"好像还能点" 不会发生 —— scroll 的绘制与命中共用同一个视口,这是刻意设计
动画期间尺寸抽搐 读属性应该走渲染层的插值;若布局读到了 prop 的终值又回落固有尺寸就会抖。正常使用不会遇到

未实现的组件(可用现有能力模拟)

想要现状 / 替代做法
tabs 选项卡条件渲染{() => tab() === 0 ? panelA : panelB} 直接写,未封装成组件
list / table / tree列表渲染 + scroll 已够用;表格与树要自己拼 row
虚拟化长列表缺 key/diff,需自行只挂可见区间
tooltiponMouseMove 可以自己实现
grid 栅格用嵌套 row + 固定宽度模拟
icon / 富文本 / spinner / 视频未实现

平台差异一览

能力WindowsLinux (X11)macOS
窗口与渲染✅(需实机验证)❌ 占位
输入法 IME
剪贴板❌ 降级(读空串 / 写 false)
原生对话框❌ 降级(confirm 取 true、openFile 取 null)
字体✅ 静态候选✅ 惰性扫描字体目录,CJK 优先
完整演示脚本

本页每个组件都对应仓库 testdata/ 下一个可运行脚本: button_demo.jsform_demo.jsinput_demo.jstextarea_demo.jsselect_demo.jsslider_demo.jsscroll_demo.jsimage_demo.jscanvas_demo.jsdialog_demo.jsdialog_native_demo.jsmenu_demo.jstransition_demo.jsmultiwindow_demo.jslist_demo.jstabs_demo.jsprogress_demo.jsclipboard_demo.jsime_demo.jsevents_demo.js。 例如 goxjs testdata/menu_demo.js