基于 CUI 1.4.0 的 tphp 窗口 UI 扩展包(type=ext),
单类 Tphp\Tui\App 立即模式封装,310 个方法,覆盖 CUI 组件面(覆盖矩阵与排除清单见
doc/coverage.md)。
build\tphp.exe ext --add C:\project\tphp\tui # 编译 + 重链(换像,下次运行生效)
build\tphp.exe run example/hello.php # 交互示例
POSIX 上为 ./build/tphp ext --add …。窗口后端按平台分栏(mod.json os.<平台>.sources):
| 平台 | 后端 | 额外依赖 |
|---|---|---|
| Windows | cui/backends/win32 |
无(引擎默认库 + 包内 gdi32/opengl32/imm32/dwmapi/comdlg32) |
| Linux | cui/backends/x11 |
X11 开发头文件(libx11-dev / xorg-x11-devel),链接 -lX11 |
| macOS | cui/backends/cocoa(.m,-fobjc-arc) |
系统 SDK 自带 Cocoa/QuartzCore/OpenGL frameworks |
$app = new \Tphp\Tui\App("demo", 1080, 720); // 第4参 true = hidden(测试用)
while ($app->frame()) { // 渲染上一段 + 等事件 + 开新段;关窗 false
$app->heading("Hello");
if ($app->button("点我")) { /* clicked */ }
$on = $app->toggle("开关", $on); // 返回新值(PHP 持状态)
}
$app->destroy();- 返回三态:
bool= 点击/可见/翻转结果;int= 新索引/句柄/页码;关联数组 = 结构化结果 (如pathBar()返回['clicked','submitted','editing'])。值型控件一律返回新值。 - buffer 往返:
input/textarea/search/otpInput/tagInput/codeEditor/dataGridCellEdit值由 PHP 持有,传入返回出(idle 帧原样返回)。 - scope 成对:
xxxBegin/xxxEnd必须配对;open类参数传入当前值、返回新值。 - 单例/静态暂存:terminal/log 需先
terminalInit()/logInit();hex 选择、image 视图、 裁剪矩形、split ratio、table/dataGrid/treeTable 的 columns 等跨段暂存(同类 scope 每段至多一层)。 - 错误一律抛异常(TypeError/ValueError/LogicException),无静默。
<?php
use Tphp\Tui\App;
$app = new App("我的窗口", 800, 600); // 第4参 true = 隐藏窗口(测试)
while ($app->frame()) { // 渲染上一段 → 等事件 → 开新段;关窗返回 false
$app->heading("Hello tui");
if ($app->button("退出")) {
$app->quit(); // 下一次 frame() 返回 false,循环自然结束
}
}
$app->destroy(); // 释放窗口/纹理(循环退出后必调)组件调用只发生在 frame() 返回 true 的段内(立即模式:每帧重放一遍调用即可,
不需要手工维护控件树)。frame() 会阻塞等事件——不要在段外空转调用组件。
立即模式没有控件对象,状态全部放在 PHP 变量里,每帧传入、取回新值:
// 1) 值型:返回新值(checkbox/toggle/slider/select/tabs/...)
$agree = false;
$tab = 0;
$vol = 0.5;
while ($app->frame()) {
$agree = $app->checkbox("同意协议", $agree);
$tab = $app->tabs("main", ["首页", "设置"], $tab); // 点击后返回新索引
$vol = $app->slider("音量", $vol, 0.0, 1.0);
}
// 2) buffer 往返:值由 PHP 持有,传入返回出(input/textarea/search/otp/tag/codeEditor)
$text = "";
while ($app->frame()) {
$text = $app->input("q", $text, 256, ["label" => "搜索"]);
if ($app->button("提交") && trim($text) !== "") {
do_search($text);
$text = "";
}
}
// 3) open 类:传入当前展开态,返回本帧新态(dialog/accordion/tree/banner/...)
$dialog_open = false;
while ($app->frame()) {
if ($app->button("打开对话框")) $dialog_open = true;
if ($app->dialogBegin("标题", $dialog_open)) {
$app->text("内容");
if ($app->button("关闭")) $dialog_open = false;
$app->dialogFooterBegin();
$app->button("OK");
$app->dialogFooterEnd();
$app->dialogEnd(); // Begin/End 必须配对(关态时封装层自动跳过)
}
}
// 4) scope 成对:xxxBegin / xxxEnd 每帧一一配对(table/scroll/split/menu/...)
while ($app->frame()) {
if ($app->tableBegin("t1", [["title" => "名"], ["title" => "值"]])) {
$app->tableRow(false);
$app->tableCell(); $app->tableCellText("a");
$app->tableCell(); $app->tableCellText("1");
$app->tableEnd();
}
}// 计数器(点击 +1,按钮返回 bool)
$count = 0;
$app->frame();
if ($app->button("+1")) $count++;
$app->text("count = " . $count);
// 列表增删(数组即状态)
$items = ["a", "b"];
$keep = [];
foreach ($items as $it) {
$on = $app->toggle($it, false); // 行内勾选态也可放平行数组
if (!$app->button("删")) $keep[] = $it;
}
$items = $keep;
// 轻反馈:toast 句柄 + alert/banner(返回 open/可见态)
$th = $app->toast("已保存");
if ($app->toastActionClicked($th)) { /* ... */ }
// 图表数据 = 每帧构造的 PHP 数组
$app->lineChart("c1", [
["name" => "访问", "values" => [120.0, 180.0, 150.0], "color" => 0x3B82F6FF],
], ["height" => 180]);
// 日期:[y, m, d] 三元组往返
$d = $app->datePicker("d1", [2026, 11, 15]);
$app->muted("选中 {$d[0]}-{$d[1]}-{$d[2]}");所有参数错误都抛异常,可正常 try/catch(tphp 原生异常):
try {
$app->setAccent(99); // ValueError: accent must be in 0..7
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}TypeError(类型不符)/ ValueError(取值越界)/ LogicException(时序错误,如未
terminalInit() 就 terminal())三类;没有静默失败分支。
写测试(hidden 窗口骨架)
<?php
$fail = 0;
function check(bool $c, string $n): void {
global $fail;
if ($c) echo "PASS $n\n"; else { echo "FAIL $n\n"; $fail++; }
}
$app = new \Tphp\Tui\App("t", 640, 480, true); // 隐藏窗口
$app->frame(); // 开首段
// —— 每帧内容:绘制 + 断言返回值 ——
check($app->button("hi") === false, "idle-not-clicked");
$app->quit();
check($app->frame() === false, "loop-stopped");
$app->destroy();
echo $fail ? "SMOKE=FAIL\n" : "SMOKE=OK\n";
exit($fail ? 1 : 0);运行 tphp run tests\xxx.php;整套冒烟用 tests\run.ps1 一键驱动。需要测
点击/翻转等交互时,用 simulateMouse/simulateMouseButton/simulateKey 注入 +
lastRect() 定位(时序规则见 doc/coverage.md §4)。
xxxBegin()返回 false(未展开/不可见)时,对应xxxEnd()仍要无条件调用 ——封装层已按 SCOPE_IF 语义内部守卫,直接配对写即可。- 同一组件每段只调用一次;id 类参数(
input("q",...)等)在同帧内保持稳定。 - 资源型对象(
imageLoad的纹理)用完调imageDestroy;窗口结束调destroy()。 terminal()/logView()等单例组件需先terminalInit()/logInit(),否则抛 LogicException。- 需要真实显示窗口时去掉第4参(hidden),关窗即
frame()返回 false。
| 组 | 方法 | 返回 |
|---|---|---|
| 生命周期 | __construct(title,w,h,hidden=false)、frame()、quit()、destroy() |
frame: bool |
| 窗口 | setTitle(title)、setSize(w,h) |
void |
| 布局原语 | columnBegin(pad,gap)、rowBegin(pad,gap)、boxEnd()、spacer()、separator() |
void |
| 基础文本 | heading(s)、secondary(s)、text(s)、label(s) |
void |
| 基础控件 | button(label)、toggle(label,value) |
bool(点击 / 新值) |
| 组 | 方法 | 返回 |
|---|---|---|
| 文本 | muted code kbd tooltip gap(size) |
void;link bool |
| 图标 | icon(name,size=0,color=-1)(0xRRGGBBAA,-1=主题色) |
void |
| 卡片 | cardBegin / cardEnd |
bool / void |
| 徽章头像 | badge avatar avatarGroup(names,maxVisible=3,size=32) |
void |
| 进度 | progress(value) progressRing(label,value,size=48) spinner(size=24) skeleton(height=20) loader() |
void |
| 状态 | emptyState(title) metric(label,value) meter(label,used,total,unit="") |
bool / void / void |
| 按钮 | iconButton copyButton(id,text) holdButton confirmButton statusButton(label,status=0..3) |
bool |
buttonGroupBegin/End、splitButton(label,items[]): int(-1 无) |
void / int | |
| 社交 | reactions(id,items[]): int(items=[icon,count,active]) |
int |
| 文本特效 | shimmerText textReveal(id,s) wordRotate(id,words,interval=2) numberRoll(id,s) gradientText counter(id,v) ticker(id,items) |
void;typewriter(id,s): bool |
| 组 | 方法 | 返回 |
|---|---|---|
| buffer 往返 | input(id,value,cap=512,opts=[])、textarea(cap=4096)、search(cap=256)、otpInput(id,code,len=6)、tagInput(id,buf,cap=512)、moneyInput(id,value) |
string / float |
| 数值 | number(id,v,min,max) slider(label,v,min,max) rulerSlider knob fader scrub |
float(新值) |
rangeSlider(label,lo,hi,min,max)、histogramSlider(label,bins,lo,hi,min,max) |
array{lo,hi} | |
levelMeter(id,levels[]): bool |
bool | |
| 勾选选择 | checkbox(label,v): bool、chip(label,v): bool |
bool(新值) |
radio(label,current,option): int、radioGroup(id,items,index): int、segmented(id,items,index): int、select(id,items,index): int、wheelPicker(id,items,index): int、rating(id,v,max): int |
int(新索引) | |
toggleGroup(id,labels,values): bool[]、multiSelect(id,items,selected): bool[] |
bool[](新状态) | |
| 日期时间 | dateToday(): [y,m,d]、calendar(id,date)、datePicker(id,date) |
[y,m,d] |
dateRangePicker(id,start,end)、rangeCalendar(id,start,end,months=1) |
[start,end] | |
timePicker(id,h,m)、timeDial(id,h,m,size=64) → [h,m];colorPicker(id,color): int(0xRRGGBBAA) |
| 组 | 方法 | 返回 |
|---|---|---|
| 容器 | boxBegin(pad=0,gap=0)/boxEnd、scrollBegin(id="",horizontal=false)/scrollEnd/scrollTo(id,y) |
void |
splitBegin(id,ratio)/splitNext/splitEnd |
splitEnd: float(新比例) | |
| 导航 | tabs(id,items,index): int、navItem(label): bool、navSection、breadcrumb(items): int(-1 无)、pagination(id,page,pageCount): int、stepper(steps,current) |
|
pathBar(id,segments): assoc、pathBarMenu(id): int、pathBarMenuEnd、toc(scrollId,items): int、tocAnchor |
||
| 覆盖层 | popoverBegin/End/Open/Close/Toggle/IsOpen、menuBegin/End/Item/Separator/Label、contextMenuBegin/End |
见名 |
dialogBegin(title,open): bool/End/dialogFooterBegin/End、sheetBegin/End、alert(title): bool(dismiss 前 true)、banner(text,open): bool |
open 语义 | |
toast(title): int 句柄、toastActionClicked(h): bool、toastDismiss(h)、hoverCardBegin/End、userMenuBegin/End |
||
speedDial(id,actions): int(-1 无)、spotlightBegin/End |
||
| 列表树 | accordionBegin(title,open): bool/End、treeBegin(label,open): bool/End/、treeClicked(): bool、listItem(title): bool |
|
virtualList(id,count,rowHeight,height=0): assoc{first,last}/End、swipeItemBegin(id,left,right)/End: int、reorderBegin(id,order)/End: int[] |
||
| 表格 | tableBegin(id,columns): bool/tableRow(selected=false): bool/tableCell/tableCellText/tableEnd |
columns=[title,sortable] |
dataGridBegin(id,columns,rowCount): false|assoc{first,last}/Row(row): bool/Cell/CellText/CellEdit(value,cap=256): string/End |
columns 增 width/align/fixed/priority | |
treeCell(label,depth,hasChildren,open,icon=""): bool、treeTableBegin(id,columns,nodes)/Row(): int/Name/Depth/End: assoc、filterBar(id,fields,selected,search): assoc |
nodes=[parent,first_child,next_sibling,flags] | |
| 工具条 | toolbarBegin(id)/End/+Button(icon): bool/Separator/Spacer、statusBarBegin/End/Spacer/Text/Item: bool/Progress(v)、menuBarBegin/End/MenuBegin: bool/MenuEnd |
|
| 停靠 | dockBegin/End/Item(icon,label): bool/Separator、dockspaceBegin/End、dockPanelBegin(title,open): bool/End、dockSave(id): string、dockLoad(id,json): bool |
|
| 轮播看板 | carouselBegin(id,count,index): int/SlideBegin/End/End、kanbanBegin/End: assoc|null+ColumnBegin(title,n): bool/CardBegin(id): bool/CardEnd/ColumnEnd、expandableCardBegin(title,open): bool/End、cardStackBegin(id,count)/ItemBegin(depth)/ItemEnd/End: int |
| 组 | 方法 | 返回 |
|---|---|---|
| 基础 | lineChart(id,series,opts) barChart(id,series,opts)(series=[name,values,color?];opts=[labels,area,no_legend,no_grid,stacked,smooth,format,height,min,max]) |
void |
donutChart(id,values,labels,opts)、sparkline(values,width=100,height=24,color=0)、gauge(label,v,size=120)、radialGauge(id,v,min,max) |
void | |
scatterChart(id,series): int(points=[[x,y]])、radarChart(id,series,axes): int |
int(hover) | |
| 网格 | heatmap(id,values,rows,cols): int、calendarHeatmap(id,start,values): int |
int |
| 展示 | treemap/funnel/waffle/splitBar/barList/activityRings(id,items): int(items=[label,value,color?]) |
int |
sankey(id,nodes,links): int(links=[from,to,value]) |
int | |
| 金融事件 | candlestick(id,candles,labels=[]): int(candles=[open,high,low,close,volume])、waterfall(id,requests): assoc{clicked,activated,hovered} |
|
dotChart(id,series): assoc{hovered,x,value,clicked}、timelineItem(title) |
| 组 | 方法 | 返回 |
|---|---|---|
| 代码文本 | codeBlock(id,code,lang="",title="")、jsonView(id,json): bool(合法=true)、markdown(id,text): assoc |
|
codeEditor(id,code,lang=""): assoc{text,changed,refused,focused,cursor,anchor,line,column,…} + codeEditorGoto/Select/Find |
||
diffView(id,old,new): assoc{added,removed,changes,current}、hexView(id,bytes): assoc + hexViewGoto(id,offset) |
||
| 终端日志 | terminalInit(textCap=65536,lineCap=4096)/terminalWrite(s)/terminalClear()/terminal(id): assoc{command,interrupt} |
单例 |
logInit(textCap,lineCap)/logAppend(level 0..4,text)/logClear()/logView(id,timestamps=false) |
单例 | |
| 向导计划 | commandPalette(id,open,commands): assoc{picked,open}、tour(id,steps,step): assoc{event,step}、gantt(id,tasks,origin,days): int |
|
| 文件 | fileBrowser(id,path): assoc{path,picked,selected,navigated,error}、filePicker(id,open,path=""): assoc{open,path} |
|
| 图片 | imageLoad(path): assoc{tex,w,h}、imageCreate(w,h,rgba): int、imageDestroy(tex)、image(tex)、imageViewer(id,tex,w,h): assoc{zoom,cx,cy}、cropper(id,tex,w,h): assoc{x,y,w,h} |
|
| emoji | emoji(s,size=0)、emojiPicker(id): assoc{picked}、emojiSupported(s): bool、emojiFontLoadFile(path): bool、emojiFontLoadSystem(): bool |
|
| 社交评论 | chatMessage(text,outgoing=false,author="",markdown=false)、chatDivider(label)、typingIndicator(name)、comment(id,author,text): int、presence(id,users,maxVisible=3,size=32) |
|
| 签名 | signaturePad(id): bool、signaturePoints(id): assoc{points,strokes}、signatureClear(id) |
|
| 节点编辑 | nodeEditorBegin(id): false|assoc{x,y,zoom}/End、nodeBegin(id,x,y,title): assoc{x,y,selected}/End、nodeInput/Output(pin,label,type=0): bool、nodeContentBegin/End、nodeLink(id,from,to): bool、nodeLinkCreated/Removed/Deleted: assoc|null |
|
| devtools | testExplorer(id,items): assoc、stackTrace(id,title,message,frames,lang=""): assoc{clicked}、requestBar(id,url,method=0,loading=false,placeholder=""): assoc、responseStatus(status,seconds,bytes) |
|
uptime(id,services): assoc{service,day}、timeHistogram(id,start,bucketSeconds,buckets,series,selStart=0,selEnd=0): assoc、coverage(id,files): assoc{activated,lines}、blame(id,lines,lineCommit,commits): assoc{clicked,hovered} |
||
featureFlags(id,flags,envs): assoc{flag,env,flags}、apiExplorer(id,endpoints): assoc{selected,try_it}、readout(label,value)、dotDisplay(text) |
| 组 | 方法 | 返回 |
|---|---|---|
| 截图 | savePng(path): bool、screenshotRgba(): assoc{w,h,data} |
|
| 系统 | fileDialog(kind=0,title="",filterName="",filter="",defaultName=""): string|false(冒烟不调用)、openUrl(url): bool(同) |
|
| 主题查询 | setTheme(0 light/1 dark)、setAccent(0..7)、setReducedMotion(bool)、dpiScale(): float、size(): assoc{w,h}、backend(): string、droppedFiles(): string[] |
|
| 测试面 | simulateMouse(x,y)、simulateMouseButton(0 left/1 right/2 middle, down=true)、simulateKey(cui_key枚举值,down,mods=0)、simulateText(utf8)、lastRect(): assoc{x,y,w,h} |
见 coverage.md「测试面时序」 |
powershell -ExecutionPolicy Bypass -File tests\run.ps1 # cwd = tui 包根
7 个冒烟(basic/widgets/form/container/chart/advanced/interaction)全部 hidden 窗口、 自断言、exit code 语义;interaction 用输入注入驱动真实点击(button 返回 true、toggle 翻转、 PNG 截图落盘校验)。任一失败退出码非 0。
| 文件 | 内容 |
|---|---|
example/hello.php |
最小循环 + 计数按钮 |
example/todo.php |
输入添加、勾选计数、删除/清空 |
example/form.php |
表单控件全集(文本/数值/选择/日期) |
example/dashboard.php |
指标 + 折线/柱状/环形/仪表 + 表格 |
example/markdown.php |
markdown / codeBlock / jsonView / hexView / diffView |
运行:build\tphp.exe run example\<name>.php(POSIX 同理)。