Compare commits
19
Commits
098c00486a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcce34cda1
|
||
|
|
bc716d27be
|
||
|
|
8394eea44c
|
||
|
|
f7fdd2d0bd
|
||
|
|
9883af2bf3
|
||
|
|
22b53e9e9e
|
||
|
|
5a7e921c7e
|
||
|
|
806451800c
|
||
|
|
7ef6ed9865
|
||
|
|
6e899d839f
|
||
|
|
4a8a35114d
|
||
|
|
f467660a2e
|
||
|
|
0cdb763e17
|
||
|
|
ad1aad8003
|
||
|
|
7e4f275f0a
|
||
|
|
96b6c1271d
|
||
|
|
03e974a139
|
||
|
|
e9d5dbe6ad
|
||
|
|
4b8fdf8df7
|
No files matched your search
@@ -1,67 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: macOS, os: macos-latest, skip: "" }
|
||||
# Snapshot goldens exist only for darwin; see docs/compat.md.
|
||||
- { name: Linux, os: ubuntu-latest, skip: "-skip Snapshot" }
|
||||
- { name: Windows, os: windows-latest, skip: "-skip Snapshot" }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
|
||||
- name: File size limit
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
big=$(git ls-files '*.go' '*.html' | xargs wc -l | awk '$2!="total" && $1>500')
|
||||
test -z "$big" || { echo "$big"; exit 1; }
|
||||
- name: Build and vet every platform
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
for os in darwin linux windows; do
|
||||
GOOS=$os go build ./...
|
||||
GOOS=$os go vet ./...
|
||||
done
|
||||
- name: Staticcheck
|
||||
if: runner.os == 'Linux'
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
||||
- name: Test
|
||||
run: go test ${{ matrix.skip }} ./...
|
||||
- name: Race
|
||||
if: runner.os != 'Windows'
|
||||
run: go test -race ${{ matrix.skip }} ./...
|
||||
- name: README example matches examples/minimal
|
||||
if: runner.os == 'Linux'
|
||||
run: diff <(awk '/^```go$/{f=1;n++;next} /^```$/{f=0} f && n==1' README.md) examples/minimal/main.go
|
||||
- name: Examples compile
|
||||
run: go build ./examples/...
|
||||
- name: Upload snapshot diffs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: snapshot-diffs-${{ matrix.name }}
|
||||
path: testdata/snapshots/**/*.got.png
|
||||
if-no-files-found: ignore
|
||||
@@ -6,3 +6,4 @@ PRD.md
|
||||
docs/prd/
|
||||
TASKS.md
|
||||
docs/acceptance.md
|
||||
/site/dist/
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
MujicaUI: court-gothic components for MyGo native UI. Go library, package `mujica`, module `mujicaui`.
|
||||
MujicaUI: court-gothic components for MyGo native UI. Go library, module `git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI`, one package per component category.
|
||||
Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/ext-*.md` (101–500).
|
||||
Planned: once 001–500 land, the root package splits into category sub-packages (TASKS T22). Until then everything stays in the root package.
|
||||
|
||||
@@ -9,43 +9,50 @@ Planned: once 001–500 land, the root package splits into category sub-packages
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Go | 1.27.1 (MyGo's minimum) |
|
||||
| MyGo | `github.com/egoist/mygo v0.2.10` (commit 459b512), pinned in go.mod |
|
||||
| MyGo | `github.com/egoist/mygo v0.5.5` (commit 4d8673c), pinned in go.mod |
|
||||
| Icons | Lucide 1.52.0, ISC, copied into `icons/svg` |
|
||||
| Web mirror | `examples/gallery -web :8080`: Go stdlib server drives the Gallery through `ui.NewTester` and streams frames to a canvas. The browser shows pixels; Go runs the components. |
|
||||
|
||||
Read MyGo source before using an API: `go env GOMODCACHE`/github.com/egoist/mygo@v0.2.10 (`ui/*.go`, `docs/ui/*.md`). Never guess.
|
||||
Read MyGo source before using an API: `go env GOMODCACHE`/github.com/egoist/mygo@v0.5.5 (`ui/*.go`, `docs/ui/*.md`, migration guide `docs/ui/migration.md`). Never guess.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
*.go one component per file, root package mujica
|
||||
*_test.go tests beside the component
|
||||
messages_<group>.go built-in copy (en-US, zh-CN) via addMessages
|
||||
core/ Use, Tokens, Density, Motion, FontSize, Msg; shared result types and helpers;
|
||||
messages_<group>.go built-in copy (en-US, zh-CN) via addMessages; docs tests
|
||||
<category>/ one component per file, package = folder name:
|
||||
layout display input datetime navigation data overlay feedback chart
|
||||
finance chat agent git code media project files messaging devtools account
|
||||
<category>/*_test.go tests beside the component; example_<group>_test.go compiled doc examples
|
||||
theme/ tokens, density, type, motion, contrast
|
||||
icons/ embedded Lucide icons: icons.Must("check")
|
||||
internal/ focus ring, ornaments
|
||||
internal/ focus ring, ornaments; internal/snaptest runs snapshot goldens
|
||||
examples/gallery/ native Gallery; pages_<group>.go per PRD 4.x group
|
||||
docs/components/ NNN-name.md, one page per component
|
||||
testdata/snapshots/ <os>/<name>-<light|dark>.png
|
||||
testdata/snapshots/ <os>/<name>-<light|dark>.png, shared by every package
|
||||
```
|
||||
|
||||
Gallery categories map to packages: Inputs, Choices, Forms, Essentials -> input; Date & Time, Calendar -> datetime; Feedback, Agent Ops & Motion -> feedback; Charts I, Charts II, Market Charts -> chart; Trading, Markets -> finance; Chat Messages, Chat Input, Chat Sessions -> chat; Terminal & Code -> code; Projects -> project; Files & Collaboration -> files; Messaging & Mail -> messaging; Dashboards & Dev Tools -> devtools; Settings & Account -> account. Imports point down only: core imports no category; a piece two categories need moves to core or internal.
|
||||
|
||||
## Component rules
|
||||
|
||||
- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) *ui.Element`. Composites may return a named struct.
|
||||
- Result structs expose the element as a named field `Element *ui.Element`, never embedded; element plus changed is `ChoiceResult`.
|
||||
- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) ui.Element`. Composites may return a named struct.
|
||||
- Result structs expose the element as a named field `Element ui.Element`, never embedded; element plus changed is `ChoiceResult`.
|
||||
- Shared helpers: `iconAction` (borderless icon button), `overlayStyle(c, panel, overlayEnter(c, panel))` (popup surface and entry fade), `internal.RuleDiamond` / `internal.Frame` (ornaments), `colorFade` (state color transition).
|
||||
- Zero `Options` gives the default look. No `map[string]any` config.
|
||||
- Explicit font sizes: `FontSize(c, theme.CaptionSize)`; it applies the desktop text scale.
|
||||
- Colors: `k := mujica.Tokens(c)`. Metrics: `theme.*` consts and `Density(c).Unit()` / `.ControlHeight()`.
|
||||
- Colors: `k := core.Tokens(c)`. Metrics: `theme.*` consts and `Density(c).Unit()` / `.ControlHeight()`.
|
||||
- Build on MyGo Bases (`ui.ButtonBase`, `ui.CheckboxBase`, `ui.SelectBase`, ...) or MyGo widgets restyled through the theme `Use` sets. Never reach into MyGo internals.
|
||||
- Focus: `internal.Ring(e, k.Focus, radius)` for the 2 DIP ring with 2 DIP gap.
|
||||
- State marks (checked, selected, progress) use `OnAccent` on `Accent`, or `AccentText`, never `Accent` alone on dark surfaces (2.44:1).
|
||||
- Ornament color only for dividers, corners, panel titles. No gradients, glow, glass or texture.
|
||||
- Animated paths: pass coordinates through `internal.Snap(p, v)`. MyGo caches path masks by quarter-pixel keys (ui/path.go); unsnapped motion reuses near-miss masks and breaks snapshots.
|
||||
- Durations: `mujica.Motion(c, theme.StateDuration)`; popups use `theme.PopupDuration`.
|
||||
- Durations: `core.Motion(c, theme.StateDuration)`; popups use `theme.PopupDuration`.
|
||||
- Disabled blocks input before handling it (`Disabled(true)`); MyGo dims disabled elements, do not add opacity.
|
||||
- Copy shown by a component comes from `msg(c, key)`; business copy comes from the caller.
|
||||
- No cross-frame `*ui.Element` caching, no goroutines touching elements.
|
||||
- Copy shown by a component comes from `core.Msg(c, key)`; business copy comes from the caller.
|
||||
- `ui.Element` is a handle valid only during its build pass: no cross-frame caching, no goroutines touching elements, no element methods inside `HandleInput` or other callbacks (read state such as `IsDisabled` into a local during the build). Keep a `ui.Handle` for persistent identity and `c.Services()` for clipboard or URLs from callbacks. The absent element is `ui.Element{}`; test it with `.Valid()`.
|
||||
- Overlay content takes initial focus with `core.AutoFocus(c, e)`, not `e.AutoFocus()`: MyGo v0.5.5 records an overlay's opener after its content builds, so same-frame autofocus loses focus restore on close.
|
||||
- Tests run on the Tester's virtual clock: `tt.Advance(d)` and `tt.Now()`, never `time.Sleep` or `time.Now()`; read element state inside the view.
|
||||
- Fail fast: panic on programmer errors (bad options); never swallow errors.
|
||||
- Log state transitions in composites with `slog.Debug("mujica: <component> <event>", ...)`. Never log passwords.
|
||||
- Files under 500 lines. Comments: one line, only when needed.
|
||||
@@ -53,7 +60,7 @@ testdata/snapshots/ <os>/<name>-<light|dark>.png
|
||||
## Tests
|
||||
|
||||
- `ui.NewTester` drives click, keys, typing. Assert values, not existence.
|
||||
- Every component: one `snapshot(t, name, w, h, view)` call (light and dark). Refresh with `go test -run <Name> -update .`
|
||||
- Every component: one `snapshot(t, name, w, h, view)` call (light and dark; each package wraps `internal/snaptest`). Refresh with `go test -run <Name> -update ./<category>`
|
||||
- Run `go vet ./... && go test ./...` before reporting done.
|
||||
- Each snapshot test renders in a fresh child process (snapshot_test.go), because MyGo's path-mask cache is process-wide. Order and neighbours never change pixels; `-run <Name> -update` is safe.
|
||||
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
# MujicaUI 产品需求文档
|
||||
|
||||
版本:1.1
|
||||
日期:2026-10-05
|
||||
交付范围:500 个组件、两套主题、三种密度、原生 Gallery、文档与测试。001—100 见本文第 4 节;101—500 见 `docs/prd/ext-1.md`、`ext-2.md`、`ext-3.md`。
|
||||
|
||||
## 1. 产品定义
|
||||
|
||||
MujicaUI 是基于 MyGo 原生 UI 的 Go 桌面组件库。采用古典宫廷与哥特风格,覆盖 Windows、macOS、Linux。面向开发桌面工具、设置界面和数据应用的 Go 开发者。
|
||||
|
||||
MyGo 原生 UI 使用 Go 构建并自绘界面,不依赖 HTML、JavaScript 或 WebView。MujicaUI 复用该路线,不开发网页组件库。[^native]
|
||||
|
||||
首版交付本文及扩展文件规定的全部功能。已有组件换肤、已有能力组合和新增组件合计 500 个,不是在 MyGo 之外再增加 500 个。尺寸、颜色、状态、子部件和模式不单独计数。
|
||||
|
||||
不包含浏览器端适配、新渲染引擎、新布局引擎、独立状态管理框架、业务后端和上传服务。图表与行情组件以 MyGo Painter 绘制,不建通用图表引擎;媒体组件只提供播放与采集界面,解码与设备访问由调用方完成;文档类组件只提供显示与轻量编辑,不建富文本编辑引擎。
|
||||
|
||||
## 2. MyGo 复用规则
|
||||
|
||||
| 情况 | 实现要求 |
|
||||
| --- | --- |
|
||||
| 只需调整颜色、字体、尺寸 | 封装现有组件,使用主题与公开样式接口。 |
|
||||
| 需要改变外形或内部布局 | 使用已公开的 Base,重做外观,保留输入、焦点和无障碍行为。 |
|
||||
| 需要组合交互 | 组合 MyGo 与 MujicaUI 组件;仅补充缺失的状态和行为。 |
|
||||
| 系统窗口操作、文件选择 | 调用 MyGo 桌面 API;系统控制的外观保持系统样式。 |
|
||||
| 公开接口不足 | 提交上游扩展;合入后固定依赖版本。不访问上游 internal,不另建渲染或输入实现。 |
|
||||
|
||||
已核验的 Base 包括 Button、Checkbox、Switch、Toggle、Radio、Slider、Tabs、Segmented、Collapsible、Select、Combobox、Popover、Dialog、TextInput 和 TextArea 对应的 Base。不得假设存在未公开的 TableBase、TreeBase 等接口。[^bases]
|
||||
|
||||
应用内 Menubar、DropdownMenu、ContextMenu 使用 MujicaUI 自绘样式。系统菜单、系统文件对话框和系统窗口按钮不承诺换肤;不得把系统菜单直接显示为已完成的哥特样式菜单。
|
||||
|
||||
MyGo 的主题可调整颜色、字体、间距与圆角,也可读取高对比度、文本缩放和减少动画设置。MujicaUI 扩展设计变量,不另建 CSS 或样式解释器。[^theme]
|
||||
|
||||
## 3. 视觉规范
|
||||
|
||||
### 3.1 风格与装饰
|
||||
|
||||
视觉参考为用户提供的宫廷场景图片:象牙白墙面、黑红服装、金色细节与衬线标题。只提取配色、线条和字形,不使用人物、海报、Logo 或照片作为组件素材。
|
||||
|
||||
页面以象牙白或炭黑为底。酒红表示主要操作与选中状态。旧金用于分隔线、角饰和面板标题,不用于正文。成功、警告和错误使用独立语义颜色。
|
||||
|
||||
按钮、输入框和数据行不加花纹。Heading、Divider 可使用单个菱形或对称细线。Card、Dialog 的双线边框与角饰通过 `Decorated` 开启,默认关闭。装饰不占焦点、不遮挡内容、不缩小点击区域。
|
||||
|
||||
不使用背景纹理、仿皮革、仿金属、发光、玻璃效果和装饰性渐变。颜色选择器中的色谱不受此限制。
|
||||
|
||||
### 3.2 颜色
|
||||
|
||||
以下为首版默认设计值,不是从参考图精确取色的结果。
|
||||
|
||||
| 变量 | 浅色主题 | 深色主题 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| Background | `#F2EBDD` | `#191618` | 窗口背景 |
|
||||
| Surface | `#FBF7EF` | `#242024` | 卡片与弹层 |
|
||||
| SurfaceHover | `#E8DFD0` | `#342C32` | 悬停背景 |
|
||||
| Text | `#211C1D` | `#F2EBDD` | 正文与标题 |
|
||||
| TextMuted | `#6A5B55` | `#BCAEAA` | 说明与占位文字 |
|
||||
| Border | `#D2C3AF` | `#51424A` | 非交互分隔线 |
|
||||
| ControlBorder | `#877662` | `#968278` | 输入框等交互边界 |
|
||||
| Accent | `#7D2034` | `#9C3C53` | 主按钮填充 |
|
||||
| AccentHover | `#651729` | `#AA425C` | 主按钮悬停 |
|
||||
| AccentPressed | `#511020` | `#813047` | 主按钮按下 |
|
||||
| OnAccent | `#FFF8EE` | `#FFF8EE` | 主按钮文字与图标 |
|
||||
| AccentText | `#7D2034` | `#E4A0AE` | 链接与强调文字 |
|
||||
| Selection | `#EAD7D9` | `#492A36` | 选中项背景 |
|
||||
| Focus | `#7D2034` | `#E4A0AE` | 焦点环 |
|
||||
| Ornament | `#AD9164` | `#BDA172` | 非语义装饰 |
|
||||
| Success | `#326247` | `#94C6A4` | 成功文字与图标 |
|
||||
| Warning | `#805714` | `#DFC080` | 警告文字与图标 |
|
||||
| Danger | `#A12635` | `#F0A1AA` | 错误文字与图标 |
|
||||
| Info | `#355E80` | `#9DBDDA` | 信息文字与图标 |
|
||||
|
||||
语义颜色默认用于文字、图标和边界,不直接拿浅色主题值填满深色主题控件。危险按钮的底色与前景色单独定义并检查对比度。焦点环与控件之间保留背景色间隔。
|
||||
|
||||
### 3.3 字体、尺寸与密度
|
||||
|
||||
标题使用衬线字体,中文标题使用宋体类字形。正文、表单、表格使用系统无衬线字体。代码与快捷键使用系统等宽字体。装饰字形不用于控件标签。
|
||||
|
||||
标题默认 28、22、18 DIP;正文 14 DIP;说明文字 12 DIP;正文行高为字号的 1.5 倍。字体允许由应用注册与覆盖;缺失字体使用系统回退,不显示空白或方框替代正常文字。MyGo 提供字体注册与字体回退接口。[^text]
|
||||
|
||||
| 密度 | 单行控件最小高度 | 数据行最小高度 | 基础间距单位 |
|
||||
| --- | --- | --- | --- |
|
||||
| Compact | 28 DIP | 28 DIP | 3 DIP |
|
||||
| Default | 36 DIP | 36 DIP | 4 DIP |
|
||||
| Comfortable | 44 DIP | 44 DIP | 5 DIP |
|
||||
|
||||
默认间距采用 4、8、12、16、24、32、48 DIP。输入框与按钮圆角 2 DIP;卡片 4 DIP;弹层 6 DIP。边框 1 DIP;焦点环 2 DIP,外留 2 DIP 间隔。图标采用 16、20、24 DIP,独立操作的命中区域不小于 28 × 28 DIP。
|
||||
|
||||
高度是下限,不是固定值。文本放大、换行或字体回退时允许增长。切换密度不缩小字号,也不改变组件语义。细线须按设备像素对齐。
|
||||
|
||||
### 3.4 组件外观与主题行为
|
||||
|
||||
Button 使用酒红填充、象牙白文字与小圆角。TextInput 使用实色背景与细边框。Checkbox 保留方框和勾选、半选含义;Radio 保留单选圆形。Slider 可使用菱形滑块,命中区域仍为矩形。Tabs 与 Sidebar 使用背景、边线和文字共同标记当前项。
|
||||
|
||||
Card 与 Dialog 的装饰只出现在边缘和标题区。DataTable 表头、行分隔和选中行不添加纹样。Button、TextInput、Checkbox、Tabs、Card、Dialog 作为视觉回归基准。
|
||||
|
||||
主题提供 `System`、`Light`、`Dark`,默认跟随系统。每个窗口可独立设置主题与密度。切换主题不得重置表单值、展开状态、选择、焦点或滚动位置。
|
||||
|
||||
样式覆盖顺序为局部参数、窗口主题、库默认值。系统高对比度、文本缩放与减少动画要求仍须生效。主色默认保留酒红,不自动替换为系统强调色;高对比度模式以可读性为先。
|
||||
|
||||
状态过渡默认 120 毫秒,弹层过渡 160 毫秒。不使用弹跳或装饰性循环动画。减少动画模式直接切换状态;加载标记保留静态说明。
|
||||
|
||||
## 4. 组件清单
|
||||
|
||||
以下每行是一个公开组件及其首版最低功能。全部适用组件同时遵守第 5 节的交互要求。
|
||||
|
||||
### 4.1 布局与桌面 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 001 | AppShell | 标题栏、侧栏、主内容、状态栏插槽;侧栏折叠;内容区独立滚动。 |
|
||||
| 002 | TitleBar | 应用标题与工具插槽;窗口拖动;为系统窗口按钮留出空间。 |
|
||||
| 003 | StatusBar | 左右分区;状态文字、进度与操作;宽度不足时折叠次要内容。 |
|
||||
| 004 | Container | 内边距、最大宽度、居中、填充;内容裁剪可配置。 |
|
||||
| 005 | Stack | 横向或纵向排列;间距、对齐、换行;隐藏项不占间距。 |
|
||||
| 006 | Grid | 行列尺寸、间距、跨行跨列;固定尺寸与剩余空间分配。 |
|
||||
| 007 | ScrollArea | 横纵滚动;滚动条;滚动到指定位置;保留滚动位置。 |
|
||||
| 008 | SplitPane | 横纵分栏;拖动分隔条;最小尺寸;折叠;键盘调整。 |
|
||||
| 009 | AspectRatio | 保持指定宽高比;在父容器约束下缩放;不拉伸子内容。 |
|
||||
| 010 | Divider | 横纵分隔;文字插槽;可选菱形装饰;不获取焦点。 |
|
||||
|
||||
### 4.2 基础展示 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 011 | Text | 换行、省略、选择复制;可编辑模式支持确认、取消和外部保存结果。 |
|
||||
| 012 | Heading | 三级标题;衬线字形;可选副标题和分隔线;支持文本缩放。 |
|
||||
| 013 | Icon | SVG 图标;尺寸与语义颜色;图标名称;加载失败回退。 |
|
||||
| 014 | Image | 位图与 SVG;包含或裁切;占位、加载、错误;替代说明。 |
|
||||
| 015 | Avatar | 图片、姓名首字母与默认图标回退;尺寸可配置。 |
|
||||
| 016 | AvatarGroup | 头像排列;最大显示数量;剩余数量;展开完整名单。 |
|
||||
| 017 | Badge | 文字或计数;上限显示;位置可配置;朗读完整数值。 |
|
||||
| 018 | Tag | 标签文字、图标;可关闭;关闭动作与标签点击分离。 |
|
||||
| 019 | Link | 外部地址或应用内动作;键盘激活;文本下划线;打开失败反馈。 |
|
||||
| 020 | Kbd | 单键与组合键展示;按平台显示 Command 或 Ctrl;仅展示、不注册快捷键。 |
|
||||
|
||||
### 4.3 基础输入 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 021 | Button | 主按钮、次按钮、描边、无底色、危险变体;图标;加载与禁用。 |
|
||||
| 022 | IconButton | 仅图标按钮;必填操作名称;提示文字;加载与禁用。 |
|
||||
| 023 | ButtonGroup | 横纵组合;统一边界与尺寸;组内方向键移动;逐项禁用。 |
|
||||
| 024 | TextInput | 单行编辑;占位、前后插槽、清空;选择、撤销、剪贴板与输入法。 |
|
||||
| 025 | TextArea | 多行编辑;最小与最大高度;滚动;字数提示;提交快捷键可配置。 |
|
||||
| 026 | NumberInput | 数值输入与步进;范围、步长、精度、空值;支持仅步进按钮模式。 |
|
||||
| 027 | PasswordInput | 密码遮蔽;显隐切换;粘贴;默认不朗读或记录密码内容。 |
|
||||
| 028 | SearchInput | 搜索与清空;提交;查找栏模式提供匹配数量、上一项和下一项。 |
|
||||
| 029 | Checkbox | 选中、未选、半选;关联标签;组内全选;键盘操作。 |
|
||||
| 030 | RadioGroup | 横纵单选组;稳定选项值;方向键选择;逐项禁用。 |
|
||||
|
||||
### 4.4 选择控件 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 031 | Switch | 开关值;标签;禁用;切换后回传结果;不自行调用业务接口。 |
|
||||
| 032 | Toggle | 保持按下状态的按钮;文字或图标;选中状态可辨认。 |
|
||||
| 033 | ToggleGroup | 单选或多选;是否允许空选;键盘导航;逐项禁用。 |
|
||||
| 034 | Slider | 数值范围、步长、刻度、值提示;拖动、方向键、Home 和 End。 |
|
||||
| 035 | RangeSlider | 双端范围;步长;两端不交叉;分别提供焦点和数值说明。 |
|
||||
| 036 | Select | 单选列表;占位、清空、禁用项、空状态;方向键与输入定位。 |
|
||||
| 037 | MultiSelect | 多选、搜索、全选、数量限制;标签折叠;无结果状态。 |
|
||||
| 038 | Combobox | 输入筛选与选项选择;可允许自定义值;异步结果由调用方提供。 |
|
||||
| 039 | Cascader | 多级选择;完整路径;叶节点限制;外部加载子项;加载失败重试。 |
|
||||
| 040 | TreeSelect | 树形单选或多选;搜索、展开、半选;父子选择规则可配置。 |
|
||||
|
||||
### 4.5 表单组合 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 041 | Form | 字段注册;校验;提交、重置;提交中防重入;错误字段定位。 |
|
||||
| 042 | FormField | 标签、必填标记、说明、错误;标签关联控件;上下或左右布局。 |
|
||||
| 043 | InputGroup | 输入框与前后文字、按钮组合;统一边框;各操作保留独立焦点。 |
|
||||
| 044 | PinInput | 位数限制;输入与退格;整段粘贴;完成事件;允许前导零。 |
|
||||
| 045 | TagsInput | 标签增删、粘贴分隔、去重、数量限制;提交前校验。 |
|
||||
| 046 | Rating | 评分与只读展示;步长配置;键盘调整;数值说明。 |
|
||||
| 047 | ColorPicker | HEX、RGB、透明度;颜色选择;预设色;输入校验;透明度背景格。 |
|
||||
| 048 | FilePicker | 调用系统文件或目录选择器;单选、多选、扩展名筛选;取消不报错。 |
|
||||
| 049 | FileDropZone | 文件拖入提示;文件列表;类型、数量和大小检查;移除与错误提示。 |
|
||||
| 050 | Transfer | 两侧列表;搜索、选择、移动、批量移动;禁用项;保留稳定条目 ID。 |
|
||||
|
||||
### 4.6 日期与时间 · 8 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 051 | Calendar | 月视图;日期选择;今日标记;不可选日期;周起始日可配置。 |
|
||||
| 052 | DatePicker | 日期输入与日历弹层;格式、范围、清空;无效输入提示。 |
|
||||
| 053 | DateRangePicker | 起止日期;范围预览;起止限制;确认与清空。 |
|
||||
| 054 | TimePicker | 时分秒;12/24 小时;步长与范围;输入与选择。 |
|
||||
| 055 | TimeRangePicker | 起止时间;范围校验;跨午夜须显式开启。 |
|
||||
| 056 | DateTimePicker | 日期与时间组合;时区由调用方指定;范围限制;确认与取消。 |
|
||||
| 057 | MonthPicker | 年份切换;月份选择;范围、禁用与清空。 |
|
||||
| 058 | YearPicker | 年份区间浏览;键盘选择;范围、禁用与清空。 |
|
||||
|
||||
### 4.7 导航 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 059 | Tabs | 标签与面板关联;横纵布局;方向键导航;禁用项;溢出滚动。 |
|
||||
| 060 | SegmentedControl | 分段单选;文字或图标;等宽或自适应;键盘导航。 |
|
||||
| 061 | Breadcrumb | 路径导航;当前项;中间项折叠;完整名称提示。 |
|
||||
| 062 | Pagination | 页码、上一页、下一页、页容量、跳页;总数变化时修正有效页码。 |
|
||||
| 063 | Steps | 当前、完成、等待、错误;横纵布局;是否可点击由调用方控制。 |
|
||||
| 064 | Sidebar | 分组与嵌套;图标、计数、选中项;展开折叠;紧凑模式。 |
|
||||
| 065 | NavigationMenu | 层级导航;当前路径;可选前进、后退按钮;接入已有导航状态。 |
|
||||
| 066 | Menubar | 应用窗口内菜单栏;子菜单;勾选和单选项;快捷键提示;键盘导航。 |
|
||||
| 067 | Toolbar | 操作分组;分隔;窄宽溢出菜单;方向键导航;图标名称。 |
|
||||
| 068 | CommandPalette | 快捷键打开;搜索、分组、无结果;方向键选择;执行后关闭。 |
|
||||
|
||||
### 4.8 内容与数据 · 12 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 069 | Card | 标题、内容、页脚插槽;描边;可选角饰;不默认增加点击行为。 |
|
||||
| 070 | List | 虚拟化列表与网格模式;稳定条目键;单选、多选;键盘导航与空状态。 |
|
||||
| 071 | DataTable | 虚拟化;排序、筛选、多选;列宽、列顺序、列显隐;自定义单元格。 |
|
||||
| 072 | Tree | 节点展开;选择;键盘导航;外部加载子项;空状态;大量节点虚拟化。 |
|
||||
| 073 | TreeTable | 树形层级与列组合;展开、选择、列宽、排序状态;可见行虚拟化。 |
|
||||
| 074 | Accordion | 单项或多项展开;是否允许全部关闭;键盘操作;展开状态受控。 |
|
||||
| 075 | Collapsible | 触发区与内容区;外部控制展开;隐藏内容退出焦点序列。 |
|
||||
| 076 | Timeline | 时间、标题、内容、状态;纵向排列;长内容换行。 |
|
||||
| 077 | DescriptionList | 名称与值;多列、跨列、复制值;窄宽时改为单列。 |
|
||||
| 078 | Statistic | 数值、单位、前后缀、精度;变化值说明;不计算业务指标。 |
|
||||
| 079 | ImageViewer | 放大、缩小、平移、适应窗口、切换图片;键盘操作与关闭。 |
|
||||
| 080 | Carousel | 上一项、下一项、位置指示;默认不自动播放;开启后支持暂停。 |
|
||||
|
||||
### 4.9 浮层与菜单 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 081 | Dialog | 标题、说明、内容、操作区;焦点限制与恢复;关闭规则可配置。 |
|
||||
| 082 | AlertDialog | 危险操作确认;默认聚焦取消;默认不允许点击遮罩关闭。 |
|
||||
| 083 | Drawer | 四侧展开;尺寸限制;模态或非模态;关闭与焦点恢复。 |
|
||||
| 084 | Popover | 锚点定位;自动避让窗口边缘;外部点击与 Escape 关闭。 |
|
||||
| 085 | Tooltip | 悬停或聚焦显示;延迟;边缘避让;不放交互内容。 |
|
||||
| 086 | HoverCard | 悬停或聚焦预览;允许移入内容;键盘可进入;延迟关闭。 |
|
||||
| 087 | DropdownMenu | 按钮触发菜单;分组、子菜单、勾选、禁用;操作后按规则关闭。 |
|
||||
| 088 | ContextMenu | 应用内右键菜单;键盘触发;按指针或焦点定位;边缘避让。 |
|
||||
| 089 | Popconfirm | 锚点旁确认与取消;说明关联;可聚焦操作;不替代高风险确认框。 |
|
||||
| 090 | Overlay | 窗口内覆盖层;定位、遮罩、模态行为;与现有 MyGo 浮层协作。 |
|
||||
|
||||
### 4.10 反馈与状态 · 10 个
|
||||
|
||||
| 编号 | 组件 | 首版功能 |
|
||||
| --- | --- | --- |
|
||||
| 091 | Alert | 信息、成功、警告、错误;标题、内容、操作;可关闭。 |
|
||||
| 092 | Toast | 消息队列;去重 ID;操作、关闭、时长;悬停或聚焦时暂停计时。 |
|
||||
| 093 | Progress | 确定与不确定进度;线形与环形;计量模式支持范围和阈值。 |
|
||||
| 094 | Spinner | 加载标记;尺寸与说明;隐藏或停止加载后停止动画。 |
|
||||
| 095 | Skeleton | 文字、圆形、矩形占位;固定内容尺寸;减少动画模式使用静态填充。 |
|
||||
| 096 | EmptyState | 标题、说明、可选图标与操作;区分无数据和无搜索结果。 |
|
||||
| 097 | Result | 成功、失败、无权限等结果页;说明与下一步操作;不执行业务跳转。 |
|
||||
| 098 | StatusIndicator | 状态标记与文字;成功、警告、错误、离线、处理中;不能只靠颜色。 |
|
||||
| 099 | NotificationCenter | 应用内消息列表;已读、未读、清除、操作;消息与持久化由调用方管理。 |
|
||||
| 100 | LoadingOverlay | 容器范围加载遮罩;阻止该范围重复操作;说明;可选取消动作。 |
|
||||
|
||||
### 4.11 上游组件归并
|
||||
|
||||
MyGo 已有能力按以下方式纳入清单,不增加计数。其余同类组件按第 2 节复用。[^native]
|
||||
|
||||
| MyGo 能力 | MujicaUI 对应项 |
|
||||
| --- | --- |
|
||||
| Primary button、Menu button | Button 的主按钮变体、DropdownMenu |
|
||||
| Stepper、Number input | NumberInput 的步进模式与输入模式 |
|
||||
| Search field、Find bar | SearchInput 的搜索模式与查找栏模式 |
|
||||
| Autocomplete、Combobox | Combobox 的自由输入与选项选择模式 |
|
||||
| Token field、Editable text | TagsInput、Text 的可编辑模式 |
|
||||
| List、Grid view | List 的列表模式与网格模式 |
|
||||
| Table、Outline、Tree | DataTable、TreeTable、Tree |
|
||||
| Date input、Time input | DatePicker、TimePicker |
|
||||
| Back and forward buttons | NavigationMenu 的历史导航操作 |
|
||||
| Meter、Progress bar | Progress 的计量模式与进度模式 |
|
||||
|
||||
## 5. 交互与状态
|
||||
|
||||
### 5.1 通用状态
|
||||
|
||||
| 状态 | 要求 |
|
||||
| --- | --- |
|
||||
| 默认 | 内容、边界与可操作性可辨认。 |
|
||||
| 悬停 | 改变背景或边界,不改变布局尺寸。 |
|
||||
| 按下 | 显示按下反馈;释放在无效区域时不误触发。 |
|
||||
| 聚焦 | 键盘焦点可见;选中或错误状态不能覆盖焦点环。 |
|
||||
| 选中 | 同时使用标记、形状或文字,不只改变颜色。 |
|
||||
| 禁用 | 不修改值,不执行操作,退出常规 Tab 序列;仍向辅助技术报告禁用。 |
|
||||
| 只读 | 不修改值;文字仍可聚焦、选择和复制;不得等同于禁用。 |
|
||||
| 加载 | 保留尺寸与标签;阻止重复提交;完成或失败后恢复操作。 |
|
||||
| 错误 | 保留输入;显示具体字段错误;提供修正或重试入口。 |
|
||||
|
||||
只为适用组件提供对应状态。展示与布局组件不添加无用途的 Loading、Disabled 等参数。
|
||||
|
||||
### 5.2 键盘与无障碍
|
||||
|
||||
所有操作必须能通过键盘完成。Tab 与 Shift+Tab 跨组移动;方向键在 Tabs、菜单、单选组和工具栏内部移动。快捷键遵循平台习惯,不抢占文本编辑和输入法按键。MyGo 已提供焦点组、平台快捷键映射和输入法处理,封装时不得移除。[^input]
|
||||
|
||||
无文字的操作必须提供名称。字段关联标签、说明与错误。复合组件提供对应角色、值、选中、展开和禁用状态。装饰不进入无障碍树。沿用 MyGo 的角色、Label、Description 与 Error 机制。[^accessibility]
|
||||
|
||||
项目对比度下限:正常文字与占位文字 4.5:1;用于识别控件、状态和焦点的图形 3:1。纯装饰边框与禁用状态不按正文处理。采用 WCAG 的对比度计算方法;不以此代替整套无障碍验收。[^text-contrast][^ui-contrast]
|
||||
|
||||
### 5.3 输入与表单
|
||||
|
||||
文本组件必须保留中文输入法、组合输入、选择、剪贴板、撤销和重做。输入法组合期间不自动提交、不跳到下一格、不拆分尚未提交的文本。PasswordInput 不在日志、测试报告或无障碍值中泄露明文。
|
||||
|
||||
Form 支持同步校验与调用方提供的异步校验结果;支持失焦和提交时校验。首次提交前不持续显示未触碰字段错误。提交失败保留所有值并定位第一个错误字段。异步旧结果不得覆盖更新后的字段值;提交中不得重复触发。
|
||||
|
||||
Form 不自带业务模型、远程接口或持久化。跨字段规则由调用方提供。重置恢复调用方给定的初始值,并清除提交与校验状态。
|
||||
|
||||
### 5.4 数据组件
|
||||
|
||||
DataTable 以稳定行 ID 和列 ID 保存状态。排序、筛选、列调整后,选择仍指向同一条数据。提供受控模式与可选本地排序、筛选辅助函数;外部数据查询由调用方负责。
|
||||
|
||||
MyGo Table 提供排序状态和界面交互,但数据仍按调用方提供的顺序显示。MujicaUI 不能只显示排序箭头而不完成排序或向调用方发出排序请求。[^table]
|
||||
|
||||
List、DataTable、Tree、TreeTable 使用虚拟化;不能为全部数据逐项创建界面元素。排序与筛选只在数据或条件变化时执行,不在每帧重复执行。异步加载分别展示首次加载、追加加载、失败和无数据。
|
||||
|
||||
### 5.5 浮层与消息
|
||||
|
||||
模态窗口限制焦点并阻止背景交互;非模态浮层不锁住整个窗口。Escape 优先交给输入法和当前编辑控件,再关闭最上层可关闭浮层。关闭后恢复到触发控件;触发控件消失时回到有效父级。复用 MyGo 的浮层和焦点机制。[^overlays]
|
||||
|
||||
弹层在窗口边缘翻转或限高滚动。嵌套 Select、菜单或 Popover 时,不提前关闭父 Dialog。危险操作默认聚焦取消;不得通过 Enter 在未确认目标时执行。
|
||||
|
||||
Toast 默认显示 5 秒,同一窗口最多同时显示 3 条,其余排队。含操作的消息在悬停或聚焦时暂停计时;错误消息默认保留到用户关闭。消息不得抢走正在输入的焦点。
|
||||
|
||||
### 5.6 日期、文件与本地化
|
||||
|
||||
日期选择保存日期值,不因隐式 UTC 转换改变日期。时间范围默认不跨午夜;日期时间由调用方指定时区。无法表示或无法确定的日期时间须提示,不静默修正。
|
||||
|
||||
FilePicker 只负责选择并返回结果。FileDropZone 只负责接收、校验与展示,不包含上传。文件内容读取、图片解码和网络访问不得阻塞界面线程。取消文件选择不作为错误。
|
||||
|
||||
组件内置文案提供 `zh-CN` 与 `en-US`,应用可覆盖。日期格式、周起始日、12/24 小时制和数字格式可配置。业务文案由调用方传入,不写死在组件内部。
|
||||
|
||||
## 6. API 与实现约束
|
||||
|
||||
### 6.1 API 约定
|
||||
|
||||
对外包名为 `mujica`。函数首参使用 `*ui.Context`;值通过状态指针或专用状态结构传入;配置使用有类型的 Options。基础组件返回 `*ui.Element`;复合组件按需返回命名部件或状态结果。不用 `map[string]any` 作为常规配置接口。
|
||||
|
||||
下列为 MujicaUI 的目标接口,不是 MyGo 现有接口:
|
||||
|
||||
```go
|
||||
func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element
|
||||
func TextInput(c *ui.Context, value *string, opts TextInputOptions) *ui.Element
|
||||
func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element
|
||||
```
|
||||
|
||||
事件沿用 `.Clicked()`、`.Changed()`、`.Submitted()` 的调用习惯。一次操作只产生一次业务事件。禁用、只读、范围等会影响行为的参数须在处理输入前生效,不依赖事后换色。
|
||||
|
||||
零值 Options 提供默认样式。必填参数、有效范围和空值规则必须写入 API 文档。子内容使用回调或命名插槽,不为每种组合另建组件。原生 MyGo 组件可以与 MujicaUI 混用。
|
||||
|
||||
### 6.2 状态与生命周期
|
||||
|
||||
业务状态由应用持有;组件只保留输入、展开、焦点、动画等界面状态。临时状态使用 MyGo 已有机制,不建立第二套全局状态仓库。
|
||||
|
||||
MyGo 的视图按帧构建,Element 只属于当前帧。不得跨帧缓存 Element,也不得在 goroutine 中操作它。异步结果通过 `Window.Update` 回到界面线程;窗口关闭后取消任务或忽略结果。动态项目使用稳定键;需即时处理输入的组件通过外层有键容器保持身份。[^views]
|
||||
|
||||
缓存只覆盖字体、图标、装饰路径和必要的布局结果。主题、缩放、字体或数据变化时使相关缓存失效。隐藏组件不持续请求重绘。
|
||||
|
||||
### 6.3 项目结构
|
||||
|
||||
单仓库、单 Go 模块。组件放在根包,测试与实现同目录;不按组件拆成 100 个包。
|
||||
|
||||
```text
|
||||
mujicaui/
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── button.go
|
||||
├── button_test.go
|
||||
├── ... 其余组件及对应测试
|
||||
├── theme/ 主题、设计变量与对比度测试
|
||||
├── icons/ 图标接口与必要资源
|
||||
├── internal/ 少量共享实现与上游适配
|
||||
├── examples/gallery/ 原生演示应用
|
||||
├── docs/components/ 100 个组件文档
|
||||
├── testdata/ 测试数据与视觉基准
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
└── THIRD_PARTY_NOTICES.md
|
||||
```
|
||||
|
||||
固定 MyGo 的已验证 tag 或 commit,并提交 go.mod 与 go.sum。Go 最低版本与系统依赖以该版本为准,写入兼容表。不使用浮动 main,不为 Go 库运行引入 Node.js、WebView 或浏览器依赖。
|
||||
|
||||
## 7. 平台与资源
|
||||
|
||||
Windows、macOS、Linux 均需运行同一套组件 API。允许字体栅格化、系统窗口按钮和系统对话框不同;不允许缺失组件功能。
|
||||
|
||||
TitleBar 使用 MyGo 提供的窗口控制预留区域与拖动接口,不覆盖系统按钮。原生路线在 Windows 不要求 WebView2,在 Linux 仍有 GTK 3 等系统依赖,不能把“不使用 WebView”写成“没有运行时依赖”。[^windows]
|
||||
|
||||
测试 100%、125%、150%、200% 显示缩放,以及跨不同缩放显示器移动窗口。文本缩放到 200% 后,不裁掉标签、字段错误和主要操作。Gallery 默认窗口为 1200 × 800 DIP,最小为 800 × 600 DIP;窄宽时折叠导航,长表格横向滚动。
|
||||
|
||||
发布说明列出已实测的操作系统版本、CPU 架构与 Linux 会话类型。X11、原生 Wayland 和 XWayland 分别记录,不把 XWayland 结果标成原生 Wayland 支持。
|
||||
|
||||
库和 Gallery 默认离线可用,无遥测、账号和强制网络字体。应用未请求时,不读取文件内容、不连接网络、不写入业务配置。字体可由应用提供;自带资源须记录来源与许可,图标和角饰不得依赖远程请求。
|
||||
|
||||
## 8. Gallery 与文档
|
||||
|
||||
Gallery 必须使用 MujicaUI 本身构建,不用网页或截图替代原生组件。提供组件分类、中英文搜索、主题切换、密度切换和示例代码复制。
|
||||
|
||||
每个组件页面包含可交互示例、适用状态展示、参数控制、事件结果和键盘说明。事件展示不得记录密码。提供设置页、文件列表、表单提交三个组合示例,验证跨组件协作,不接业务后端。
|
||||
|
||||
文档包含安装、最小运行示例、主题配置、MyGo 兼容版本和平台依赖。每个组件单独一页,列明参数、默认值、状态、事件、键盘操作、可复制代码和限制。所有 Go 示例必须通过编译检查。
|
||||
|
||||
文档中的组件截图由原生 Gallery 或渲染测试生成。文档可作为静态站发布,但浏览器页面不宣称能运行 Go 原生组件。API 名称与代码使用英文,首版说明文档使用中文。
|
||||
|
||||
## 9. 测试与性能
|
||||
|
||||
### 9.1 功能测试
|
||||
|
||||
使用 MyGo `ui.NewTester` 执行无窗口点击、键盘、输入、滚动与状态测试;使用图像快照检查渲染。MyGo 测试接口还提供缩放、主题、偏好和输入法组合模拟。[^testing]
|
||||
|
||||
每个组件均需覆盖适用的正常、边界、错误与键盘路径。复用上游不免除封装层测试;新增样式不能破坏点击区域、焦点与无障碍名称。
|
||||
|
||||
快照覆盖全部组件的浅色、深色默认状态。六个视觉基准组件额外覆盖三种密度和全部适用交互状态。各平台使用独立字体与快照基准,不要求不同系统逐像素相同。
|
||||
|
||||
真机补测系统文件选择、窗口控制、中文输入法、跨屏缩放与屏幕阅读器。无窗口测试不能替代这些项目。发布测试记录包含 VoiceOver、Windows Narrator 或 NVDA,以及 Linux Orca 的结果。
|
||||
|
||||
### 9.2 性能要求
|
||||
|
||||
以下为验收目标,不是已测量结果。三个平台各固定一台基准设备,记录 CPU、GPU、内存、系统、驱动、字体、显示缩放与 MyGo 版本;使用发布构建,关闭 Inspector。
|
||||
|
||||
| 场景 | 验收要求 |
|
||||
| --- | --- |
|
||||
| 大列表 | 100,000 条固定高度记录可滚动、搜索和选择;每帧创建的元素数量由可见区域与预取范围决定。 |
|
||||
| 大表格 | 100,000 行、10 列纯文本数据;排序与筛选后保留有效选择;不逐帧重算排序。 |
|
||||
| 大树 | 100,000 个节点;折叠子树不创建界面元素;展开、搜索与滚动不阻塞输入。 |
|
||||
| 渲染开销 | 同设备、同尺寸、同内容,对有等价上游实现的控件比较;MujicaUI 的界面构建与绘制 P95 耗时增幅不超过 20%。 |
|
||||
| 空闲 | 关闭加载和过渡动画后,连续观察 60 秒,无组件定时器或自身逻辑持续触发重绘。 |
|
||||
| 生命周期 | 连续打开、关闭同一弹层 100 次;回收后无持续增加的组件实例、后台任务或事件订阅。 |
|
||||
|
||||
持续集成执行格式检查、静态检查、单元测试、示例编译与快照检查。支持的环境运行竞态检测。平台集成与性能结果随发布保存,不用单一平台的结果代替三平台验证。
|
||||
|
||||
## 10. 发布验收
|
||||
|
||||
代码采用 MIT 许可证。仓库保留第三方声明;任何复制的上游代码须保留其声明。发布 Go 模块、源码、三平台 Gallery 构建产物、文档和兼容表。
|
||||
|
||||
| 验收项 | 通过条件 |
|
||||
| --- | --- |
|
||||
| 数量 | 001—500 均有实现、文档、Gallery 示例与测试;变体不重复计数。 |
|
||||
| 功能 | 每项最低功能可操作;不存在占位按钮、空事件或仅静态展示的交互组件。 |
|
||||
| 样式 | 全部组件覆盖浅深主题;无 MyGo 默认外观混入;系统控制的界面按已声明例外处理。 |
|
||||
| 主题与密度 | 切换无需重启;业务状态、焦点与滚动位置不丢失;无文字裁切。 |
|
||||
| 输入与无障碍 | 键盘、中文输入法、焦点恢复、标签关联与屏幕阅读器测试通过。 |
|
||||
| 数据与生命周期 | 大数据测试通过;无状态串行、重复提交、后台任务或订阅泄漏。 |
|
||||
| 三平台 | 兼容表内的平台及架构均完成构建和实机验证;限制写入对应文档。 |
|
||||
| 交付 | 固定依赖、示例可编译、Gallery 可运行、默认离线可用、资源许可齐全。 |
|
||||
|
||||
首次公开发布须满足全部验收项,不以占位组件或后续补齐代替首版范围。
|
||||
|
||||
## 参考资料
|
||||
|
||||
上游能力核验日期:2026-10-05。实现与发布以固定依赖版本的代码为准。
|
||||
|
||||
[^native]: MyGo — [Native UI](https://mygo.egoist.dev/docs/ui).
|
||||
[^bases]: MyGo — [Custom widgets](https://mygo.egoist.dev/docs/ui/custom-widgets).
|
||||
[^theme]: MyGo — [Styling and themes](https://mygo.egoist.dev/docs/ui/styling).
|
||||
[^text]: MyGo — [Text](https://mygo.egoist.dev/docs/ui/text).
|
||||
[^input]: MyGo — [Input](https://mygo.egoist.dev/docs/ui/input).
|
||||
[^accessibility]: MyGo — [Accessibility](https://mygo.egoist.dev/docs/ui/accessibility).
|
||||
[^table]: MyGo — [Table](https://mygo.egoist.dev/docs/ui/table).
|
||||
[^overlays]: MyGo — [Overlays](https://mygo.egoist.dev/docs/ui/overlays).
|
||||
[^views]: MyGo — [Views](https://mygo.egoist.dev/docs/ui/views).
|
||||
[^windows]: MyGo — [Windows with native UI](https://mygo.egoist.dev/docs/ui/windows).
|
||||
[^testing]: MyGo — [Testing](https://mygo.egoist.dev/docs/ui/testing).
|
||||
[^text-contrast]: W3C — [Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html).
|
||||
[^ui-contrast]: W3C — [Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html).
|
||||
@@ -6,10 +6,8 @@
|
||||
|
||||
## 安装
|
||||
|
||||
模块路径暂为 `mujicaui`,尚未发布到代码托管地址。本地使用:
|
||||
|
||||
```sh
|
||||
go mod edit -require=mujicaui@v0.0.0 -replace=mujicaui=/path/to/MujicaUI
|
||||
go get git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI
|
||||
```
|
||||
|
||||
要求 Go 1.27.1 与 MyGo v0.2.10(见 [兼容表](docs/compat.md))。
|
||||
@@ -27,16 +25,17 @@ import (
|
||||
"github.com/egoist/mygo"
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
type app struct{ name string }
|
||||
|
||||
func (a *app) view(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Locale: mujica.ZhCN})
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
ui.Column(c).Fill().Center().Gap(12).Children(func() {
|
||||
mujica.TextInput(c, &a.name, mujica.TextInputOptions{Placeholder: "姓名"})
|
||||
if mujica.Button(c, "觐见", mujica.ButtonOptions{}).Clicked() {
|
||||
input.TextInput(c, &a.name, input.TextInputOptions{Placeholder: "姓名"})
|
||||
if input.Button(c, "觐见", input.ButtonOptions{}).Clicked() {
|
||||
log.Println("hello", a.name)
|
||||
}
|
||||
})
|
||||
@@ -55,15 +54,15 @@ func main() {
|
||||
|
||||
## 主题
|
||||
|
||||
每帧开头调用 `mujica.Use(c, settings)`。`Settings` 决定模式(`System`、`Light`、`Dark`)、密度(`theme.Compact`、`theme.Default`、`theme.Comfortable`)、语言与文案覆盖。每个窗口各自设置,切换不重置业务状态。
|
||||
每帧开头调用 `core.Use(c, settings)`。`Settings` 决定模式(`System`、`Light`、`Dark`)、密度(`theme.Compact`、`theme.Default`、`theme.Comfortable`)、语言与文案覆盖。每个窗口各自设置,切换不重置业务状态。
|
||||
|
||||
```go
|
||||
mujica.Use(c, mujica.Settings{
|
||||
Mode: mujica.Dark,
|
||||
core.Use(c, core.Settings{
|
||||
Mode: core.Dark,
|
||||
Density: theme.Compact,
|
||||
Locale: mujica.ZhCN,
|
||||
Locale: core.ZhCN,
|
||||
})
|
||||
k := mujica.Tokens(c) // 当前窗口的颜色
|
||||
k := core.Tokens(c) // 当前窗口的颜色
|
||||
```
|
||||
|
||||
覆盖顺序:组件参数,窗口 `Settings`,库默认值。系统高对比度、文本缩放、减少动画始终生效。
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# TASKS
|
||||
|
||||
Ordered by PRD sections. Check an item only after its codex review passes. Review rounds noted as `r1`, `r2`, `r3`.
|
||||
|
||||
- [x] T01 §1–2 Module, MyGo v0.2.10 pin, reuse rules, AGENTS.md — r1 FAIL (line height, enum checks), r2 PASS
|
||||
- [x] T02 §3 Tokens, density, type, motion, contrast tests, `Use`, icons, i18n — r1 FAIL, r2 PASS
|
||||
- [x] T03 §4.1 Layout 001–010 — r1 FAIL 8, r2 FAIL 2, r3 FAIL 1 (folded focus); fixed b34958b, verified by coordinator red/green after cap
|
||||
- [x] T04 §4.2 Display 011–020 — r1 FAIL 5, r2 FAIL 5, r3 FAIL 2 (image sizing, Retry clip); fixed e922651, verified by coordinator tests and snapshot after cap
|
||||
- [x] T05 §4.3 Inputs 021–030 — r1 FAIL 10, r2 FAIL 5, r3 FAIL 2 (float grid, password in test output); fixed 07a90f4, verified by coordinator red/green after cap
|
||||
- [x] T06 §4.4 Choices 031–040 — r1 FAIL 16, r2 FAIL 5, r3 FAIL 1 (tiny-step direction); fixed 9e78900, verified by coordinator red/green after cap
|
||||
- [x] T07 §4.5 Forms 041–050 — r1 FAIL 7, r2 FAIL 4, r3 FAIL 1 (disabled cut); fixed 49b3b78, coordinator red/green verified
|
||||
- [x] T08 §4.6 Date and time 051–058 — r1 FAIL 9, r2 FAIL 5, r3 FAIL 2 (popup pick commit, selection mark); fixed e3eaef7, coordinator red/green verified
|
||||
- [x] T09 §4.7 Navigation 059–068 — r1 FAIL 5, r2 FAIL 5, r3 FAIL 1 (Tab out of menubar); fixed 7e4d862, coordinator red/green verified
|
||||
- [x] T10 §4.8 Content and data 069–080 — r1 FAIL 9, r2 FAIL 4, r3 PASS
|
||||
- [x] T11 §4.9 Overlays and menus 081–090 — r1 FAIL 12, r2 FAIL 5, r3 PASS
|
||||
- [x] T12 §4.10 Feedback 091–100 — r1 FAIL 5, r2 FAIL 4, r3 PASS
|
||||
- [ ] T12.01 §4.12 Charts I 101–120 (docs/prd)
|
||||
- [ ] T12.02 §4.13 Charts II 121–140 (docs/prd)
|
||||
- [ ] T12.03 §4.14 Market Charts 141–160 (docs/prd)
|
||||
- [ ] T12.04 §4.15 Trading 161–180 (docs/prd)
|
||||
- [ ] T12.05 §4.16 Markets 181–200 (docs/prd)
|
||||
- [ ] T12.06 §4.17 Chat Messages 201–220 (docs/prd)
|
||||
- [ ] T12.07 §4.18 Chat Input 221–240 (docs/prd)
|
||||
- [ ] T12.08 §4.19 Chat Sessions 241–260 (docs/prd)
|
||||
- [ ] T12.09 §4.20 Agent 261–280 (docs/prd)
|
||||
- [ ] T12.10 §4.21 Agent Ops & Motion 281–300 (docs/prd)
|
||||
- [ ] T12.11 §4.22 Git 301–320 (docs/prd)
|
||||
- [ ] T12.12 §4.23 Terminal & Code 321–340 (docs/prd)
|
||||
- [ ] T12.13 §4.24 Media 341–360 (docs/prd)
|
||||
- [ ] T12.14 §4.25 Projects 361–380 (docs/prd)
|
||||
- [ ] T12.15 §4.26 Files & Collaboration 381–400 (docs/prd)
|
||||
- [ ] T12.16 §4.27 Messaging & Mail 401–420 (docs/prd)
|
||||
- [ ] T12.17 §4.28 Calendar 421–440 (docs/prd)
|
||||
- [ ] T12.18 §4.29 Dashboards & Dev Tools 441–460 (docs/prd)
|
||||
- [ ] T12.19 §4.30 Settings & Account 461–480 (docs/prd)
|
||||
- [ ] T12.20 §4.31 Essentials 481–500 (docs/prd)
|
||||
- [ ] T13 §4.11 Upstream mapping documented
|
||||
- [ ] T14 §5 States, keyboard, a11y, input, overlays: cross-cutting tests
|
||||
- [ ] T15 §6 API and lifecycle audit; README, LICENSE, THIRD_PARTY_NOTICES
|
||||
- [ ] T16 §7 Platform table, offline, resources
|
||||
- [ ] T17 §8 Native Gallery: categories, zh/en search, theme, density, code copy, three composite demos
|
||||
- [ ] T18 §8 Web mirror of the Gallery (user request)
|
||||
- [ ] T19 §8 Docs: install, minimal example, theme, compat; 100 component pages; examples compile
|
||||
- [ ] T20 §9 Snapshots, density baselines, large-data and lifecycle tests
|
||||
- [ ] T21 §9 CI workflow
|
||||
- [ ] T22 §6.3 Split into category sub-packages after 001–500 land (user decision 2026-10-05); update PRD §6.3, docs, Gallery, CI
|
||||
- [ ] T23 §10 Acceptance checklist
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AccentColorPickerOptions configure an AccentColorPicker.
|
||||
@@ -31,7 +33,7 @@ func accentPickerReadable(k theme.Tokens, accent ui.Color) bool {
|
||||
}
|
||||
|
||||
// AccentColorPicker selects an accent and marks insufficient contrast.
|
||||
func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOptions) ChoiceResult {
|
||||
func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOptions) core.ChoiceResult {
|
||||
cands := opts.Candidates
|
||||
if cands == nil {
|
||||
cands = accentPickerColors
|
||||
@@ -39,13 +41,13 @@ func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOpt
|
||||
if len(cands) == 0 {
|
||||
panic("mujica: AccentColorPicker needs candidates")
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var r ChoiceResult
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 2)
|
||||
r.Element.Children(func() {
|
||||
low := false
|
||||
ui.Row(c).Gap(d.Unit() * 2).Wrap().Role(ui.RoleRadioGroup).Label(msg(c, "accent_color_picker.label")).
|
||||
ui.Row(c).Gap(d.Unit() * 2).Wrap().Role(ui.RoleRadioGroup).Label(core.Msg(c, "accent_color_picker.label")).
|
||||
FocusGroup(ui.Horizontal).Disabled(opts.Disabled).Children(func() {
|
||||
selected := color
|
||||
if r.Element.IsDisabled() || opts.Disabled {
|
||||
@@ -55,13 +57,13 @@ func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOpt
|
||||
for _, col := range cands {
|
||||
ok := accentPickerReadable(k, col)
|
||||
low = low || !ok
|
||||
hex := strings.ToUpper(colorPickerHex(col))
|
||||
hex := strings.ToUpper(input.ColorPickerHex(col))
|
||||
sw := ui.RadioBase(c, selected, col).Size(36, 36).Center().Radius(theme.CardRadius).Label(hex)
|
||||
if !ok {
|
||||
sw.Description(msg(c, "accent_color_picker.low"))
|
||||
sw.Description(core.Msg(c, "accent_color_picker.low"))
|
||||
}
|
||||
if sw.Changed() && !sw.IsDisabled() {
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: accent_color_picker chose", "color", hex)
|
||||
}
|
||||
internal.Ring(sw, k.Focus, theme.CardRadius)
|
||||
@@ -86,12 +88,12 @@ func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOpt
|
||||
if low {
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12)
|
||||
ui.Text(c, msg(c, "accent_color_picker.low_note")).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, core.Msg(c, "accent_color_picker.low_note")).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
if !accentPickerReadable(k, *color) {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "accent_color_picker.chosen_low"), strings.ToUpper(colorPickerHex(*color)))).
|
||||
TextColor(k.Warning).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "accent_color_picker.chosen_low"), strings.ToUpper(input.ColorPickerHex(*color)))).
|
||||
TextColor(k.Warning).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
|
||||
}
|
||||
})
|
||||
return r
|
||||
@@ -1,11 +1,11 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestAccentColorPickerMarksLowContrast(t *testing.T) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,9 +6,14 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Account is a signed-in account a user can switch to.
|
||||
@@ -24,7 +29,7 @@ type AccountSwitcherOptions struct {
|
||||
|
||||
// AccountSwitcherResult reports account changes and addition requests.
|
||||
type AccountSwitcherResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
changed bool
|
||||
add bool
|
||||
}
|
||||
@@ -62,16 +67,16 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
|
||||
accountSwitcherLarge(c, b, open, current, accounts, &r)
|
||||
return r
|
||||
}
|
||||
popupMenuOpen(c, b, open, func(m *PopupMenu) {
|
||||
m.Group(msg(c, "account_switcher.accounts"), func() {
|
||||
overlay.PopupMenuOpen(c, b, open, func(m *overlay.PopupMenu) {
|
||||
m.Group(core.Msg(c, "account_switcher.accounts"), func() {
|
||||
for _, a := range accounts {
|
||||
on := a.ID == *current
|
||||
label := a.Name
|
||||
if a.Email != "" {
|
||||
label += " · " + a.Email
|
||||
}
|
||||
ui.Column(c).Key(a.ID).Shrink(0).Children(func() {
|
||||
if m.Check(label, &on, PopupMenuItemOptions{}) && a.ID != *current && !b.IsDisabled() {
|
||||
ui.Column(c.Key(a.ID)).Shrink(0).Children(func() {
|
||||
if m.Check(label, &on, overlay.PopupMenuItemOptions{}) && a.ID != *current && !b.IsDisabled() {
|
||||
*current, r.changed = a.ID, true
|
||||
slog.Debug("mujica: account_switcher switched")
|
||||
}
|
||||
@@ -79,7 +84,7 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
|
||||
}
|
||||
})
|
||||
m.Separator()
|
||||
if m.Item(msg(c, "account_switcher.add"), PopupMenuItemOptions{}) && !b.IsDisabled() {
|
||||
if m.Item(core.Msg(c, "account_switcher.add"), overlay.PopupMenuItemOptions{}) && !b.IsDisabled() {
|
||||
r.add = true
|
||||
slog.Debug("mujica: account_switcher add")
|
||||
}
|
||||
@@ -87,15 +92,15 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
|
||||
return r
|
||||
}
|
||||
|
||||
func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *string, accounts []Account, r *AccountSwitcherResult) {
|
||||
Popover(c, b, open, PopoverOptions{Label: msg(c, "account_switcher.accounts"), Width: 320}, func() {
|
||||
func accountSwitcherLarge(c *ui.Context, b ui.Element, open *bool, current *string, accounts []Account, r *AccountSwitcherResult) {
|
||||
overlay.Popover(c, b, open, overlay.PopoverOptions{Label: core.Msg(c, "account_switcher.accounts"), Width: 320}, func() {
|
||||
cursor := ui.Local(b, "cursor", func() int { return -1 })
|
||||
rows := ui.Local(b, "list", func() ui.ListState { return ui.ListState{} })
|
||||
rows.Key = func(i int) any { return accounts[i].ID }
|
||||
rows.Label = func(i int) string { return accounts[i].Name }
|
||||
rows.Selected = cursor
|
||||
restore := dataSelectTheme(c, 0)
|
||||
list := ui.List(c, rows, len(accounts), func(i int) {
|
||||
restore := data.DataSelectTheme(c, 0)
|
||||
list := core.AutoFocus(c, ui.List(c, rows, len(accounts), func(i int) {
|
||||
a := accounts[i]
|
||||
row := workspaceRow(c, Workspace{ID: a.ID, Name: a.Name, Detail: a.Email}, a.ID == *current, i == *cursor)
|
||||
if row.Focused() && !b.IsDisabled() {
|
||||
@@ -104,9 +109,10 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str
|
||||
if row.Clicked() && !row.IsDisabled() && !b.IsDisabled() {
|
||||
r.changed, *current, *open = a.ID != *current, a.ID, false
|
||||
}
|
||||
}).Height(280).MinHeight(0).AutoFocus()
|
||||
}).Height(280).MinHeight(0))
|
||||
|
||||
restore()
|
||||
internal.Ring(list, Tokens(c).Focus, theme.ControlRadius)
|
||||
internal.Ring(list, core.Tokens(c).Focus, theme.ControlRadius)
|
||||
if list.Changed() && !b.IsDisabled() {
|
||||
list.Focus()
|
||||
}
|
||||
@@ -114,7 +120,7 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str
|
||||
id := accounts[*cursor].ID
|
||||
r.changed, *current, *open = id != *current, id, false
|
||||
}
|
||||
if Button(c, msg(c, "account_switcher.add"), ButtonOptions{Variant: Ghost}).Clicked() && !b.IsDisabled() {
|
||||
if input.Button(c, core.Msg(c, "account_switcher.add"), input.ButtonOptions{Variant: input.Ghost}).Clicked() && !b.IsDisabled() {
|
||||
r.add, *open = true, false
|
||||
}
|
||||
})
|
||||
@@ -124,11 +130,11 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str
|
||||
}
|
||||
|
||||
// accountTrigger displays an avatar, name and detail.
|
||||
func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disabled bool) *ui.Element {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disabled bool) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
b := ui.ButtonBase(c).Role(ui.RoleMenuButton).MinHeight(d.ControlHeight()+d.Unit()*2).Padding(d.Unit(), d.Unit()*3, d.Unit(), d.Unit()*2).
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Justify(ui.Start).Disabled(disabled).Label(name).Transition(colorFade)
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Justify(ui.Start).Disabled(disabled).Label(name).Transition(core.ColorFade)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case b.Pressed():
|
||||
@@ -137,11 +143,11 @@ func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disab
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
Avatar(c, name, AvatarOptions{Image: avatar, Size: 28})
|
||||
display.Avatar(c, name, display.AvatarOptions{Image: avatar, Size: 28})
|
||||
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
|
||||
ui.Text(c, name).FontWeight(600).SingleLine().Ellipsis("…")
|
||||
if detail != "" {
|
||||
ui.Text(c, detail).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…")
|
||||
ui.Text(c, detail).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…")
|
||||
}
|
||||
})
|
||||
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -9,8 +9,12 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// APIKey contains public metadata and the visible prefix.
|
||||
@@ -53,7 +57,7 @@ type ApiKeyManagerOptions struct {
|
||||
|
||||
// ApiKeyManagerResult reports creation and revocation requests.
|
||||
type ApiKeyManagerResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
create, revoke string
|
||||
}
|
||||
|
||||
@@ -65,8 +69,8 @@ func (r ApiKeyManagerResult) Revoked() (string, bool) { return r.revoke, r.revok
|
||||
|
||||
// ApiKeyManager masks stored keys and confirms revocation.
|
||||
func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts ApiKeyManagerOptions) ApiKeyManagerResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r ApiKeyManagerResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 3).MinWidth(460).Disabled(opts.Disabled)
|
||||
disabled := r.Element.IsDisabled()
|
||||
@@ -89,24 +93,24 @@ func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts Api
|
||||
byID := s.index
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Children(func() {
|
||||
if Button(c, msg(c, "api_key_manager.create"), ButtonOptions{Icon: icons.Must("plus"), Disabled: disabled}).Clicked() && !disabled {
|
||||
if input.Button(c, core.Msg(c, "api_key_manager.create"), input.ButtonOptions{Icon: icons.Must("plus"), Disabled: disabled}).Clicked() && !disabled {
|
||||
s.creating, s.name, s.err = true, "", ""
|
||||
}
|
||||
})
|
||||
if len(keys) == 0 {
|
||||
EmptyState(c, EmptyStateOptions{Title: msg(c, "api_key_manager.empty")})
|
||||
feedback.EmptyState(c, feedback.EmptyStateOptions{Title: core.Msg(c, "api_key_manager.empty")})
|
||||
}
|
||||
accountRows(c, len(keys), func(i int) any { return keys[i].ID }, func(i int) {
|
||||
key := keys[i]
|
||||
ui.Row(c).Key(key.ID).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center).
|
||||
ui.Row(c.Key(key.ID)).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center).
|
||||
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
|
||||
ui.Text(c, key.Name).FontWeight(600).SingleLine()
|
||||
ui.Text(c, apiKeyMasked(key.Prefix)).Font(theme.MonoFont).TextColor(k.TextMuted).SingleLine()
|
||||
ui.Text(c, apiKeyDates(c, key)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, apiKeyDates(c, key)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
b := Button(c, msg(c, "api_key_manager.revoke"), ButtonOptions{Variant: Secondary, Disabled: disabled}).
|
||||
Label(fmt.Sprintf(msg(c, "api_key_manager.revoke_named"), key.Name))
|
||||
b := input.Button(c, core.Msg(c, "api_key_manager.revoke"), input.ButtonOptions{Variant: input.Secondary, Disabled: disabled}).
|
||||
Label(fmt.Sprintf(core.Msg(c, "api_key_manager.revoke_named"), key.Name))
|
||||
if b.Clicked() && !disabled {
|
||||
s.revoking = key.ID
|
||||
}
|
||||
@@ -119,8 +123,8 @@ func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts Api
|
||||
}
|
||||
victim, exists := byID[s.revoking]
|
||||
open := s.revoking != "" && exists
|
||||
res := AlertDialog(c, &open, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "api_key_manager.revoke_title"), victim.Name),
|
||||
Description: msg(c, "api_key_manager.revoke_text"), ConfirmLabel: msg(c, "api_key_manager.revoke")})
|
||||
res := overlay.AlertDialog(c, &open, overlay.AlertDialogOptions{Title: fmt.Sprintf(core.Msg(c, "api_key_manager.revoke_title"), victim.Name),
|
||||
Description: core.Msg(c, "api_key_manager.revoke_text"), ConfirmLabel: core.Msg(c, "api_key_manager.revoke")})
|
||||
if res.Confirmed && !disabled {
|
||||
r.revoke = victim.ID
|
||||
slog.Debug("mujica: api_key_manager revoke")
|
||||
@@ -136,36 +140,36 @@ func apiKeyMasked(prefix string) string { return prefix + strings.Repeat("•",
|
||||
|
||||
// apiKeyDates says when a key was made and last used.
|
||||
func apiKeyDates(c *ui.Context, key APIKey) string {
|
||||
used := msg(c, "api_key_manager.never")
|
||||
used := core.Msg(c, "api_key_manager.never")
|
||||
if !key.LastUsed.IsZero() {
|
||||
used = key.LastUsed.Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf(msg(c, "api_key_manager.dates"), key.Created.Format("2006-01-02"), used)
|
||||
return fmt.Sprintf(core.Msg(c, "api_key_manager.dates"), key.Created.Format("2006-01-02"), used)
|
||||
}
|
||||
|
||||
// apiKeyCreateDialog collects a name and reveals the generated secret.
|
||||
func apiKeyCreateDialog(c *ui.Context, s *ApiKeyManagerState, disabled bool) (string, bool) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
open := !disabled && (s.creating || s.pending || s.secret != "")
|
||||
was := open
|
||||
asked := false
|
||||
title := msg(c, "api_key_manager.create_title")
|
||||
title := core.Msg(c, "api_key_manager.create_title")
|
||||
if s.secret != "" {
|
||||
title = msg(c, "api_key_manager.secret_title")
|
||||
title = core.Msg(c, "api_key_manager.secret_title")
|
||||
}
|
||||
Dialog(c, &open, DialogOptions{Title: title, KeepOnBackdrop: true, KeepOnEscape: s.pending, HideClose: s.pending, Actions: func() {
|
||||
overlay.Dialog(c, &open, overlay.DialogOptions{Title: title, KeepOnBackdrop: true, KeepOnEscape: s.pending, HideClose: s.pending, Actions: func() {
|
||||
switch {
|
||||
case s.secret != "":
|
||||
if Button(c, msg(c, "api_key_manager.done"), ButtonOptions{}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "api_key_manager.done"), input.ButtonOptions{}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
default:
|
||||
if Button(c, msg(c, "api_key_manager.cancel"), ButtonOptions{Variant: Secondary, Disabled: s.pending}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "api_key_manager.cancel"), input.ButtonOptions{Variant: input.Secondary, Disabled: s.pending}).Clicked() {
|
||||
open = false
|
||||
}
|
||||
ok := strings.TrimSpace(s.name) != ""
|
||||
if Button(c, msg(c, "api_key_manager.create"), ButtonOptions{Loading: s.pending, Disabled: !ok}).Clicked() && ok && !s.pending {
|
||||
if input.Button(c, core.Msg(c, "api_key_manager.create"), input.ButtonOptions{Loading: s.pending, Disabled: !ok}).Clicked() && ok && !s.pending {
|
||||
asked = true
|
||||
}
|
||||
}
|
||||
@@ -173,21 +177,21 @@ func apiKeyCreateDialog(c *ui.Context, s *ApiKeyManagerState, disabled bool) (st
|
||||
if s.secret != "" {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Padding(d.Unit() * 3).Radius(theme.ControlRadius).Background(k.Warning.Alpha(0.12)).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(16, 16).TextColor(k.Warning).Shrink(0)
|
||||
ui.Text(c, msg(c, "api_key_manager.once")).Grow(1)
|
||||
ui.Text(c, core.Msg(c, "api_key_manager.once")).Grow(1)
|
||||
})
|
||||
ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(d.Unit()*2, d.Unit()*3).Radius(theme.ControlRadius).
|
||||
Background(k.Background).Border(theme.BorderWidth, k.Border).Children(func() {
|
||||
ui.Text(c, s.secret).Font(theme.MonoFont).Selectable().Label(msg(c, "api_key_manager.secret_title")).Grow(1).MinWidth(0)
|
||||
if iconAction(c, icons.Must("copy"), msg(c, "api_key_manager.copy")).Clicked() {
|
||||
ui.Text(c, s.secret).Font(theme.MonoFont).Selectable().Label(core.Msg(c, "api_key_manager.secret_title")).Grow(1).MinWidth(0)
|
||||
if core.IconAction(c, icons.Must("copy"), core.Msg(c, "api_key_manager.copy")).Clicked() {
|
||||
c.WriteClipboard(s.secret)
|
||||
c.Announce(msg(c, "api_key_manager.copied"))
|
||||
c.Announce(core.Msg(c, "api_key_manager.copied"))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
res := TextInput(c, &s.name, TextInputOptions{Label: msg(c, "api_key_manager.name"), Placeholder: msg(c, "api_key_manager.name"),
|
||||
res := input.TextInput(c, &s.name, input.TextInputOptions{Label: core.Msg(c, "api_key_manager.name"), Placeholder: core.Msg(c, "api_key_manager.name"),
|
||||
Disabled: s.pending, Error: s.err})
|
||||
res.Input.AutoFocus()
|
||||
core.AutoFocus(c, res.Input)
|
||||
if res.Submitted() && strings.TrimSpace(s.name) != "" && !s.pending {
|
||||
asked = true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,18 +6,21 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CoachmarkStep uses an anchor built in the current frame.
|
||||
type CoachmarkStep struct {
|
||||
Anchor *ui.Element
|
||||
Anchor ui.Element
|
||||
Title, Body string
|
||||
}
|
||||
|
||||
// CoachmarkResult reports tour completion or dismissal.
|
||||
type CoachmarkResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
finished, dismissed bool
|
||||
}
|
||||
|
||||
@@ -37,15 +40,15 @@ func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) Coac
|
||||
return r
|
||||
}
|
||||
cur := steps[*step]
|
||||
if cur.Anchor == nil || cur.Title == "" {
|
||||
if !cur.Anchor.Valid() || cur.Title == "" {
|
||||
panic("mujica: CoachmarkStep needs an Anchor and a Title")
|
||||
}
|
||||
if cur.Anchor.IsDisabled() {
|
||||
*open = false
|
||||
return r
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
if b := cur.Anchor.Bounds(); b.W > 0 {
|
||||
const pad = 4
|
||||
ui.Overlay(c, func() {
|
||||
@@ -54,26 +57,26 @@ func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) Coac
|
||||
})
|
||||
}
|
||||
from := *step
|
||||
r.Element = Popover(c, cur.Anchor, open, PopoverOptions{Title: cur.Title, Width: 300}, func() {
|
||||
r.Element = overlay.Popover(c, cur.Anchor, open, overlay.PopoverOptions{Title: cur.Title, Width: 300}, func() {
|
||||
ui.Column(c).Gap(d.Unit() * 3).Children(func() {
|
||||
if cur.Body != "" {
|
||||
ui.Text(c, cur.Body)
|
||||
}
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "coachmark.count"), *step+1, len(steps))).TextColor(k.TextMuted).
|
||||
FontSize(FontSize(c, theme.CaptionSize)).Grow(1)
|
||||
if *step < len(steps)-1 && Button(c, msg(c, "coachmark.skip"), ButtonOptions{Variant: Ghost}).Clicked() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "coachmark.count"), *step+1, len(steps))).TextColor(k.TextMuted).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).Grow(1)
|
||||
if *step < len(steps)-1 && input.Button(c, core.Msg(c, "coachmark.skip"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
|
||||
*open, r.dismissed = false, true
|
||||
}
|
||||
if *step > 0 && Button(c, msg(c, "coachmark.back"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
if *step > 0 && input.Button(c, core.Msg(c, "coachmark.back"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
|
||||
*step--
|
||||
}
|
||||
last := *step == len(steps)-1
|
||||
next := msg(c, "coachmark.next")
|
||||
next := core.Msg(c, "coachmark.next")
|
||||
if last {
|
||||
next = msg(c, "coachmark.done")
|
||||
next = core.Msg(c, "coachmark.done")
|
||||
}
|
||||
if Button(c, next, ButtonOptions{}).AutoFocus().Clicked() {
|
||||
if core.AutoFocus(c, input.Button(c, next, input.ButtonOptions{})).Clicked() {
|
||||
if last {
|
||||
*open, r.finished = false, true
|
||||
} else {
|
||||
@@ -1,16 +1,18 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func coachmarkView(c *ui.Context, open *bool, step *int, r *CoachmarkResult) {
|
||||
var a, b *ui.Element
|
||||
var a, b ui.Element
|
||||
ui.Row(c).Gap(40).Padding(20).Children(func() {
|
||||
a = Button(c, "Compose", ButtonOptions{})
|
||||
b = Button(c, "Archive", ButtonOptions{Variant: Secondary})
|
||||
a = input.Button(c, "Compose", input.ButtonOptions{})
|
||||
b = input.Button(c, "Archive", input.ButtonOptions{Variant: input.Secondary})
|
||||
})
|
||||
*r = Coachmark(c, open, step, []CoachmarkStep{
|
||||
{Anchor: a, Title: "Write here", Body: "Start a new letter."},
|
||||
@@ -63,7 +65,7 @@ func TestCoachmarkSnapshot(t *testing.T) {
|
||||
func TestCoachmarkDisabledAnchorCloses(t *testing.T) {
|
||||
open, step, disabled := true, 0, false
|
||||
tt := accountTester(600, 400, func(c *ui.Context) {
|
||||
a := Button(c, "Compose", ButtonOptions{Disabled: disabled})
|
||||
a := input.Button(c, "Compose", input.ButtonOptions{Disabled: disabled})
|
||||
Coachmark(c, &open, &step, []CoachmarkStep{{Anchor: a, Title: "Write here"}})
|
||||
})
|
||||
disabled = true
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package account holds MujicaUI settings and account management.
|
||||
package account
|
||||
@@ -0,0 +1,242 @@
|
||||
package account_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/account"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func ExampleSettingsLayout() {
|
||||
page := ""
|
||||
sound := true
|
||||
pages := []account.SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.SettingsLayout(c, &page, pages, account.SettingsLayoutOptions{}, func(id string) {
|
||||
account.SettingsSection(c, "Notifications", func() {
|
||||
account.SettingsRow(c, "Sound", account.SettingsRowOptions{ID: "sound"}, func() ui.Element {
|
||||
return input.Switch(c, &sound, "", input.SwitchOptions{})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSettingsSearch() {
|
||||
query, page := "", "general"
|
||||
entries := []account.SettingEntry{{ID: "sound", Page: "general", Label: "Sound", Keywords: []string{"audio"}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if e, ok := account.SettingsSearch(c, &query, entries, account.SettingsSearchOptions{}).Chosen(); ok {
|
||||
page = e.Page
|
||||
}
|
||||
fmt.Println("page", page)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleThemeSelector() {
|
||||
mode := core.System
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: mode})
|
||||
account.ThemeSelector(c, &mode, account.ThemeSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAccentColorPicker() {
|
||||
accent := ui.Hex("#7D2034")
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.AccentColorPicker(c, &accent, account.AccentColorPickerOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleShortcutRecorder() {
|
||||
palette := account.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.ShortcutRecorder(c, &palette, account.ShortcutRecorderOptions{
|
||||
Label: "Command palette",
|
||||
Taken: []account.ShortcutBinding{{Action: "Save", Shortcut: account.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}}},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleKeyboardShortcutsList() {
|
||||
query := ""
|
||||
groups := []account.ShortcutGroup{{Title: "General", Bindings: []account.ShortcutBinding{
|
||||
{Action: "Command palette", Shortcut: account.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}},
|
||||
}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.KeyboardShortcutsList(c, &query, groups, account.KeyboardShortcutsListOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLoginForm() {
|
||||
var state account.LoginFormState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.LoginForm(c, &state, account.LoginFormOptions{OnSubmit: func(account, password string, remember bool) {
|
||||
// Submit credentials through the caller-provided auth service.
|
||||
state.EndSubmit("")
|
||||
}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleOAuthButtons() {
|
||||
providers := []account.OAuthProvider{{ID: "sso", Name: "Company SSO"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if id, ok := account.OAuthButtons(c, providers, account.OAuthButtonsOptions{}).Chosen(); ok {
|
||||
fmt.Println("start sign-in with", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTwoFactorInput() {
|
||||
code := ""
|
||||
resendAt := time.Now().Add(30 * time.Second)
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := account.TwoFactorInput(c, &code, account.TwoFactorInputOptions{ResendAt: resendAt})
|
||||
if r.Completed() {
|
||||
fmt.Println("verify the code")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleProfileEditor() {
|
||||
saved := account.Profile{Name: "Sakiko", Bio: "Keyboard."}
|
||||
profile := saved
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if account.ProfileEditor(c, &profile, account.ProfileEditorOptions{Saved: saved}).Save() {
|
||||
saved = profile
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAccountSwitcher() {
|
||||
current := "a"
|
||||
accounts := []account.Account{{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if account.AccountSwitcher(c, ¤t, accounts, account.AccountSwitcherOptions{}).Add() {
|
||||
fmt.Println("sign in another account")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleWorkspaceSwitcher() {
|
||||
current := "ave"
|
||||
spaces := []account.Workspace{{ID: "ave", Name: "Ave Mujica"}, {ID: "mygo", Name: "MyGO!!!!!"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.WorkspaceSwitcher(c, ¤t, spaces, account.WorkspaceSwitcherOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleUserMenu() {
|
||||
status := account.UserStatusOnline
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if account.UserMenu(c, &status, account.UserMenuOptions{Name: "Sakiko Togawa"}).SignOut() {
|
||||
fmt.Println("sign out")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSessionList() {
|
||||
sessions := []account.Session{{ID: "s1", Device: "MacBook Pro", Current: true}, {ID: "s2", Device: "iPhone", Location: "Osaka"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if id, ok := account.SessionList(c, sessions, account.SessionListOptions{}).Revoked(); ok {
|
||||
fmt.Println("sign out", id)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleApiKeyManager() {
|
||||
var state account.ApiKeyManagerState
|
||||
keys := []account.APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if name, ok := account.ApiKeyManager(c, &state, keys, account.ApiKeyManagerOptions{}).Create(); ok {
|
||||
fmt.Println("create", name)
|
||||
state.Reveal("secret from your key service")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleUsageQuota() {
|
||||
items := []account.QuotaItem{{Label: "API calls", Used: 4200, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.UsageQuota(c, items, account.UsageQuotaOptions{ResetAt: time.Now().Add(72 * time.Hour)})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleOnboardingWizard() {
|
||||
step := 0
|
||||
steps := []account.WizardStep{{Title: "Welcome"}, {Title: "Pick a theme"}, {Title: "Done"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if account.OnboardingWizard(c, &step, steps, account.OnboardingWizardOptions{}).Finished() {
|
||||
fmt.Println("onboarded")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCoachmark() {
|
||||
open, step := true, 0
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
compose := input.Button(c, "Compose", input.ButtonOptions{})
|
||||
account.Coachmark(c, &open, &step, []account.CoachmarkStep{{Anchor: compose, Title: "Write here", Body: "Start a new letter."}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleWhatsNewDialog() {
|
||||
open, hide := true, false
|
||||
releases := []account.Release{{Version: "1.1", Changes: []account.ReleaseChange{{Kind: account.ChangeNew, Text: "500 components."}}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
account.WhatsNewDialog(c, &open, &hide, releases, account.WhatsNewDialogOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFeedbackWidget() {
|
||||
var state account.FeedbackWidgetState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if f, ok := account.FeedbackWidget(c, &state, account.FeedbackWidgetOptions{}).Submitted(); ok {
|
||||
fmt.Println("send", f.Kind)
|
||||
state.EndSubmit("")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
)
|
||||
|
||||
// FeedbackKind is what the feedback is about.
|
||||
@@ -27,7 +31,7 @@ type Feedback struct {
|
||||
// FeedbackWidgetState holds the draft and its form state.
|
||||
type FeedbackWidgetState struct {
|
||||
Draft Feedback
|
||||
Form FormState
|
||||
Form input.FormState
|
||||
}
|
||||
|
||||
// EndSubmit clears successful drafts and preserves failed ones.
|
||||
@@ -49,7 +53,7 @@ type FeedbackWidgetOptions struct {
|
||||
|
||||
// FeedbackWidgetResult carries the submitted feedback.
|
||||
type FeedbackWidgetResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
feedback Feedback
|
||||
submitted bool
|
||||
}
|
||||
@@ -69,41 +73,41 @@ func FeedbackWidget(c *ui.Context, s *FeedbackWidgetState, opts FeedbackWidgetOp
|
||||
if minLen == 0 {
|
||||
minLen = 10
|
||||
}
|
||||
d := Density(c)
|
||||
d := core.Density(c)
|
||||
var r FeedbackWidgetResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 4).Width(420).Disabled(opts.Disabled)
|
||||
disabled := r.Element.IsDisabled()
|
||||
if disabled {
|
||||
frozen := *s
|
||||
frozen.Form.order = append([]string(nil), s.Form.order...)
|
||||
frozen.Form.fields = make(map[string]*formFieldState, len(s.Form.fields))
|
||||
for name, field := range s.Form.fields {
|
||||
frozen.Form.Order = append([]string(nil), s.Form.Order...)
|
||||
frozen.Form.Fields = make(map[string]*input.FormFieldState, len(s.Form.Fields))
|
||||
for name, field := range s.Form.Fields {
|
||||
copy := *field
|
||||
frozen.Form.fields[name] = ©
|
||||
frozen.Form.Fields[name] = ©
|
||||
}
|
||||
frozen.Form.requested, frozen.Form.awaiting = false, false
|
||||
frozen.Form.Requested, frozen.Form.Awaiting = false, false
|
||||
s = &frozen
|
||||
}
|
||||
locked := disabled || s.Form.Submitting()
|
||||
r.Element.Children(func() {
|
||||
kind := int(s.Draft.Kind)
|
||||
var segs []Segment
|
||||
var segs []navigation.Segment
|
||||
for f := FeedbackBug; f <= FeedbackOther; f++ {
|
||||
segs = append(segs, Segment{Label: msg(c, fmt.Sprintf("feedback_widget.kind%d", f))})
|
||||
segs = append(segs, navigation.Segment{Label: core.Msg(c, fmt.Sprintf("feedback_widget.kind%d", f))})
|
||||
}
|
||||
SegmentedControl(c, &kind, segs, SegmentedControlOptions{Equal: true, Label: msg(c, "feedback_widget.kind"), Disabled: locked})
|
||||
navigation.SegmentedControl(c, &kind, segs, navigation.SegmentedControlOptions{Equal: true, Label: core.Msg(c, "feedback_widget.kind"), Disabled: locked})
|
||||
if !locked {
|
||||
s.Draft.Kind = FeedbackKind(kind)
|
||||
}
|
||||
res := Form(c, &s.Form, FormOptions{SubmitLabel: msg(c, "feedback_widget.send"), ResetLabel: msg(c, "feedback_widget.clear")}, func(f *FormScope) {
|
||||
FormItem(c, f, "text", &s.Draft.Text, FormItemOptions[string]{Label: msg(c, "feedback_widget.message"), Required: true,
|
||||
res := input.Form(c, &s.Form, input.FormOptions{SubmitLabel: core.Msg(c, "feedback_widget.send"), ResetLabel: core.Msg(c, "feedback_widget.clear")}, func(f *input.FormScope) {
|
||||
input.FormItem(c, f, "text", &s.Draft.Text, input.FormItemOptions[string]{Label: core.Msg(c, "feedback_widget.message"), Required: true,
|
||||
Validate: func(v string) string {
|
||||
if n := len([]rune(strings.TrimSpace(v))); n < minLen {
|
||||
return fmt.Sprintf(msg(c, "feedback_widget.short"), minLen)
|
||||
return fmt.Sprintf(core.Msg(c, "feedback_widget.short"), minLen)
|
||||
}
|
||||
return ""
|
||||
}}, func() *ui.Element {
|
||||
return TextArea(c, &s.Draft.Text, TextAreaOptions{MinHeight: 96, MaxLength: 2000, Disabled: locked}).Input
|
||||
}}, func() ui.Element {
|
||||
return input.TextArea(c, &s.Draft.Text, input.TextAreaOptions{MinHeight: 96, MaxLength: 2000, Disabled: locked}).Input
|
||||
})
|
||||
if opts.Attachments != nil {
|
||||
ui.Column(c).Gap(d.Unit()).Disabled(locked).Children(opts.Attachments)
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func TestFeedbackWidgetValidatesSubmitsOnce(t *testing.T) {
|
||||
@@ -63,7 +65,7 @@ func TestFeedbackWidgetSnapshot(t *testing.T) {
|
||||
})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
FeedbackWidget(c, s, FeedbackWidgetOptions{Attachments: func() {
|
||||
Button(c, "Attach screenshot", ButtonOptions{Variant: Secondary})
|
||||
input.Button(c, "Attach screenshot", input.ButtonOptions{Variant: input.Secondary})
|
||||
}})
|
||||
})
|
||||
})
|
||||
@@ -74,7 +76,7 @@ func TestFeedbackWidgetPendingFreezesDraftAndAttachments(t *testing.T) {
|
||||
attachments := 0
|
||||
tt := accountTester(500, 500, func(c *ui.Context) {
|
||||
FeedbackWidget(c, &s, FeedbackWidgetOptions{Attachments: func() {
|
||||
if Button(c, "Attach", ButtonOptions{}).Clicked() {
|
||||
if input.Button(c, "Attach", input.ButtonOptions{}).Clicked() {
|
||||
attachments++
|
||||
}
|
||||
}})
|
||||
@@ -115,7 +117,7 @@ func TestFeedbackWidgetDisabledKeepsFormState(t *testing.T) {
|
||||
s := FeedbackWidgetState{Draft: Feedback{Text: "A long enough message."}}
|
||||
s.Form.Submit()
|
||||
accountTester(600, 500, func(c *ui.Context) { FeedbackWidget(c, &s, FeedbackWidgetOptions{Disabled: true}) })
|
||||
if s.Form.Submitting() || !s.Form.requested || s.Form.fields != nil {
|
||||
if s.Form.Submitting() || !s.Form.Requested || s.Form.Fields != nil {
|
||||
t.Fatal("disabled frame edits form state")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ShortcutGroup is a titled group of bindings in a KeyboardShortcutsList.
|
||||
@@ -32,20 +35,20 @@ type shortcutsListState struct {
|
||||
}
|
||||
|
||||
// KeyboardShortcutsList displays searchable, grouped shortcuts using visible rows.
|
||||
func KeyboardShortcutsList(c *ui.Context, query *string, groups []ShortcutGroup, opts KeyboardShortcutsListOptions) *ui.Element {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
func KeyboardShortcutsList(c *ui.Context, query *string, groups []ShortcutGroup, opts KeyboardShortcutsListOptions) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "keyboard_shortcuts_list.search")
|
||||
label = core.Msg(c, "keyboard_shortcuts_list.search")
|
||||
}
|
||||
e := ui.Column(c).Gap(d.Unit() * 4).MinWidth(320)
|
||||
st := ui.Local(e, "shortcuts", func() shortcutsListState { return shortcutsListState{} })
|
||||
e.Children(func() {
|
||||
SearchInput(c, query, SearchInputOptions{Placeholder: label, Label: label})
|
||||
input.SearchInput(c, query, input.SearchInputOptions{Placeholder: label, Label: label})
|
||||
shortcutsListPlan(st, groups, strings.ToLower(strings.TrimSpace(*query)))
|
||||
if len(st.rows) == 0 {
|
||||
EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: *query})
|
||||
feedback.EmptyState(c, feedback.EmptyStateOptions{Kind: feedback.EmptyNoResults, Query: *query})
|
||||
return
|
||||
}
|
||||
accountRows(c, len(st.rows), func(i int) any { return st.rows[i] }, func(i int) {
|
||||
@@ -93,11 +96,11 @@ func shortcutsListPlan(st *shortcutsListState, groups []ShortcutGroup, q string)
|
||||
}
|
||||
}
|
||||
|
||||
func accountRows(c *ui.Context, n int, key func(int) any, row func(int)) *ui.Element {
|
||||
func accountRows(c *ui.Context, n int, key func(int) any, row func(int)) ui.Element {
|
||||
if n <= 8 {
|
||||
return ui.Column(c).Children(func() {
|
||||
for i := range n {
|
||||
ui.Column(c).Key(key(i)).Children(func() { row(i) })
|
||||
ui.Column(c.Key(key(i))).Children(func() { row(i) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// LoginFormState holds the form; only OnSubmit sees the password.
|
||||
@@ -38,46 +40,46 @@ type LoginFormOptions struct {
|
||||
}
|
||||
|
||||
// LoginForm collects credentials; it never checks or stores them.
|
||||
func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) *ui.Element {
|
||||
func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) ui.Element {
|
||||
if opts.OnSubmit == nil {
|
||||
panic("mujica: LoginForm needs OnSubmit")
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
accountLabel := opts.AccountLabel
|
||||
if accountLabel == "" {
|
||||
accountLabel = msg(c, "login_form.account")
|
||||
accountLabel = core.Msg(c, "login_form.account")
|
||||
}
|
||||
e := ui.Column(c).Gap(d.Unit() * 4).Width(340).Disabled(opts.Disabled)
|
||||
disabled := e.IsDisabled()
|
||||
missing := func(v string) string {
|
||||
if s.tried && strings.TrimSpace(v) == "" {
|
||||
return msg(c, "form.required")
|
||||
return core.Msg(c, "form.required")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
submit := false
|
||||
var acc, pw InputResult
|
||||
var acc, pw input.InputResult
|
||||
e.Children(func() {
|
||||
FormField(c, accountLabel, FormFieldOptions{Required: true}, func() *ui.Element {
|
||||
acc = TextInput(c, &s.Account, TextInputOptions{Disabled: disabled || s.submitting, Error: missing(s.Account)})
|
||||
input.FormField(c, accountLabel, input.FormFieldOptions{Required: true}, func() ui.Element {
|
||||
acc = input.TextInput(c, &s.Account, input.TextInputOptions{Disabled: disabled || s.submitting, Error: missing(s.Account)})
|
||||
return acc.Input
|
||||
})
|
||||
FormField(c, msg(c, "login_form.password"), FormFieldOptions{Required: true}, func() *ui.Element {
|
||||
pw = PasswordInput(c, &s.password, PasswordInputOptions{Disabled: disabled || s.submitting, Error: missing(s.password)})
|
||||
input.FormField(c, core.Msg(c, "login_form.password"), input.FormFieldOptions{Required: true}, func() ui.Element {
|
||||
pw = input.PasswordInput(c, &s.password, input.PasswordInputOptions{Disabled: disabled || s.submitting, Error: missing(s.password)})
|
||||
return pw.Input
|
||||
})
|
||||
if acc.Changed() || pw.Changed() {
|
||||
s.err = ""
|
||||
}
|
||||
Checkbox(c, &s.Remember, msg(c, "login_form.remember"), CheckboxOptions{Disabled: disabled || s.submitting})
|
||||
input.Checkbox(c, &s.Remember, core.Msg(c, "login_form.remember"), input.CheckboxOptions{Disabled: disabled || s.submitting})
|
||||
if s.err != "" {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0)
|
||||
ui.Text(c, s.err).Grow(1)
|
||||
})
|
||||
}
|
||||
b := Button(c, msg(c, "login_form.submit"), ButtonOptions{Loading: s.submitting, Disabled: disabled})
|
||||
b := input.Button(c, core.Msg(c, "login_form.submit"), input.ButtonOptions{Loading: s.submitting, Disabled: disabled})
|
||||
b.FillWidth()
|
||||
submit = b.Clicked() || acc.Submitted() || pw.Submitted()
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,10 +1,13 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// OAuthProvider is a sign-in provider; icons are plain, not trademarks.
|
||||
@@ -22,7 +25,7 @@ type OAuthButtonsOptions struct {
|
||||
|
||||
// OAuthButtonsResult carries the selected provider.
|
||||
type OAuthButtonsResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
chosen string
|
||||
}
|
||||
|
||||
@@ -34,7 +37,7 @@ func OAuthButtons(c *ui.Context, providers []OAuthProvider, opts OAuthButtonsOpt
|
||||
if len(providers) == 0 {
|
||||
panic("mujica: OAuthButtons needs providers")
|
||||
}
|
||||
d := Density(c)
|
||||
d := core.Density(c)
|
||||
var r OAuthButtonsResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 2).Width(340).Disabled(opts.Disabled)
|
||||
disabled := r.Element.IsDisabled()
|
||||
@@ -43,9 +46,9 @@ func OAuthButtons(c *ui.Context, providers []OAuthProvider, opts OAuthButtonsOpt
|
||||
if p.ID == "" || p.Name == "" {
|
||||
panic("mujica: OAuthProvider needs an ID and a Name")
|
||||
}
|
||||
label := fmt.Sprintf(msg(c, "oauth_buttons.continue"), p.Name)
|
||||
ui.Column(c).Key(p.ID).AlignItems(ui.Stretch).Children(func() {
|
||||
b := Button(c, label, ButtonOptions{Variant: Secondary, Icon: p.Icon, Loading: opts.Pending == p.ID,
|
||||
label := fmt.Sprintf(core.Msg(c, "oauth_buttons.continue"), p.Name)
|
||||
ui.Column(c.Key(p.ID)).AlignItems(ui.Stretch).Children(func() {
|
||||
b := input.Button(c, label, input.ButtonOptions{Variant: input.Secondary, Icon: p.Icon, Loading: opts.Pending == p.ID,
|
||||
Disabled: opts.Pending != "" && opts.Pending != p.ID})
|
||||
if b.Clicked() && !disabled && opts.Pending == "" {
|
||||
r.chosen = p.ID
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// WizardStep is one page of an OnboardingWizard.
|
||||
@@ -25,7 +28,7 @@ type OnboardingWizardOptions struct {
|
||||
|
||||
// OnboardingWizardResult reports completion or skipping.
|
||||
type OnboardingWizardResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
finished, skipped bool
|
||||
}
|
||||
|
||||
@@ -40,26 +43,26 @@ func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts Onboard
|
||||
if len(steps) == 0 || *step < 0 || *step >= len(steps) {
|
||||
panic(fmt.Sprintf("mujica: OnboardingWizard step %d outside %d steps", *step, len(steps)))
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r OnboardingWizardResult
|
||||
from := *step
|
||||
r.Element = ui.Column(c).Gap(d.Unit()*5).Width(520).Padding(d.Unit()*6).Radius(theme.CardRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
r.Element.Children(func() {
|
||||
head := make([]Step, len(steps))
|
||||
head := make([]navigation.Step, len(steps))
|
||||
for i, s := range steps {
|
||||
if s.Title == "" {
|
||||
panic("mujica: WizardStep needs a Title")
|
||||
}
|
||||
head[i] = Step{Title: s.Title}
|
||||
head[i] = navigation.Step{Title: s.Title}
|
||||
}
|
||||
at := *step
|
||||
Steps(c, &at, head, StepsOptions{Label: msg(c, "onboarding_wizard.progress")})
|
||||
navigation.Steps(c, &at, head, navigation.StepsOptions{Label: core.Msg(c, "onboarding_wizard.progress")})
|
||||
cur := steps[*step]
|
||||
ui.Column(c).Key(*step).Gap(d.Unit() * 3).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "onboarding_wizard.count"), *step+1, len(steps))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, cur.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).FontWeight(600)
|
||||
ui.Column(c.Key(*step)).Gap(d.Unit() * 3).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "onboarding_wizard.count"), *step+1, len(steps))).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, cur.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).FontWeight(600)
|
||||
if cur.Description != "" {
|
||||
ui.Text(c, cur.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
@@ -70,18 +73,18 @@ func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts Onboard
|
||||
last := *step == len(steps)-1
|
||||
ready := cur.Ready == nil || cur.Ready()
|
||||
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||
if !opts.NoSkip && !last && Button(c, msg(c, "onboarding_wizard.skip"), ButtonOptions{Variant: Ghost}).Clicked() {
|
||||
if !opts.NoSkip && !last && input.Button(c, core.Msg(c, "onboarding_wizard.skip"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
|
||||
r.skipped = true
|
||||
}
|
||||
ui.Box(c).Grow(1)
|
||||
if *step > 0 && Button(c, msg(c, "onboarding_wizard.back"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
if *step > 0 && input.Button(c, core.Msg(c, "onboarding_wizard.back"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
|
||||
*step--
|
||||
}
|
||||
next := msg(c, "onboarding_wizard.next")
|
||||
next := core.Msg(c, "onboarding_wizard.next")
|
||||
if last {
|
||||
next = msg(c, "onboarding_wizard.finish")
|
||||
next = core.Msg(c, "onboarding_wizard.finish")
|
||||
}
|
||||
if Button(c, next, ButtonOptions{Disabled: !ready}).AutoFocus().Clicked() && ready {
|
||||
if core.AutoFocus(c, input.Button(c, next, input.ButtonOptions{Disabled: !ready})).Clicked() && ready {
|
||||
if last {
|
||||
r.finished = true
|
||||
} else {
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func TestOnboardingWizardStepsSkipFinish(t *testing.T) {
|
||||
@@ -15,7 +17,7 @@ func TestOnboardingWizardStepsSkipFinish(t *testing.T) {
|
||||
{Title: "Done"},
|
||||
}
|
||||
tt := accountTester(600, 500, func(c *ui.Context) {
|
||||
steps[1].Content = func() { TextInput(c, &name, TextInputOptions{Label: "Name"}) }
|
||||
steps[1].Content = func() { input.TextInput(c, &name, input.TextInputOptions{Label: "Name"}) }
|
||||
r := OnboardingWizard(c, &step, steps, OnboardingWizardOptions{})
|
||||
if r.Finished() {
|
||||
finished++
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Profile is what a ProfileEditor edits.
|
||||
@@ -31,7 +34,7 @@ type ProfileEditorOptions struct {
|
||||
|
||||
// ProfileEditorResult reports save, avatar and discard requests.
|
||||
type ProfileEditorResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
save, avatar, reset bool
|
||||
}
|
||||
|
||||
@@ -53,28 +56,28 @@ func ProfileEditor(c *ui.Context, p *Profile, opts ProfileEditorOptions) Profile
|
||||
if limit == 0 {
|
||||
limit = 160
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r ProfileEditorResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 4).Width(420).Disabled(opts.Disabled)
|
||||
locked := r.Element.IsDisabled() || opts.Saving
|
||||
dirty := *p != opts.Saved
|
||||
nameErr := ""
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
nameErr = msg(c, "form.required")
|
||||
nameErr = core.Msg(c, "form.required")
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 4).AlignItems(ui.Center).Children(func() {
|
||||
Avatar(c, p.Name, AvatarOptions{Image: opts.Avatar, Size: 64})
|
||||
if Button(c, msg(c, "profile_editor.avatar"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
display.Avatar(c, p.Name, display.AvatarOptions{Image: opts.Avatar, Size: 64})
|
||||
if input.Button(c, core.Msg(c, "profile_editor.avatar"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
r.avatar = true
|
||||
}
|
||||
})
|
||||
FormField(c, msg(c, "profile_editor.name"), FormFieldOptions{Required: true}, func() *ui.Element {
|
||||
return TextInput(c, &p.Name, TextInputOptions{Disabled: locked, Error: nameErr}).Input
|
||||
input.FormField(c, core.Msg(c, "profile_editor.name"), input.FormFieldOptions{Required: true}, func() ui.Element {
|
||||
return input.TextInput(c, &p.Name, input.TextInputOptions{Disabled: locked, Error: nameErr}).Input
|
||||
})
|
||||
FormField(c, msg(c, "profile_editor.bio"), FormFieldOptions{}, func() *ui.Element {
|
||||
return TextArea(c, &p.Bio, TextAreaOptions{MaxLength: limit, Disabled: locked}).Input
|
||||
input.FormField(c, core.Msg(c, "profile_editor.bio"), input.FormFieldOptions{}, func() ui.Element {
|
||||
return input.TextArea(c, &p.Bio, input.TextAreaOptions{MaxLength: limit, Disabled: locked}).Input
|
||||
})
|
||||
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||
if dirty {
|
||||
@@ -82,16 +85,16 @@ func ProfileEditor(c *ui.Context, p *Profile, opts ProfileEditorOptions) Profile
|
||||
ui.Box(c).Size(10, 10).Draw(func(pt *ui.Painter, rect ui.Rect) {
|
||||
pt.FillPath(internal.Diamond(internal.Snap(pt, rect.X+5), internal.Snap(pt, rect.Y+5), 10), k.Warning)
|
||||
})
|
||||
ui.Text(c, msg(c, "profile_editor.unsaved")).TextColor(k.Warning).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600)
|
||||
ui.Text(c, core.Msg(c, "profile_editor.unsaved")).TextColor(k.Warning).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600)
|
||||
})
|
||||
} else {
|
||||
ui.Box(c).Grow(1)
|
||||
}
|
||||
if Button(c, msg(c, "profile_editor.discard"), ButtonOptions{Variant: Ghost, Disabled: locked || !dirty}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "profile_editor.discard"), input.ButtonOptions{Variant: input.Ghost, Disabled: locked || !dirty}).Clicked() && !locked {
|
||||
*p, r.reset = opts.Saved, true
|
||||
}
|
||||
valid := strings.TrimSpace(p.Name) != "" && utf8.RuneCountInString(p.Bio) <= limit
|
||||
if Button(c, msg(c, "profile_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: r.Element.IsDisabled() || !dirty || !valid}).Clicked() && !locked && valid {
|
||||
if input.Button(c, core.Msg(c, "profile_editor.save"), input.ButtonOptions{Loading: opts.Saving, Disabled: r.Element.IsDisabled() || !dirty || !valid}).Clicked() && !locked && valid {
|
||||
r.save = true
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,7 +8,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Session describes a device without carrying its authentication token.
|
||||
@@ -26,7 +30,7 @@ type SessionListOptions struct {
|
||||
|
||||
// SessionListResult reports individual and bulk sign-out requests.
|
||||
type SessionListResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
revoke string
|
||||
others bool
|
||||
}
|
||||
@@ -46,8 +50,8 @@ type sessionListState struct {
|
||||
|
||||
// SessionList displays devices and requests sign-out through events.
|
||||
func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) SessionListResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r SessionListResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 3).MinWidth(420).Disabled(opts.Disabled)
|
||||
st := ui.Local(r.Element, "sessions", func() sessionListState { return sessionListState{} })
|
||||
@@ -67,23 +71,23 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
|
||||
}
|
||||
others := st.others
|
||||
r.Element.Children(func() {
|
||||
ui.Column(c).Role(ui.RoleList).Label(msg(c, "session_list.label")).Children(func() {
|
||||
ui.Column(c).Role(ui.RoleList).Label(core.Msg(c, "session_list.label")).Children(func() {
|
||||
accountRows(c, len(sessions), func(i int) any { return sessions[i].ID }, func(i int) {
|
||||
s := sessions[i]
|
||||
ui.Row(c).Key(s.ID).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center).
|
||||
ui.Row(c.Key(s.ID)).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center).
|
||||
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Role(ui.RoleListItem).Children(func() {
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, s.Device).FontWeight(600).SingleLine()
|
||||
if s.Current {
|
||||
Badge(c, BadgeOptions{Text: msg(c, "session_list.current")}, nil)
|
||||
display.Badge(c, display.BadgeOptions{Text: core.Msg(c, "session_list.current")}, nil)
|
||||
}
|
||||
})
|
||||
ui.Text(c, sessionListDetail(c, s)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
ui.Text(c, sessionListDetail(c, s)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
if !s.Current {
|
||||
b := Button(c, msg(c, "session_list.sign_out"), ButtonOptions{Variant: Secondary, Disabled: disabled}).
|
||||
Label(fmt.Sprintf(msg(c, "session_list.sign_out_of"), s.Device))
|
||||
b := input.Button(c, core.Msg(c, "session_list.sign_out"), input.ButtonOptions{Variant: input.Secondary, Disabled: disabled}).
|
||||
Label(fmt.Sprintf(core.Msg(c, "session_list.sign_out_of"), s.Device))
|
||||
if b.Clicked() && !disabled {
|
||||
r.revoke = s.ID
|
||||
}
|
||||
@@ -92,7 +96,7 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
|
||||
})
|
||||
})
|
||||
ui.Row(c).Children(func() {
|
||||
if Button(c, msg(c, "session_list.sign_out_others"), ButtonOptions{Variant: Danger, Disabled: disabled || others == 0}).Clicked() && !disabled {
|
||||
if input.Button(c, core.Msg(c, "session_list.sign_out_others"), input.ButtonOptions{Variant: input.Danger, Disabled: disabled || others == 0}).Clicked() && !disabled {
|
||||
st.confirm = true
|
||||
}
|
||||
})
|
||||
@@ -100,8 +104,8 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
|
||||
if disabled || others == 0 {
|
||||
st.confirm = false
|
||||
}
|
||||
res := AlertDialog(c, &st.confirm, AlertDialogOptions{Title: msg(c, "session_list.confirm_title"),
|
||||
Description: fmt.Sprintf(msg(c, "session_list.confirm_text"), others), ConfirmLabel: msg(c, "session_list.sign_out_others")})
|
||||
res := overlay.AlertDialog(c, &st.confirm, overlay.AlertDialogOptions{Title: core.Msg(c, "session_list.confirm_title"),
|
||||
Description: fmt.Sprintf(core.Msg(c, "session_list.confirm_text"), others), ConfirmLabel: core.Msg(c, "session_list.sign_out_others")})
|
||||
r.others = res.Confirmed && !disabled
|
||||
if r.revoke != "" || r.others {
|
||||
slog.Debug("mujica: session_list sign out", "others", r.others)
|
||||
@@ -111,9 +115,9 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
|
||||
|
||||
// sessionListDetail formats the location and last active time.
|
||||
func sessionListDetail(c *ui.Context, s Session) string {
|
||||
when := msg(c, "session_list.now")
|
||||
when := core.Msg(c, "session_list.now")
|
||||
if ago := c.Now().Sub(s.LastActive); s.LastActive.IsZero() {
|
||||
when = msg(c, "session_list.unknown")
|
||||
when = core.Msg(c, "session_list.unknown")
|
||||
} else if ago >= time.Minute {
|
||||
when = s.LastActive.Format("2006-01-02 15:04")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SettingsPage is one page of a SettingsLayout's navigation.
|
||||
@@ -22,18 +24,18 @@ type SettingsLayoutOptions struct {
|
||||
}
|
||||
|
||||
// SettingsLayout builds section navigation and independently scrolling content.
|
||||
func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts SettingsLayoutOptions, content func(id string)) ChoiceResult {
|
||||
func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts SettingsLayoutOptions, content func(id string)) core.ChoiceResult {
|
||||
if len(pages) == 0 || content == nil {
|
||||
panic("mujica: SettingsLayout needs pages and content")
|
||||
}
|
||||
found := false
|
||||
items := make([]SidebarItem, len(pages))
|
||||
items := make([]navigation.SidebarItem, len(pages))
|
||||
for i, p := range pages {
|
||||
if p.ID == "" || p.Label == "" {
|
||||
panic("mujica: SettingsLayout page needs an ID and a Label")
|
||||
}
|
||||
found = found || p.ID == *page
|
||||
items[i] = SidebarItem{ID: p.ID, Label: p.Label, Icon: p.Icon}
|
||||
items[i] = navigation.SidebarItem{ID: p.ID, Label: p.Label, Icon: p.Icon}
|
||||
}
|
||||
if *page == "" {
|
||||
*page, found = pages[0].ID, true
|
||||
@@ -43,32 +45,32 @@ func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts Sett
|
||||
}
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = msg(c, "settings_layout.title")
|
||||
title = core.Msg(c, "settings_layout.title")
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var r ChoiceResult
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Row(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
|
||||
r.Element.Children(func() {
|
||||
nav := Sidebar(c, page, []SidebarSection{{Title: title, Items: items}}, SidebarOptions{Width: 220})
|
||||
nav := navigation.Sidebar(c, page, []navigation.SidebarSection{{Title: title, Items: items}}, navigation.SidebarOptions{Width: 220})
|
||||
if nav.Changed() {
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: settings_layout page", "page", *page)
|
||||
}
|
||||
ui.Scroll(c).Grow(1).MinWidth(0).Padding(d.Unit()*6, d.Unit()*8).Children(func() {
|
||||
ui.Column(c).Key(*page).MaxWidth(720).Gap(d.Unit() * 8).Children(func() { content(*page) })
|
||||
ui.Column(c.Key(*page)).MaxWidth(720).Gap(d.Unit() * 8).Children(func() { content(*page) })
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// SettingsSection groups rows under a serif heading.
|
||||
func SettingsSection(c *ui.Context, title string, rows func()) *ui.Element {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
func SettingsSection(c *ui.Context, title string, rows func()) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
e := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(title)
|
||||
e.Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Role(ui.RoleNone)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Role(ui.RoleNone)
|
||||
ui.Column(c).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Padding(0, d.Unit()*4).
|
||||
Dividers(theme.BorderWidth, k.Border).Children(rows)
|
||||
})
|
||||
@@ -92,30 +94,30 @@ func settingsReveal(c *ui.Context, id string) {
|
||||
type settingsRowState struct{ at time.Time }
|
||||
|
||||
// SettingsRow associates a label and description with its control.
|
||||
func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control func() *ui.Element) *ui.Element {
|
||||
func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control func() ui.Element) ui.Element {
|
||||
if label == "" || control == nil {
|
||||
panic("mujica: SettingsRow needs a label and a control")
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
row := ui.Row(c).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*4).AlignItems(ui.Center).Padding(d.Unit()*3, 0)
|
||||
if opts.ID != "" {
|
||||
row.Key(opts.ID)
|
||||
}
|
||||
st := ui.Local(row, "reveal", func() settingsRowState { return settingsRowState{} })
|
||||
reveal := ui.Local(c.Root(), settingsRevealKey{}, func() string { return "" })
|
||||
var ctrl, text *ui.Element
|
||||
var ctrl, text ui.Element
|
||||
row.Children(func() {
|
||||
text = ui.Column(c).Grow(1).MinWidth(0).Gap(d.Unit())
|
||||
text.Children(func() {
|
||||
ui.Text(c, label).FontWeight(500).Role(ui.RoleNone)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
|
||||
}
|
||||
})
|
||||
ui.Row(c).Shrink(0).Children(func() { ctrl = control() })
|
||||
})
|
||||
if ctrl == nil {
|
||||
if !ctrl.Valid() {
|
||||
panic("mujica: SettingsRow " + label + ": control returned nil")
|
||||
}
|
||||
ctrl.Label(label).Description(opts.Description)
|
||||
@@ -129,7 +131,7 @@ func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control f
|
||||
slog.Debug("mujica: settings_layout reveal", "row", opts.ID)
|
||||
}
|
||||
// A revealed row's face fades from the selection color.
|
||||
if dur := Motion(c, theme.StateDuration); !st.at.IsZero() && dur > 0 {
|
||||
if dur := core.Motion(c, theme.StateDuration); !st.at.IsZero() && dur > 0 {
|
||||
if t := c.Now().Sub(st.at); t < dur {
|
||||
row.Background(k.Selection.Alpha(1 - float32(t)/float32(dur)))
|
||||
c.AnimationFrame()
|
||||
@@ -1,15 +1,18 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// accountTester runs a themed view inside a padded column.
|
||||
func accountTester(w, h int, view func(c *ui.Context)) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
|
||||
}, w, h)
|
||||
}
|
||||
@@ -21,14 +24,14 @@ func settingsView(c *ui.Context, page *string, sound, track *bool, changes *int)
|
||||
switch id {
|
||||
case "general":
|
||||
SettingsSection(c, "Notifications", func() {
|
||||
SettingsRow(c, "Sound", SettingsRowOptions{ID: "sound", Description: "Chime on new messages."}, func() *ui.Element {
|
||||
return Switch(c, sound, "", SwitchOptions{})
|
||||
SettingsRow(c, "Sound", SettingsRowOptions{ID: "sound", Description: "Chime on new messages."}, func() ui.Element {
|
||||
return input.Switch(c, sound, "", input.SwitchOptions{})
|
||||
})
|
||||
})
|
||||
case "privacy":
|
||||
SettingsSection(c, "Data", func() {
|
||||
SettingsRow(c, "Usage reports", SettingsRowOptions{ID: "track"}, func() *ui.Element {
|
||||
return Switch(c, track, "", SwitchOptions{})
|
||||
SettingsRow(c, "Usage reports", SettingsRowOptions{ID: "track"}, func() ui.Element {
|
||||
return input.Switch(c, track, "", input.SwitchOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SettingEntry identifies a searchable setting and its destination.
|
||||
@@ -30,7 +32,7 @@ type SettingsSearchOptions struct {
|
||||
|
||||
// SettingsSearchResult carries the selected destination.
|
||||
type SettingsSearchResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
chosen *SettingEntry
|
||||
}
|
||||
|
||||
@@ -60,12 +62,12 @@ func SettingsSearch(c *ui.Context, query *string, entries []SettingEntry, opts S
|
||||
if limit == 0 {
|
||||
limit = 8
|
||||
}
|
||||
d := Density(c)
|
||||
d := core.Density(c)
|
||||
var r SettingsSearchResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 2).MinWidth(280)
|
||||
st := ui.Local(r.Element, "search", func() settingsSearchState { return settingsSearchState{} })
|
||||
r.Element.Children(func() {
|
||||
res := SearchInput(c, query, SearchInputOptions{Placeholder: msg(c, "settings_search.placeholder"), Label: msg(c, "settings_search.placeholder")})
|
||||
res := input.SearchInput(c, query, input.SearchInputOptions{Placeholder: core.Msg(c, "settings_search.placeholder"), Label: core.Msg(c, "settings_search.placeholder")})
|
||||
in := res.Input
|
||||
q := strings.ToLower(strings.TrimSpace(*query))
|
||||
settingsSearchPlan(st, entries, q, limit)
|
||||
@@ -87,7 +89,7 @@ func SettingsSearch(c *ui.Context, query *string, entries []SettingEntry, opts S
|
||||
r.chosen = &entries[hits[st.cursor]]
|
||||
}
|
||||
if q != "" && len(hits) == 0 {
|
||||
ui.Text(c, msg(c, "settings_search.none")).TextColor(Tokens(c).TextMuted).Role(ui.RoleStatus)
|
||||
ui.Text(c, core.Msg(c, "settings_search.none")).TextColor(core.Tokens(c).TextMuted).Role(ui.RoleStatus)
|
||||
}
|
||||
list := accountRows(c, len(hits), func(n int) any { return entries[hits[n]].ID }, func(n int) {
|
||||
i := hits[n]
|
||||
@@ -131,9 +133,9 @@ func settingsSearchPlan(st *settingsSearchState, entries []SettingEntry, q strin
|
||||
|
||||
// settingsSearchHit builds a marked, clickable search result.
|
||||
func settingsSearchHit(c *ui.Context, e SettingEntry, q string, current bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
b := ui.ButtonBase(c).Key(e.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start).
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
b := ui.ButtonBase(c.Key(e.ID)).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start).
|
||||
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(e.Label).Description(e.Description).Selected(current).Gap(d.Unit() * 2)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
@@ -160,8 +162,8 @@ func settingsSearchHit(c *ui.Context, e SettingEntry, q string, current bool) bo
|
||||
|
||||
// settingsSearchSpans highlights matches while preserving original Unicode text.
|
||||
func settingsSearchSpans(c *ui.Context, s, q string, size float32) []ui.Span {
|
||||
k := Tokens(c)
|
||||
size = FontSize(c, size)
|
||||
k := core.Tokens(c)
|
||||
size = core.FontSize(c, size)
|
||||
plain := func(t string) ui.Span { return ui.Span{Text: t, Size: size, Color: k.Text} }
|
||||
if q == "" {
|
||||
return []ui.Span{plain(s)}
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var settingEntries = []SettingEntry{
|
||||
@@ -74,7 +76,7 @@ func TestSettingsSearchHighlightsUnicodeCaseMapping(t *testing.T) {
|
||||
func TestSettingsSearchPlainSpansHaveReadableColor(t *testing.T) {
|
||||
accountTester(400, 220, func(c *ui.Context) {
|
||||
for _, span := range settingsSearchSpans(c, "Usage reports", "us", 14) {
|
||||
if span.Color != Tokens(c).Text {
|
||||
if span.Color != core.Tokens(c).Text {
|
||||
t.Error("search text does not use the readable Text token")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Shortcut combines modifiers and a key; zero means unassigned.
|
||||
@@ -81,10 +83,10 @@ var shortcutNames = map[ui.Key]string{
|
||||
// shortcutKbd displays keycaps or a muted unassigned mark.
|
||||
func shortcutKbd(c *ui.Context, s Shortcut) {
|
||||
if s.IsZero() {
|
||||
ui.Text(c, "—").TextColor(Tokens(c).TextMuted)
|
||||
ui.Text(c, "—").TextColor(core.Tokens(c).TextMuted)
|
||||
return
|
||||
}
|
||||
Kbd(c, s.keys()...)
|
||||
display.Kbd(c, s.keys()...)
|
||||
}
|
||||
|
||||
// ShortcutBinding names an action that already owns a shortcut.
|
||||
@@ -109,12 +111,12 @@ type shortcutRecorderState struct {
|
||||
}
|
||||
|
||||
// ShortcutRecorder records key combinations; Escape cancels.
|
||||
func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptions) ChoiceResult {
|
||||
func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptions) core.ChoiceResult {
|
||||
if opts.Label == "" {
|
||||
panic("mujica: ShortcutRecorder needs a Label")
|
||||
}
|
||||
d := Density(c)
|
||||
var r ChoiceResult
|
||||
d := core.Density(c)
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit()).AlignItems(ui.Start).Disabled(opts.Disabled)
|
||||
r.Element.Children(func() {
|
||||
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(160).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).
|
||||
@@ -129,11 +131,11 @@ func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptio
|
||||
st.caught, st.recording, st.conflict = nil, false, ""
|
||||
for _, t := range opts.Taken {
|
||||
if t.Shortcut == s && s != *value {
|
||||
st.conflict = fmt.Sprintf(msg(c, "shortcut_recorder.conflict"), s.String(), t.Action)
|
||||
st.conflict = fmt.Sprintf(core.Msg(c, "shortcut_recorder.conflict"), s.String(), t.Action)
|
||||
}
|
||||
}
|
||||
if st.conflict == "" && s != *value {
|
||||
*value, r.changed = s, true
|
||||
*value, r = s, core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: shortcut_recorder set", "shortcut", s.String())
|
||||
}
|
||||
}
|
||||
@@ -143,7 +145,7 @@ func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptio
|
||||
}
|
||||
if st.recording {
|
||||
b.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if b.IsDisabled() {
|
||||
if disabled {
|
||||
return false
|
||||
}
|
||||
return shortcutRecorderKey(st, ev)
|
||||
@@ -176,12 +178,12 @@ func shortcutRecorderKey(st *shortcutRecorderState, ev ui.InputEvent) bool {
|
||||
}
|
||||
|
||||
// shortcutRecorderLook displays the shortcut or recording prompt.
|
||||
func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderState, value Shortcut, name string) {
|
||||
k := Tokens(c)
|
||||
func shortcutRecorderLook(c *ui.Context, b ui.Element, st *shortcutRecorderState, value Shortcut, name string) {
|
||||
k := core.Tokens(c)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
label := name + ": " + value.String()
|
||||
if value.IsZero() {
|
||||
label = name + ": " + msg(c, "shortcut_recorder.none")
|
||||
label = name + ": " + core.Msg(c, "shortcut_recorder.none")
|
||||
}
|
||||
b.Label(label)
|
||||
bg, border := k.Surface, k.ControlBorder
|
||||
@@ -193,7 +195,7 @@ func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderStat
|
||||
b.Background(bg).Border(theme.BorderWidth, border)
|
||||
b.Children(func() {
|
||||
if st.recording {
|
||||
ui.Text(c, msg(c, "shortcut_recorder.press")).TextColor(k.AccentText).SingleLine()
|
||||
ui.Text(c, core.Msg(c, "shortcut_recorder.press")).TextColor(k.AccentText).SingleLine()
|
||||
} else {
|
||||
shortcutKbd(c, value)
|
||||
}
|
||||
@@ -205,13 +207,13 @@ func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderStat
|
||||
func shortcutRecorderNote(c *ui.Context, st *shortcutRecorderState) {
|
||||
text := st.conflict
|
||||
if st.needMod && st.recording {
|
||||
text = msg(c, "shortcut_recorder.needs_modifier")
|
||||
text = core.Msg(c, "shortcut_recorder.needs_modifier")
|
||||
}
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
ui.Row(c).Gap(4).AlignItems(ui.Center).TextColor(Tokens(c).Warning).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Row(c).Gap(4).AlignItems(ui.Center).TextColor(core.Tokens(c).Warning).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
|
||||
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func shortcutRecorderTester(v *Shortcut, opts *ShortcutRecorderOptions, n *int) *ui.Tester {
|
||||
@@ -81,7 +83,7 @@ func TestShortcutRecorderFunctionKeyAndTabCancellation(t *testing.T) {
|
||||
if ShortcutRecorder(c, &v, opts).Changed() {
|
||||
n++
|
||||
}
|
||||
Button(c, "Next control", ButtonOptions{})
|
||||
input.Button(c, "Next control", input.ButtonOptions{})
|
||||
})
|
||||
if err := tt.Click("Refresh: none"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ThemeSelectorOptions configure a ThemeSelector.
|
||||
@@ -17,14 +18,14 @@ type ThemeSelectorOptions struct {
|
||||
}
|
||||
|
||||
// ThemeSelector selects an appearance using preview cards.
|
||||
func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceResult {
|
||||
if *mode != System && *mode != Light && *mode != Dark {
|
||||
func ThemeSelector(c *ui.Context, mode *core.Mode, opts ThemeSelectorOptions) core.ChoiceResult {
|
||||
if *mode != core.System && *mode != core.Light && *mode != core.Dark {
|
||||
panic(fmt.Sprintf("mujica: ThemeSelector mode %d is not System, Light or Dark", *mode))
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Row(c).Gap(d.Unit() * 3).Wrap().Role(ui.RoleRadioGroup).Label(msg(c, "theme_selector.label")).
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Row(c).Gap(d.Unit() * 3).Wrap().Role(ui.RoleRadioGroup).Label(core.Msg(c, "theme_selector.label")).
|
||||
FocusGroup(ui.Horizontal).Disabled(opts.Disabled)
|
||||
selected := mode
|
||||
if r.Element.IsDisabled() {
|
||||
@@ -32,10 +33,10 @@ func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceR
|
||||
selected = ©
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
for _, m := range []Mode{System, Light, Dark} {
|
||||
card := ui.RadioBase(c, selected, m).Column().Gap(d.Unit() * 2).Padding(d.Unit() * 2).Radius(theme.CardRadius).AlignItems(ui.Stretch).Transition(colorFade)
|
||||
for _, m := range []core.Mode{core.System, core.Light, core.Dark} {
|
||||
card := ui.RadioBase(c, selected, m).Column().Gap(d.Unit() * 2).Padding(d.Unit() * 2).Radius(theme.CardRadius).AlignItems(ui.Stretch).Transition(core.ColorFade)
|
||||
if card.Changed() && !card.IsDisabled() {
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: theme_selector chose", "mode", m.String())
|
||||
}
|
||||
on := *mode == m
|
||||
@@ -52,9 +53,9 @@ func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceR
|
||||
card.Children(func() {
|
||||
ui.Box(c).Size(132, 84).Role(ui.RoleNone).Draw(func(p *ui.Painter, rect ui.Rect) {
|
||||
switch m {
|
||||
case Light:
|
||||
case core.Light:
|
||||
themePreview(p, rect, theme.Light())
|
||||
case Dark:
|
||||
case core.Dark:
|
||||
themePreview(p, rect, theme.Dark())
|
||||
default:
|
||||
half := ui.Rect{X: rect.X, Y: rect.Y, W: rect.W / 2, H: rect.H}
|
||||
@@ -66,7 +67,7 @@ func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceR
|
||||
if on {
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Text(c, msg(c, "theme_selector."+m.String())).SingleLine()
|
||||
ui.Text(c, core.Msg(c, "theme_selector."+m.String())).SingleLine()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
|
||||
mode, n := System, 0
|
||||
mode, n := core.System, 0
|
||||
disabled := false
|
||||
tt := accountTester(600, 220, func(c *ui.Context) {
|
||||
if ThemeSelector(c, &mode, ThemeSelectorOptions{Disabled: disabled}).Changed() {
|
||||
@@ -17,18 +19,18 @@ func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
|
||||
if err := tt.Click("Dark"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != Dark || n != 1 {
|
||||
if mode != core.Dark || n != 1 {
|
||||
t.Fatalf("click: %v %d", mode, n)
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if mode != Light || n != 2 {
|
||||
if mode != core.Light || n != 2 {
|
||||
t.Fatalf("keys: %v %d", mode, n)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("System")
|
||||
if mode != Light {
|
||||
if mode != core.Light {
|
||||
t.Fatal("disabled selector changed")
|
||||
}
|
||||
defer func() {
|
||||
@@ -36,13 +38,13 @@ func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
|
||||
t.Fatal("unknown mode accepted")
|
||||
}
|
||||
}()
|
||||
bad := Mode(9)
|
||||
bad := core.Mode(9)
|
||||
accountTester(100, 100, func(c *ui.Context) { ThemeSelector(c, &bad, ThemeSelectorOptions{}) })
|
||||
}
|
||||
|
||||
func TestThemeSelectorSnapshot(t *testing.T) {
|
||||
snapshot(t, "theme_selector", 520, 170, func(c *ui.Context) {
|
||||
m := ui.Local(c.Root(), "m", func() Mode { return Dark })
|
||||
m := ui.Local(c.Root(), "m", func() core.Mode { return core.Dark })
|
||||
ui.Column(c).Padding(16).Children(func() { ThemeSelector(c, m, ThemeSelectorOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TwoFactorInputOptions configure a TwoFactorInput.
|
||||
@@ -23,7 +25,7 @@ type TwoFactorInputOptions struct {
|
||||
|
||||
// TwoFactorResult reports completion and resend events.
|
||||
type TwoFactorResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
completed, resend bool
|
||||
}
|
||||
|
||||
@@ -38,30 +40,30 @@ func TwoFactorInput(c *ui.Context, code *string, opts TwoFactorInputOptions) Two
|
||||
if code == nil || len(*code) > 6 || strings.Trim(*code, "0123456789") != "" {
|
||||
panic("mujica: TwoFactorInput needs up to six digits")
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r TwoFactorResult
|
||||
r.Element = ui.Column(c).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||
disabled := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
pin := PinInput(c, code, PinInputOptions{Length: 6, Label: msg(c, "two_factor_input.label"), Disabled: disabled, Invalid: opts.Error != ""})
|
||||
pin := input.PinInput(c, code, input.PinInputOptions{Length: 6, Label: core.Msg(c, "two_factor_input.label"), Disabled: disabled, Invalid: opts.Error != ""})
|
||||
r.completed = pin.Completed() && !disabled
|
||||
if opts.Error != "" {
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(14, 14)
|
||||
ui.Text(c, opts.Error).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, opts.Error).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
wait := opts.ResendAt.Sub(c.Now())
|
||||
label := msg(c, "two_factor_input.resend")
|
||||
label := core.Msg(c, "two_factor_input.resend")
|
||||
if wait > 0 {
|
||||
secs := int((wait + time.Second - 1) / time.Second)
|
||||
label = fmt.Sprintf(msg(c, "two_factor_input.resend_in"), secs/60, secs%60)
|
||||
label = fmt.Sprintf(core.Msg(c, "two_factor_input.resend_in"), secs/60, secs%60)
|
||||
// Wake when the shown second changes.
|
||||
c.After(wait - time.Duration(secs-1)*time.Second)
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
b := Button(c, label, ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"), Disabled: disabled || wait > 0})
|
||||
b := input.Button(c, label, input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("refresh-cw"), Disabled: disabled || wait > 0})
|
||||
if b.Clicked() && !disabled && wait <= 0 {
|
||||
r.resend = true
|
||||
slog.Debug("mujica: two_factor_input resend")
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -37,7 +37,7 @@ func TestTwoFactorInputPasteCompleteAndResend(t *testing.T) {
|
||||
if resent != 1 {
|
||||
t.Fatal("resend not reported")
|
||||
}
|
||||
opts.ResendAt = time.Now().Add(65 * time.Second)
|
||||
opts.ResendAt = tt.Now().Add(65 * time.Second)
|
||||
opts.Error = "That code has expired."
|
||||
tt.Frame()
|
||||
if !tt.HasText("Resend in 1:05") && !tt.HasText("Resend in 1:04") || !tt.HasText("That code has expired.") {
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// QuotaItem is one metered resource: Used of Limit, in Unit.
|
||||
@@ -27,7 +29,7 @@ type UsageQuotaOptions struct {
|
||||
}
|
||||
|
||||
// UsageQuota displays usage bars and reset time.
|
||||
func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.Element {
|
||||
func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) ui.Element {
|
||||
warn := opts.Warn
|
||||
if warn == 0 {
|
||||
warn = 0.8
|
||||
@@ -35,8 +37,8 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El
|
||||
if warn < 0 || warn > 1 || math.IsNaN(warn) {
|
||||
panic("mujica: UsageQuota Warn must be between 0 and 1")
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
e := ui.Column(c).Gap(d.Unit() * 4).MinWidth(320)
|
||||
e.Children(func() {
|
||||
for i, it := range items {
|
||||
@@ -47,22 +49,22 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El
|
||||
col, icon, note := k.AccentText, "", ""
|
||||
switch {
|
||||
case share >= 1:
|
||||
col, icon, note = k.Danger, "circle-x", msg(c, "usage_quota.over")
|
||||
col, icon, note = k.Danger, "circle-x", core.Msg(c, "usage_quota.over")
|
||||
case share >= warn:
|
||||
col, icon, note = k.Warning, "triangle-alert", msg(c, "usage_quota.near")
|
||||
col, icon, note = k.Warning, "triangle-alert", core.Msg(c, "usage_quota.near")
|
||||
}
|
||||
amount := fmt.Sprintf(msg(c, "usage_quota.amount"), statisticFormat(it.Used, 0), statisticFormat(it.Limit, 0), it.Unit)
|
||||
ui.Column(c).Key(i).Gap(d.Unit()).Role(ui.RoleMeter).Label(it.Label).
|
||||
Description(fmt.Sprintf("%s, %s%% %s", amount, statisticFormat(share*100, 0), note)).Children(func() {
|
||||
amount := fmt.Sprintf(core.Msg(c, "usage_quota.amount"), data.StatisticFormat(it.Used, 0), data.StatisticFormat(it.Limit, 0), it.Unit)
|
||||
ui.Column(c.Key(i)).Gap(d.Unit()).Role(ui.RoleMeter).Label(it.Label).
|
||||
Description(fmt.Sprintf("%s, %s%% %s", amount, data.StatisticFormat(share*100, 0), note)).Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, it.Label).FontWeight(500).Grow(1)
|
||||
if icon != "" {
|
||||
ui.Row(c).Gap(d.Unit() / 2).AlignItems(ui.Center).TextColor(col).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12)
|
||||
ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600)
|
||||
ui.Text(c, note).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600)
|
||||
})
|
||||
}
|
||||
ui.Text(c, amount).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
|
||||
ui.Text(c, amount).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
|
||||
})
|
||||
ui.Box(c).Height(6).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, k.SurfaceHover, theme.ControlRadius)
|
||||
@@ -73,7 +75,7 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El
|
||||
if !opts.ResetAt.IsZero() {
|
||||
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
|
||||
ui.Icon(c, icons.Must("clock")).Size(12, 12)
|
||||
ui.Text(c, usageQuotaReset(c, opts.ResetAt)).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, usageQuotaReset(c, opts.ResetAt)).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -85,9 +87,9 @@ func usageQuotaReset(c *ui.Context, at time.Time) string {
|
||||
left := at.Sub(c.Now())
|
||||
switch {
|
||||
case left <= 0:
|
||||
return msg(c, "usage_quota.reset_due")
|
||||
return core.Msg(c, "usage_quota.reset_due")
|
||||
case left < 24*time.Hour:
|
||||
return fmt.Sprintf(msg(c, "usage_quota.reset_hours"), int(math.Ceil(left.Hours())))
|
||||
return fmt.Sprintf(core.Msg(c, "usage_quota.reset_hours"), int(math.Ceil(left.Hours())))
|
||||
}
|
||||
return fmt.Sprintf(msg(c, "usage_quota.reset_on"), at.Format("2006-01-02"))
|
||||
return fmt.Sprintf(core.Msg(c, "usage_quota.reset_on"), at.Format("2006-01-02"))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,18 +6,21 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// UserStatus is the presence a user shows to others.
|
||||
type UserStatus int
|
||||
|
||||
const (
|
||||
PresenceOnline UserStatus = iota
|
||||
PresenceAway
|
||||
PresenceBusy
|
||||
PresenceInvisible
|
||||
UserStatusOnline UserStatus = iota
|
||||
UserStatusAway
|
||||
UserStatusBusy
|
||||
UserStatusInvisible
|
||||
)
|
||||
|
||||
// UserMenuOptions configure a UserMenu. Name is required.
|
||||
@@ -29,7 +32,7 @@ type UserMenuOptions struct {
|
||||
|
||||
// UserMenuResult reports presence, settings and sign-out events.
|
||||
type UserMenuResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
changed, settings, leave bool
|
||||
}
|
||||
|
||||
@@ -47,14 +50,14 @@ func UserMenu(c *ui.Context, status *UserStatus, opts UserMenuOptions) UserMenuR
|
||||
if opts.Name == "" {
|
||||
panic("mujica: UserMenu needs a Name")
|
||||
}
|
||||
if *status < PresenceOnline || *status > PresenceInvisible {
|
||||
if *status < UserStatusOnline || *status > UserStatusInvisible {
|
||||
panic(fmt.Sprintf("mujica: UserMenu status %d is unknown", *status))
|
||||
}
|
||||
k := Tokens(c)
|
||||
k := core.Tokens(c)
|
||||
var r UserMenuResult
|
||||
word := msg(c, fmt.Sprintf("user_menu.status%d", *status))
|
||||
word := core.Msg(c, fmt.Sprintf("user_menu.status%d", *status))
|
||||
b := ui.ButtonBase(c).Role(ui.RoleMenuButton).Size(36, 36).Radius(18).Disabled(opts.Disabled).
|
||||
Label(fmt.Sprintf(msg(c, "user_menu.label"), opts.Name, word))
|
||||
Label(fmt.Sprintf(core.Msg(c, "user_menu.label"), opts.Name, word))
|
||||
r.Element = b
|
||||
internal.Ring(b, k.Focus, 18)
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
@@ -65,25 +68,25 @@ func UserMenu(c *ui.Context, status *UserStatus, opts UserMenuOptions) UserMenuR
|
||||
*open = true
|
||||
}
|
||||
st := *status
|
||||
b.Children(func() { Avatar(c, opts.Name, AvatarOptions{Image: opts.Avatar, Size: 32}) })
|
||||
b.Children(func() { display.Avatar(c, opts.Name, display.AvatarOptions{Image: opts.Avatar, Size: 32}) })
|
||||
b.DrawOver(func(p *ui.Painter, rect ui.Rect) { userMenuDot(p, k, rect.X+rect.W-6, rect.Y+rect.H-6, st) })
|
||||
popupMenuOpen(c, b, open, func(m *PopupMenu) {
|
||||
overlay.PopupMenuOpen(c, b, open, func(m *overlay.PopupMenu) {
|
||||
head := opts.Name
|
||||
if opts.Email != "" {
|
||||
head += " · " + opts.Email
|
||||
}
|
||||
m.Group(head, func() {
|
||||
for s := PresenceOnline; s <= PresenceInvisible; s++ {
|
||||
for s := UserStatusOnline; s <= UserStatusInvisible; s++ {
|
||||
on := s == *status
|
||||
if m.Check(msg(c, fmt.Sprintf("user_menu.status%d", s)), &on, PopupMenuItemOptions{}) && s != *status && !disabled {
|
||||
if m.Check(core.Msg(c, fmt.Sprintf("user_menu.status%d", s)), &on, overlay.PopupMenuItemOptions{}) && s != *status && !disabled {
|
||||
*status, r.changed = s, true
|
||||
slog.Debug("mujica: user_menu status", "status", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
m.Separator()
|
||||
r.settings = m.Item(msg(c, "user_menu.settings"), PopupMenuItemOptions{}) && !disabled
|
||||
r.leave = m.Item(msg(c, "user_menu.sign_out"), PopupMenuItemOptions{}) && !disabled
|
||||
r.settings = m.Item(core.Msg(c, "user_menu.settings"), overlay.PopupMenuItemOptions{}) && !disabled
|
||||
r.leave = m.Item(core.Msg(c, "user_menu.sign_out"), overlay.PopupMenuItemOptions{}) && !disabled
|
||||
})
|
||||
if r.settings || r.leave {
|
||||
slog.Debug("mujica: user_menu chose", "settings", r.settings, "sign_out", r.leave)
|
||||
@@ -97,11 +100,11 @@ func userMenuDot(p *ui.Painter, k theme.Tokens, cx, cy float32, s UserStatus) {
|
||||
r := ui.Rect{X: cx - d/2, Y: cy - d/2, W: d, H: d}
|
||||
p.Fill(ui.Rect{X: r.X - 2, Y: r.Y - 2, W: d + 4, H: d + 4}, k.Surface, d)
|
||||
switch s {
|
||||
case PresenceOnline:
|
||||
case UserStatusOnline:
|
||||
p.Fill(r, k.Success, d)
|
||||
case PresenceAway:
|
||||
case UserStatusAway:
|
||||
p.Stroke(r, k.Warning, d, 3)
|
||||
case PresenceBusy:
|
||||
case UserStatusBusy:
|
||||
p.Fill(r, k.Danger, d)
|
||||
p.Fill(ui.Rect{X: r.X + 2.5, Y: cy - 1, W: d - 5, H: 2}, k.Surface, 0)
|
||||
default:
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestUserMenuStatusSettingsSignOut(t *testing.T) {
|
||||
status := PresenceOnline
|
||||
status := UserStatusOnline
|
||||
var changes, settings, leaves int
|
||||
tt := accountTester(500, 400, func(c *ui.Context) {
|
||||
r := UserMenu(c, &status, UserMenuOptions{Name: "Sakiko Togawa", Email: "sakiko@court"})
|
||||
@@ -28,7 +28,7 @@ func TestUserMenuStatusSettingsSignOut(t *testing.T) {
|
||||
t.Fatalf("header: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Do not disturb")
|
||||
if status != PresenceBusy || changes != 1 {
|
||||
if status != UserStatusBusy || changes != 1 {
|
||||
t.Fatalf("status %v", status)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
@@ -44,16 +44,16 @@ func TestUserMenuStatusSettingsSignOut(t *testing.T) {
|
||||
func TestUserMenuSnapshot(t *testing.T) {
|
||||
snapshot(t, "user_menu", 220, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(16).Children(func() {
|
||||
for s := PresenceOnline; s <= PresenceInvisible; s++ {
|
||||
for s := UserStatusOnline; s <= UserStatusInvisible; s++ {
|
||||
st := s
|
||||
ui.Box(c).Key(int(s)).Children(func() { UserMenu(c, &st, UserMenuOptions{Name: "Sakiko Togawa"}) })
|
||||
ui.Box(c.Key(int(s))).Children(func() { UserMenu(c, &st, UserMenuOptions{Name: "Sakiko Togawa"}) })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserMenuDisabledAndInvalidStatus(t *testing.T) {
|
||||
status, disabled, actions := PresenceOnline, false, 0
|
||||
status, disabled, actions := UserStatusOnline, false, 0
|
||||
tt := accountTester(600, 500, func(c *ui.Context) {
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r := UserMenu(c, &status, UserMenuOptions{Name: "User"})
|
||||
@@ -68,7 +68,7 @@ func TestUserMenuDisabledAndInvalidStatus(t *testing.T) {
|
||||
disabled = true
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if status != PresenceOnline || actions != 0 || tt.HasText("Sign out") {
|
||||
if status != UserStatusOnline || actions != 0 || tt.HasText("Sign out") {
|
||||
t.Fatal("disabled user menu acts")
|
||||
}
|
||||
status = UserStatus(99)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,7 +6,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChangeKind tags a release note.
|
||||
@@ -37,7 +41,7 @@ type WhatsNewDialogOptions struct {
|
||||
}
|
||||
|
||||
// WhatsNewDialog displays release notes and a persistent hide choice.
|
||||
func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, opts WhatsNewDialogOptions) ChoiceResult {
|
||||
func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, opts WhatsNewDialogOptions) core.ChoiceResult {
|
||||
if len(releases) == 0 {
|
||||
panic("mujica: WhatsNewDialog needs releases")
|
||||
}
|
||||
@@ -54,19 +58,19 @@ func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, o
|
||||
if ui.Box(c).IsDisabled() {
|
||||
*open = false
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = msg(c, "whats_new_dialog.title")
|
||||
title = core.Msg(c, "whats_new_dialog.title")
|
||||
}
|
||||
var r ChoiceResult
|
||||
var r core.ChoiceResult
|
||||
was := *open
|
||||
before := *hide
|
||||
r.Element = Dialog(c, open, DialogOptions{Title: title, Width: 480, Decorated: true, Actions: func() {
|
||||
Checkbox(c, hide, msg(c, "whats_new_dialog.hide"), CheckboxOptions{})
|
||||
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{Title: title, Width: 480, Decorated: true, Actions: func() {
|
||||
input.Checkbox(c, hide, core.Msg(c, "whats_new_dialog.hide"), input.CheckboxOptions{})
|
||||
ui.Box(c).Grow(1)
|
||||
if Button(c, msg(c, "whats_new_dialog.close"), ButtonOptions{}).AutoFocus().Clicked() {
|
||||
if core.AutoFocus(c, input.Button(c, core.Msg(c, "whats_new_dialog.close"), input.ButtonOptions{})).Clicked() {
|
||||
*open = false
|
||||
}
|
||||
}}, func() {
|
||||
@@ -74,24 +78,24 @@ func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, o
|
||||
if rel.Version == "" {
|
||||
panic("mujica: Release needs a Version")
|
||||
}
|
||||
ui.Column(c).Key(i).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(rel.Version).Children(func() {
|
||||
ui.Column(c.Key(i)).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(rel.Version).Children(func() {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.End).Children(func() {
|
||||
ui.Text(c, rel.Version).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
ui.Text(c, rel.Version).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
if rel.Date != "" {
|
||||
ui.Text(c, rel.Date).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||
ui.Text(c, rel.Date).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
for _, ch := range rel.Changes {
|
||||
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() {
|
||||
tone := map[ChangeKind]BadgeTone{ChangeNew: BadgeAccent, ChangeImproved: BadgeInfo, ChangeFixed: BadgeSuccess}[ch.Kind]
|
||||
Badge(c, BadgeOptions{Text: msg(c, fmt.Sprintf("whats_new_dialog.kind%d", ch.Kind)), Tone: tone}, nil).Shrink(0)
|
||||
tone := map[ChangeKind]display.BadgeTone{ChangeNew: display.BadgeAccent, ChangeImproved: display.BadgeInfo, ChangeFixed: display.BadgeSuccess}[ch.Kind]
|
||||
display.Badge(c, display.BadgeOptions{Text: core.Msg(c, fmt.Sprintf("whats_new_dialog.kind%d", ch.Kind)), Tone: tone}, nil).Shrink(0)
|
||||
ui.Text(c, ch.Text).Grow(1).MinWidth(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
r.changed = *hide != before
|
||||
r = core.NewChoiceResult(r.Element, *hide != before)
|
||||
if was && !*open {
|
||||
slog.Debug("mujica: whats_new_dialog close", "hide", *hide)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Workspace is a place a user can switch to.
|
||||
@@ -36,7 +39,7 @@ type workspaceSwitcherState struct {
|
||||
}
|
||||
|
||||
// WorkspaceSwitcher filters workspaces and changes the current selection.
|
||||
func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts WorkspaceSwitcherOptions) ChoiceResult {
|
||||
func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts WorkspaceSwitcherOptions) core.ChoiceResult {
|
||||
var cur *Workspace
|
||||
for i := range spaces {
|
||||
if spaces[i].ID == "" || spaces[i].Name == "" {
|
||||
@@ -49,7 +52,7 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
|
||||
if cur == nil {
|
||||
panic(fmt.Sprintf("mujica: WorkspaceSwitcher current %q is not a workspace", *current))
|
||||
}
|
||||
var r ChoiceResult
|
||||
var r core.ChoiceResult
|
||||
b := accountTrigger(c, cur.Name, cur.Detail, nil, opts.Disabled)
|
||||
r.Element = b
|
||||
st := ui.Local(b, "ws", func() workspaceSwitcherState { return workspaceSwitcherState{} })
|
||||
@@ -61,15 +64,15 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
|
||||
}
|
||||
choose := func(i int) {
|
||||
if !disabled && spaces[i].ID != *current {
|
||||
*current, r.changed = spaces[i].ID, true
|
||||
*current, r = spaces[i].ID, core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: workspace_switcher switched")
|
||||
}
|
||||
st.open = false
|
||||
}
|
||||
Popover(c, b, &st.open, PopoverOptions{Width: 300, Label: msg(c, "workspace_switcher.label")}, func() {
|
||||
res := SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "workspace_switcher.search"), Label: msg(c, "workspace_switcher.search")})
|
||||
overlay.Popover(c, b, &st.open, overlay.PopoverOptions{Width: 300, Label: core.Msg(c, "workspace_switcher.label")}, func() {
|
||||
res := input.SearchInput(c, &st.query, input.SearchInputOptions{Placeholder: core.Msg(c, "workspace_switcher.search"), Label: core.Msg(c, "workspace_switcher.search")})
|
||||
in := res.Input
|
||||
in.AutoFocus()
|
||||
core.AutoFocus(c, in)
|
||||
q := strings.ToLower(strings.TrimSpace(st.query))
|
||||
if !st.ready || st.filtered != q || !slices.Equal(st.source, spaces) {
|
||||
st.source, st.filtered, st.ready = slices.Clone(spaces), q, true
|
||||
@@ -94,7 +97,7 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
|
||||
choose(hits[st.cursor])
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
ui.Text(c, msg(c, "workspace_switcher.none")).TextColor(Tokens(c).TextMuted).Padding(Density(c).Unit() * 2).Role(ui.RoleStatus)
|
||||
ui.Text(c, core.Msg(c, "workspace_switcher.none")).TextColor(core.Tokens(c).TextMuted).Padding(core.Density(c).Unit() * 2).Role(ui.RoleStatus)
|
||||
}
|
||||
list := accountRows(c, len(hits), func(n int) any { return spaces[hits[n]].ID }, func(n int) {
|
||||
i := hits[n]
|
||||
@@ -112,10 +115,10 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
|
||||
}
|
||||
|
||||
// workspaceRow displays a clickable workspace with a selection mark.
|
||||
func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) *ui.Element {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
b := ui.ButtonBase(c).Key(w.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(d.Unit() * 2).
|
||||
func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
b := ui.ButtonBase(c.Key(w.ID)).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(d.Unit() * 2).
|
||||
Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(w.Name).Selected(current)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
@@ -133,7 +136,7 @@ func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) *ui.Element
|
||||
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
|
||||
ui.Text(c, w.Name).SingleLine().Ellipsis("…")
|
||||
if w.Detail != "" {
|
||||
ui.Text(c, w.Detail).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
ui.Text(c, w.Detail).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,13 +1,14 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// FileChange is a proposed edit; empty Old adds a file.
|
||||
@@ -51,7 +52,7 @@ func (dc *agentDiffCache) update(ch FileChange) {
|
||||
}
|
||||
|
||||
// agentDiffOf returns the cached diff of ch kept on holder.
|
||||
func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache {
|
||||
func agentDiffOf(holder ui.Element, ch FileChange) *agentDiffCache {
|
||||
dc := ui.Local(holder, "mujica.agent.diff", func() *agentDiffCache { return &agentDiffCache{} })
|
||||
(*dc).update(ch)
|
||||
return *dc
|
||||
@@ -59,9 +60,9 @@ func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache {
|
||||
|
||||
// agentDiffStats shows signed added and removed counts.
|
||||
func agentDiffStats(c *ui.Context, add, del int) {
|
||||
k := Tokens(c)
|
||||
size := FontSize(c, theme.CaptionSize+1)
|
||||
ui.Row(c).Gap(Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(msg(c, "file_change_card.stats"), add, del)).Children(func() {
|
||||
k := core.Tokens(c)
|
||||
size := core.FontSize(c, theme.CaptionSize+1)
|
||||
ui.Row(c).Gap(core.Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(core.Msg(c, "file_change_card.stats"), add, del)).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("+%d", add)).TextColor(k.Success).FontSize(size).Font(theme.MonoFont)
|
||||
ui.Text(c, fmt.Sprintf("−%d", del)).TextColor(k.Danger).FontSize(size).Font(theme.MonoFont)
|
||||
})
|
||||
@@ -69,8 +70,8 @@ func agentDiffStats(c *ui.Context, add, del int) {
|
||||
|
||||
// agentDiffRow draws one numbered, signed, tinted diff line.
|
||||
func agentDiffRow(c *ui.Context, l internal.DiffLine) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
sign, bg, fg := " ", ui.Transparent, k.TextMuted
|
||||
switch l.Op {
|
||||
case internal.DiffInsert:
|
||||
@@ -97,27 +98,27 @@ func agentDiffRow(c *ui.Context, l internal.DiffLine) {
|
||||
}
|
||||
|
||||
// agentDiffView shows lines[from:to] in a capped well, virtualized when long.
|
||||
func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) *ui.Element {
|
||||
w := agentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
|
||||
return w.Padding(Density(c).Unit(), 0)
|
||||
func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) ui.Element {
|
||||
w := AgentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
|
||||
return w.Padding(core.Density(c).Unit(), 0)
|
||||
}
|
||||
|
||||
// agentDecisionMark shows a decision by icon and word.
|
||||
func agentDecisionMark(c *ui.Context, d FileDecision) {
|
||||
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
agentCaption(c, agentDecisionIcon(c, d))
|
||||
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
AgentCaption(c, agentDecisionIcon(c, d))
|
||||
})
|
||||
}
|
||||
|
||||
// agentDecisionIcon draws a named decision icon, returning its word.
|
||||
func agentDecisionIcon(c *ui.Context, d FileDecision) string {
|
||||
k := Tokens(c)
|
||||
tone, icon, word := k.TextMuted, "circle", msg(c, "file_change_card.pending")
|
||||
k := core.Tokens(c)
|
||||
tone, icon, word := k.TextMuted, "circle", core.Msg(c, "file_change_card.pending")
|
||||
switch d {
|
||||
case FileAccepted:
|
||||
tone, icon, word = k.Success, "circle-check", msg(c, "file_change_card.accepted")
|
||||
tone, icon, word = k.Success, "circle-check", core.Msg(c, "file_change_card.accepted")
|
||||
case FileRejected:
|
||||
tone, icon, word = k.Danger, "circle-x", msg(c, "file_change_card.rejected")
|
||||
tone, icon, word = k.Danger, "circle-x", core.Msg(c, "file_change_card.rejected")
|
||||
case FilePending:
|
||||
default:
|
||||
panic(fmt.Sprintf("mujica: unknown FileDecision %d", int(d)))
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AgentState is an agent or step state; zero is pending.
|
||||
@@ -66,19 +67,19 @@ func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) {
|
||||
}
|
||||
|
||||
// agentMark draws s; running spins unless still.
|
||||
func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element {
|
||||
tone, icon := agentTone(Tokens(c), s)
|
||||
func agentMark(c *ui.Context, s AgentState, size float32, still bool) ui.Element {
|
||||
tone, icon := agentTone(core.Tokens(c), s)
|
||||
if s == AgentRunning && !still {
|
||||
return Spinner(c, SpinnerOptions{Size: size, Color: tone, Label: msg(c, "agent_status.running")})
|
||||
return core.Spinner(c, core.SpinnerOptions{Size: size, Color: tone, Label: core.Msg(c, "agent_status.running")})
|
||||
}
|
||||
return ui.Icon(c, icon).Size(size, size).TextColor(tone).Shrink(0)
|
||||
}
|
||||
|
||||
// agentWord is the built-in word for s.
|
||||
func agentWord(c *ui.Context, s AgentState) string { return msg(c, "agent_status."+s.String()) }
|
||||
func agentWord(c *ui.Context, s AgentState) string { return core.Msg(c, "agent_status."+s.String()) }
|
||||
|
||||
// agentDuration formats d like "320 ms" or "4.2 s".
|
||||
func agentDuration(d time.Duration) string {
|
||||
// AgentDuration formats d like "320 ms" or "4.2 s".
|
||||
func AgentDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
panic(fmt.Sprintf("mujica: negative duration %v", d))
|
||||
}
|
||||
@@ -93,35 +94,35 @@ func agentDuration(d time.Duration) string {
|
||||
return fmt.Sprintf("%d h %02d m", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
// agentCard is the bordered surface every agent card sits on.
|
||||
func agentCard(c *ui.Context) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
// AgentCard is the bordered surface every agent card sits on.
|
||||
func AgentCard(c *ui.Context) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
|
||||
// agentCaption is small muted text.
|
||||
func agentCaption(c *ui.Context, s string) *ui.Element {
|
||||
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
|
||||
// AgentCaption is small muted text.
|
||||
func AgentCaption(c *ui.Context, s string) ui.Element {
|
||||
return ui.Text(c, s).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted)
|
||||
}
|
||||
|
||||
// agentWell styles a sunken monospace well.
|
||||
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
func agentWell(c *ui.Context, l ui.Element, maxHeight float32) ui.Element {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius).
|
||||
Background(k.Background).Border(theme.BorderWidth, k.Border).Font(theme.MonoFont).
|
||||
FontSize(FontSize(c, theme.CaptionSize+1))
|
||||
FontSize(core.FontSize(c, theme.CaptionSize+1))
|
||||
}
|
||||
|
||||
// agentWellRows builds n rows in a well.
|
||||
func agentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
|
||||
return agentWell(c, agentRows(c, n, row), maxHeight)
|
||||
// AgentWellRows builds n rows in a well.
|
||||
func AgentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) ui.Element {
|
||||
return agentWell(c, AgentRows(c, n, row), maxHeight)
|
||||
}
|
||||
|
||||
// agentRows builds n scrolling rows, virtualized when many.
|
||||
func agentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
|
||||
// AgentRows builds n scrolling rows, virtualized when many.
|
||||
func AgentRows(c *ui.Context, n int, row func(i int)) ui.Element {
|
||||
if n > agentVirtualRows {
|
||||
return ui.List(c, nil, n, row)
|
||||
}
|
||||
@@ -182,7 +183,7 @@ func agentTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
|
||||
|
||||
// agentCodeLine builds one highlighted line of language.
|
||||
func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
|
||||
k := Tokens(c)
|
||||
k := core.Tokens(c)
|
||||
toks, _ := internal.Highlight(language, line, inBlock)
|
||||
spans := make([]ui.Span, 0, len(toks))
|
||||
for _, t := range toks {
|
||||
@@ -194,15 +195,15 @@ func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
|
||||
ui.RichText(c, spans...)
|
||||
}
|
||||
|
||||
// agentCode shows highlighted text in a capped well.
|
||||
func agentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
|
||||
// AgentCode shows highlighted text in a capped well.
|
||||
func AgentCode(c *ui.Context, language, text string, maxHeight float32) ui.Element {
|
||||
holder := ui.Column(c).Shrink(1)
|
||||
cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} })
|
||||
(*cc).update(language, text)
|
||||
lines, open := (*cc).lines, (*cc).open
|
||||
row := func(i int) { agentCodeLine(c, language, lines[i], open[i]) }
|
||||
holder.Children(func() {
|
||||
agentWellRows(c, len(lines), row, maxHeight)
|
||||
AgentWellRows(c, len(lines), row, maxHeight)
|
||||
})
|
||||
return holder
|
||||
}
|
||||
@@ -259,11 +260,11 @@ func agentAnsiSpans(k theme.Tokens, in []internal.AnsiSpan) []ui.Span {
|
||||
}
|
||||
|
||||
// agentBar is a thin progress bar named label.
|
||||
func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
|
||||
func agentBar(c *ui.Context, frac float64, label string) ui.Element {
|
||||
if frac < 0 || frac > 1 || frac != frac {
|
||||
panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac))
|
||||
}
|
||||
k := Tokens(c)
|
||||
k := core.Tokens(c)
|
||||
return ui.Box(c).FillWidth().Height(4).Shrink(0).Role(ui.RoleProgress).Label(label).
|
||||
Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, k.Border, 2)
|
||||
@@ -273,10 +274,10 @@ func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
|
||||
})
|
||||
}
|
||||
|
||||
// agentToggle is a small text button reporting a click.
|
||||
func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
// AgentToggle is a small text button reporting a click.
|
||||
func AgentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u).
|
||||
Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
@@ -287,7 +288,7 @@ func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
|
||||
if icon != "" {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
}
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
})
|
||||
return !b.IsDisabled() && b.Clicked()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PlanItem is a plan task: pending, running, done or skipped.
|
||||
@@ -28,7 +30,7 @@ type AgentPlanOptions struct {
|
||||
type agentPlanState struct{ draft string }
|
||||
|
||||
// AgentPlan is a live checklist with skip, restore and add.
|
||||
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult {
|
||||
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) core.ChoiceResult {
|
||||
if items == nil {
|
||||
panic("mujica: AgentPlan needs items")
|
||||
}
|
||||
@@ -49,40 +51,40 @@ func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceRe
|
||||
}
|
||||
done += agentB2I(it.State == AgentDone)
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = msg(c, "agent_plan.title")
|
||||
title = core.Msg(c, "agent_plan.title")
|
||||
}
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(title)
|
||||
var r core.ChoiceResult
|
||||
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(title)
|
||||
st := ui.Local(r.Element, "mujica.plan", func() agentPlanState { return agentPlanState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
AgentCaption(c, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted))
|
||||
})
|
||||
frac := 0.0
|
||||
if counted > 0 {
|
||||
frac = float64(done) / float64(counted)
|
||||
}
|
||||
agentBar(c, frac, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
|
||||
agentBar(c, frac, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted))
|
||||
list := *items
|
||||
for i := range list {
|
||||
ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() {
|
||||
ui.Row(c.Key(list[i].ID)).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() {
|
||||
if agentPlanRow(c, &list[i], locked, opts.Still) {
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !locked && agentPlanAdd(c, &st.draft) {
|
||||
*items = append(*items, PlanItem{ID: agentPlanID(seen), Text: strings.TrimSpace(st.draft)})
|
||||
st.draft = ""
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
})
|
||||
if r.changed {
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: agent_plan change", "items", len(*items))
|
||||
}
|
||||
return r
|
||||
@@ -90,9 +92,9 @@ func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceRe
|
||||
|
||||
// agentPlanRow builds one item, reporting a user change.
|
||||
func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
|
||||
k := Tokens(c)
|
||||
k := core.Tokens(c)
|
||||
checked := it.State == AgentDone
|
||||
box := Checkbox(c, &checked, "", CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
|
||||
box := input.Checkbox(c, &checked, "", input.CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
|
||||
changed := !locked && box.Changed()
|
||||
if changed {
|
||||
it.State = AgentPending
|
||||
@@ -106,16 +108,16 @@ func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
|
||||
t.Strikethrough().TextColor(k.TextMuted)
|
||||
case AgentRunning:
|
||||
agentMark(c, AgentRunning, 14, still)
|
||||
agentCaption(c, msg(c, "agent_plan.doing"))
|
||||
AgentCaption(c, core.Msg(c, "agent_plan.doing"))
|
||||
}
|
||||
if it.State == AgentDone || locked {
|
||||
return changed
|
||||
}
|
||||
label, icon, next := msg(c, "agent_plan.skip"), "ban", AgentSkipped
|
||||
label, icon, next := core.Msg(c, "agent_plan.skip"), "ban", AgentSkipped
|
||||
if it.State == AgentSkipped {
|
||||
label, icon, next = msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
|
||||
label, icon, next = core.Msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
|
||||
}
|
||||
if agentToggle(c, label, icon, false) {
|
||||
if AgentToggle(c, label, icon, false) {
|
||||
it.State = next
|
||||
changed = true
|
||||
}
|
||||
@@ -124,13 +126,13 @@ func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
|
||||
|
||||
// agentPlanAdd builds the new-item field and reports a non-empty submit.
|
||||
func agentPlanAdd(c *ui.Context, draft *string) bool {
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
add := false
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
in := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "agent_plan.placeholder"), Label: msg(c, "agent_plan.placeholder")})
|
||||
in := input.TextInput(c, draft, input.TextInputOptions{Placeholder: core.Msg(c, "agent_plan.placeholder"), Label: core.Msg(c, "agent_plan.placeholder")})
|
||||
in.Element.Grow(1)
|
||||
blank := strings.TrimSpace(*draft) == ""
|
||||
add = Button(c, msg(c, "agent_plan.add"), ButtonOptions{Variant: Secondary, Disabled: blank}).Clicked()
|
||||
add = input.Button(c, core.Msg(c, "agent_plan.add"), input.ButtonOptions{Variant: input.Secondary, Disabled: blank}).Clicked()
|
||||
add = (add || in.Submitted()) && !blank
|
||||
})
|
||||
return add
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func agentPlanSample() []PlanItem {
|
||||
@@ -17,7 +19,7 @@ func agentPlanSample() []PlanItem {
|
||||
func planTester(items *[]PlanItem, opts AgentPlanOptions, changes *int) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if AgentPlan(c, items, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
)
|
||||
|
||||
// AgentProgressOptions configure an AgentProgress.
|
||||
@@ -16,19 +19,19 @@ type AgentProgressOptions struct {
|
||||
}
|
||||
|
||||
// AgentProgress shows progress 0 to 1 and the current action.
|
||||
func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element {
|
||||
func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) ui.Element {
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "agent_progress.label")
|
||||
label = core.Msg(c, "agent_progress.label")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
col := ui.Column(c).FillWidth().Shrink(0).Gap(u * 1.5)
|
||||
col.Children(func() {
|
||||
Progress(c, value, ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
|
||||
feedback.Progress(c, value, feedback.ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
|
||||
if opts.Action != "" {
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(opts.Action).Children(func() {
|
||||
agentMark(c, AgentRunning, 14, opts.Still)
|
||||
agentCaption(c, opts.Action)
|
||||
AgentCaption(c, opts.Action)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,15 +1,17 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestAgentProgressValueAndAction(t *testing.T) {
|
||||
value, action := 0.25, "Reading ledger.md"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
AgentProgress(c, value, AgentProgressOptions{Action: action, Still: true})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("25%") || !tt.HasText("Reading ledger.md") || !tt.HasText("Progress") {
|
||||
@@ -24,7 +26,7 @@ func TestAgentProgressValueAndAction(t *testing.T) {
|
||||
|
||||
func TestAgentProgressIndeterminate(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
AgentProgress(c, 0, AgentProgressOptions{Label: "Research", Indeterminate: true, Still: true})
|
||||
}, 400, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
@@ -40,7 +42,7 @@ func TestAgentProgressRejectsOutOfRange(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
AgentProgress(c, 1.5, AgentProgressOptions{})
|
||||
}, 200, 100)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AgentStatusOptions configure an AgentStatus.
|
||||
@@ -15,20 +16,20 @@ type AgentStatusOptions struct {
|
||||
}
|
||||
|
||||
// AgentStatus shows a state by icon shape and word.
|
||||
func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element {
|
||||
func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) ui.Element {
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = agentWord(c, state) // panics on an unknown state
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
tone, _ := agentTone(k, state)
|
||||
pill := ui.Row(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*1.5).Padding(u, u*2.5).
|
||||
Radius(theme.CardRadius).Border(theme.BorderWidth, tone).Background(k.Surface).
|
||||
Role(ui.RoleStatus).Label(label)
|
||||
pill.Children(func() {
|
||||
agentMark(c, state, 14, opts.Still)
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
return pill
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestAgentStatusWords(t *testing.T) {
|
||||
states := []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
for _, s := range states {
|
||||
AgentStatus(c, s, AgentStatusOptions{Still: true})
|
||||
}
|
||||
@@ -26,21 +28,19 @@ func TestAgentStatusWords(t *testing.T) {
|
||||
func TestAgentStatusAnimationStopsWhenStill(t *testing.T) {
|
||||
still := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
AgentStatus(c, AgentRunning, AgentStatusOptions{Still: still})
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
moving := string(tt.Image().Pix)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(80 * time.Millisecond)
|
||||
if moving == string(tt.Image().Pix) {
|
||||
t.Fatal("running status did not animate")
|
||||
}
|
||||
still = true
|
||||
tt.Frame()
|
||||
a := string(tt.Image().Pix)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
tt.Advance(80 * time.Millisecond)
|
||||
if a != string(tt.Image().Pix) {
|
||||
t.Fatal("still status kept moving")
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestAgentStatusRejectsUnknownState(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
AgentStatus(c, AgentState(42), AgentStatusOptions{})
|
||||
}, 200, 100)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AgentStep is one step of an agent run.
|
||||
@@ -29,7 +30,7 @@ type AgentStepListOptions struct {
|
||||
|
||||
// AgentStepListResult reports this frame's events.
|
||||
type AgentStepListResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Retry is the failed step to retry, else "".
|
||||
Retry string
|
||||
// Toggled is the step whose detail toggled, else "".
|
||||
@@ -54,7 +55,7 @@ func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions)
|
||||
open := ui.Local(r.Element, "mujica.steps.open", func() map[string]bool { return map[string]bool{} })
|
||||
r.Element.Children(func() {
|
||||
for i, s := range steps {
|
||||
ui.Column(c).Key(s.ID).Children(func() {
|
||||
ui.Column(c.Key(s.ID)).Children(func() {
|
||||
if agentStepRow(c, s, i, len(steps), i == current, (*open)[s.ID], opts.Still, &r) {
|
||||
(*open)[s.ID] = !(*open)[s.ID]
|
||||
r.Toggled = s.ID
|
||||
@@ -78,8 +79,8 @@ func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions)
|
||||
|
||||
// agentStepRow builds one step, reporting a header click.
|
||||
func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still bool, r *AgentStepListResult) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
clicked := false
|
||||
top := (d.ControlHeight() - 18) / 2 // centers the mark on the step header
|
||||
@@ -109,11 +110,11 @@ func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still boo
|
||||
head.Children(func() {
|
||||
ui.Text(c, s.Title).FontWeight(600).Grow(1).Shrink(1)
|
||||
if current {
|
||||
agentCaption(c, msg(c, "agent_step_list.current"))
|
||||
AgentCaption(c, core.Msg(c, "agent_step_list.current"))
|
||||
}
|
||||
agentCaption(c, agentWord(c, s.State))
|
||||
AgentCaption(c, agentWord(c, s.State))
|
||||
if s.State == AgentDone || s.State == AgentFailed {
|
||||
agentCaption(c, agentDuration(s.Duration))
|
||||
AgentCaption(c, AgentDuration(s.Duration))
|
||||
}
|
||||
if s.Detail != "" {
|
||||
chev := "chevron-right"
|
||||
@@ -129,7 +130,7 @@ func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still boo
|
||||
}
|
||||
if s.State == AgentFailed {
|
||||
ui.Row(c).Children(func() {
|
||||
if agentToggle(c, msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
|
||||
if AgentToggle(c, core.Msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
|
||||
r.Retry = s.ID
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var agentStepsSample = []AgentStep{
|
||||
@@ -16,7 +18,7 @@ var agentStepsSample = []AgentStep{
|
||||
|
||||
func stepTester(steps []AgentStep, opts AgentStepListOptions, got *[]AgentStepListResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := AgentStepList(c, steps, opts)
|
||||
if r.Retry != "" || r.Toggled != "" {
|
||||
*got = append(*got, r)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ArtifactKind is what an artifact holds; zero is code.
|
||||
@@ -41,7 +42,7 @@ type ArtifactPanelOptions struct {
|
||||
|
||||
// ArtifactPanelResult reports this frame's events.
|
||||
type ArtifactPanelResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Copied reports the content went to the clipboard.
|
||||
Copied bool
|
||||
// Exported asks the caller to export the artifact.
|
||||
@@ -71,35 +72,35 @@ func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) Artif
|
||||
if h == 0 {
|
||||
h = 320
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r ArtifactPanelResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(art.Title)
|
||||
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(art.Title)
|
||||
st := ui.Local(r.Element, "mujica.artifact", func() artifactPanelState { return artifactPanelState{source: art.Kind == ArtifactCode} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
if opts.Preview != nil {
|
||||
artifactViewSwitch(c, &st.source, locked)
|
||||
}
|
||||
copied := !st.copiedAt.IsZero() && c.Now().Sub(st.copiedAt) < 2*time.Second
|
||||
label, icon := msg(c, "artifact_panel.copy"), "copy"
|
||||
label, icon := core.Msg(c, "artifact_panel.copy"), "copy"
|
||||
if copied {
|
||||
label, icon = msg(c, "artifact_panel.copied"), "check"
|
||||
label, icon = core.Msg(c, "artifact_panel.copied"), "check"
|
||||
c.After(2*time.Second - c.Now().Sub(st.copiedAt))
|
||||
}
|
||||
if agentToggle(c, label, icon, locked) {
|
||||
if AgentToggle(c, label, icon, locked) {
|
||||
c.WriteClipboard(art.Content)
|
||||
st.copiedAt, r.Copied = c.Now(), true
|
||||
}
|
||||
if agentToggle(c, msg(c, "artifact_panel.export"), "upload", locked) {
|
||||
if AgentToggle(c, core.Msg(c, "artifact_panel.export"), "upload", locked) {
|
||||
r.Exported = true
|
||||
}
|
||||
})
|
||||
ui.Column(c).Height(h).Shrink(0).Children(func() {
|
||||
if st.source || opts.Preview == nil {
|
||||
agentCode(c, art.Language, art.Content, h).Grow(1)
|
||||
AgentCode(c, art.Language, art.Content, h).Grow(1)
|
||||
return
|
||||
}
|
||||
ui.Scroll(c).Grow(1).Padding(u*3).Radius(theme.ControlRadius).Background(k.Background).
|
||||
@@ -114,14 +115,14 @@ func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) Artif
|
||||
|
||||
// artifactViewSwitch is the Preview / Source segmented switch.
|
||||
func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ui.Row(c).Shrink(0).FocusGroup(ui.Horizontal).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.ControlRadius).
|
||||
Role(ui.RoleRadioGroup).Children(func() {
|
||||
for _, src := range []bool{false, true} {
|
||||
label, icon := msg(c, "artifact_panel.preview"), "eye"
|
||||
label, icon := core.Msg(c, "artifact_panel.preview"), "eye"
|
||||
if src {
|
||||
label, icon = msg(c, "artifact_panel.source"), "file-text"
|
||||
label, icon = core.Msg(c, "artifact_panel.source"), "file-text"
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Role(ui.RoleRadio).Label(label).
|
||||
Disabled(locked)
|
||||
@@ -137,7 +138,7 @@ func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
|
||||
ui.Icon(c, icons.Must("check")).Size(12, 12).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1))
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1))
|
||||
})
|
||||
if !b.IsDisabled() && b.Clicked() && !on {
|
||||
*source = src
|
||||
@@ -1,16 +1,18 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var artifactCodeSample = Artifact{Title: "seating.go", Language: "go", Content: "package court\n\n// Seats maps guests to tables.\nfunc Seats() map[string]int {\n\treturn map[string]int{\"Sakiko\": 1}\n}\n"}
|
||||
|
||||
func artifactTester(art Artifact, opts ArtifactPanelOptions, got *[]ArtifactPanelResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := ArtifactPanel(c, art, opts)
|
||||
if r.Copied || r.Exported {
|
||||
*got = append(*got, r)
|
||||
@@ -37,7 +39,7 @@ func TestArtifactPanelCodeCopyExport(t *testing.T) {
|
||||
func TestArtifactPanelPreviewSourceSwitch(t *testing.T) {
|
||||
doc := Artifact{Title: "Invitation", Kind: ArtifactDocument, Content: "# You are invited\n"}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ArtifactPanel(c, doc, ArtifactPanelOptions{Preview: func() { ui.Text(c, "Rendered invitation") }})
|
||||
}, 600, 460)
|
||||
if !tt.HasText("Rendered invitation") || tt.HasText("# You are invited") {
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
// ArtifactVersion is one saved version of an artifact.
|
||||
@@ -22,7 +23,7 @@ type ArtifactVersionSwitcherOptions struct {
|
||||
|
||||
// ArtifactVersionResult reports this frame's events.
|
||||
type ArtifactVersionResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Changed reports *current moved.
|
||||
Changed bool
|
||||
// Compare asks to diff current against the previous version.
|
||||
@@ -39,11 +40,11 @@ func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVer
|
||||
panic(fmt.Sprintf("mujica: ArtifactVersionSwitcher version %d needs a Label", i))
|
||||
}
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
n := len(versions)
|
||||
var r ArtifactVersionResult
|
||||
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled).
|
||||
Label(msg(c, "artifact_version_switcher.label"))
|
||||
Label(core.Msg(c, "artifact_version_switcher.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
move := func(d int) {
|
||||
if to := *current + d; !locked && to >= 0 && to < n {
|
||||
@@ -51,25 +52,25 @@ func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVer
|
||||
}
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
if iconAction(c, icons.Must("chevron-left"), msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
|
||||
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
|
||||
move(-1)
|
||||
}
|
||||
v := versions[*current]
|
||||
ui.Column(c).Shrink(1).Role(ui.RoleStatus).Label(v.Label).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, v.Label).FontWeight(600).SingleLine()
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "artifact_version_switcher.count"), *current+1, n))
|
||||
AgentCaption(c, fmt.Sprintf(core.Msg(c, "artifact_version_switcher.count"), *current+1, n))
|
||||
})
|
||||
if v.Note != "" {
|
||||
agentCaption(c, v.Note).SingleLine()
|
||||
AgentCaption(c, v.Note).SingleLine()
|
||||
}
|
||||
})
|
||||
if iconAction(c, icons.Must("chevron-right"), msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
|
||||
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
|
||||
move(1)
|
||||
}
|
||||
if *current > 0 {
|
||||
label := fmt.Sprintf(msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
|
||||
if agentToggle(c, label, "arrow-up-down", locked) {
|
||||
label := fmt.Sprintf(core.Msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
|
||||
if AgentToggle(c, label, "arrow-up-down", locked) {
|
||||
r.Compare = true
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var artifactVersionsSample = []ArtifactVersion{
|
||||
@@ -14,7 +16,7 @@ var artifactVersionsSample = []ArtifactVersion{
|
||||
|
||||
func versionTester(cur *int, opts ArtifactVersionSwitcherOptions, got *[]ArtifactVersionResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := ArtifactVersionSwitcher(c, cur, artifactVersionsSample, opts)
|
||||
if r.Changed || r.Compare {
|
||||
*got = append(*got, r)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Checkpoint is a saved point to roll back to.
|
||||
@@ -30,7 +32,7 @@ type checkpointListState struct {
|
||||
}
|
||||
|
||||
// CheckpointList marks the current point and confirms rollbacks.
|
||||
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult {
|
||||
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) core.ChoiceResult {
|
||||
if current == nil {
|
||||
panic("mujica: CheckpointList needs current")
|
||||
}
|
||||
@@ -48,9 +50,9 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
k := Tokens(c)
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "checkpoint_list.label"))
|
||||
k := core.Tokens(c)
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(core.Msg(c, "checkpoint_list.label"))
|
||||
st := ui.Local(r.Element, "mujica.checkpoints", func() checkpointListState { return checkpointListState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
if locked {
|
||||
@@ -58,10 +60,10 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
if len(points) == 0 {
|
||||
agentCaption(c, msg(c, "checkpoint_list.empty"))
|
||||
AgentCaption(c, core.Msg(c, "checkpoint_list.empty"))
|
||||
return
|
||||
}
|
||||
agentRows(c, len(points), func(i int) {
|
||||
AgentRows(c, len(points), func(i int) {
|
||||
if checkpointRow(c, points[i], points[i].ID == *current, locked) {
|
||||
st.asking, st.target = true, points[i].ID
|
||||
}
|
||||
@@ -73,10 +75,10 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
|
||||
label = p.Label
|
||||
}
|
||||
}
|
||||
res := AlertDialog(c, &st.asking, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "checkpoint_list.confirm"), label),
|
||||
Description: msg(c, "checkpoint_list.warning"), ConfirmLabel: msg(c, "checkpoint_list.rollback")})
|
||||
res := overlay.AlertDialog(c, &st.asking, overlay.AlertDialogOptions{Title: fmt.Sprintf(core.Msg(c, "checkpoint_list.confirm"), label),
|
||||
Description: core.Msg(c, "checkpoint_list.warning"), ConfirmLabel: core.Msg(c, "checkpoint_list.rollback")})
|
||||
if res.Confirmed && !locked && seen[st.target] {
|
||||
*current, r.changed = st.target, true
|
||||
*current, r = st.target, core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: checkpoint_list rollback", "to", st.target)
|
||||
}
|
||||
})
|
||||
@@ -85,11 +87,11 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
|
||||
|
||||
// checkpointRow shows one checkpoint and reports a rollback request.
|
||||
func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
asked := false
|
||||
row := ui.Row(c).Key(p.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3)
|
||||
row := ui.Row(c.Key(p.ID)).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3)
|
||||
if current {
|
||||
row.Background(k.Selection)
|
||||
}
|
||||
@@ -105,14 +107,14 @@ func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
|
||||
ui.Text(c, p.Label).FontWeight(600).SingleLine()
|
||||
if p.Note != "" {
|
||||
agentCaption(c, p.Note)
|
||||
AgentCaption(c, p.Note)
|
||||
}
|
||||
})
|
||||
if current {
|
||||
agentCaption(c, msg(c, "checkpoint_list.current"))
|
||||
AgentCaption(c, core.Msg(c, "checkpoint_list.current"))
|
||||
return
|
||||
}
|
||||
asked = agentToggle(c, msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
|
||||
asked = AgentToggle(c, core.Msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
|
||||
})
|
||||
row.Label(p.Label)
|
||||
return asked
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var checkpointsSample = []Checkpoint{
|
||||
@@ -15,7 +17,7 @@ var checkpointsSample = []Checkpoint{
|
||||
|
||||
func checkpointTester(cur *string, points []Checkpoint, opts CheckpointListOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if CheckpointList(c, cur, points, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CommandRun is a command run; the caller appends Output.
|
||||
@@ -57,7 +59,7 @@ func (o *commandOutput) update(out string) {
|
||||
}
|
||||
|
||||
// CommandExecutionCard shows a command run; Changed requests an abort.
|
||||
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult {
|
||||
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) core.ChoiceResult {
|
||||
if run.Command == "" {
|
||||
panic("mujica: CommandExecutionCard needs a Command")
|
||||
}
|
||||
@@ -68,10 +70,10 @@ func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCa
|
||||
if h == 0 {
|
||||
h = 240
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(run.Command)
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(run.Command)
|
||||
out := ui.Local(r.Element, "mujica.cmd.output", func() *commandOutput { return &commandOutput{} })
|
||||
o := *out
|
||||
o.list.FollowEnd = true
|
||||
@@ -82,32 +84,32 @@ func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCa
|
||||
ui.Text(c, run.Command).Font(theme.MonoFont).FontWeight(600).Grow(1).Shrink(1).Selectable()
|
||||
if run.Running {
|
||||
agentMark(c, AgentRunning, 16, opts.Still)
|
||||
agentCaption(c, msg(c, "agent_status.running"))
|
||||
AgentCaption(c, core.Msg(c, "agent_status.running"))
|
||||
locked := r.Element.IsDisabled()
|
||||
if Button(c, msg(c, "command_execution_card.abort"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() && !locked {
|
||||
r.changed = true
|
||||
if input.Button(c, core.Msg(c, "command_execution_card.abort"), input.ButtonOptions{Variant: input.Outline, Disabled: locked}).Clicked() && !locked {
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
commandExit(c, run.ExitCode)
|
||||
agentCaption(c, agentDuration(run.Duration))
|
||||
AgentCaption(c, AgentDuration(run.Duration))
|
||||
})
|
||||
if run.Dir != "" {
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Children(func() {
|
||||
ui.Icon(c, icons.Must("folder")).Size(14, 14).TextColor(k.TextMuted)
|
||||
agentCaption(c, run.Dir).Font(theme.MonoFont)
|
||||
AgentCaption(c, run.Dir).Font(theme.MonoFont)
|
||||
})
|
||||
}
|
||||
well := ui.List(c, &o.list, len(o.lines), func(i int) {
|
||||
ui.RichText(c, agentAnsiSpans(k, o.lines[i])...)
|
||||
})
|
||||
agentWell(c, well, h).Height(h).Label(msg(c, "command_execution_card.output")).Children(func() {
|
||||
agentWell(c, well, h).Height(h).Label(core.Msg(c, "command_execution_card.output")).Children(func() {
|
||||
if len(o.lines) == 0 {
|
||||
agentCaption(c, msg(c, "command_execution_card.no_output"))
|
||||
AgentCaption(c, core.Msg(c, "command_execution_card.no_output"))
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.changed {
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: command_execution_card abort", "command", run.Command)
|
||||
}
|
||||
return r
|
||||
@@ -115,14 +117,14 @@ func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCa
|
||||
|
||||
// commandExit shows the exit code by icon and word.
|
||||
func commandExit(c *ui.Context, code int) {
|
||||
k := Tokens(c)
|
||||
k := core.Tokens(c)
|
||||
tone, icon := k.Success, "circle-check"
|
||||
if code != 0 {
|
||||
tone, icon = k.Danger, "circle-x"
|
||||
}
|
||||
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "command_execution_card.exit"), code)).TextColor(tone).
|
||||
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "command_execution_card.exit"), code)).TextColor(tone).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func cmdTester(run *CommandRun, opts CommandExecutionCardOptions, aborts *int) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if CommandExecutionCard(c, *run, opts).Changed() {
|
||||
*aborts++
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package agent holds MujicaUI agent plans, tool calls and approvals.
|
||||
package agent
|
||||
@@ -0,0 +1,244 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func ExampleToolCallCard() {
|
||||
call := agent.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
|
||||
Result: "{\"lines\":40}", State: agent.AgentDone, Duration: 420 * time.Millisecond}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.ToolCallCard(c, call, agent.ToolCallCardOptions{OpenArgs: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolCallGroup() {
|
||||
open := false
|
||||
calls := []agent.ToolCall{
|
||||
{ID: "a", Name: "list_dir", State: agent.AgentDone, Duration: 300 * time.Millisecond},
|
||||
{ID: "b", Name: "read_file", State: agent.AgentFailed, Error: "not found"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.ToolCallGroup(c, &open, calls, agent.ToolCallGroupOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolApprovalDialog() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := agent.ToolApprovalDialog(c, &open, agent.ToolApprovalOptions{
|
||||
Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.",
|
||||
})
|
||||
if r.Allowed {
|
||||
fmt.Println("run the tool")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePermissionPrompt() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := agent.PermissionPrompt(c, &open, agent.PermissionPromptOptions{
|
||||
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
|
||||
})
|
||||
if r.Choice == agent.PermissionAllowAlways {
|
||||
fmt.Println("remember the grant")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentStepList() {
|
||||
steps := []agent.AgentStep{
|
||||
{ID: "read", Title: "Read the guest list", State: agent.AgentDone, Duration: time.Second},
|
||||
{ID: "seat", Title: "Assign the seats", State: agent.AgentFailed, Detail: "Table 4 is full."},
|
||||
{ID: "send", Title: "Send invitations", State: agent.AgentPending},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if r := agent.AgentStepList(c, steps, agent.AgentStepListOptions{}); r.Retry != "" {
|
||||
fmt.Println("retry", r.Retry)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentPlan() {
|
||||
items := []agent.PlanItem{
|
||||
{ID: "a", Text: "Read the guest list", State: agent.AgentDone},
|
||||
{ID: "b", Text: "Assign the seats", State: agent.AgentRunning},
|
||||
{ID: "c", Text: "Order the flowers"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.AgentPlan(c, &items, agent.AgentPlanOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentStatus() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.AgentStatus(c, agent.AgentWaiting, agent.AgentStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentProgress() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.AgentProgress(c, 0.4, agent.AgentProgressOptions{Action: "Reading ledger.md"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSubAgentTree() {
|
||||
agents := []agent.SubAgent{{ID: "lead", Name: "Steward", State: agent.AgentRunning, Children: []agent.SubAgent{
|
||||
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: agent.AgentWaiting},
|
||||
}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.SubAgentTree(c, agents, agent.SubAgentTreeOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFileChangeCard() {
|
||||
decision := agent.FilePending
|
||||
change := agent.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if agent.FileChangeCard(c, &decision, change, agent.FileChangeCardOptions{}).OpenDiff {
|
||||
fmt.Println("open the full diff")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMultiFileDiffReview() {
|
||||
files := []agent.FileChange{
|
||||
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
|
||||
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
|
||||
}
|
||||
decisions := make([]agent.FileDecision, len(files))
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.MultiFileDiffReview(c, &decisions, files, agent.MultiFileDiffReviewOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommandExecutionCard() {
|
||||
run := agent.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if agent.CommandExecutionCard(c, run, agent.CommandExecutionCardOptions{}).Changed() {
|
||||
fmt.Println("abort the process")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScreenshotStream() {
|
||||
var st agent.ScreenshotStreamState
|
||||
var frames []agent.ScreenshotFrame // appended as the agent captures them
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.ScreenshotStream(c, &st, frames, agent.ScreenshotStreamOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleArtifactPanel() {
|
||||
art := agent.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if agent.ArtifactPanel(c, art, agent.ArtifactPanelOptions{}).Exported {
|
||||
fmt.Println("save the file")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleArtifactVersionSwitcher() {
|
||||
current := 1
|
||||
versions := []agent.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if agent.ArtifactVersionSwitcher(c, ¤t, versions, agent.ArtifactVersionSwitcherOptions{}).Compare {
|
||||
fmt.Println("diff v1 and v2")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSandboxStatus() {
|
||||
info := agent.SandboxInfo{Name: "sandbox-7", State: agent.SandboxRunning,
|
||||
Resources: []agent.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
agent.SandboxStatus(c, info, agent.SandboxStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMCPServerList() {
|
||||
servers := []agent.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: agent.MCPConnected, Tools: 12}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if r := agent.MCPServerList(c, servers, agent.MCPServerListOptions{}); r.Action == agent.MCPStop {
|
||||
fmt.Println("stop", r.Server)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMemoryPanel() {
|
||||
entries := []agent.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if r := agent.MemoryPanel(c, &entries, agent.MemoryPanelOptions{}); r.Deleted != "" {
|
||||
fmt.Println("forget", r.Deleted)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCheckpointList() {
|
||||
current := "c2"
|
||||
points := []agent.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if agent.CheckpointList(c, ¤t, points, agent.CheckpointListOptions{}).Changed() {
|
||||
fmt.Println("restore", current)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHumanInputRequest() {
|
||||
answer := ""
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := agent.HumanInputRequest(c, &answer, agent.HumanInputRequestOptions{
|
||||
Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true,
|
||||
})
|
||||
if r.Submitted {
|
||||
fmt.Println("reply", answer)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// FileChangeCardOptions configure a FileChangeCard.
|
||||
@@ -18,7 +20,7 @@ type FileChangeCardOptions struct {
|
||||
|
||||
// FileChangeResult reports this frame's events.
|
||||
type FileChangeResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Decided reports that *decision changed.
|
||||
Decided bool
|
||||
// OpenDiff asks the caller to show the full diff.
|
||||
@@ -37,20 +39,20 @@ func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, op
|
||||
if preview == 0 {
|
||||
preview = 8
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
var r FileChangeResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(change.Path)
|
||||
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(change.Path)
|
||||
dc := agentDiffOf(r.Element, change)
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
|
||||
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(Tokens(c).TextMuted).Shrink(0)
|
||||
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(core.Tokens(c).TextMuted).Shrink(0)
|
||||
ui.Text(c, change.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
switch {
|
||||
case change.Old == "" && change.New != "":
|
||||
agentCaption(c, msg(c, "file_change_card.new"))
|
||||
AgentCaption(c, core.Msg(c, "file_change_card.new"))
|
||||
case change.New == "" && change.Old != "":
|
||||
agentCaption(c, msg(c, "file_change_card.deleted"))
|
||||
AgentCaption(c, core.Msg(c, "file_change_card.deleted"))
|
||||
}
|
||||
agentDiffStats(c, dc.add, dc.del)
|
||||
agentDecisionMark(c, *decision)
|
||||
@@ -61,19 +63,19 @@ func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, op
|
||||
agentDiffView(c, shown, 320).Font(theme.MonoFont)
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Wrap().Children(func() {
|
||||
if Button(c, msg(c, "file_change_card.open"), ButtonOptions{Variant: Ghost, Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.open"), input.ButtonOptions{Variant: input.Ghost, Disabled: locked}).Clicked() && !locked {
|
||||
r.OpenDiff = true
|
||||
}
|
||||
if *decision != FilePending {
|
||||
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.undo"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*decision, r.Decided = FilePending, true
|
||||
}
|
||||
return
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.reject"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*decision, r.Decided = FileRejected, true
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.accept"), input.ButtonOptions{Disabled: locked}).Clicked() && !locked {
|
||||
*decision, r.Decided = FileAccepted, true
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var fileChangeSample = FileChange{Path: "court/ledger.md",
|
||||
@@ -12,7 +14,7 @@ var fileChangeSample = FileChange{Path: "court/ledger.md",
|
||||
|
||||
func fileChangeTester(d *FileDecision, ch FileChange, opts FileChangeCardOptions, got *[]FileChangeResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := FileChangeCard(c, d, ch, opts)
|
||||
if r.Decided || r.OpenDiff {
|
||||
*got = append(*got, r)
|
||||
@@ -0,0 +1,30 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
// snapshotAfter is snapshot with drive sending input before the capture.
|
||||
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// HumanInputRequestOptions configure a HumanInputRequest. Question is required.
|
||||
@@ -26,7 +28,7 @@ type HumanInputRequestOptions struct {
|
||||
|
||||
// HumanInputResult reports this frame's events.
|
||||
type HumanInputResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
Submitted bool
|
||||
// TimedOut reports the frame the deadline passed.
|
||||
TimedOut bool
|
||||
@@ -45,10 +47,10 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
|
||||
if len(opts.Options) == 0 && !opts.AllowFree {
|
||||
panic("mujica: HumanInputRequest needs Options or AllowFree")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r HumanInputResult
|
||||
r.Element = agentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
|
||||
r.Element = AgentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
|
||||
st := ui.Local(r.Element, "mujica.humaninput", func() humanInputState { return humanInputState{} })
|
||||
left := time.Duration(0)
|
||||
if !opts.Deadline.IsZero() {
|
||||
@@ -65,19 +67,19 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
|
||||
ui.Text(c, opts.Question).FontWeight(600).Grow(1).Shrink(1)
|
||||
})
|
||||
if len(opts.Options) > 0 {
|
||||
items := make([]RadioItem[string], len(opts.Options))
|
||||
items := make([]input.RadioItem[string], len(opts.Options))
|
||||
for i, o := range opts.Options {
|
||||
items[i] = RadioItem[string]{Value: o, Label: o}
|
||||
items[i] = input.RadioItem[string]{Value: o, Label: o}
|
||||
}
|
||||
chosen := *answer
|
||||
if RadioGroup(c, &chosen, items, RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
|
||||
if input.RadioGroup(c, &chosen, items, input.RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
|
||||
*answer, st.draft = chosen, ""
|
||||
}
|
||||
}
|
||||
submit := false
|
||||
if opts.AllowFree {
|
||||
in := TextInput(c, &st.draft, TextInputOptions{Placeholder: msg(c, "human_input_request.free"),
|
||||
Label: msg(c, "human_input_request.free"), Disabled: locked})
|
||||
in := input.TextInput(c, &st.draft, input.TextInputOptions{Placeholder: core.Msg(c, "human_input_request.free"),
|
||||
Label: core.Msg(c, "human_input_request.free"), Disabled: locked})
|
||||
if in.Changed() && !locked {
|
||||
*answer = st.draft
|
||||
}
|
||||
@@ -86,7 +88,7 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
humanInputClock(c, opts.Deadline, left, st.expired)
|
||||
blank := strings.TrimSpace(*answer) == ""
|
||||
if Button(c, msg(c, "human_input_request.send"), ButtonOptions{Disabled: locked || blank}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "human_input_request.send"), input.ButtonOptions{Disabled: locked || blank}).Clicked() {
|
||||
submit = true
|
||||
}
|
||||
r.Submitted = submit && !locked && !blank
|
||||
@@ -103,24 +105,24 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
|
||||
|
||||
// humanInputClock shows the time left, or that it ran out.
|
||||
func humanInputClock(c *ui.Context, deadline time.Time, left time.Duration, expired bool) {
|
||||
k := Tokens(c)
|
||||
k := core.Tokens(c)
|
||||
text, tone := "", k.TextMuted
|
||||
switch {
|
||||
case expired:
|
||||
text, tone = msg(c, "human_input_request.timeout"), k.Warning
|
||||
text, tone = core.Msg(c, "human_input_request.timeout"), k.Warning
|
||||
case !deadline.IsZero():
|
||||
text = fmt.Sprintf(msg(c, "human_input_request.left"), agentDuration(left.Truncate(time.Second)))
|
||||
text = fmt.Sprintf(core.Msg(c, "human_input_request.left"), AgentDuration(left.Truncate(time.Second)))
|
||||
wait := left % time.Second
|
||||
if wait == 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
c.After(wait)
|
||||
}
|
||||
ui.Row(c).Grow(1).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
|
||||
ui.Row(c).Grow(1).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(tone)
|
||||
ui.Text(c, text).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1))
|
||||
ui.Text(c, text).TextColor(tone).FontSize(core.FontSize(c, theme.CaptionSize+1))
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func humanTester(answer *string, opts *HumanInputRequestOptions, got *[]HumanInputResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := HumanInputRequest(c, answer, *opts)
|
||||
if r.Submitted || r.TimedOut {
|
||||
*got = append(*got, r)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// MCPState is a server connection; zero is stopped.
|
||||
@@ -51,7 +53,7 @@ type MCPServerListOptions struct {
|
||||
|
||||
// MCPServerListResult reports this frame's request.
|
||||
type MCPServerListResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
Action MCPAction
|
||||
// Server is the ID the action is for.
|
||||
Server string
|
||||
@@ -74,15 +76,15 @@ func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions
|
||||
maxH = 360
|
||||
}
|
||||
var r MCPServerListResult
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "mcp_server_list.label"))
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(core.Msg(c, "mcp_server_list.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
if len(servers) == 0 {
|
||||
agentCaption(c, msg(c, "mcp_server_list.empty"))
|
||||
AgentCaption(c, core.Msg(c, "mcp_server_list.empty"))
|
||||
return
|
||||
}
|
||||
k := Tokens(c)
|
||||
agentRows(c, len(servers), func(i int) {
|
||||
k := core.Tokens(c)
|
||||
AgentRows(c, len(servers), func(i int) {
|
||||
if a := mcpServerRow(c, servers[i], locked, opts.Still); a != MCPNone && !locked {
|
||||
r.Action, r.Server = a, servers[i].ID
|
||||
}
|
||||
@@ -97,13 +99,13 @@ func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions
|
||||
|
||||
// mcpServerRow builds one server, returning its pressed action.
|
||||
func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
words := [...]string{"stopped", "connecting", "connected", "error"}
|
||||
word := msg(c, "mcp_server_list."+words[s.State])
|
||||
word := core.Msg(c, "mcp_server_list."+words[s.State])
|
||||
act := MCPNone
|
||||
ui.Row(c).Key(s.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3).
|
||||
ui.Row(c.Key(s.ID)).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3).
|
||||
Label(s.Name + ", " + word).Children(func() {
|
||||
switch s.State {
|
||||
case MCPConnecting:
|
||||
@@ -118,25 +120,25 @@ func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction {
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, s.Name).FontWeight(600).SingleLine()
|
||||
agentCaption(c, s.Transport).Font(theme.MonoFont)
|
||||
AgentCaption(c, s.Transport).Font(theme.MonoFont)
|
||||
})
|
||||
line := word
|
||||
if s.State == MCPConnected {
|
||||
line += " · " + fmt.Sprintf(msg(c, "mcp_server_list.tools"), s.Tools)
|
||||
line += " · " + fmt.Sprintf(core.Msg(c, "mcp_server_list.tools"), s.Tools)
|
||||
}
|
||||
agentCaption(c, line)
|
||||
AgentCaption(c, line)
|
||||
if s.State == MCPError && s.Error != "" {
|
||||
ui.Text(c, s.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Selectable()
|
||||
ui.Text(c, s.Error).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).Selectable()
|
||||
}
|
||||
})
|
||||
if s.State == MCPError && Button(c, msg(c, "mcp_server_list.reconnect"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() {
|
||||
if s.State == MCPError && input.Button(c, core.Msg(c, "mcp_server_list.reconnect"), input.ButtonOptions{Variant: input.Outline, Disabled: locked}).Clicked() {
|
||||
act = MCPReconnect
|
||||
}
|
||||
if s.State == MCPStopped {
|
||||
if Button(c, msg(c, "mcp_server_list.start"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "mcp_server_list.start"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() {
|
||||
act = MCPStart
|
||||
}
|
||||
} else if Button(c, msg(c, "mcp_server_list.stop"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
|
||||
} else if input.Button(c, core.Msg(c, "mcp_server_list.stop"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() {
|
||||
act = MCPStop
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var mcpServersSample = []MCPServer{
|
||||
@@ -17,7 +19,7 @@ var mcpServersSample = []MCPServer{
|
||||
func mcpTester(servers []MCPServer, opts MCPServerListOptions, got *[]MCPServerListResult) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := MCPServerList(c, servers, opts)
|
||||
if r.Action != MCPNone {
|
||||
*got = append(*got, r)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// MemoryEntry is one memory; the caller persists it.
|
||||
@@ -27,7 +30,7 @@ type MemoryPanelOptions struct {
|
||||
|
||||
// MemoryPanelResult reports this frame's in-place edits.
|
||||
type MemoryPanelResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
// Edited and Deleted are changed IDs, else "".
|
||||
Edited string
|
||||
Deleted string
|
||||
@@ -53,26 +56,26 @@ func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions)
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
var r MemoryPanelResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "memory_panel.title"))
|
||||
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(core.Msg(c, "memory_panel.title"))
|
||||
st := ui.Local(r.Element, "mujica.memory", func() memoryPanelState { return memoryPanelState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "memory_panel.count"), len(*entries)))
|
||||
ui.Text(c, core.Msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
AgentCaption(c, fmt.Sprintf(core.Msg(c, "memory_panel.count"), len(*entries)))
|
||||
})
|
||||
SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "memory_panel.search"), Disabled: locked})
|
||||
input.SearchInput(c, &st.query, input.SearchInputOptions{Placeholder: core.Msg(c, "memory_panel.search"), Disabled: locked})
|
||||
memoryFilter(st, *entries)
|
||||
if len(st.shown) == 0 {
|
||||
agentCaption(c, msg(c, "memory_panel.empty"))
|
||||
AgentCaption(c, core.Msg(c, "memory_panel.empty"))
|
||||
return
|
||||
}
|
||||
agentRows(c, len(st.shown), func(i int) {
|
||||
AgentRows(c, len(st.shown), func(i int) {
|
||||
idx := st.shown[i]
|
||||
ui.Column(c).Key((*entries)[idx].ID).Children(func() { memoryRow(c, entries, idx, st, locked, &r) })
|
||||
}).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, Tokens(c).Border)
|
||||
ui.Column(c.Key((*entries)[idx].ID)).Children(func() { memoryRow(c, entries, idx, st, locked, &r) })
|
||||
}).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, core.Tokens(c).Border)
|
||||
})
|
||||
if r.Deleted != "" {
|
||||
for i, e := range *entries {
|
||||
@@ -117,16 +120,16 @@ func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelSt
|
||||
if e.ID == "" {
|
||||
panic(fmt.Sprintf("mujica: MemoryPanel entry %d needs an ID", idx))
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() {
|
||||
if st.editing == e.ID && !locked {
|
||||
TextArea(c, &st.draft, TextAreaOptions{Label: msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus()
|
||||
core.AutoFocus(c, input.TextArea(c, &st.draft, input.TextAreaOptions{Label: core.Msg(c, "memory_panel.edit"), MinHeight: 60}).Input)
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if Button(c, msg(c, "memory_panel.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "memory_panel.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
|
||||
st.editing = ""
|
||||
}
|
||||
blank := strings.TrimSpace(st.draft) == ""
|
||||
if Button(c, msg(c, "memory_panel.save"), ButtonOptions{Disabled: blank}).Clicked() && !blank {
|
||||
if input.Button(c, core.Msg(c, "memory_panel.save"), input.ButtonOptions{Disabled: blank}).Clicked() && !blank {
|
||||
e.Text, st.editing, st.key = strings.TrimSpace(st.draft), "", ""
|
||||
r.Edited = e.ID
|
||||
}
|
||||
@@ -135,19 +138,19 @@ func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelSt
|
||||
}
|
||||
ui.Text(c, e.Text).Selectable()
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
agentCaption(c, e.Source).Grow(1)
|
||||
if agentToggle(c, msg(c, "memory_panel.edit"), "", locked) {
|
||||
AgentCaption(c, e.Source).Grow(1)
|
||||
if AgentToggle(c, core.Msg(c, "memory_panel.edit"), "", locked) {
|
||||
st.editing, st.draft = e.ID, e.Text
|
||||
}
|
||||
del := ui.Row(c)
|
||||
del.Children(func() {
|
||||
if agentToggle(c, msg(c, "memory_panel.delete"), "trash-2", locked) {
|
||||
if AgentToggle(c, core.Msg(c, "memory_panel.delete"), "trash-2", locked) {
|
||||
st.confirm = e.ID
|
||||
}
|
||||
})
|
||||
open := st.confirm == e.ID && !locked
|
||||
res := Popconfirm(c, del, &open, PopconfirmOptions{Title: msg(c, "memory_panel.confirm"), Description: e.Text,
|
||||
ConfirmLabel: msg(c, "memory_panel.delete"), Danger: true})
|
||||
res := overlay.Popconfirm(c, del, &open, overlay.PopconfirmOptions{Title: core.Msg(c, "memory_panel.confirm"), Description: e.Text,
|
||||
ConfirmLabel: core.Msg(c, "memory_panel.delete"), Danger: true})
|
||||
if st.confirm == e.ID && !open {
|
||||
st.confirm = ""
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func memorySample() []MemoryEntry {
|
||||
@@ -17,7 +19,7 @@ func memorySample() []MemoryEntry {
|
||||
|
||||
func memoryTester(entries *[]MemoryEntry, opts MemoryPanelOptions, got *[]MemoryPanelResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
r := MemoryPanel(c, entries, opts)
|
||||
if r.Edited != "" || r.Deleted != "" {
|
||||
*got = append(*got, r)
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// MultiFileDiffReviewOptions configure a MultiFileDiffReview.
|
||||
@@ -20,7 +22,7 @@ type MultiFileDiffReviewOptions struct {
|
||||
type multiFileDiffState struct{ selected int }
|
||||
|
||||
// MultiFileDiffReview reviews many file changes, one diff at a time.
|
||||
func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult {
|
||||
func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) core.ChoiceResult {
|
||||
if decisions == nil || len(*decisions) != len(files) {
|
||||
panic("mujica: MultiFileDiffReview needs one decision per file")
|
||||
}
|
||||
@@ -40,34 +42,34 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
|
||||
if h == 0 {
|
||||
h = 420
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Height(h).Disabled(opts.Disabled).Label(msg(c, "multi_file_diff_review.title"))
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = AgentCard(c).Height(h).Disabled(opts.Disabled).Label(core.Msg(c, "multi_file_diff_review.title"))
|
||||
st := ui.Local(r.Element, "mujica.mfdr", func() multiFileDiffState { return multiFileDiffState{} })
|
||||
st.selected = max(0, min(st.selected, len(files)-1))
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
|
||||
ui.Text(c, core.Msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
AgentCaption(c, fmt.Sprintf(core.Msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
|
||||
all := reviewed < len(files)
|
||||
if Button(c, msg(c, "multi_file_diff_review.accept_all"), ButtonOptions{Variant: Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
|
||||
if input.Button(c, core.Msg(c, "multi_file_diff_review.accept_all"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
|
||||
for i := range *decisions {
|
||||
if (*decisions)[i] == FilePending {
|
||||
(*decisions)[i] = FileAccepted
|
||||
}
|
||||
}
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
})
|
||||
frac := 0.0
|
||||
if len(files) > 0 {
|
||||
frac = float64(reviewed) / float64(len(files))
|
||||
}
|
||||
agentBar(c, frac, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
|
||||
agentBar(c, frac, fmt.Sprintf(core.Msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
|
||||
if len(files) == 0 {
|
||||
agentCaption(c, msg(c, "multi_file_diff_review.empty"))
|
||||
AgentCaption(c, core.Msg(c, "multi_file_diff_review.empty"))
|
||||
return
|
||||
}
|
||||
ui.Row(c).Grow(1).Shrink(1).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
|
||||
@@ -77,7 +79,7 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
|
||||
slog.Debug("mujica: multi_file_diff_review select", "path", files[i].Path)
|
||||
}
|
||||
}
|
||||
var list *ui.Element
|
||||
var list ui.Element
|
||||
if len(files) > agentVirtualRows {
|
||||
list = ui.List(c, nil, len(files), row)
|
||||
} else {
|
||||
@@ -92,12 +94,12 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u * 2).Children(func() {
|
||||
i := st.selected
|
||||
if fileChangeDecide(c, &(*decisions)[i], files[i], locked) {
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
if r.changed {
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: multi_file_diff_review decide", "reviewed", reviewed)
|
||||
}
|
||||
return r
|
||||
@@ -105,9 +107,9 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
|
||||
|
||||
// multiFileRow is one listed file; it reports a choice.
|
||||
func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(Density(c).ControlHeight()).
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
row := ui.ButtonBase(c.Key(f.Path)).Justify(ui.Start).Gap(u*2).MinHeight(core.Density(c).ControlHeight()).
|
||||
Padding(0, u*2).Radius(theme.ControlRadius).Label(f.Path)
|
||||
internal.Ring(row, k.Focus, theme.ControlRadius)
|
||||
dc := agentDiffOf(row, f)
|
||||
@@ -121,7 +123,7 @@ func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bo
|
||||
}
|
||||
row.Children(func() {
|
||||
agentDecisionIcon(c, d)
|
||||
ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
|
||||
ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
|
||||
agentDiffStats(c, dc.add, dc.del)
|
||||
})
|
||||
return row.Clicked()
|
||||
@@ -129,9 +131,9 @@ func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bo
|
||||
|
||||
// fileChangeDecide shows a full diff and its decision buttons.
|
||||
func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool) bool {
|
||||
u := Density(c).Unit()
|
||||
u := core.Density(c).Unit()
|
||||
changed := false
|
||||
holder := ui.Column(c).Key(f.Path).Grow(1).Shrink(1).Gap(u * 2)
|
||||
holder := ui.Column(c.Key(f.Path)).Grow(1).Shrink(1).Gap(u * 2)
|
||||
dc := agentDiffOf(holder, f)
|
||||
holder.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
@@ -141,15 +143,15 @@ func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool)
|
||||
agentDiffView(c, dc.lines, 1e5).Grow(1).Font(theme.MonoFont)
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if *d != FilePending {
|
||||
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.undo"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*d, changed = FilePending, true
|
||||
}
|
||||
return
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.reject"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*d, changed = FileRejected, true
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
|
||||
if input.Button(c, core.Msg(c, "file_change_card.accept"), input.ButtonOptions{Disabled: locked}).Clicked() && !locked {
|
||||
*d, changed = FileAccepted, true
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func reviewFiles() []FileChange {
|
||||
@@ -17,7 +19,7 @@ func reviewFiles() []FileChange {
|
||||
|
||||
func reviewTester(d *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(8).Children(func() {
|
||||
if MultiFileDiffReview(c, d, files, opts).Changed() {
|
||||
*changes++
|
||||
@@ -1,12 +1,15 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PermissionChoice answers a PermissionPrompt; zero is no answer.
|
||||
@@ -29,7 +32,7 @@ type PermissionPromptOptions struct {
|
||||
|
||||
// PermissionResult reports the choice made in this frame.
|
||||
type PermissionResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
Choice PermissionChoice
|
||||
}
|
||||
|
||||
@@ -40,18 +43,18 @@ func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) P
|
||||
}
|
||||
var r PermissionResult
|
||||
was := *open
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
r.Element = Dialog(c, open, DialogOptions{
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{
|
||||
Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true,
|
||||
Actions: func() {
|
||||
if Button(c, msg(c, "permission_prompt.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
|
||||
if core.AutoFocus(c, input.Button(c, core.Msg(c, "permission_prompt.deny"), input.ButtonOptions{Variant: input.Secondary})).Clicked() {
|
||||
r.Choice = PermissionDeny
|
||||
}
|
||||
if Button(c, msg(c, "permission_prompt.always"), ButtonOptions{Variant: Outline}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "permission_prompt.always"), input.ButtonOptions{Variant: input.Outline}).Clicked() {
|
||||
r.Choice = PermissionAllowAlways
|
||||
}
|
||||
if Button(c, msg(c, "permission_prompt.once"), ButtonOptions{}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "permission_prompt.once"), input.ButtonOptions{}).Clicked() {
|
||||
r.Choice = PermissionAllowOnce
|
||||
}
|
||||
},
|
||||
@@ -60,7 +63,7 @@ func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) P
|
||||
Border(1, k.Border).Children(func() {
|
||||
ui.Icon(c, icons.Must("lock")).Size(16, 16).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
|
||||
ui.Column(c).Gap(u / 2).Shrink(1).Children(func() {
|
||||
agentCaption(c, msg(c, "permission_prompt.scope"))
|
||||
AgentCaption(c, core.Msg(c, "permission_prompt.scope"))
|
||||
ui.Text(c, opts.Scope)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,18 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func permissionTester(open *bool, got *[]PermissionChoice) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Ask", ButtonOptions{}).Clicked() {
|
||||
core.Use(c, core.Settings{})
|
||||
if input.Button(c, "Ask", input.ButtonOptions{}).Clicked() {
|
||||
*open = true
|
||||
}
|
||||
r := PermissionPrompt(c, open, PermissionPromptOptions{Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger"})
|
||||
@@ -64,7 +67,7 @@ func TestPermissionPromptNeedsScope(t *testing.T) {
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
PermissionPrompt(c, &open, PermissionPromptOptions{Title: "x"})
|
||||
}, 200, 200)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SandboxState is a sandbox's lifecycle. The zero value is SandboxStarting.
|
||||
@@ -44,7 +45,7 @@ type SandboxStatusOptions struct {
|
||||
}
|
||||
|
||||
// SandboxStatus shows sandbox state, resources and time left.
|
||||
func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element {
|
||||
func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) ui.Element {
|
||||
agent := map[SandboxState]AgentState{SandboxStarting: AgentPlanning, SandboxRunning: AgentRunning, SandboxStopped: AgentSkipped, SandboxFailed: AgentFailed}
|
||||
state, ok := agent[info.State]
|
||||
if !ok {
|
||||
@@ -55,10 +56,10 @@ func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *
|
||||
panic(fmt.Sprintf("mujica: SandboxStatus resource %q needs a name and 0 <= Used <= Limit", r.Name))
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
word := msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State])
|
||||
card := agentCard(c).Label(info.Name + ", " + word)
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
word := core.Msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State])
|
||||
card := AgentCard(c).Label(info.Name + ", " + word)
|
||||
card.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, info.Name).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
@@ -68,21 +69,21 @@ func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *
|
||||
frac := r.Used / r.Limit
|
||||
ui.Column(c).Gap(u).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
agentCaption(c, r.Name).Grow(1)
|
||||
AgentCaption(c, r.Name).Grow(1)
|
||||
if frac >= 0.9 {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(msg(c, "sandbox_status.high"))
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(core.Msg(c, "sandbox_status.high"))
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Limit), r.Unit)).
|
||||
FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
|
||||
ui.Text(c, fmt.Sprintf("%s / %s %s", SandboxNum(r.Used), SandboxNum(r.Limit), r.Unit)).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
|
||||
})
|
||||
agentBar(c, frac, fmt.Sprintf("%s %.0f%%", r.Name, frac*100))
|
||||
})
|
||||
}
|
||||
if !info.Expires.IsZero() && info.State != SandboxStopped && info.State != SandboxFailed {
|
||||
left := info.Expires.Sub(c.Now())
|
||||
text := msg(c, "sandbox_status.expired")
|
||||
text := core.Msg(c, "sandbox_status.expired")
|
||||
if left > 0 {
|
||||
text = fmt.Sprintf(msg(c, "sandbox_status.left"), agentDuration(left.Truncate(time.Second)))
|
||||
text = fmt.Sprintf(core.Msg(c, "sandbox_status.left"), AgentDuration(left.Truncate(time.Second)))
|
||||
wait := left % time.Second
|
||||
if wait == 0 {
|
||||
wait = time.Second
|
||||
@@ -91,15 +92,15 @@ func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *
|
||||
}
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
|
||||
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(k.TextMuted)
|
||||
agentCaption(c, text)
|
||||
AgentCaption(c, text)
|
||||
})
|
||||
}
|
||||
})
|
||||
return card
|
||||
}
|
||||
|
||||
// sandboxNum prints a resource amount with at most one decimal.
|
||||
func sandboxNum(v float64) string {
|
||||
// SandboxNum prints a resource amount with at most one decimal.
|
||||
func SandboxNum(v float64) string {
|
||||
if v == math.Trunc(v) {
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func sandboxSample(expires time.Time) SandboxInfo {
|
||||
@@ -18,7 +20,7 @@ func sandboxSample(expires time.Time) SandboxInfo {
|
||||
func TestSandboxStatusResourcesAndCountdown(t *testing.T) {
|
||||
info := sandboxSample(time.Now().Add(90 * time.Second))
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
SandboxStatus(c, info, SandboxStatusOptions{Still: true})
|
||||
}, 480, 300)
|
||||
for _, s := range []string{"sandbox-7", "Running", "1.5 / 2 cores", "3.8 / 4 GB", "Near the limit"} {
|
||||
@@ -54,7 +56,7 @@ func TestSandboxStatusRejectsBadResource(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
SandboxStatus(c, SandboxInfo{Name: "x", Resources: []SandboxResource{{Name: "CPU", Used: 3, Limit: 2}}}, SandboxStatusOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ScreenshotFrame is one caller-supplied frame of the agent's screen.
|
||||
@@ -34,7 +36,7 @@ type ScreenshotStreamOptions struct {
|
||||
}
|
||||
|
||||
// ScreenshotStream shows screen frames live, paused or stepped.
|
||||
func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult {
|
||||
func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) core.ChoiceResult {
|
||||
if st == nil {
|
||||
panic("mujica: ScreenshotStream needs a state")
|
||||
}
|
||||
@@ -50,8 +52,8 @@ func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []Screens
|
||||
if h == 0 {
|
||||
h = 280
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
n := len(frames)
|
||||
if st.Index < 0 {
|
||||
panic(fmt.Sprintf("mujica: ScreenshotStream Index %d is negative", st.Index))
|
||||
@@ -60,24 +62,24 @@ func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []Screens
|
||||
|
||||
st.Index = max(0, n-1)
|
||||
}
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "screenshot_stream.label"))
|
||||
var r core.ChoiceResult
|
||||
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(core.Msg(c, "screenshot_stream.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
step := func(d int) {
|
||||
if n == 0 || locked {
|
||||
return
|
||||
}
|
||||
st.Index = max(0, min(n-1, st.Index+d))
|
||||
st.Paused, r.changed = true, true
|
||||
st.Paused, r = true, core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
view := ui.Box(c).FillWidth().Height(h).Center().Clip().Radius(theme.ControlRadius).
|
||||
Background(k.Background).Border(theme.BorderWidth, k.Border).Focusable().
|
||||
Label(msg(c, "screenshot_stream.label"))
|
||||
Label(core.Msg(c, "screenshot_stream.label"))
|
||||
internal.Ring(view, k.Focus, theme.ControlRadius)
|
||||
view.Children(func() {
|
||||
if n == 0 {
|
||||
agentCaption(c, msg(c, "screenshot_stream.waiting"))
|
||||
AgentCaption(c, core.Msg(c, "screenshot_stream.waiting"))
|
||||
return
|
||||
}
|
||||
ui.Image(c, frames[st.Index].Image).Fill().Fit(ui.Contain).Label(frames[st.Index].Caption)
|
||||
@@ -89,36 +91,36 @@ func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []Screens
|
||||
case view.Shortcut(0, ui.KeyRight):
|
||||
step(1)
|
||||
case view.Shortcut(0, ui.KeySpace):
|
||||
st.Paused, r.changed = !st.Paused, true
|
||||
st.Paused, r = !st.Paused, core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
if iconAction(c, icons.Must("chevron-left"), msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
|
||||
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
|
||||
step(-1)
|
||||
}
|
||||
pause := msg(c, "screenshot_stream.pause")
|
||||
pause := core.Msg(c, "screenshot_stream.pause")
|
||||
if st.Paused {
|
||||
pause = msg(c, "screenshot_stream.live")
|
||||
pause = core.Msg(c, "screenshot_stream.live")
|
||||
}
|
||||
if Button(c, pause, ButtonOptions{Variant: Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
|
||||
st.Paused, r.changed = !st.Paused, true
|
||||
if input.Button(c, pause, input.ButtonOptions{Variant: input.Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
|
||||
st.Paused, r = !st.Paused, core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
if iconAction(c, icons.Must("chevron-right"), msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
|
||||
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
|
||||
step(1)
|
||||
}
|
||||
counter := fmt.Sprintf(msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n)
|
||||
agentCaption(c, counter).Role(ui.RoleStatus)
|
||||
counter := fmt.Sprintf(core.Msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n)
|
||||
AgentCaption(c, counter).Role(ui.RoleStatus)
|
||||
if n > 0 {
|
||||
f := frames[st.Index]
|
||||
ui.Text(c, f.Caption).SingleLine().Grow(1).Shrink(1)
|
||||
agentCaption(c, agentDuration(f.At))
|
||||
AgentCaption(c, AgentDuration(f.At))
|
||||
}
|
||||
if !st.Paused && n > 0 {
|
||||
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: msg(c, "screenshot_stream.following"), Still: true})
|
||||
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: core.Msg(c, "screenshot_stream.following"), Still: true})
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.changed {
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: screenshot_stream move", "index", st.Index, "paused", st.Paused)
|
||||
}
|
||||
return r
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// screenshotFrames makes n solid frames of shifting colors.
|
||||
@@ -26,7 +28,7 @@ func screenshotFrames(n int) []ScreenshotFrame {
|
||||
|
||||
func screenshotTester(st *ScreenshotStreamState, frames *[]ScreenshotFrame, opts ScreenshotStreamOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if ScreenshotStream(c, st, *frames, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SubAgent is one agent in a hierarchy.
|
||||
@@ -27,7 +28,7 @@ type SubAgentTreeOptions struct {
|
||||
}
|
||||
|
||||
// SubAgentTree shows nested agents and their states.
|
||||
func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult {
|
||||
func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) core.ChoiceResult {
|
||||
seen := map[string]bool{}
|
||||
var check func([]SubAgent)
|
||||
check = func(list []SubAgent) {
|
||||
@@ -40,7 +41,7 @@ func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) Cho
|
||||
}
|
||||
}
|
||||
check(roots)
|
||||
var r ChoiceResult
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
|
||||
open := ui.Local(r.Element, "mujica.subagents.open", func() map[string]bool { return map[string]bool{} })
|
||||
r.Element.Children(func() {
|
||||
@@ -49,7 +50,7 @@ func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) Cho
|
||||
for _, a := range list {
|
||||
if subAgentRow(c, a, depth, (*open)[a.ID], opts.Still) {
|
||||
(*open)[a.ID] = !(*open)[a.ID]
|
||||
r.changed = true
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: sub_agent_tree toggle", "id", a.ID, "open", (*open)[a.ID])
|
||||
}
|
||||
if (*open)[a.ID] {
|
||||
@@ -69,11 +70,11 @@ func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) Cho
|
||||
|
||||
// subAgentRow builds one row, reporting an expand or collapse.
|
||||
func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
parent := len(a.Children) > 0
|
||||
row := ui.ButtonBase(c).Key(a.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).
|
||||
row := ui.ButtonBase(c.Key(a.ID)).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).
|
||||
Padding(0, u*2, 0, u*2+float32(depth)*u*5).Radius(theme.ControlRadius).
|
||||
Label(a.Name + ", " + agentWord(c, a.State))
|
||||
internal.Ring(row, k.Focus, theme.ControlRadius)
|
||||
@@ -91,10 +92,10 @@ func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool {
|
||||
}
|
||||
agentMark(c, a.State, 16, still)
|
||||
ui.Text(c, a.Name).FontWeight(600).SingleLine().Shrink(0)
|
||||
ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
|
||||
agentCaption(c, agentWord(c, a.State))
|
||||
ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
|
||||
AgentCaption(c, agentWord(c, a.State))
|
||||
if parent {
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "sub_agent_tree.children"), len(a.Children)))
|
||||
AgentCaption(c, fmt.Sprintf(core.Msg(c, "sub_agent_tree.children"), len(a.Children)))
|
||||
}
|
||||
})
|
||||
if !parent || row.IsDisabled() {
|
||||
@@ -1,9 +1,11 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var subAgentSample = []SubAgent{
|
||||
@@ -18,7 +20,7 @@ var subAgentSample = []SubAgent{
|
||||
func subAgentTester(opts SubAgentTreeOptions, changes *int) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if SubAgentTree(c, subAgentSample, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
@@ -66,7 +68,7 @@ func TestSubAgentTreeRejectsDuplicateIDs(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
SubAgentTree(c, []SubAgent{{ID: "a", Name: "x", Children: []SubAgent{{ID: "a", Name: "y"}}}}, SubAgentTreeOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ToolApprovalOptions configure a ToolApprovalDialog. Tool is required.
|
||||
@@ -19,7 +22,7 @@ type ToolApprovalOptions struct {
|
||||
|
||||
// ToolApprovalResult reports the choice made in this frame.
|
||||
type ToolApprovalResult struct {
|
||||
Element *ui.Element
|
||||
Element ui.Element
|
||||
Allowed bool
|
||||
Denied bool
|
||||
}
|
||||
@@ -31,26 +34,26 @@ func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) Too
|
||||
}
|
||||
var r ToolApprovalResult
|
||||
was := *open
|
||||
u := Density(c).Unit()
|
||||
r.Element = Dialog(c, open, DialogOptions{
|
||||
Title: msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true,
|
||||
u := core.Density(c).Unit()
|
||||
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{
|
||||
Title: core.Msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true,
|
||||
Actions: func() {
|
||||
// Deny first: a reflexive Enter never runs the tool.
|
||||
if Button(c, msg(c, "tool_approval_dialog.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
|
||||
if core.AutoFocus(c, input.Button(c, core.Msg(c, "tool_approval_dialog.deny"), input.ButtonOptions{Variant: input.Secondary})).Clicked() {
|
||||
r.Denied = true
|
||||
}
|
||||
if Button(c, msg(c, "tool_approval_dialog.allow"), ButtonOptions{}).Clicked() {
|
||||
if input.Button(c, core.Msg(c, "tool_approval_dialog.allow"), input.ButtonOptions{}).Clicked() {
|
||||
r.Allowed = true
|
||||
}
|
||||
},
|
||||
}, func() {
|
||||
ui.Column(c).Gap(u * 2).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
agentCaption(c, msg(c, "tool_approval_dialog.tool"))
|
||||
AgentCaption(c, core.Msg(c, "tool_approval_dialog.tool"))
|
||||
ui.Text(c, opts.Tool).Font(theme.MonoFont).FontWeight(600)
|
||||
})
|
||||
if opts.Args != "" {
|
||||
agentCode(c, "json", agentPrettyJSON(opts.Args), 260)
|
||||
AgentCode(c, "json", agentPrettyJSON(opts.Args), 260)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
type approvalApp struct {
|
||||
@@ -12,8 +15,8 @@ type approvalApp struct {
|
||||
}
|
||||
|
||||
func (a *approvalApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Run", ButtonOptions{}).Clicked() {
|
||||
core.Use(c, core.Settings{})
|
||||
if input.Button(c, "Run", input.ButtonOptions{}).Clicked() {
|
||||
a.open = true
|
||||
}
|
||||
r := ToolApprovalDialog(c, &a.open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"*.tmp"}`, Reason: "Clean the build folder."})
|
||||
@@ -83,7 +86,7 @@ func TestToolApprovalDialogNeedsTool(t *testing.T) {
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolApprovalDialog(c, &open, ToolApprovalOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ToolCall is one tool invocation; the caller owns it.
|
||||
@@ -33,14 +35,14 @@ type ToolCallCardOptions struct {
|
||||
type toolCallCardState struct{ args, result bool }
|
||||
|
||||
// ToolCallCard shows a tool call with foldable arguments and result.
|
||||
func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult {
|
||||
func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) core.ChoiceResult {
|
||||
if call.Name == "" {
|
||||
panic("mujica: ToolCallCard needs a Name")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State))
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = AgentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State))
|
||||
st := ui.Local(r.Element, "mujica.toolcall", func() toolCallCardState {
|
||||
return toolCallCardState{opts.OpenArgs, opts.OpenResult}
|
||||
})
|
||||
@@ -48,31 +50,31 @@ func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) Choice
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
|
||||
agentMark(c, call.State, 16, opts.Still)
|
||||
ui.Text(c, call.Name).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
agentCaption(c, agentWord(c, call.State))
|
||||
AgentCaption(c, agentWord(c, call.State))
|
||||
if call.State == AgentDone || call.State == AgentFailed {
|
||||
agentCaption(c, agentDuration(call.Duration))
|
||||
AgentCaption(c, AgentDuration(call.Duration))
|
||||
}
|
||||
})
|
||||
if call.Args != "" {
|
||||
r.changed = Collapsible(c, &st.args, CollapsibleOptions{Title: msg(c, "tool_call_card.args")}, func() {
|
||||
agentCode(c, "json", agentPrettyJSON(call.Args), 240)
|
||||
}).Changed() || r.changed
|
||||
r = core.NewChoiceResult(r.Element, data.Collapsible(c, &st.args, data.CollapsibleOptions{Title: core.Msg(c, "tool_call_card.args")}, func() {
|
||||
AgentCode(c, "json", agentPrettyJSON(call.Args), 240)
|
||||
}).Changed() || r.Changed())
|
||||
}
|
||||
body, title := call.Result, msg(c, "tool_call_card.result")
|
||||
body, title := call.Result, core.Msg(c, "tool_call_card.result")
|
||||
if call.Error != "" {
|
||||
body, title = call.Error, msg(c, "tool_call_card.error")
|
||||
body, title = call.Error, core.Msg(c, "tool_call_card.error")
|
||||
}
|
||||
if body != "" {
|
||||
r.changed = Collapsible(c, &st.result, CollapsibleOptions{Title: title}, func() {
|
||||
r = core.NewChoiceResult(r.Element, data.Collapsible(c, &st.result, data.CollapsibleOptions{Title: title}, func() {
|
||||
if call.Error != "" {
|
||||
ui.Text(c, call.Error).TextColor(k.Danger).Selectable()
|
||||
return
|
||||
}
|
||||
agentCode(c, "json", agentPrettyJSON(body), 320)
|
||||
}).Changed() || r.changed
|
||||
AgentCode(c, "json", agentPrettyJSON(body), 320)
|
||||
}).Changed() || r.Changed())
|
||||
}
|
||||
})
|
||||
if r.changed {
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: tool_call_card toggle", "tool", call.Name, "args", st.args, "result", st.result)
|
||||
}
|
||||
return r
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var toolCallSample = ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`,
|
||||
@@ -16,7 +18,7 @@ func TestToolCallCardShowsAndFolds(t *testing.T) {
|
||||
call := toolCallSample
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if ToolCallCard(c, call, ToolCallCardOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
@@ -43,7 +45,7 @@ func TestToolCallCardShowsAndFolds(t *testing.T) {
|
||||
func TestToolCallCardErrorAndPlainText(t *testing.T) {
|
||||
call := ToolCall{Name: "shell", Args: "not json at all", Error: "permission denied", State: AgentFailed, Duration: 3 * time.Second}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolCallCard(c, call, ToolCallCardOptions{OpenArgs: true, OpenResult: true})
|
||||
}, 500, 400)
|
||||
for _, s := range []string{"Failed", "3.0 s", "Error", "permission denied", "not json at all"} {
|
||||
@@ -56,7 +58,7 @@ func TestToolCallCardErrorAndPlainText(t *testing.T) {
|
||||
func TestToolCallCardRunningHidesDuration(t *testing.T) {
|
||||
call := ToolCall{Name: "search", State: AgentRunning, Duration: time.Second}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolCallCard(c, call, ToolCallCardOptions{Still: true})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("Running") || tt.HasText("1.0 s") {
|
||||
@@ -71,7 +73,7 @@ func TestToolCallCardLargeResultIsVirtualized(t *testing.T) {
|
||||
}
|
||||
call := ToolCall{Name: "dump", Result: b.String(), State: AgentDone}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolCallCard(c, call, ToolCallCardOptions{OpenResult: true})
|
||||
}, 500, 500)
|
||||
if n := len(tt.Texts()); n > 200 {
|
||||
@@ -89,7 +91,7 @@ func TestToolCallCardNeedsName(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolCallCard(c, ToolCall{}, ToolCallCardOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ToolCallGroupOptions configure a ToolCallGroup.
|
||||
@@ -17,7 +19,7 @@ type ToolCallGroupOptions struct {
|
||||
}
|
||||
|
||||
// ToolCallGroup folds tool calls under a count, time, failures summary.
|
||||
func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult {
|
||||
func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) core.ChoiceResult {
|
||||
if open == nil {
|
||||
panic("mujica: ToolCallGroup needs open")
|
||||
}
|
||||
@@ -33,11 +35,11 @@ func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGro
|
||||
failed += agentB2I(call.State == AgentFailed)
|
||||
running = running || call.State == AgentRunning
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf(msg(c, "tool_call_group.count"), len(calls))
|
||||
title = fmt.Sprintf(core.Msg(c, "tool_call_group.count"), len(calls))
|
||||
}
|
||||
state := AgentDone
|
||||
switch {
|
||||
@@ -46,23 +48,23 @@ func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGro
|
||||
case failed > 0:
|
||||
state = AgentFailed
|
||||
}
|
||||
summary := title + " · " + agentDuration(total)
|
||||
summary := title + " · " + AgentDuration(total)
|
||||
if failed > 0 {
|
||||
summary += " · " + fmt.Sprintf(msg(c, "tool_call_group.failed"), failed)
|
||||
summary += " · " + fmt.Sprintf(core.Msg(c, "tool_call_group.failed"), failed)
|
||||
}
|
||||
return Collapsible(c, open, CollapsibleOptions{Title: summary, Trigger: func() {
|
||||
return data.Collapsible(c, open, data.CollapsibleOptions{Title: summary, Trigger: func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
agentMark(c, state, 16, opts.Still)
|
||||
ui.Text(c, title).FontWeight(600).SingleLine()
|
||||
agentCaption(c, agentDuration(total))
|
||||
AgentCaption(c, AgentDuration(total))
|
||||
if failed > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger).
|
||||
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
}
|
||||
})
|
||||
}}, func() {
|
||||
for _, call := range calls {
|
||||
ui.Column(c).Key(call.ID).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) })
|
||||
ui.Column(c.Key(call.ID)).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var toolCallGroupSample = []ToolCall{
|
||||
@@ -16,7 +18,7 @@ var toolCallGroupSample = []ToolCall{
|
||||
func TestToolCallGroupSummaryAndFold(t *testing.T) {
|
||||
open, changes := false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
if ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}).Changed() {
|
||||
changes++
|
||||
}
|
||||
@@ -40,7 +42,7 @@ func TestToolCallGroupRunningAndEmpty(t *testing.T) {
|
||||
open := false
|
||||
calls := []ToolCall{{ID: "x", Name: "fetch", State: AgentRunning}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolCallGroup(c, &open, calls, ToolCallGroupOptions{Title: "Research", Still: true})
|
||||
ToolCallGroup(c, &open, nil, ToolCallGroupOptions{})
|
||||
}, 500, 300)
|
||||
@@ -57,7 +59,7 @@ func TestToolCallGroupRejectsDuplicateIDs(t *testing.T) {
|
||||
}()
|
||||
open := false
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
ToolCallGroup(c, &open, []ToolCall{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, ToolCallGroupOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ApiRequest is an HTTP request being built; the caller sends it.
|
||||
type ApiRequest struct {
|
||||
// Method is GET when empty.
|
||||
Method string
|
||||
URL string
|
||||
Params []KeyValue
|
||||
Headers []KeyValue
|
||||
Body string
|
||||
}
|
||||
|
||||
// ApiRequestBuilderOptions configure an ApiRequestBuilder.
|
||||
type ApiRequestBuilderOptions struct {
|
||||
// Sending shows the request in flight and blocks another send.
|
||||
Sending bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ApiRequestResult reports this frame's events.
|
||||
type ApiRequestResult struct {
|
||||
Element *ui.Element
|
||||
// Send asks the caller to send the request.
|
||||
Send bool
|
||||
Changed bool
|
||||
}
|
||||
|
||||
type apiRequestState struct{ tab int }
|
||||
|
||||
var apiMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
|
||||
// ApiRequestBuilder edits method, URL, query parameters, headers and body, and reports a send.
|
||||
func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOptions) ApiRequestResult {
|
||||
if req == nil {
|
||||
panic("mujica: ApiRequestBuilder needs a request")
|
||||
}
|
||||
if req.Method == "" {
|
||||
req.Method = "GET"
|
||||
}
|
||||
if !slices.Contains(apiMethods, req.Method) {
|
||||
panic("mujica: ApiRequestBuilder method " + req.Method + " is not offered")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
var r ApiRequestResult
|
||||
r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Disabled(opts.Disabled)
|
||||
st := ui.Local(r.Element, "mujica.apirequest", func() apiRequestState { return apiRequestState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
full, urlErr := apiRequestURL(*req)
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
methods := make([]SelectOption[string], len(apiMethods))
|
||||
for i, m := range apiMethods {
|
||||
methods[i] = SelectOption[string]{Value: m, Label: m}
|
||||
}
|
||||
r.Changed = Select(c, &req.Method, methods, SelectOptions{Label: msg(c, "api_request_builder.method"), Disabled: locked}).Changed()
|
||||
shownErr := ""
|
||||
if req.URL != "" && urlErr != nil {
|
||||
shownErr = urlErr.Error()
|
||||
}
|
||||
in := TextInput(c, &req.URL, TextInputOptions{Placeholder: "https://", Label: msg(c, "api_request_builder.url"), Disabled: locked, Error: shownErr})
|
||||
in.Element.Grow(1)
|
||||
r.Changed = r.Changed || in.Changed()
|
||||
ready := urlErr == nil && !opts.Sending && !locked
|
||||
send := Button(c, msg(c, "api_request_builder.send"), ButtonOptions{Icon: icons.Must("upload"), Loading: opts.Sending, Disabled: locked || urlErr != nil})
|
||||
r.Send = ready && (send.Clicked() || in.Submitted())
|
||||
})
|
||||
if urlErr == nil {
|
||||
codeMono(c, req.Method+" "+full).TextColor(Tokens(c).TextMuted)
|
||||
}
|
||||
tabs := []Tab{
|
||||
{Label: fmt.Sprintf(msg(c, "api_request_builder.params"), len(keyValueEnabled(req.Params))), Panel: func() {
|
||||
r.Changed = KeyValueInput(c, &req.Params, KeyValueInputOptions{Label: msg(c, "api_request_builder.params_label"), Disabled: locked}).Changed() || r.Changed
|
||||
}},
|
||||
{Label: fmt.Sprintf(msg(c, "api_request_builder.headers"), len(keyValueEnabled(req.Headers))), Panel: func() {
|
||||
r.Changed = KeyValueInput(c, &req.Headers, KeyValueInputOptions{Label: msg(c, "api_request_builder.headers_label"), Disabled: locked}).Changed() || r.Changed
|
||||
}},
|
||||
{Label: msg(c, "api_request_builder.body"), Panel: func() {
|
||||
if req.Method == "GET" || req.Method == "HEAD" {
|
||||
agentCaption(c, msg(c, "api_request_builder.no_body"))
|
||||
}
|
||||
body := TextArea(c, &req.Body, TextAreaOptions{Label: msg(c, "api_request_builder.body"), MinHeight: 120, MaxHeight: 320, Disabled: locked})
|
||||
body.Input.Font(theme.MonoFont)
|
||||
r.Changed = r.Changed || body.Changed()
|
||||
}},
|
||||
}
|
||||
Tabs(c, &st.tab, tabs, TabsOptions{Label: msg(c, "api_request_builder.parts")})
|
||||
})
|
||||
if r.Send {
|
||||
slog.Debug("mujica: api_request_builder send", "method", req.Method) // never the URL: it may hold a token
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// apiRequestURL joins the URL with its enabled query parameters, or says why it is invalid.
|
||||
func apiRequestURL(req ApiRequest) (string, error) {
|
||||
if req.URL == "" {
|
||||
return "", fmt.Errorf("empty URL")
|
||||
}
|
||||
u, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", fmt.Errorf("the URL needs http:// or https:// and a host")
|
||||
}
|
||||
q := u.Query()
|
||||
for _, kv := range keyValueEnabled(req.Params) {
|
||||
q.Add(kv.Key, kv.Value)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CalendarWeekViewOptions configure a CalendarWeekView.
|
||||
type CalendarWeekViewOptions struct {
|
||||
// Location is the zone days are cut in; required.
|
||||
Location *time.Location
|
||||
// Version must change when events change in place.
|
||||
Version int
|
||||
WeekStart WeekStart
|
||||
// SlotMinutes is the grid step; zero means 30.
|
||||
SlotMinutes int
|
||||
// Today is marked; zero uses the system clock.
|
||||
Today Date
|
||||
// Now places the current-time line; zero uses the system clock.
|
||||
Now time.Time
|
||||
Hour12 bool
|
||||
Disabled bool
|
||||
Label string
|
||||
}
|
||||
|
||||
// CalendarWeekView shows *week as a time grid; drags edit events.
|
||||
func CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult {
|
||||
datetimeCheckDate("CalendarWeekView week", *week)
|
||||
if week.IsZero() {
|
||||
panic("mujica: CalendarWeekView needs a week")
|
||||
}
|
||||
cfg := timeGridConfig{name: "calendar_week_view", loc: eventLocation("CalendarWeekView", opts.Location), version: opts.Version,
|
||||
slot: timeGridSlot("CalendarWeekView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12}
|
||||
eventsCheck("CalendarWeekView", events)
|
||||
first := calendarFirstWeekday(c, opts.WeekStart)
|
||||
start := week.AddDays(-((int(week.Weekday()) - int(first) + 7) % 7))
|
||||
days := make([]Date, 7)
|
||||
for i := range days {
|
||||
days[i] = start.AddDays(i)
|
||||
}
|
||||
title := weekViewTitle(c, start, start.AddDays(6))
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = title
|
||||
}
|
||||
r := CalendarViewResult{}
|
||||
root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
|
||||
r.Element = root
|
||||
root.Children(func() {
|
||||
calendarViewHeader(c, title, msg(c, "calendar_week_view.prev"), msg(c, "calendar_week_view.next"), func(dir int) {
|
||||
*week = week.AddDays(7 * dir)
|
||||
})
|
||||
timeGrid(c, root, days, events, cfg, &r)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// calendarViewHeader is the title row with previous and next buttons.
|
||||
func calendarViewHeader(c *ui.Context, title, prev, next string, step func(dir int)) {
|
||||
k := Tokens(c)
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() {
|
||||
if b := iconAction(c, icons.Must("chevron-left"), prev); b.Clicked() && !b.IsDisabled() {
|
||||
step(-1)
|
||||
}
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).TextAlign(ui.Center)
|
||||
if b := iconAction(c, icons.Must("chevron-right"), next); b.Clicked() && !b.IsDisabled() {
|
||||
step(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// weekViewTitle names a span of days.
|
||||
func weekViewTitle(c *ui.Context, a, b Date) string {
|
||||
return fmt.Sprintf(msg(c, "calendar_week_view.title"), datetimeMonthName(c, a.Month), a.Day, datetimeMonthName(c, b.Month), b.Day, b.Year)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
@@ -24,15 +24,15 @@ type AreaChartOptions struct {
|
||||
// AreaChart fills series to the baseline, or stacked.
|
||||
func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult {
|
||||
n := chartCheckSeries(series)
|
||||
ch := newChart(c, opts.Frame)
|
||||
ax := chartMemo(ch, "x", [3]string{chartID(opts.X), chartID(opts.Times), chartID(series)}, func() chartX { return newChartX(n, opts.X, opts.Times) })
|
||||
ch := NewChart(c, opts.Frame)
|
||||
ax := ChartMemo(ch, "x", [3]string{ChartID(opts.X), ChartID(opts.Times), ChartID(series)}, func() ChartX { return NewChartX(n, opts.X, opts.Times) })
|
||||
deps := struct {
|
||||
id string
|
||||
stacked bool
|
||||
base float64
|
||||
}{chartSeriesID(ch, series), opts.Stacked, opts.Baseline}
|
||||
bands := chartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.hidden, opts.Stacked, opts.Baseline) })
|
||||
lo, hi, ok := chartYRange(ch, series, false, bands[0])
|
||||
bands := ChartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.Hidden, opts.Stacked, opts.Baseline) })
|
||||
lo, hi, ok := ChartYRange(ch, series, false, bands[0])
|
||||
if !ok {
|
||||
n = 0
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mujica
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
@@ -35,7 +35,7 @@ func TestAreaChartStackedReadout(t *testing.T) {
|
||||
if got := chartReadout(tt); got != "0 · Pews 3 · Candles 5" {
|
||||
t.Fatalf("readout shows the series values, not the stack: %q", got)
|
||||
}
|
||||
b := h.res.Element.Bounds()
|
||||
b := h.box(tt)
|
||||
tt.Move(b.X+52+(b.W-64)*0.5, b.Y+b.H/2)
|
||||
if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" || h.res.Point != 2 {
|
||||
t.Fatalf("hover %q", got)
|
||||
@@ -1,12 +1,13 @@
|
||||
package mujica
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BarChartOptions configure a BarChart.
|
||||
@@ -28,15 +29,15 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
if n := chartCheckSeries(series); n != len(categories) && len(series) > 0 {
|
||||
panic("mujica: BarChart series length differs from categories")
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
k := Tokens(c)
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
keys := make([]ChartLegendItem, len(series))
|
||||
var vis []int
|
||||
for i, s := range series {
|
||||
keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
|
||||
if !ch.hidden(s.Name) {
|
||||
if !ch.Hidden(s.Name) {
|
||||
vis = append(vis, i)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +47,7 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
from []float64
|
||||
lo, hi float64
|
||||
}
|
||||
lay := chartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
|
||||
lay := ChartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
|
||||
var l layout
|
||||
for ci := range categories {
|
||||
pos, neg := 0.0, 0.0
|
||||
@@ -81,11 +82,11 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
|
||||
if opts.Horizontal {
|
||||
plot := chartInset(r, 96, 26)
|
||||
plot := ChartInset(r, 96, 26)
|
||||
return plot, internal.Band{N: len(categories), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.3},
|
||||
internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
|
||||
}
|
||||
plot := chartInset(r, 52, 26)
|
||||
plot := ChartInset(r, 52, 26)
|
||||
return plot, internal.Band{N: len(categories), R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
@@ -111,10 +112,10 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
return ui.Rect{X: at, Y: a, W: w, H: max(b-a, 1)}
|
||||
}
|
||||
value := func(i int) float64 { m := lay.marks[i]; return series[m.series].Values[m.cat] }
|
||||
spec := ChartSpec{Marks: len(lay.marks), Legend: keys, Hidden: ch.toggles(len(series) > 1)}
|
||||
spec := ChartSpec{Marks: len(lay.marks), Legend: keys, Hidden: ch.Toggles(len(series) > 1)}
|
||||
spec.Readout = func(i int) string {
|
||||
m := lay.marks[i]
|
||||
return categories[m.cat] + " · " + series[m.series].Name + " " + chartValue(c, value(i), opts.Format)
|
||||
return categories[m.cat] + " · " + series[m.series].Name + " " + ChartValue(c, value(i), opts.Format)
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
_, band, vs := geom(r)
|
||||
@@ -139,10 +140,10 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
for _, t := range ticks {
|
||||
x := internal.Snap(p, vs.Map(t))
|
||||
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.Border)
|
||||
chartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
|
||||
ChartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
|
||||
}
|
||||
for i, y := range pos {
|
||||
chartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
|
||||
ChartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
|
||||
}
|
||||
p.Line(internal.Snap(p, zero), plot.Y, internal.Snap(p, zero), plot.Y+plot.H, 1, k.ControlBorder)
|
||||
} else {
|
||||
@@ -162,11 +163,11 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
internal.Marker(p, m.series, b.X+b.W/2, b.Y+b.H/2, min(b.W, b.H, 14)*0.6, k.Surface)
|
||||
}
|
||||
if opts.Labels && !opts.Stacked {
|
||||
barLabel(p, chartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
|
||||
barLabel(p, ChartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -174,12 +175,12 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
|
||||
func barLabel(p *ui.Painter, s string, b ui.Rect, neg, horizontal bool, size float32, col ui.Color) {
|
||||
switch {
|
||||
case horizontal && neg:
|
||||
chartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
|
||||
ChartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
|
||||
case horizontal:
|
||||
chartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
|
||||
ChartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
|
||||
case neg:
|
||||
chartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
|
||||
ChartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
|
||||
default:
|
||||
chartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
|
||||
ChartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package mujica
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var barCats = []string{"Nave", "Choir", "Crypt"}
|
||||
@@ -42,7 +44,7 @@ func TestBarChartHoverStackedHorizontal(t *testing.T) {
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Tithes")
|
||||
found := map[string]bool{}
|
||||
b := h.res.Element.Bounds()
|
||||
b := h.box(tt)
|
||||
for y := b.Y + 30; y < b.Y+b.H-40; y += 4 {
|
||||
for x := b.X + 60; x < b.X+b.W-16; x += 8 {
|
||||
tt.Move(x, y)
|
||||
@@ -71,7 +73,7 @@ func TestBarChartBadAndEmpty(t *testing.T) {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
core.Use(c, core.Settings{})
|
||||
BarChart(c, barCats[:2], barSeries(), BarChartOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
Loaded 100 of 2011 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user