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~255 或 0~1。
| 属性 | 作用于 | 说明 |
|---|---|---|
width / height / margin | 所有元素 | 整数像素;不写时由 intrinsicSize 按内容算 |
padding | 容器 | 四边统一值,不可分边设置 |
gap | column / row | 子元素间距 |
background | 所有元素 | 容器上是填充色;组件标签上是"强调色"语义(选中填充、进度前景、开关轨道) |
border | 所有元素 | 固定 1px 描边色,不可调宽度/圆角 |
color | 所有元素 | 文本色,沿祖先链继承 |
font | text | 像素字号 |
disabled | 所有元素 | 沿祖先链继承;子树既不响应事件也不参与焦点切换,整体降饱和 |
⑤ 未知标签会被警告
h() 对标签名做白名单校验,集合外的标签会往 stderr 打印一次警告并渲染成普通盒子:
gfx: unknown tag "foo" (rendered as a plain box; see docs/gui-component-status.md)
这是刻意设计的 —— 没登记的组件此前会静默渲染成空白,最难排查。看到这行警告就说明标签名拼错了或用了未实现的组件。
非 column / row 的标签(包括未知标签、rect)
即使有了尺寸,子元素也全部叠在左上角。需要分组容器就老实用
<column> 或 <row>。
1布局容器
布局原语只有两个方向,加上滚动、分隔与占位,共 4 组。
<column> / <row>
稳定 布局原语
唯一的两个真布局容器。子元素按主轴依次排列(column 纵排、row 横排),
交叉轴可按 alignItems 对齐。尺寸未显式给出时按内容自适应。
| Prop | 类型 | 说明 |
|---|---|---|
| gap | number | 子元素间距(像素),缺省 0 |
| padding | number | 四边统一内边距,缺省 0 |
| alignItems | string | stretch(默认) / start / center / end |
| justifyContent | string | start(默认) / center / end / between |
| width / height | number | 不写则按内容算;作为根容器时不写则铺满窗口 |
| 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>
- 无
flexWrap、alignSelf、flexShrink、order。 - 没有
grid:复杂栅格要靠嵌套row+ 固定宽度模拟。
<scroll>
稳定 纵向滚动纵向滚动容器:内容超出视口时可滚轮滚动,右侧自动出现 8px 轨道 + 比例滑块。 绘制裁剪与命中裁剪共用同一个视口 —— 滚出视口的行既画不出来也点不中,不会出现"看不见却点得到"的幽灵点击。
| Prop | 类型 | 说明 |
|---|---|---|
| width / height | number | height 不给时缺省 200;内容不足一屏则不出滚动条 |
| onWheel | function | 容器滚到边界后,滚轮事件才继续往外冒泡给脚本;回调收到 {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>
- 只支持纵向滚动;横向滚动与滚动条鼠标拖拽 v1 未实现(只能滚轮或脚本改
offsetY)。 - 没有虚拟化:20 行就挂 20 个节点,长列表请自行只挂可见区间。
- 滚轮遵循 DOM 滚动链语义:先在容器内消费,到边界才冒泡。
<separator>
稳定1px 分隔线。横向时高度固定 1px、宽度由容器 stretch 拉满;加 vertical 后变成 1px 宽的竖线。
| Prop | 类型 | 说明 |
|---|---|---|
| vertical | boolean | true 时画竖线;此时主轴(高度)需显式给 height |
| background | 颜色 | 线条颜色 |
<text>上半区</text>
<separator />
<text>下半区</text>
<spacer>
稳定 零绘制弹性占位:零固有尺寸、零绘制,专门用来吃主轴的富余空间。最常见的用法是"把两个元素推到两端"。
<row>
<text>左</text>
<spacer flexGrow={1} />
<text>右</text>
</row>
<rect>
稳定 通用盒子
矩形色块,也是最通用的"盒子":填充 background、描边 border,
宽高常用来做色条、分隔块、仪表底色。点击区域、右键菜单等交互常直接挂在它上面。
| Prop | 类型 | 说明 |
|---|---|---|
| width / height | number | 尺寸;缺省为 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)} />
它是通用盒子分支,子元素全部叠放在左上角。想在色块里排版,就用
<column> / <row> 当容器,把
<rect> 当纯色块用。
2表单控件
全部是受控组件,显示只看 prop,交互只派发回调(见第 0 节约定 ③)。
<button>
稳定 受控外观按钮。尺寸按内容自适应(文本 + 8px 左右内边距),文本垂直居中;自带悬停提亮与按压压暗反馈。
| Prop | 类型 | 说明 |
|---|---|---|
| onClick | function | 左键抬起时触发。回调没有参数 |
| disabled | boolean | 整体降饱和,点击被拦截,也不抢键盘焦点 |
| 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 | 类型 | 说明 |
|---|---|---|
| checked | boolean / function | 是否选中。通常传函数绑定 signal |
| onClick | function | 点击时触发,无参数;自己在回调里翻转 signal |
| disabled | boolean | 点击被拦截 |
| 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 | 类型 | 说明 |
|---|---|---|
| value | string / function | 显示内容,显示只看它 |
| onInput | function | 内容变化时派发,收到 {value}(string)。移动光标不派发 |
| placeholder | string | 值为空时以灰字显示;此时光标停在最左,不被灰字挤走 |
| onKeyDown / onKeyUp | function | 未被消费的键冒泡上来,收到 {key, ctrl, shift, alt} |
| onFocus / onBlur | function | 焦点进入 / 离开 |
| width | number | 缺省 160(刻意不按内容算宽,也不参与交叉轴 stretch) |
| disabled | boolean | 不可编辑、不参与焦点 |
键盘归属
输入框只消费编辑类按键;其余一律放行给上层,这样"输入框放在对话框里按 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();
- 无选区/拖选/复制粘贴按键(剪贴板要走 gx/gfx 的同步 API 手动接)。
- 单字符直输路径只认 BMP;中文等需经输入法提交通道(Windows 已支持,X11 暂无 IME)。
- 输入法提交不产生
onKeyDown—— 统计"敲了几次键盘"不能用它代替。
<textarea>
稳定 IME:仅 Windows
多行文本编辑。光标是二维的 {行, 列},内容超出可视高度时纵向滚动,并且滚动跟随光标
(在最后一行回车时不会把光标顶出框外)。
| Prop | 类型 | 说明 |
|---|---|---|
| value | string / function | 显示内容,受控 |
| onInput | function | 收到 {value};输入法一次提交只派发一次 |
| rows | number | 可见行数,决定缺省高度 |
| placeholder | string | 空值时的灰字提示 |
| width / height | number | 显式尺寸 |
| onKeyDown | function | 不含 Enter(被编辑框消费了),但 Escape 会冒泡上来 |
| disabled | boolean | 不可编辑 |
多行框里 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();
}}
/>
- 不做软换行:超长行被右侧裁掉,而不是折到下一行 —— 换行只由
\n决定,这样"光标行号"与文本严格一一对应。 - 无选区、无撤销栈、无横向滚动。
<select>
稳定 弹层下拉框。点击展开选项弹层(自带逃逸裁剪,不会被 28px 的字段盒裁剪,也不会被后面的兄弟节点盖住), 支持键盘开合与移动高亮。
| Prop | 类型 | 说明 |
|---|---|---|
| options | array | 字符串数组,或 {value, label} 对象数组 |
| value | string / function | 当前值,显示只看它 |
| onChange | function | 选中时派发,收到 {value} |
| placeholder | string | 无选中值时的灰字 |
| width | number | 字段宽度,缺省按内容 |
| disabled | boolean | 不可展开 |
键盘操作:获焦后 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 | 类型 | 说明 |
|---|---|---|
| value | number / function | 当前值,决定滑块位置 |
| onInput | function | 拖动/点击时派发,收到 {value} —— 是 number,不是字符串 |
| min / max | number | 取值范围,缺省 0 / 100 |
| step | number | 步长,缺省 1;step <= 0 表示连续取值 |
| width | number | 缺省 160 |
| disabled | boolean | 拖不动,整体降饱和 |
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 })
- 无纵向滑块、无双端 range、无刻度/数值标签、无键盘微调(←/→)。
- 拖动是独占手势:期间鼠标划过别的控件不会给它们加悬停高亮。
- 量程异常值会被安全处理:
max < min塌缩到 min,NaN 不污染几何。
3内容展示
<text>
稳定
文本。它是文本块唯一载体 —— JSX 里的裸字符串子节点会变成隐式的 #text 节点,
而 #text 永远是单行的。想折行必须用 <text> 并开
wrap。
| Prop | 类型 | 说明 |
|---|---|---|
| font | number | 像素字号 |
| color | 颜色 | 文本色,沿祖先链继承 |
| wrap | boolean | true 时按可用宽度贪心折行,高度按行数增长 |
| ellipsis | number | 限制行数上限,超出补 "…";隐含开启 wrap |
| width / height | number | 折行需要一个宽度约束(显式 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>
否则它在 column 里会按"未折行的整行宽"撑开并溢出容器。
不想被拉满就显式写 width。另外文本块高度是"算两遍"的(父容器定下盒宽后回头重算),
这是实现细节,你不需要管,但要知道它意味着"折行文本的高度依赖父容器给的宽度"。
<image>
稳定 同步解码图片显示。用 Go 标准库解码 PNG / JPEG / GIF(不引入新依赖),挂载时同步解码并带 16 项 LRU 缓存。
| Prop | 类型 | 说明 |
|---|---|---|
| src | string | 文件路径,相对进程工作目录解析 |
| width / height | number | 不给用图片自然尺寸;给了则最近邻缩放 |
| disabled | boolean | 罩一层半透明灰 |
<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} /> {/* 失败:灰底 + 交叉线 */}
- 路径按进程工作目录解释,不是"脚本所在目录"(脚本可能来自 stdin / 字符串 / 打包产物,没有所在目录这个概念)。从仓库根运行演示脚本时可写
testdata/xxx.png。 - 不支持网络 URL、无异步加载、无缩放质量选项(总是最近邻)。
- 加载失败画灰底 + 45° 交叉线占位,并每个路径只往 stderr 警告一次,不影响其它内容。
<progress>
稳定
进度条。value 取 0~1(自动钳位),缺省 200×8,轨道浅灰 + 前景绿。
| Prop | 类型 | 说明 |
|---|---|---|
| value | number / function | 0~1,超出范围自动钳位 |
| background | 颜色 | 前景色(强调色语义),缺省 #27ae60 |
| width / height | number | 缺省 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反馈与弹层
这一类的标签自带弹层层级基线(overlayZBase),会自动逃逸祖先裁剪,不需要手动写
escapeClipping。
<dialog>
稳定 模态遮罩模态对话框:40% 黑遮罩铺满窗口 + 居中卡片。流内子节点就是卡片内容, 卡片之外的区域属于遮罩。遮罩存在时会吞掉其下所有点击。
| Prop | 类型 | 说明 |
|---|---|---|
| open | boolean / function | 受控显示开关;关闭时不绘制也不参与命中 |
| onClose | function | 点击遮罩 / 按 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 只关一层,优先级是:菜单 > 下拉框 > 对话框。 所以对话框里展开着的下拉框会先收起来,再按才轮到对话框。
<toast>
稳定 非模态轻提示:固定在窗口右上角,非模态(它下面的内容照常可点)。挂在树上就显示,挂载/卸载完全由 JS 控制, 内核不管定时器。
| Prop | 类型 | 说明 |
|---|---|---|
| message | string | 提示文本 |
| level | string | success / 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 函数表达式)。
- 只有这三个 API:无自定义按钮、无多选、无选目录。
- 模态期间界面仍会重绘(系统替我们泵消息),但不派发任何 JS 回调。
- 非 Windows 后端会降级:内容打到 stderr 并立即返回 ——
confirm取 true、openFile取 null(视作已取消)。
5导航与菜单
<menubar> / <menu> / <menuitem>
稳定 弹层自绘菜单栏 + 下拉菜单 + 子菜单。支持互斥展开、点击外部关闭、键盘导航,菜单项可挂全局快捷键。
| 元素 | Prop | 说明 |
|---|---|---|
| menubar | — | 普通容器:高度固定 26px,横向排列子项 |
| 子节点 | <menu> 标题与任意其它元素(如右侧的 status 文本) | |
| menu | label | 菜单标题(顶级)或多个菜单项的容器(子菜单) |
| 子节点 | <menuitem> / <separator> | |
| menuitem | label | 菜单项文本 |
| 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 看不到已被输入框消费的按键。规则:
- 只认带
Ctrl/Alt的组合。shortcut="S"这种裸字母键不会被受理(否则全应用都打不出s)。 - 修饰键全等比较:
Ctrl+S不会被Ctrl+Shift+S触发。 Cmd自动归一到Ctrl。- 菜单项没有
onClick也可以挂shortcut,只是命中后无事发生。
键盘导航
焦点在菜单栏时:← / → 在标题间循环切换(换过去就展开),
↓ / Enter / Space 展开,
Esc 收起当前层。
- 无鼠标滑过切换标题、无悬停自动展开子菜单、无勾选项(checked menu item)。
- 分隔线本身也能命中(命中测试只认带处理器的节点,所以每一行都挂了内置处理器)。
openContextMenu(x, y, items)
稳定 弹层 同步 API
在指定坐标就地弹出右键菜单。这是数据式 API,不是 contextMenu prop ——
理由见下方提示。
| 参数 | 类型 | 说明 |
|---|---|---|
| x, y | number | 弹出的屏幕坐标,通常来自 onContextMenu 的 e.x/e.y |
| items | array | <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")} />
])
}
/>
JSX 元素是单次挂载的对象,一个节点只有一个 Parent 字段。
把同一个 <menu> 做成 prop 挂到多处会互相争抢宿主。做成"每次调用现构造一份"的数据式 API 就没有这个问题。
行为:靠近屏幕右下角时会自动向左上翻折保证整块可见;点选项或点外部关闭;在菜单上再点右键会被吞掉(不会误关)。
6媒体与自绘
<canvas>
稳定 响应式自绘
自绘画布。在 onDraw(ctx) 里用 7 个原语直接落笔,坐标是画布局部坐标
(0,0 就是画布左上角),越界部分自动裁掉,不会溢出到界面其它地方。
在 onDraw 里读到的 signal 变化会自动触发重绘。
| Prop | 类型 | 说明 |
|---|---|---|
| onDraw | function | 绘制函数,收到 ctx。必须传函数本身 |
| width / height | number | 缺省 200×120;canvas 不是容器,拿不到父容器交叉轴 stretch |
| background / border | 颜色 | 画布盒自身的底色与描边 |
| disabled | boolean | 降饱和 |
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 / height | number(只读) | 画布尺寸 |
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,那条分支不跑就收不到订阅)。
- 无路径 / 变换 / 渐变 / 抗锯齿开关 / 可调线宽 /
drawImage。 - 无内置帧循环:动画要自己挂
requestAnimationFrame。 - 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>
数组或条件变化时整组重建子树(旧子树的 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 | 类型 | 说明 |
|---|---|---|
| zIndex | number | 同层内的绘制与命中顺序,越大越靠上。用稳定排序,不改变子节点顺序 |
| position | string | 设成 "absolute" 即脱离常规流,此时 left / top 生效(相对父内容区) |
| left / top | number | 绝对定位的偏移 |
| escapeClipping | boolean | 让该子树的绘制与命中溢出父盒,并收集到根层级最后绘制 |
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 | 类型 | 说明 |
|---|---|---|
| transition | number 或对象 | transition={400} 是简写(对所有可动属性生效);transition={{width: 400, opacity: 350}} 按属性分别定时长 |
| opacity | number | 0~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 里维护。
- 缓动曲线固定为 ease-out,不可自定义;无 keyframes、无
transition-delay。 - 颜色过渡 v1 不做。
value/padding/gap/margin/flexGrow刻意排除:前者的过渡会与脚本自己的受控写回打架,后者半像素中间值会让文字穿透。- 静止时零开销(没有活动动画就不续表)。
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");
- 全部同步 API,无 Promise —— 本地小文件读写,不需要异步仪式。
- 单文件
storage.json整读整写,临时文件 + rename 原子替换;文件损坏按空存储处理并打告警,应用照常起。 - 存函数 / 循环引用会在写入时抛 TypeError(而不是静默存成 null 读不回来)。
GOX_STORAGE_DIR环境变量可整体替换根目录(测试隔离 / 便携部署)。
多窗口
稳定
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");
- 关掉其中一个,其余继续正常响应;全部关闭后进程才退出。
- 窗口之间不共享节点。想同步状态就共享同一个
createSignal(在脚本顶层建),不要指望 prop 自动串起来。 - 每个窗口是独立的 app 实例:悬停链、按压态、键盘焦点、快捷键表、弹层状态互不干扰。
- 关了"最近 Mount 的窗口"之后,剪贴板 / 原生对话框这类没有节点上下文的 API 会自动改指到幸存窗口。
- 无运行中改标题 / 尺寸、无窗口位置控制、无模态子窗口。
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,需自行只挂可见区间 |
| tooltip | 有 onMouseMove 可以自己实现 |
| grid 栅格 | 用嵌套 row + 固定宽度模拟 |
| icon / 富文本 / spinner / 视频 | 未实现 |
平台差异一览
| 能力 | Windows | Linux (X11) | macOS |
|---|---|---|---|
| 窗口与渲染 | ✅ | ✅(需实机验证) | ❌ 占位 |
| 输入法 IME | ✅ | ❌ | — |
| 剪贴板 | ✅ | ❌ 降级(读空串 / 写 false) | — |
| 原生对话框 | ✅ | ❌ 降级(confirm 取 true、openFile 取 null) | — |
| 字体 | ✅ 静态候选 | ✅ 惰性扫描字体目录,CJK 优先 | — |
本页每个组件都对应仓库 testdata/ 下一个可运行脚本:
button_demo.js、form_demo.js、input_demo.js、
textarea_demo.js、select_demo.js、slider_demo.js、
scroll_demo.js、image_demo.js、canvas_demo.js、
dialog_demo.js、dialog_native_demo.js、menu_demo.js、
transition_demo.js、multiwindow_demo.js、list_demo.js、
tabs_demo.js、progress_demo.js、clipboard_demo.js、
ime_demo.js、events_demo.js。
例如 goxjs testdata/menu_demo.js。