Merge branch 'worktree-agent-a085d9a07ef787c18'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:38:04 -04:00
commit 67df84a3c9
121 files changed
+5737 -1

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AudioMessageOptions describe a voice message the caller plays.
type AudioMessageOptions struct {
// Duration is the length; required.
Duration time.Duration
// Position is how far playback has gone, 0 to Duration.
Position time.Duration
Playing bool
// Waveform holds loudness samples from 0 to 1; empty draws a flat line.
Waveform []float32
}
// AudioMessageResult is an AudioMessage's element and the user's requests this frame.
type AudioMessageResult struct {
Element *ui.Element
toggled bool
seek time.Duration
seeked bool
}
// Toggled reports a press of play or pause.
func (r AudioMessageResult) Toggled() bool { return r.toggled }
// Seek returns the position the user moved to by click, drag or keys.
func (r AudioMessageResult) Seek() (time.Duration, bool) { return r.seek, r.seeked }
// audioBarWidth and audioBarGap space the waveform's bars.
const audioBarWidth, audioBarGap float32 = 2, 2
// AudioMessage shows a voice bar: play button, waveform with progress, and times.
func AudioMessage(c *ui.Context, opts AudioMessageOptions) AudioMessageResult {
if opts.Duration <= 0 || opts.Position < 0 || opts.Position > opts.Duration {
panic("mujica: AudioMessage needs 0 <= Position <= Duration and Duration > 0")
}
for _, v := range opts.Waveform {
if !(v >= 0 && v <= 1) {
panic("mujica: AudioMessage Waveform samples must be from 0 to 1")
}
}
k := Tokens(c)
u := Density(c).Unit()
var r AudioMessageResult
r.Element = ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u*3, u, u).Width(300).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
icon, name := "play", msg(c, "audio_message.play")
if opts.Playing {
icon, name = "pause", msg(c, "audio_message.pause")
}
if iconAction(c, icons.Must(icon), name).Clicked() && !disabled {
r.toggled = true
slog.Debug("mujica: audio_message toggle", "playing", !opts.Playing)
}
pos := opts.Position.Seconds()
s := ui.SliderBase(c, &pos, 0, opts.Duration.Seconds()).Grow(1).Height(theme.MinHitSize).
Label(msg(c, "audio_message.seek")).Description(audioClock(opts.Position) + " / " + audioClock(opts.Duration))
internal.Ring(s, k.Focus, theme.ControlRadius)
if s.Changed() && !disabled {
r.seek, r.seeked = time.Duration(pos*float64(time.Second)), true
slog.Debug("mujica: audio_message seek", "at", r.seek)
}
frac := float32(opts.Position.Seconds() / opts.Duration.Seconds())
s.Draw(func(p *ui.Painter, b ui.Rect) { audioWave(p, b, opts.Waveform, frac, k) })
ui.Text(c, audioClock(opts.Position)+" / "+audioClock(opts.Duration)).FontFeatures("tnum").
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Shrink(0)
})
return r
}
// audioWave draws bars, played ones in AccentText, with a position line so progress is not color alone.
func audioWave(p *ui.Painter, b ui.Rect, wave []float32, frac float32, k theme.Tokens) {
n := int((b.W + audioBarGap) / (audioBarWidth + audioBarGap))
if n <= 0 {
return
}
cy := b.Y + b.H/2
for i := range n {
v := float32(0.12)
if len(wave) > 0 {
v = max(v, wave[i*len(wave)/n])
}
h := max(2, v*(b.H-8))
x := b.X + float32(i)*(audioBarWidth+audioBarGap)
col := k.TextMuted.Alpha(0.55)
if (x-b.X)/b.W < frac {
col = k.AccentText
}
p.Fill(ui.Rect{X: x, Y: cy - h/2, W: audioBarWidth, H: h}, col, 1)
}
at := internal.Snap(p, b.X+b.W*frac)
p.Fill(ui.Rect{X: at - 1, Y: b.Y + 2, W: 2, H: b.H - 4}, k.Text, 1)
}
// audioClock formats d as m:ss.
func audioClock(d time.Duration) string {
s := int(d.Round(time.Second).Seconds())
return fmt.Sprintf("%d:%02d", s/60, s%60)
}
+92
View File
@@ -0,0 +1,92 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var audioMessageWave = []float32{0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8, 0.6, 0.2, 0.5}
func TestAudioMessagePlaySeekAndKeys(t *testing.T) {
playing, pos := false, 10*time.Second
var seeks []time.Duration
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := AudioMessage(c, AudioMessageOptions{Duration: time.Minute, Position: pos, Playing: playing, Waveform: audioMessageWave})
if r.Toggled() {
playing = !playing
}
if d, ok := r.Seek(); ok {
pos = d
seeks = append(seeks, d)
}
}, 400, 200)
if !tt.HasText("0:10 / 1:00") {
t.Fatalf("times %q", tt.Texts())
}
_ = tt.Click("Play")
if !playing {
t.Fatal("play not reported")
}
if _, ok := tt.Find("Pause"); !ok {
t.Fatal("button did not turn to pause")
}
r, _ := tt.Find("Playback position")
tt.ClickAt(r.X+r.W*0.75, r.Y+r.H/2)
if len(seeks) == 0 || pos < 40*time.Second || pos > 50*time.Second {
t.Fatalf("click at 3/4 seeked to %v", pos)
}
tt.Key(0, ui.KeyHome)
if pos != 0 {
t.Fatalf("Home gave %v", pos)
}
tt.Key(0, ui.KeyEnd)
if pos != time.Minute {
t.Fatalf("End gave %v", pos)
}
}
func TestAudioMessageDisabled(t *testing.T) {
events := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(true).Children(func() {
r := AudioMessage(c, AudioMessageOptions{Duration: time.Minute})
if _, ok := r.Seek(); ok || r.Toggled() {
events++
}
})
}, 400, 200)
_ = tt.Click("Play")
r, _ := tt.Find("Playback position")
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
if events != 0 {
t.Fatalf("disabled audio reported %d events", events)
}
}
func TestAudioMessageBadOptions(t *testing.T) {
for _, o := range []AudioMessageOptions{{}, {Duration: time.Second, Position: 2 * time.Second}, {Duration: time.Second, Waveform: []float32{2}}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", o)
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AudioMessage(c, o)
}, 100, 100)
}()
}
}
func TestAudioMessageSnapshot(t *testing.T) {
snapshot(t, "audio_message", 340, 80, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AudioMessage(c, AudioMessageOptions{Duration: 47 * time.Second, Position: 18 * time.Second, Playing: true, Waveform: audioMessageWave})
})
})
}
+52
View File
@@ -0,0 +1,52 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BranchNavigator switches among count branches of a message, showing "< 2 / 3 >"; Left and Right step.
func BranchNavigator(c *ui.Context, index *int, count int) ChoiceResult {
if count < 1 || *index < 0 || *index >= count {
panic("mujica: BranchNavigator needs 0 <= index < count")
}
k := Tokens(c)
label := fmt.Sprintf(msg(c, "branch_navigator.label"), *index+1, count)
var r ChoiceResult
r.Element = ui.Row(c).Focusable().AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleGroup).Label(label)
internal.Ring(r.Element, k.Focus, theme.ControlRadius)
disabled := r.Element.IsDisabled()
step := func(d int) {
if i := *index + d; i >= 0 && i < count && !disabled {
*index, r.changed = i, true
c.Announce(fmt.Sprintf(msg(c, "branch_navigator.label"), i+1, count))
slog.Debug("mujica: branch_navigator change", "index", i)
}
}
switch {
case r.Element.Shortcut(0, ui.KeyLeft):
step(-1)
case r.Element.Shortcut(0, ui.KeyRight):
step(1)
case r.Element.Shortcut(0, ui.KeyHome):
step(-*index)
case r.Element.Shortcut(0, ui.KeyEnd):
step(count - 1 - *index)
}
r.Element.Children(func() {
if numberInputButton(c, "chevron-left", msg(c, "branch_navigator.previous"), *index > 0) {
step(-1)
}
ui.Text(c, fmt.Sprintf("%d / %d", *index+1, count)).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).SingleLine()
if numberInputButton(c, "chevron-right", msg(c, "branch_navigator.next"), *index < count-1) {
step(1)
}
})
return r
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBranchNavigatorButtonsAndKeys(t *testing.T) {
i, changes, off := 1, 0, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(off).Children(func() {
if BranchNavigator(c, &i, 3).Changed() {
changes++
}
})
}, 300, 100)
if !tt.HasText("2 / 3") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Next branch")
_ = tt.Click("Next branch")
if i != 2 || changes != 1 {
t.Fatalf("index %d changes %d at the end", i, changes)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyLeft)
if i != 0 || changes != 3 {
t.Fatalf("keys: index %d changes %d", i, changes)
}
tt.Key(0, ui.KeyEnd)
if i != 2 {
t.Fatalf("End gave %d", i)
}
off = true
tt.Key(0, ui.KeyLeft)
_ = tt.Click("Previous branch")
if i != 2 {
t.Fatalf("disabled navigator moved to %d", i)
}
}
func TestBranchNavigatorBounds(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an index past count")
}
}()
i := 3
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BranchNavigator(c, &i, 3)
}, 100, 100)
}
func TestBranchNavigatorSnapshot(t *testing.T) {
i := 1
snapshot(t, "branch_navigator", 200, 60, func(c *ui.Context) {
ui.Column(c).Padding(12).Children(func() { BranchNavigator(c, &i, 3) })
})
}
+48
View File
@@ -0,0 +1,48 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// ChatContainerOptions configure a ChatContainer.
type ChatContainerOptions struct {
// List configures the message area; its ID is required.
List MessageListOptions
// Input builds the input area under the messages.
Input func()
// Sidebar builds a side column, as a session list; nil shows none.
Sidebar func()
// SidebarWidth is the side column's width in DIPs; zero means 260.
SidebarWidth float32
}
// ChatContainer lays out a chat: sidebar, messages that follow the newest, and the input.
func ChatContainer(c *ui.Context, s *MessageListState, n int, opts ChatContainerOptions, row func(i int)) MessageListResult {
if opts.SidebarWidth < 0 {
panic("mujica: ChatContainer SidebarWidth must not be negative")
}
w := opts.SidebarWidth
if w == 0 {
w = 260
}
k := Tokens(c)
u := Density(c).Unit()
var r MessageListResult
shell := ui.Row(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
shell.Children(func() {
if opts.Sidebar != nil {
ui.Column(c).Width(w).Shrink(0).MinHeight(0).Background(k.Surface).
BorderWidth(0, theme.BorderWidth, 0, 0).BorderColor(k.Border).Children(opts.Sidebar)
}
ui.Column(c).Grow(1).MinWidth(0).MinHeight(0).Children(func() {
r = MessageList(c, s, n, opts.List, row)
if opts.Input != nil {
ui.Column(c).Shrink(0).Padding(u*3, u*4).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).Children(opts.Input)
}
})
})
r.Element = shell
return r
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
// chatContainerApp is a chat with a sidebar and an input.
type chatContainerApp struct {
msgs []string
state MessageListState
draft string
}
func (a *chatContainerApp) view(c *ui.Context) {
Use(c, Settings{})
ChatContainer(c, &a.state, len(a.msgs), ChatContainerOptions{
List: MessageListOptions{ID: func(i int) string { return a.msgs[i] }},
Sidebar: func() { ui.Text(c, "Sessions").Padding(12) },
Input: func() { TextInput(c, &a.draft, TextInputOptions{Label: "Prompt"}) },
}, func(i int) {
MessageBubble(c, MessageAssistant, MessageBubbleOptions{}, func() { ui.Text(c, a.msgs[i]) })
})
}
func TestChatContainerSlotsAndFollow(t *testing.T) {
a := &chatContainerApp{}
for i := range 40 {
a.msgs = append(a.msgs, "reply "+string(rune('A'+i%26))+string(rune('a'+i/26)))
}
tt := ui.NewTester(a.view, 600, 400)
tt.Frame()
if !tt.HasText("Sessions") || !tt.HasText("reply Nb") {
t.Fatalf("slots or newest message missing: %q", tt.Texts())
}
_ = tt.Click("Prompt")
tt.Type("hi")
if a.draft != "hi" {
t.Fatalf("input slot draft %q", a.draft)
}
a.msgs = append(a.msgs, "fresh")
tt.Frame()
tt.Frame()
if !tt.HasText("fresh") {
t.Fatal("new message not followed")
}
tt.Scroll(400, 150, 0, -3000)
tt.Frame()
a.msgs = append(a.msgs, "later")
tt.Frame()
if tt.HasText("later") {
t.Fatal("followed after the user scrolled up")
}
}
func TestChatContainerSnapshot(t *testing.T) {
snapshot(t, "chat_container", 560, 300, func(c *ui.Context) {
a := ui.Local(c.Root(), "app", func() *chatContainerApp {
return &chatContainerApp{msgs: []string{"Bonjour, Madame.", "How may I serve the court today?"}}
})
(*a).view(c)
})
}
+158
View File
@@ -0,0 +1,158 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CopyResult is a block's element and whether its copy action ran this frame.
type CopyResult struct {
Element *ui.Element
copied bool
}
// Copied reports that the block wrote its content to the clipboard this frame.
func (r CopyResult) Copied() bool { return r.copied }
// CodeBlockOptions configure a CodeBlock.
type CodeBlockOptions struct {
// Language picks the highlighting: go, javascript, typescript, python, json, shell, sql.
Language string
LineNumbers bool
// Wrap and Collapsed set the toggles' first state.
Wrap, Collapsed bool
}
// codeBlockState holds the user's toggles and the copy confirmation.
type codeBlockState struct {
wrap, collapsed bool
copiedAt time.Time
}
// codeBlockCopiedFor is how long the copy button shows its check.
const codeBlockCopiedFor = 2 * time.Second
// CodeBlock shows code with a language label, highlighting, copy, collapse and wrap toggles.
func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult {
k := Tokens(c)
u := Density(c).Unit()
var r CopyResult
r.Element = ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).Clip().MinWidth(0)
st := ui.Local(r.Element, "code", func() codeBlockState { return codeBlockState{wrap: opts.Wrap, collapsed: opts.Collapsed} })
disabled := r.Element.IsDisabled()
lines := strings.Split(strings.TrimSuffix(code, "\n"), "\n")
r.Element.Children(func() {
ui.Row(c).Padding(0, u, 0, u*3).Gap(u).AlignItems(ui.Center).Background(k.SurfaceHover).Children(func() {
lang := opts.Language
if lang == "" {
lang = msg(c, "code_block.plain")
}
ui.Text(c, lang).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Grow(1)
if codeBlockToggle(c, "wrap-text", msg(c, "code_block.wrap"), &st.wrap) {
slog.Debug("mujica: code_block wrap", "on", st.wrap)
}
icon, name := "copy", msg(c, "code_block.copy")
if left := codeBlockCopiedFor - c.Now().Sub(st.copiedAt); left > 0 {
icon, name = "check", msg(c, "code_block.copied")
c.After(left)
}
if iconAction(c, icons.Must(icon), name).Clicked() && !disabled {
c.WriteClipboard(code)
st.copiedAt, r.copied = c.Now(), true
c.Announce(msg(c, "code_block.copied"))
slog.Debug("mujica: code_block copy", "lines", len(lines))
}
fold, foldName := "chevron-up", msg(c, "code_block.collapse")
if st.collapsed {
fold, foldName = "chevron-down", fmt.Sprintf(msg(c, "code_block.expand"), len(lines))
}
if iconAction(c, icons.Must(fold), foldName).Clicked() && !disabled {
st.collapsed = !st.collapsed
slog.Debug("mujica: code_block collapse", "on", st.collapsed)
}
})
if st.collapsed {
return
}
var body *ui.Element
if st.wrap {
body = ui.Column(c).Padding(u*2, u*3).MinWidth(0)
} else {
body = ui.ScrollHorizontal(c).Padding(u*2, u*3).AlignItems(ui.Start)
}
body.Font(theme.MonoFont).FontSize(FontSize(c, 13)).Children(func() {
ui.Column(c).MinWidth(0).Grow(1).Children(func() { codeBlockLines(c, lines, opts, st.wrap) })
})
})
return r
}
// codeBlockLines builds numbered, highlighted lines.
func codeBlockLines(c *ui.Context, lines []string, opts CodeBlockOptions, wrap bool) {
k := Tokens(c)
u := Density(c).Unit()
digits := len(fmt.Sprint(len(lines)))
inBlock := false
for i, line := range lines {
var toks []internal.Token
toks, inBlock = internal.Highlight(opts.Language, line, inBlock)
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
if opts.LineNumbers {
ui.Text(c, fmt.Sprintf("%*d", digits, i+1)).TextColor(k.TextMuted).Shrink(0).NoWrap()
}
spans := make([]ui.Span, len(toks))
for j, t := range toks {
spans[j] = ui.Span{Text: t.Text, Color: codeBlockColor(k, t.Kind), Italic: t.Kind == internal.TokComment}
}
if len(spans) == 0 {
spans = []ui.Span{{Text: " "}}
}
t := ui.RichText(c, spans...).MinWidth(0)
if wrap {
t.Grow(1)
} else {
t.NoWrap()
}
})
}
}
// codeBlockColor colors a token from the theme; comments are also italic.
func codeBlockColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
switch kind {
case internal.TokKeyword:
return k.AccentText
case internal.TokString:
return k.Success
case internal.TokNumber:
return k.Warning
case internal.TokComment, internal.TokPunct:
return k.TextMuted
case internal.TokType:
return k.Info
}
return k.Text
}
// codeBlockToggle is a pressed-state icon button; on shows a Selection face and frame.
func codeBlockToggle(c *ui.Context, icon, label string, on *bool) bool {
k := Tokens(c)
b := ui.ToggleBase(c, on).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case *on:
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).TextColor(k.AccentText)
case b.Hovered():
b.Background(k.SurfaceHover).TextColor(k.Text)
}
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
return b.Changed()
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
const codeBlockSample = "// greet the court\nfunc greet(name string) int {\n\treturn len(\"hi \" + name) + 42\n}"
func codeBlockTester(opts CodeBlockOptions, copies *int, disabled *bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(360).Disabled(*disabled).Children(func() {
if CodeBlock(c, codeBlockSample, opts).Copied() {
*copies++
}
})
}, 400, 300)
}
func TestCodeBlockLabelNumbersAndCopy(t *testing.T) {
copies, off := 0, false
tt := codeBlockTester(CodeBlockOptions{Language: "go", LineNumbers: true}, &copies, &off)
for _, s := range []string{"go", "1", "4", "func", "greet", "42"} {
if !tt.HasText(s) {
t.Errorf("missing %q in %q", s, tt.Texts())
}
}
if err := tt.Click("Copy code"); err != nil {
t.Fatal(err)
}
if tt.Clipboard() != codeBlockSample || copies != 1 {
t.Fatalf("copied %d, clipboard matches %v", copies, tt.Clipboard() == codeBlockSample)
}
if _, ok := tt.Find("Copied"); !ok {
t.Fatal("no copied confirmation")
}
}
func TestCodeBlockCollapseAndWrapByKeyboard(t *testing.T) {
copies, off := 0, false
tt := codeBlockTester(CodeBlockOptions{Language: "go"}, &copies, &off)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !tt.Focused("Wrap lines") {
t.Fatal("Tab did not reach the wrap toggle")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if tt.HasText("greet") {
t.Fatal("collapse left the code built")
}
if err := tt.Click("Show 4 lines"); err != nil {
t.Fatal(err)
}
if !tt.HasText("greet") {
t.Fatal("expand did not show the code")
}
}
func TestCodeBlockDisabledBlocksActions(t *testing.T) {
copies, off := 0, true
tt := codeBlockTester(CodeBlockOptions{Collapsed: true}, &copies, &off)
_ = tt.Click("Copy code")
_ = tt.Click("Show 4 lines")
if copies != 0 || tt.Clipboard() != "" || tt.HasText("greet") {
t.Fatalf("disabled block acted: copies %d", copies)
}
}
func TestCodeBlockSnapshot(t *testing.T) {
snapshot(t, "code_block", 440, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CodeBlock(c, codeBlockSample, CodeBlockOptions{Language: "go", LineNumbers: true})
})
})
}
+43
View File
@@ -0,0 +1,43 @@
# 201 ChatContainer 对话容器
## 用途
整页对话布局:左侧可选侧栏插槽,右侧为消息区与底部输入区。消息区就是 MessageList:新消息到来时自动滚到底部,用户向上翻看时停止跟随,并在底部显示回到最新按钮。
## 最小示例
```go
mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
Input: func() { ui.Text(c, "Composer goes here") },
}, func(i int) { ui.Text(c, msgs[i]) })
```
签名:`func ChatContainer(c *ui.Context, s *MessageListState, n int, opts ChatContainerOptions, row func(i int)) MessageListResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `s` | `*MessageListState` | 必填 | 消息区的位置与已读状态,由调用方持有。 |
| `n / row` | `int / func(i int)` | 必填 | 消息数量与第 i 条消息的构建函数;只构建可见消息。 |
| `List` | `MessageListOptions` | `ID` 必填 | 传给消息列表的选项,见 202。 |
| `Input` | `func()` | nil | 输入区插槽,位于消息区下方,带上边线。 |
| `Sidebar` | `func()` | nil | 侧栏插槽;nil 时不显示侧栏。 |
| `SidebarWidth` | `float32` | 260 | 侧栏宽度 DIP;负数 panic。 |
## 状态
消息区跟随底部 / 用户上翻后停止跟随;离开底部时出现带未读数的回到最新按钮。
## 事件
返回的 `MessageListResult.Unread()` 给出离开底部后新到的消息数。
## 键盘操作
Tab 在侧栏、消息与输入区之间移动;消息区获得焦点后滚轮与 PageUp/PageDown(Linux、Windows)滚动。
## 限制
- 侧栏宽度固定,不提供拖动调整;需要时用 SplitPane(008)包裹。
+42
View File
@@ -0,0 +1,42 @@
# 202 MessageList 消息列表
## 用途
虚拟化的消息列表,基于 MyGo `ui.List`:只构建可见消息,以稳定消息 ID 保持位置;从底部开始并跟随新消息,用户上翻后停止跟随,并显示新消息提示;可按日期插入分隔。
## 最小示例
```go
r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
ID: func(i int) string { return ids[i] },
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
fmt.Println("unread", r.Unread())
```
签名:`func MessageList(c *ui.Context, s *MessageListState, n int, opts MessageListOptions, row func(i int)) MessageListResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `ID` | `func(i int) string` | 必填 | 消息的稳定 ID;在上方插入旧消息时视野中的消息保持不动。ID 须唯一。 |
| `Date` | `func(i int) time.Time` | nil | 设置后在每个新日期的第一条消息前显示“今天 / 昨天 / 日期”分隔。 |
| `Label` | `func(i int) string` | nil | 消息的无障碍名称,也用于键入定位。 |
| `n` | `int` | 必填 | 消息数;负数 panic。 |
## 状态
跟随底部;离开底部时底部居中出现 ScrollToBottomButton(含未读数);点击后回到底部并清零。
## 事件
`Unread()`:离开底部后新增的消息数。`MessageListState.ScrollToEnd()` 与 `AtEnd()` 供调用方控制。
## 键盘操作
列表获得焦点后,滚轮、方向键与 PageUp/PageDown 滚动;回到最新按钮可用 Tab 聚焦,Enter 或 Space 触发。
## 限制
- 不检查 ID 是否重复(逐帧检查 10 万条代价过高);重复 ID 会让两条消息共享状态。
- 消息高度由 MyGo 在显示时测量,未显示消息按已测高度估算,长距离跳转时滚动条可能微调。
+41
View File
@@ -0,0 +1,41 @@
# 203 MessageBubble 消息气泡
## 用途
按角色框住一条消息:用户靠右、Selection 底色;助手靠左、Surface 底与细边;系统居中、灰色小字;错误带 Danger 边框与警示图标。头像与名称为插槽,宽度可限制。
## 最小示例
```go
mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
ui.Text(c, "The ball begins at nine.")
})
```
签名:`func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `role` | `MessageRole` | 必填 | `MessageUser`、`MessageAssistant`、`MessageSystem`、`MessageError`。 |
| `Name` | `string` | "" | 显示在气泡上方的名称。 |
| `Avatar` | `func()` | nil | 头像插槽,用户在右侧、其他角色在左侧;系统消息不显示。 |
| `MaxWidth` | `float32` | 560 | 气泡最大宽度 DIP;负数 panic。 |
| `content` | `func()` | 必填 | 气泡内容,如 MarkdownView 或 StreamingText。 |
## 状态
四种角色外观;错误角色同时使用图标、边框与文字,不只依赖颜色。
## 事件
无;交互由内容与 MessageActions 提供。
## 键盘操作
气泡本身不获取焦点;内容中的链接、按钮按 Tab 顺序访问。辅助技术读到“角色,名称”分组。
## 限制
- 气泡不自带操作栏;把 MessageActions 放在 content 中或气泡下方。
+37
View File
@@ -0,0 +1,37 @@
# 204 MessageHeader 消息头
## 用途
在消息上方显示模型名、时间与状态(发送中、生成中、已发送、失败),状态同时用图标和文字表达。
## 最小示例
```go
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})
```
签名:`func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Model` | `string` | "" | 模型名,加粗;空则不显示。 |
| `Time` | `time.Time` | 零值 | 时间,按 `15:04` 显示;零值不显示。 |
| `Status` | `MessageStatus` | `MessageNoStatus` | `MessageSending`、`MessageStreaming`、`MessageSent`、`MessageFailed`;未知值 panic。 |
## 状态
生成中显示菱形加载标记(减少动画时静止);失败用 Danger 色并带警示图标。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 时间格式固定为 24 小时制时分,日期由 MessageList 的日期分隔显示。
+38
View File
@@ -0,0 +1,38 @@
# 205 StreamingText 流式文本
## 用途
显示随流式输出不断增长的文本。可平滑出字(按速率逐字显示),可显示流式光标;减少动画时直接显示全部文本。文本被替换为不以旧文本开头的内容时,从共同前缀继续。
## 最小示例
```go
mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})
```
签名:`func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `text` | `string` | 必填 | 当前收到的全部文本。 |
| `Streaming` | `bool` | false | 在文本末尾显示 AccentText 色的静态光标 ▍。 |
| `Smooth` | `bool` | false | 新到的文本按 Rate 逐字显示;关闭时立即显示。 |
| `Rate` | `float64` | 80 | 平滑出字速度,字符/秒;负数 panic。 |
## 状态
出字中 / 完成;减少动画时始终为完成。光标不闪烁,避免装饰性循环。
## 事件
无。
## 键盘操作
不获取焦点。辅助技术读到的名称始终是完整文本。
## 限制
- 只渲染纯文本;Markdown 内容请交给 MarkdownView(206),它的增量解析同样适合流式输出。
+39
View File
@@ -0,0 +1,39 @@
# 206 MarkdownView Markdown 视图
## 用途
渲染 Markdown 子集:标题、段落、粗体、斜体、删除线、行内代码、有序与无序列表(含缩进层级)、引用、链接、围栏代码块(交给 CodeBlock)、表格(交给 TableBlock)与分隔线(菱形饰线)。解析器在 `internal/markdown.go`,按块增量解析:新文本只是在旧文本后追加时,只重新解析最后一块,已完成的块保持不变。
## 最小示例
```go
if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
fmt.Println("open", url)
}
```
签名:`func MarkdownView(c *ui.Context, src string) MarkdownResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `src` | `string` | 必填 | Markdown 源文本;流式输出时每帧传入增长后的全文即可。 |
## 状态
围栏未闭合的代码块按已收到的内容显示;未闭合的强调标记按字面显示,闭合后再加样式。
## 事件
`Link()` 返回本帧被点击或用 Enter 激活的链接地址;打开链接由调用方决定。
## 键盘操作
链接可用 Tab 聚焦,Enter 激活;代码块与表格的按钮按 Tab 顺序访问。
## 限制
- 只支持上述子集:不支持 HTML、图片、脚注、任务列表、Setext 标题、链接引用定义,列表项内不支持多段落。
- 表格单元格按纯文本显示,不解析行内样式。
- 源文本被整体替换(不是在末尾追加)时重新解析全部块。
+39
View File
@@ -0,0 +1,39 @@
# 207 CodeBlock 代码块
## 用途
显示代码:语言标签、可选行号、基础语法着色(`internal.Highlight`,支持 go、javascript、typescript、python、json、shell、sql),复制、折叠与自动换行切换。
## 最小示例
```go
mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})
```
签名:`func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Language` | `string` | ""(显示“文本”) | 着色语言;未知语言按纯文本显示。 |
| `LineNumbers` | `bool` | false | 显示右对齐的行号。 |
| `Wrap` | `bool` | false | 自动换行的初始状态;关闭时横向滚动。 |
| `Collapsed` | `bool` | false | 折叠的初始状态。 |
## 状态
换行开关以 Selection 底色加边框表示开启;复制后按钮在 2 秒内显示对勾“已复制”;折叠时只显示标题栏,展开按钮写明行数。关键字、字符串、数字、类型使用不同颜色,注释同时使用斜体。
## 事件
`Copied()`:本帧把代码写入了剪贴板。
## 键盘操作
Tab 依次到换行开关、复制、折叠按钮;Space 或 Enter 触发。
## 限制
- 着色是逐行的词法着色,不做语法分析;块注释可跨行。
- 不做虚拟化,适合消息中的代码片段,不适合上万行文件。
+39
View File
@@ -0,0 +1,39 @@
# 208 TableBlock 表格块
## 用途
在消息中显示小型表格:表头加粗,列按内容宽度排列并铺满消息宽度,过宽时横向滚动;可复制为 TSV。
## 最小示例
```go
if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
fmt.Println("copied as TSV")
}
```
签名:`func TableBlock(c *ui.Context, header []string, rows [][]string, opts TableBlockOptions) CopyResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `header` | `[]string` | 必填 | 表头;为空 panic。 |
| `rows` | `[][]string` | nil | 数据行;每行列数必须与表头相同,否则 panic。 |
| `Align` | `[]ui.Align` | 全部 `ui.Start` | 各列对齐:`ui.Start`、`ui.Center`、`ui.End`;多于列数 panic。 |
## 状态
复制后按钮 2 秒内显示对勾。
## 事件
`Copied()`:本帧写入了 TSV(制表符分列、换行分行;单元格内的制表符与换行替换为空格)。
## 键盘操作
Tab 到复制按钮,Enter 或 Space 复制。
## 限制
- 不排序、不选择;需要这些功能请使用 DataTable(069 起)。
+40
View File
@@ -0,0 +1,40 @@
# 209 ImageGrid 图片网格
## 用途
在消息中排列 1 至 9 张图片:1 张大图,2 与 4 张两列,其余三列。点击已加载的图片打开 ImageViewer;加载中与失败状态复用 Image 组件,失败时可重试。
## 最小示例
```go
if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
fmt.Println("reload", i)
}
```
签名:`func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `images` | `[]GridImage` | 必填 | 1 至 9 张,否则 panic。 |
| `Source` | `ui.ImageSource` | nil | 已加载的位图或 SVG。 |
| `Alt` | `string` | "" | 替代说明,也是按钮名称与查看器标题。 |
| `Loading / Err` | `bool / error` | false / nil | 调用方的加载中与失败状态。 |
## 状态
已加载、加载中(菱形加载标记)、失败(重试按钮);只有已加载的图片可点击。
## 事件
`Retried()` 返回本帧点击了重试的图片下标。查看器在组件内部打开与关闭。
## 键盘操作
Tab 聚焦图片,Enter 或 Space 打开查看器;查看器内的键盘操作见 ImageViewer。
## 限制
- 查看器只包含已加载的图片,序号按已加载图片计算。
+40
View File
@@ -0,0 +1,40 @@
# 210 FileMessage 文件消息
## 用途
显示消息中的文件:按类型的图标、文件名、大小,以及打开与下载回调。
## 最小示例
```go
r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
if r.Downloaded() {
fmt.Println("save treaty.pdf")
}
```
签名:`func FileMessage(c *ui.Context, opts FileMessageOptions) FileMessageResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Name` | `string` | 必填 | 文件名;为空 panic。 |
| `Size` | `int64` | 0 | 字节数,按 B、KB、MB、GB 显示(1024 进制);负数 panic。 |
| `Type` | `string` | "" | MIME 类型;为空时按扩展名选择图标。 |
## 状态
悬停时文件区域加深;禁用时两个操作都不响应。
## 事件
`Opened()`:点击或在文件区按 Enter。`Downloaded()`:点击下载按钮。
## 键盘操作
Tab 依次到文件区与下载按钮,Enter 或 Space 触发。
## 限制
- 组件不读取文件、不下载:打开与下载由调用方完成。
+41
View File
@@ -0,0 +1,41 @@
# 211 AudioMessage 语音消息
## 用途
语音条:播放/暂停按钮、波形、播放进度与时长。播放由调用方驱动:组件只报告播放切换与跳转请求,调用方更新 Position 与 Playing。波形基于 MyGo `ui.SliderBase`,可拖动与键盘跳转。
## 最小示例
```go
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
if d, ok := r.Seek(); ok {
pos = d
}
```
签名:`func AudioMessage(c *ui.Context, opts AudioMessageOptions) AudioMessageResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Duration` | `time.Duration` | 必填 | 时长,必须大于 0。 |
| `Position` | `time.Duration` | 0 | 当前进度,0 到 Duration 之间,否则 panic。 |
| `Playing` | `bool` | false | 按钮显示暂停(播放中)或播放。 |
| `Waveform` | `[]float32` | nil | 0 到 1 的响度采样,按宽度重采样为竖条;为空画平线;越界 panic。 |
## 状态
已播放的竖条为 AccentText,未播放为半透明 TextMuted,当前位置另有一条竖线,进度不只靠颜色表达;右侧显示 `m:ss / m:ss`。
## 事件
`Toggled()`:按下播放或暂停。`Seek()`:点击、拖动或按键请求的新位置。
## 键盘操作
Tab 到播放按钮(Enter 或 Space)与波形;波形上 Left/Right(Up/Down)按时长的 1% 移动,Home、End 到开头与结尾。
## 限制
- 不解码音频、不生成波形:采样由调用方提供。
+42
View File
@@ -0,0 +1,42 @@
# 212 LinkPreviewCard 链接预览卡片
## 用途
显示链接的站点、标题、描述与缩略图;元数据由调用方获取。整张卡片是一个按钮,悬停显示完整地址。
## 最小示例
```go
p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
if mujica.LinkPreviewCard(c, p).Clicked() {
fmt.Println("open", p.URL)
}
```
签名:`func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `URL` | `string` | 必填 | 链接地址,作为悬停提示。 |
| `Title` | `string` | 必填 | 标题,最多两行,AccentText 色。 |
| `Description` | `string` | "" | 描述,最多两行。 |
| `Site` | `string` | URL | 站点名;为空时显示 URL。 |
| `Thumbnail` | `ui.ImageSource` | nil | 缩略图,64×64 裁切显示。 |
## 状态
悬停时底色加深;聚焦时显示焦点环。
## 事件
`Clicked()`:点击或 Enter、Space;打开链接由调用方完成。
## 键盘操作
Tab 聚焦,Enter 或 Space 触发。
## 限制
- 组件不联网:标题、描述与缩略图都由调用方提供。
+38
View File
@@ -0,0 +1,38 @@
# 213 QuoteReply 引用回复
## 用途
显示被引用消息的作者与单行摘要(多余空白折叠,过长省略);点击跳转到原消息。
## 最小示例
```go
if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
fmt.Println("jump to the quoted message")
}
```
签名:`func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `author` | `string` | 必填 | 原消息作者;为空 panic。 |
| `excerpt` | `string` | "" | 原消息摘要;换行与连续空白合并为一个空格。 |
## 状态
左侧 3 DIP AccentText 竖线标明引用;悬停时底色变为 Selection。
## 事件
`Clicked()`:点击或 Enter、Space;跳转由调用方完成(如 MessageList 的 ScrollTo)。
## 键盘操作
Tab 聚焦,Enter 或 Space 触发。辅助技术读到“回复 作者:摘要”。
## 限制
- 只显示一行摘要,不渲染原消息的格式。
+38
View File
@@ -0,0 +1,38 @@
# 214 MessageActions 消息操作
## 用途
消息下方的操作工具栏:复制、重新生成、编辑、赞、踩、朗读、分享,按角色显示:用户消息为复制、编辑;助手消息为复制、重新生成、赞、踩、朗读、分享;系统消息为复制;错误消息为复制、重新生成。
## 最小示例
```go
if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
fmt.Println("action", a)
}
```
签名:`func MessageActions(c *ui.Context, role MessageRole, opts MessageActionsOptions) MessageActionsResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `role` | `MessageRole` | 必填 | 决定显示哪些操作。 |
| `Feedback` | `*MessageFeedback` | nil | 赞踩状态;nil 时不显示赞与踩。再次点击已选的赞或踩会取消。 |
## 状态
赞或踩选中时为按下状态:Selection 底色、AccentText 边框与图标;辅助技术读到切换按钮的按下状态。
## 事件
`Action()` 返回本帧的操作:`ActionCopy`、`ActionRegenerate`、`ActionEdit`、`ActionLike`、`ActionDislike`、`ActionSpeak`、`ActionShare`。复制、朗读与分享由调用方执行。
## 键盘操作
整个工具栏是一个 Tab 停靠点,Left/Right 在操作间移动,Home、End 到首末;Enter 或 Space 触发。
## 限制
- 不内置复制内容与朗读:调用方收到操作后自行处理。
+38
View File
@@ -0,0 +1,38 @@
# 215 MessageEditor 消息编辑器
## 用途
编辑已发送的消息:多行文本框加取消与保存。保存时调用方据草稿产生新分支(配合 BranchNavigator)。
## 最小示例
```go
if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
fmt.Println("new branch from", draft)
}
```
签名:`func MessageEditor(c *ui.Context, draft *string, opts MessageEditorOptions) MessageEditorResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `draft` | `*string` | 必填 | 正在编辑的文本。 |
| `Original` | `string` | "" | 原文;草稿与原文相同或只有空白时不能保存。 |
## 状态
保存按钮在内容无效时禁用;文本框保留输入法、撤销与剪贴板行为。
## 事件
`Saved()`:点击保存或 Cmd/Ctrl+Enter。`Canceled()`:点击取消或在文本框中按 Escape。
## 键盘操作
Cmd/Ctrl+Enter 保存,Escape 取消;Tab 依次到文本框、取消、保存。
## 限制
- 不保存历史:分支与原文由调用方保存。
+38
View File
@@ -0,0 +1,38 @@
# 216 BranchNavigator 分支切换
## 用途
在一条消息的多个分支间切换,显示“< 2 / 3 >”。
## 最小示例
```go
if mujica.BranchNavigator(c, &branch, 3).Changed() {
fmt.Println("show branch", branch)
}
```
签名:`func BranchNavigator(c *ui.Context, index *int, count int) ChoiceResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `index` | `*int` | 必填 | 当前分支,0 起;不在 0 到 count−1 之间时 panic。 |
| `count` | `int` | 必填 | 分支数,至少 1。 |
## 状态
首末分支时对应箭头禁用;切换时向辅助技术播报“第 n 个分支,共 m 个”。
## 事件
`Changed()`:分支改变。
## 键盘操作
整个控件是一个 Tab 停靠点:Left/Right 切换,Home、End 到第一个与最后一个;箭头按钮只响应指针。
## 限制
- 箭头按钮不进入 Tab 序列,避免到达首末分支时焦点停在禁用按钮上。
+40
View File
@@ -0,0 +1,40 @@
# 217 ErrorMessage 错误消息
## 用途
显示失败的回复:标题、原始错误文本(等宽、可选择复制,不改写、不截断)与重试按钮;限流时显示倒计时,倒计时结束前不能重试。
## 最小示例
```go
opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
fmt.Println("send again")
}
```
签名:`func ErrorMessage(c *ui.Context, text string, opts ErrorMessageOptions) ErrorMessageResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `text` | `string` | 必填 | 原始错误文本,原样显示;为空 panic。 |
| `Title` | `string` | “出错了” | 概括失败原因。 |
| `RetryAt` | `time.Time` | 零值 | 限流解除时间;之前重试按钮禁用并显示“n 秒后重试”与“已限流”。 |
## 状态
可重试 / 限流倒计时;Danger 边框、图标与标题文字共同表示错误。倒计时只在秒数变化时重绘。
## 事件
`Retried()`:按下重试。
## 键盘操作
Tab 到错误文本(可用 Shift+方向键选择、Cmd/Ctrl+C 复制)与重试按钮。
## 限制
- 倒计时依据窗口时钟;调用方的 RetryAt 应来自服务端的 Retry-After。
+40
View File
@@ -0,0 +1,40 @@
# 218 ThinkingBlock 思考过程
## 用途
可折叠的思考过程:思考中显示加载标记与实时秒数,结束后显示“思考了 n 秒”。默认折叠或展开由调用方传入的 `*open` 初值决定。基于 Collapsible。
## 最小示例
```go
mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
ui.Text(c, "Weigh the guest list.")
})
```
签名:`func ThinkingBlock(c *ui.Context, open *bool, opts ThinkingBlockOptions, content func()) ChoiceResult`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `open` | `*bool` | 必填 | 是否展开;其初值即默认状态。 |
| `Thinking` | `bool` | false | 思考中:显示加载标记与从 Started 起的秒数。 |
| `Started` | `time.Time` | 零值 | 思考开始时间;Thinking 时必填,否则 panic。 |
| `Elapsed` | `time.Duration` | 0 | 思考结束后的总耗时;负数 panic。 |
## 状态
思考中 / 已完成;展开时内容带左侧细线、TextMuted 色。减少动画时加载标记静止,秒数仍每秒更新。
## 事件
`Changed()`:展开状态改变。
## 键盘操作
Tab 聚焦标题,Enter 或 Space 展开与折叠。
## 限制
- 思考中每秒重绘一次以更新秒数;折叠且完成后不再重绘。
@@ -0,0 +1,40 @@
# 219 ScrollToBottomButton 回到底部按钮
## 用途
离开底部时出现在容器底部居中的圆角按钮,带未读计数;点击回到底部。MessageList 已内置,也可单独用于其他滚动区域。
## 最小示例
```go
ui.Box(c).Fill().Children(func() {
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
list.ScrollToEnd()
}
})
```
签名:`func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `away` | `bool` | 必填 | 是否已离开底部;false 时返回不可见的空元素,其 Clicked 为 false。 |
| `unread` | `int` | 0 | 未读数,大于 0 时显示;负数 panic。 |
## 状态
出现时 160 毫秒淡入(减少动画时直接显示);悬停时底色加深。
## 事件
`Clicked()`:点击或 Enter、Space;滚动由调用方完成。
## 键盘操作
Tab 聚焦,Enter 或 Space 触发。辅助技术读到“回到最新,n 条新消息”。
## 限制
- 按钮以绝对定位贴在父元素底部,父元素需要占满滚动区域。
+35
View File
@@ -0,0 +1,35 @@
# 220 TypingIndicator 正在输入
## 用途
对方正在输入的提示:三个依次明暗变化的圆点;减少动画时改为静态文字“某某 正在输入…”。
## 最小示例
```go
mujica.TypingIndicator(c, "Herald")
```
签名:`func TypingIndicator(c *ui.Context, name string) *ui.Element`
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `name` | `string` | 必填 | 正在输入的一方;为空 panic。 |
## 状态
动画 / 静态文字;状态角色,辅助技术读到“某某 正在输入…”。
## 事件
无。
## 键盘操作
不获取焦点。
## 限制
- 动画只在显示时请求下一帧,移出界面后停止重绘。
+72
View File
@@ -0,0 +1,72 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// ErrorMessageOptions configure an ErrorMessage.
type ErrorMessageOptions struct {
// Title sums the failure up; empty uses the built-in "Something went wrong".
Title string
// RetryAt is when a rate limit lifts; until then retry waits with a countdown.
RetryAt time.Time
}
// ErrorMessageResult is the error's element and a retry the user asked for.
type ErrorMessageResult struct {
Element *ui.Element
retried bool
}
// Retried reports a press of retry.
func (r ErrorMessageResult) Retried() bool { return r.retried }
// ErrorMessage shows a failed reply with the original error text, selectable, and retry.
func ErrorMessage(c *ui.Context, text string, opts ErrorMessageOptions) ErrorMessageResult {
if text == "" {
panic("mujica: ErrorMessage needs the error text")
}
k := Tokens(c)
u := Density(c).Unit()
title := opts.Title
if title == "" {
title = msg(c, "error_message.title")
}
wait := opts.RetryAt.Sub(c.Now())
var r ErrorMessageResult
r.Element = ui.Column(c).Gap(u*2).Padding(u*3, u*4).MaxWidth(560).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Danger).Role(ui.RoleGroup).Label(title)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0).TextColor(k.Danger)
ui.Text(c, title).FontWeight(600).TextColor(k.Danger)
})
ui.Text(c, text).Selectable().Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text)
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
label := msg(c, "error_message.retry")
if wait > 0 {
secs := int(math.Ceil(wait.Seconds()))
label = fmt.Sprintf(msg(c, "error_message.wait"), secs)
// Wake when the shown second changes.
c.After(wait - time.Duration(secs-1)*time.Second)
}
if Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw"), Disabled: wait > 0}).Clicked() && !disabled {
r.retried = true
slog.Debug("mujica: error_message retry")
}
if wait > 0 {
ui.Text(c, msg(c, "error_message.limited")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
})
return r
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
const errorMessageText = `429 Too Many Requests: {"error":"rate_limit","retry_after":30}`
func TestErrorMessageKeepsTextAndRetries(t *testing.T) {
retries := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ErrorMessage(c, errorMessageText, ErrorMessageOptions{}).Retried() {
retries++
}
}, 500, 200)
if !tt.HasText(errorMessageText) || !tt.HasText("Something went wrong") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Retry")
if retries != 1 {
t.Fatalf("retries %d", retries)
}
}
func TestErrorMessageRateLimitCountdown(t *testing.T) {
retries := 0
until := time.Now().Add(2500 * time.Millisecond)
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ErrorMessage(c, errorMessageText, ErrorMessageOptions{Title: "Rate limited by the API", RetryAt: until}).Retried() {
retries++
}
}, 500, 200)
if !tt.HasText("Retry in 3 s") {
t.Fatalf("countdown missing: %q", tt.Texts())
}
_ = tt.Click("Retry in 3 s")
if retries != 0 {
t.Fatal("retried during the rate limit")
}
until = time.Now().Add(-time.Second)
tt.Frame()
_ = tt.Click("Retry")
if retries != 1 {
t.Fatalf("retries %d after the limit lifted", retries)
}
}
func TestErrorMessageNeedsText(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without error text")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ErrorMessage(c, "", ErrorMessageOptions{})
}, 100, 100)
}
func TestErrorMessageSnapshot(t *testing.T) {
snapshot(t, "error_message", 520, 150, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { ErrorMessage(c, errorMessageText, ErrorMessageOptions{}) })
})
}
+222
View File
@@ -0,0 +1,222 @@
package mujica_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleChatContainer() {
var chat mujica.MessageListState
msgs := []string{"Hello", "Bonjour"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
Input: func() { ui.Text(c, "Composer goes here") },
}, func(i int) { ui.Text(c, msgs[i]) })
}
ui.Render(view, 400, 300, 1)
}
func ExampleMessageList() {
var list mujica.MessageListState
ids := []string{"m1", "m2", "m3"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
ID: func(i int) string { return ids[i] },
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
fmt.Println("unread", r.Unread())
}
ui.Render(view, 400, 300, 1)
}
func ExampleMessageBubble() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
ui.Text(c, "The ball begins at nine.")
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMessageHeader() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStreamingText() {
reply := "The decree reads"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMarkdownView() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
fmt.Println("open", url)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCodeBlock() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTableBlock() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
fmt.Println("copied as TSV")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleImageGrid() {
pic := ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="4" height="3"/>`))
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
fmt.Println("reload", i)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileMessage() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
if r.Downloaded() {
fmt.Println("save treaty.pdf")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAudioMessage() {
pos := 5 * time.Second
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
if d, ok := r.Seek(); ok {
pos = d
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleLinkPreviewCard() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
if mujica.LinkPreviewCard(c, p).Clicked() {
fmt.Println("open", p.URL)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleQuoteReply() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
fmt.Println("jump to the quoted message")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMessageActions() {
rating := mujica.FeedbackNone
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
fmt.Println("action", a)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMessageEditor() {
draft := "Hello"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
fmt.Println("new branch from", draft)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleBranchNavigator() {
branch := 1
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.BranchNavigator(c, &branch, 3).Changed() {
fmt.Println("show branch", branch)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleErrorMessage() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
fmt.Println("send again")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleThinkingBlock() {
open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
ui.Text(c, "Weigh the guest list.")
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleScrollToBottomButton() {
var list mujica.MessageListState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Box(c).Fill().Children(func() {
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
list.ScrollToEnd()
}
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTypingIndicator() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TypingIndicator(c, "Herald")
}
ui.Render(view, 400, 300, 1)
}
+259 -1
View File
@@ -1,3 +1,261 @@
package main
var chatmsgPages = []page{}
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
const chatmsgCategory = "Chat Messages"
var chatmsgPages = append(append(chatmsgListPages, chatmsgBlockPages...), chatmsgActionPages...)
// chatmsgReplies are the lines the demo assistant sends.
var chatmsgReplies = []string{
"The ball begins at nine, in the Hall of Mirrors.",
"Guests arrive by the **east door**; carriages wait in the *lower court*.",
"Seating follows rank: dukes nearest the throne, then the envoys.",
"Shall I send the invitations tonight?",
}
// chatmsgDemo is a chat whose messages the demo adds.
type chatmsgDemo struct {
ids []string
texts []string
list mujica.MessageListState
next int
sidebar bool
dated bool
}
func newChatmsgDemo(n int) *chatmsgDemo {
d := &chatmsgDemo{sidebar: true, dated: true}
for range n {
d.add()
}
return d
}
func (d *chatmsgDemo) add() {
d.ids = append(d.ids, fmt.Sprintf("msg-%d", d.next))
d.texts = append(d.texts, chatmsgReplies[d.next%len(chatmsgReplies)])
d.next++
}
// day puts three messages on each day, ending today.
func (d *chatmsgDemo) day(i int) time.Time {
last := (len(d.ids) - 1) / 3
return time.Now().AddDate(0, 0, i/3-last)
}
// row shows message i as a bubble, users and the assistant taking turns.
func (d *chatmsgDemo) row(c *ui.Context, i int) {
role := mujica.MessageAssistant
if i%2 == 0 {
role = mujica.MessageUser
}
mujica.MessageBubble(c, role, mujica.MessageBubbleOptions{}, func() { mujica.MarkdownView(c, d.texts[i]) })
}
// chatmsgControls adds messages to a demo chat and logs it.
func chatmsgControls(c *ui.Context, log func(string), d *chatmsgDemo) {
ui.Row(c).Gap(8).Children(func() {
if mujica.Button(c, "Add message", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
d.add()
log(fmt.Sprintf("message %s added", d.ids[len(d.ids)-1]))
}
if mujica.Button(c, "Add 10", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for range 10 {
d.add()
}
log(fmt.Sprintf("10 messages added, %d in all", len(d.ids)))
}
if mujica.Button(c, "Jump to latest", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
d.list.ScrollToEnd()
log("scrolled to end")
}
})
}
var chatmsgListPages = []page{
{ID: "201", Name: "ChatContainer", ZhName: "对话容器", Category: chatmsgCategory,
Keys: "Tab 在侧栏、消息与输入区之间移动;消息区滚轮与 PageUp/PageDown 滚动;上翻后出现回到最新按钮。",
Code: `mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
Input: func() { ui.Text(c, "Composer goes here") },
}, func(i int) { ui.Text(c, msgs[i]) })`,
Demo: func(c *ui.Context, log func(string)) {
d := *ui.Local(c.Root(), "201", func() *chatmsgDemo { return newChatmsgDemo(6) })
draft := ui.Local(c.Root(), "201.draft", func() string { return "" })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Sidebar": &d.sidebar}, "Sidebar")
chatmsgControls(c, log, d)
})
inputSection(c, "Live", func() {
ui.Box(c).Height(420).Border(1, mujica.Tokens(c).Border).Clip().Children(func() {
opts := mujica.ChatContainerOptions{
List: mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }},
Input: func() {
r := mujica.TextInput(c, draft, mujica.TextInputOptions{Label: "Message", Placeholder: "Write to the herald"})
if r.Submitted() && strings.TrimSpace(*draft) != "" {
d.add()
d.texts[len(d.texts)-1] = *draft
log("sent a message")
*draft = ""
}
},
}
if d.sidebar {
opts.Sidebar = func() {
ui.Column(c).Padding(12).Gap(8).Children(func() {
for _, s := range []string{"The ball", "Seating", "Envoys"} {
ui.Text(c, s)
}
})
}
}
r := mujica.ChatContainer(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) })
if r.Unread() > 0 {
ui.Text(c, fmt.Sprintf("%d unread", r.Unread())).Absolute().Top(8).Right(12)
}
})
})
}},
{ID: "202", Name: "MessageList", ZhName: "消息列表", Category: chatmsgCategory,
Keys: "滚轮与方向键滚动;上翻后停止跟随;Tab 到回到最新按钮,Enter 回到底部。",
Code: `r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
ID: func(i int) string { return ids[i] },
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
fmt.Println("unread", r.Unread())`,
Demo: func(c *ui.Context, log func(string)) {
d := *ui.Local(c.Root(), "202", func() *chatmsgDemo { return newChatmsgDemo(30) })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Date separators": &d.dated}, "Date separators")
chatmsgControls(c, log, d)
ui.Row(c).Children(func() {
if mujica.Button(c, "Load 100,000", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for len(d.ids) < 100000 {
d.add()
}
log("100,000 messages loaded")
}
})
})
inputSection(c, "Live", func() {
opts := mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }, Label: func(i int) string { return d.texts[i] }}
if d.dated {
opts.Date = d.day
}
ui.Column(c).Height(360).Border(1, mujica.Tokens(c).Border).Children(func() {
r := mujica.MessageList(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) })
ui.Text(c, fmt.Sprintf("%d messages, %d unread, at end %v", len(d.ids), r.Unread(), d.list.AtEnd())).Padding(8)
})
})
}},
{ID: "203", Name: "MessageBubble", ZhName: "消息气泡", Category: chatmsgCategory,
Keys: "气泡不获取焦点;内容中的链接与按钮按 Tab 顺序访问。",
Code: `mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
ui.Text(c, "The ball begins at nine.")
})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
role mujica.MessageRole
avatar, named bool
width float64
}
s := ui.Local(c.Root(), "203", func() state { return state{role: mujica.MessageAssistant, avatar: true, named: true, width: 360} })
roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"},
{Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}}
inputSection(c, "Parameters", func() {
if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() {
log(fmt.Sprintf("role %d", s.role))
}
inputFlags(c, log, map[string]*bool{"Avatar": &s.avatar, "Name": &s.named}, "Avatar", "Name")
if mujica.NumberInput(c, &s.width, mujica.NumberInputOptions{Min: 160, Max: 640, Step: 40, Label: "MaxWidth"}).Changed() {
log(fmt.Sprintf("MaxWidth %v", s.width))
}
})
inputSection(c, "Live", func() {
opts := mujica.MessageBubbleOptions{MaxWidth: float32(s.width)}
if s.named {
opts.Name = "Herald"
}
if s.avatar {
opts.Avatar = func() { mujica.Avatar(c, "Herald", mujica.AvatarOptions{}) }
}
mujica.MessageBubble(c, s.role, opts, func() { ui.Text(c, chatmsgReplies[0]+" "+chatmsgReplies[2]) })
})
inputSection(c, "States", func() {
for _, r := range roles {
mujica.MessageBubble(c, r.Value, mujica.MessageBubbleOptions{Name: r.Label}, func() { ui.Text(c, r.Label+" message") })
}
})
}},
{ID: "204", Name: "MessageHeader", ZhName: "消息头", Category: chatmsgCategory, Keys: "不获取焦点。",
Code: `mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})`,
Demo: func(c *ui.Context, log func(string)) {
status := ui.Local(c.Root(), "204", func() mujica.MessageStatus { return mujica.MessageSent })
items := []mujica.RadioItem[mujica.MessageStatus]{{Value: mujica.MessageNoStatus, Label: "None"}, {Value: mujica.MessageSending, Label: "Sending"},
{Value: mujica.MessageStreaming, Label: "Streaming"}, {Value: mujica.MessageSent, Label: "Sent"}, {Value: mujica.MessageFailed, Label: "Failed"}}
inputSection(c, "Parameters", func() {
if mujica.RadioGroup(c, status, items, mujica.RadioGroupOptions{Horizontal: true, Label: "Status"}).Changed() {
log(fmt.Sprintf("status %d", *status))
}
})
inputSection(c, "Live", func() {
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: *status})
})
inputSection(c, "States", func() {
for _, it := range items[1:] {
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: it.Value})
}
})
}},
{ID: "205", Name: "StreamingText", ZhName: "流式文本", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时直接显示全部文本。",
Code: `mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
text string
smooth, streaming, auto bool
rate float64
at time.Time
}
s := ui.Local(c.Root(), "205", func() state { return state{smooth: true, streaming: true, rate: 80} })
const full = "By order of the Queen, the ball begins at nine. Guests arrive by the east door, and carriages wait in the lower court until midnight."
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Smooth": &s.smooth, "Streaming cursor": &s.streaming, "Stream": &s.auto}, "Smooth", "Streaming cursor", "Stream")
if mujica.NumberInput(c, &s.rate, mujica.NumberInputOptions{Min: 10, Max: 400, Step: 10, Label: "Rate"}).Changed() {
log(fmt.Sprintf("Rate %v", s.rate))
}
ui.Row(c).Children(func() {
if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.text = ""
log("stream restarted")
}
})
})
// The demo's own source appends a word every 150 ms while streaming.
if s.auto && len(s.text) < len(full) && c.Now().Sub(s.at) > 150*time.Millisecond {
rest := full[len(s.text):]
n := strings.IndexByte(rest[1:], ' ') + 2
if n <= 1 {
n = len(rest)
}
s.text += rest[:n]
s.at = c.Now()
if len(s.text) == len(full) {
log("stream finished")
}
}
if s.auto && len(s.text) < len(full) {
c.After(150 * time.Millisecond)
}
inputSection(c, "Live", func() {
mujica.StreamingText(c, s.text, mujica.StreamingTextOptions{Streaming: s.streaming && len(s.text) < len(full), Smooth: s.smooth, Rate: s.rate})
})
}},
}
+292
View File
@@ -0,0 +1,292 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var chatmsgActionPages = []page{
{ID: "211", Name: "AudioMessage", ZhName: "语音消息", Category: chatmsgCategory, Keys: "Tab 到播放按钮与波形;波形上 Left/Right 跳转,Home、End 到首尾。",
Code: `r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
if d, ok := r.Seek(); ok {
pos = d
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
pos time.Duration
playing bool
at time.Time
}
s := ui.Local(c.Root(), "211", func() state { return state{pos: 12 * time.Second} })
const length = 47 * time.Second
// The demo plays: it moves the position while playing.
if s.playing {
s.pos += c.Now().Sub(s.at)
if s.pos >= length {
s.pos, s.playing = length, false
log("playback ended")
}
c.After(100 * time.Millisecond)
}
s.at = c.Now()
inputSection(c, "Live", func() {
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Position: s.pos, Playing: s.playing, Waveform: chatmsgWave})
if r.Toggled() {
s.playing = !s.playing
if s.pos >= length {
s.pos = 0
}
log(fmt.Sprintf("playing %v", s.playing))
}
if d, ok := r.Seek(); ok {
s.pos = d
log(fmt.Sprintf("seek to %v", d.Round(time.Second)))
}
})
inputSection(c, "States", func() {
ui.Column(c).Disabled(true).Children(func() {
mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Waveform: chatmsgWave})
})
})
}},
{ID: "212", Name: "LinkPreviewCard", ZhName: "链接预览卡片", Category: chatmsgCategory, Keys: "Tab 聚焦卡片,Enter 或 Space 打开。",
Code: `p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
if mujica.LinkPreviewCard(c, p).Clicked() {
fmt.Println("open", p.URL)
}`,
Demo: func(c *ui.Context, log func(string)) {
thumb := ui.Local(c.Root(), "212", func() bool { return true })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Thumbnail": thumb}, "Thumbnail")
})
p := mujica.LinkPreview{URL: "https://example.com/versailles", Title: "The Palace of Versailles", Site: "example.com",
Description: "A royal château in the Île-de-France region, seat of the French court from 1682 until the Revolution."}
if *thumb {
p.Thumbnail = chatmsgPicture("#AD9164")
}
inputSection(c, "Live", func() {
if mujica.LinkPreviewCard(c, p).Clicked() {
log("open " + p.URL)
}
if mujica.LinkPreviewCard(c, mujica.LinkPreview{URL: "https://example.com/hall", Title: "Hall of Mirrors"}).Clicked() {
log("open https://example.com/hall")
}
})
}},
{ID: "213", Name: "QuoteReply", ZhName: "引用回复", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 跳转。",
Code: `if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
fmt.Println("jump to the quoted message")
}`,
Demo: func(c *ui.Context, log func(string)) {
inputSection(c, "Live", func() {
for _, q := range [][2]string{{"Marie", "Let them eat cake."}, {"Herald", chatmsgMarkdown}} {
if mujica.QuoteReply(c, q[0], q[1]).Clicked() {
log("jump to " + q[0] + ": " + chatmsgSnippet(q[1], 24))
}
}
})
}},
{ID: "214", Name: "MessageActions", ZhName: "消息操作", Category: chatmsgCategory, Keys: "Tab 进入工具栏,Left/Right 移动,Enter 或 Space 触发。",
Code: `if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
fmt.Println("action", a)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
role mujica.MessageRole
rating mujica.MessageFeedback
rated bool
}
s := ui.Local(c.Root(), "214", func() state { return state{role: mujica.MessageAssistant, rated: true} })
names := map[mujica.MessageAction]string{mujica.ActionCopy: "copy", mujica.ActionRegenerate: "regenerate", mujica.ActionEdit: "edit",
mujica.ActionLike: "like", mujica.ActionDislike: "dislike", mujica.ActionSpeak: "speak", mujica.ActionShare: "share"}
inputSection(c, "Parameters", func() {
roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"},
{Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}}
if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() {
log(fmt.Sprintf("role %d", s.role))
}
inputFlags(c, log, map[string]*bool{"Rating": &s.rated}, "Rating")
})
inputSection(c, "Live", func() {
opts := mujica.MessageActionsOptions{}
if s.rated {
opts.Feedback = &s.rating
}
if a, ok := mujica.MessageActions(c, s.role, opts).Action(); ok {
log(fmt.Sprintf("action %s, rating %d", names[a], s.rating))
}
})
}},
{ID: "215", Name: "MessageEditor", ZhName: "消息编辑器", Category: chatmsgCategory, Keys: "Cmd/Ctrl+Enter 保存,Escape 取消。",
Code: `if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
fmt.Println("new branch from", draft)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
original, draft string
editing bool
branches []string
index int
}
s := ui.Local(c.Root(), "215", func() state {
return state{original: "Seat the ambassador.", branches: []string{"Seat the ambassador."}}
})
inputSection(c, "Live", func() {
if !s.editing {
mujica.MessageBubble(c, mujica.MessageUser, mujica.MessageBubbleOptions{}, func() { ui.Text(c, s.branches[s.index]) })
ui.Row(c).Gap(8).Justify(ui.End).Children(func() {
if mujica.BranchNavigator(c, &s.index, len(s.branches)).Changed() {
log(fmt.Sprintf("branch %d", s.index+1))
}
if mujica.Button(c, "Edit", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
s.editing, s.draft = true, s.branches[s.index]
log("editing")
}
})
return
}
r := mujica.MessageEditor(c, &s.draft, mujica.MessageEditorOptions{Original: s.branches[s.index]})
if r.Saved() {
s.branches = append(s.branches, s.draft)
s.index, s.editing = len(s.branches)-1, false
log(fmt.Sprintf("saved as branch %d", len(s.branches)))
}
if r.Canceled() {
s.editing = false
log("edit canceled")
}
})
}},
{ID: "216", Name: "BranchNavigator", ZhName: "分支切换", Category: chatmsgCategory, Keys: "Tab 聚焦,Left/Right 切换,Home、End 到首末。",
Code: `if mujica.BranchNavigator(c, &branch, 3).Changed() {
fmt.Println("show branch", branch)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
index int
count float64
disabled bool
}
s := ui.Local(c.Root(), "216", func() state { return state{index: 1, count: 3} })
inputSection(c, "Parameters", func() {
if mujica.NumberInput(c, &s.count, mujica.NumberInputOptions{Min: 1, Max: 9, StepperOnly: true, Label: "Branches"}).Changed() {
s.index = min(s.index, int(s.count)-1)
log(fmt.Sprintf("%v branches", s.count))
}
inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled")
})
inputSection(c, "Live", func() {
ui.Row(c).Disabled(s.disabled).Children(func() {
if mujica.BranchNavigator(c, &s.index, int(s.count)).Changed() {
log(fmt.Sprintf("branch %d of %v", s.index+1, s.count))
}
})
})
}},
{ID: "217", Name: "ErrorMessage", ZhName: "错误消息", Category: chatmsgCategory, Keys: "Tab 到错误文本(可选择复制)与重试按钮。",
Code: `opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
fmt.Println("send again")
}`,
Demo: func(c *ui.Context, log func(string)) {
until := ui.Local(c.Root(), "217", func() time.Time { return time.Time{} })
inputSection(c, "Parameters", func() {
ui.Row(c).Children(func() {
if mujica.Button(c, "Rate limit for 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
*until = c.Now().Add(10 * time.Second)
log("rate limited for 10 s")
}
})
})
inputSection(c, "Live", func() {
opts := mujica.ErrorMessageOptions{RetryAt: *until}
if mujica.ErrorMessage(c, `429 Too Many Requests: {"type":"rate_limit_error","message":"Number of requests exceeded"}`, opts).Retried() {
log("retry")
}
if mujica.ErrorMessage(c, "dial tcp 10.0.0.7:443: connection refused", mujica.ErrorMessageOptions{Title: "Could not reach the server"}).Retried() {
log("retry connection")
}
})
}},
{ID: "218", Name: "ThinkingBlock", ZhName: "思考过程", Category: chatmsgCategory, Keys: "Tab 聚焦标题,Enter 或 Space 展开与折叠。",
Code: `mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
ui.Text(c, "Weigh the guest list.")
})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
open, thinking bool
started time.Time
elapsed time.Duration
}
s := ui.Local(c.Root(), "218", func() state { return state{elapsed: 14 * time.Second} })
inputSection(c, "Parameters", func() {
label := "Start thinking"
if s.thinking {
label = "Finish thinking"
}
ui.Row(c).Children(func() {
if mujica.Button(c, label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
if s.thinking {
s.elapsed = c.Now().Sub(s.started)
} else {
s.started = c.Now()
}
s.thinking = !s.thinking
log(fmt.Sprintf("thinking %v", s.thinking))
}
})
})
inputSection(c, "Live", func() {
opts := mujica.ThinkingBlockOptions{Thinking: s.thinking, Started: s.started, Elapsed: s.elapsed}
if mujica.ThinkingBlock(c, &s.open, opts, func() {
ui.Text(c, "Forty guests, thirty-six seats. The envoys outrank the counts; four counts dine in the antechamber.")
}).Changed() {
log(fmt.Sprintf("open %v", s.open))
}
})
}},
{ID: "219", Name: "ScrollToBottomButton", ZhName: "回到底部按钮", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 回到底部。",
Code: `ui.Box(c).Fill().Children(func() {
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
list.ScrollToEnd()
}
})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
away bool
unread float64
}
s := ui.Local(c.Root(), "219", func() state { return state{away: true, unread: 3} })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Away from bottom": &s.away}, "Away from bottom")
if mujica.NumberInput(c, &s.unread, mujica.NumberInputOptions{Min: 0, Max: 99, Label: "Unread"}).Changed() {
log(fmt.Sprintf("unread %v", s.unread))
}
})
inputSection(c, "Live", func() {
ui.Box(c).Height(120).Background(mujica.Tokens(c).Surface).Border(1, mujica.Tokens(c).Border).Children(func() {
if mujica.ScrollToBottomButton(c, s.away, int(s.unread)).Clicked() {
s.away, s.unread = false, 0
log("back to the bottom")
}
})
})
}},
{ID: "220", Name: "TypingIndicator", ZhName: "正在输入", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时显示静态文字。",
Code: `mujica.TypingIndicator(c, "Herald")`,
Demo: func(c *ui.Context, log func(string)) {
typing := ui.Local(c.Root(), "220", func() bool { return true })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Typing": typing}, "Typing")
})
inputSection(c, "Live", func() {
if *typing {
mujica.TypingIndicator(c, "Herald")
}
})
}},
}
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"errors"
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
const chatmsgMarkdown = "## The Decree\n\nBy order of **the Queen**, *all* guests use `the east door`. See [the protocol](https://example.com/protocol).\n\n" +
"1. Arrive at nine\n2. Bow to the throne\n - dukes first\n\n> Silence in the Hall of Mirrors.\n\n" +
"```python\ndef bow(guest):\n return f\"{guest} bows\" # deeply\n```\n\n| Guest | Seat |\n|---|--:|\n| Marie | 1 |\n| Axel | 14 |\n\n---\n\nVive la reine."
// chatmsgPicture is a flat colored picture for image demos.
func chatmsgPicture(color string) ui.ImageSource {
return ui.MustParseSVG([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="%s"/><circle cx="120" cy="90" r="40" fill="#FBF7EF" opacity="0.5"/></svg>`, color)))
}
var chatmsgColors = []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"}
var chatmsgBlockPages = []page{
{ID: "206", Name: "MarkdownView", ZhName: "Markdown 视图", Category: chatmsgCategory, Keys: "Tab 到链接,Enter 激活;代码块与表格按钮按 Tab 顺序访问。",
Code: `if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
fmt.Println("open", url)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
src string
stream bool
at time.Time
}
s := ui.Local(c.Root(), "206", func() state { return state{src: chatmsgMarkdown} })
inputSection(c, "Parameters", func() {
ui.Row(c).Children(func() {
if mujica.Button(c, "Stream it again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.src, s.stream = "", true
log("streaming started")
}
})
mujica.TextArea(c, &s.src, mujica.TextAreaOptions{Label: "Markdown source", MaxHeight: 160})
})
if s.stream && len(s.src) < len(chatmsgMarkdown) && c.Now().Sub(s.at) > 40*time.Millisecond {
s.src = chatmsgMarkdown[:min(len(chatmsgMarkdown), len(s.src)+6)]
s.at = c.Now()
if len(s.src) == len(chatmsgMarkdown) {
s.stream = false
log("streaming finished; finished blocks were never parsed again")
}
}
if s.stream {
c.After(40 * time.Millisecond)
}
inputSection(c, "Live", func() {
if url, ok := mujica.MarkdownView(c, s.src).Link(); ok {
log("link " + url)
}
})
}},
{ID: "207", Name: "CodeBlock", ZhName: "代码块", Category: chatmsgCategory, Keys: "Tab 到换行、复制、折叠按钮;Space 或 Enter 触发。",
Code: `mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
lang string
numbers, wrap bool
}
s := ui.Local(c.Root(), "207", func() state { return state{lang: "go", numbers: true} })
samples := map[string]string{
"go": "// greet the court\nfunc greet(name string) int {\n\treturn len(\"Your Majesty, \" + name + \", the ball awaits you in the Hall of Mirrors\") + 42\n}",
"python": "def bow(guest):\n # deeply, always\n return f\"{guest} bows\" * 3",
"sql": "SELECT name, seat FROM guests WHERE rank > 2 ORDER BY seat LIMIT 10; -- the envoys",
}
inputSection(c, "Parameters", func() {
langs := []mujica.RadioItem[string]{{Value: "go", Label: "Go"}, {Value: "python", Label: "Python"}, {Value: "sql", Label: "SQL"}}
if mujica.RadioGroup(c, &s.lang, langs, mujica.RadioGroupOptions{Horizontal: true, Label: "Language"}).Changed() {
log("language " + s.lang)
}
inputFlags(c, log, map[string]*bool{"LineNumbers": &s.numbers, "Wrap at first": &s.wrap}, "LineNumbers", "Wrap at first")
})
inputSection(c, "Live", func() {
ui.Column(c).Key(s.lang + fmt.Sprint(s.wrap)).Children(func() {
if mujica.CodeBlock(c, samples[s.lang], mujica.CodeBlockOptions{Language: s.lang, LineNumbers: s.numbers, Wrap: s.wrap}).Copied() {
log("code copied")
}
})
})
inputSection(c, "States", func() {
mujica.CodeBlock(c, samples["go"], mujica.CodeBlockOptions{Language: "go", Collapsed: true})
})
}},
{ID: "208", Name: "TableBlock", ZhName: "表格块", Category: chatmsgCategory, Keys: "Tab 到复制按钮,Enter 或 Space 复制为 TSV。",
Code: `if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
fmt.Println("copied as TSV")
}`,
Demo: func(c *ui.Context, log func(string)) {
wide := ui.Local(c.Root(), "208", func() bool { return false })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Wide table": wide}, "Wide table")
})
header := []string{"Guest", "Title", "Seat"}
rows := [][]string{{"Marie", "Queen of France", "1"}, {"Axel", "Count of Fersen", "14"}}
if *wide {
header = append(header, "Arrival", "Carriage", "Retinue", "Notes")
for i := range rows {
rows[i] = append(rows[i], "21:00", "Gilded coach", "Twelve", "Seated near the orchestra, away from the draughts")
}
}
inputSection(c, "Live", func() {
ui.Column(c).MaxWidth(520).Children(func() {
if mujica.TableBlock(c, header, rows, mujica.TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}}).Copied() {
log("table copied as TSV")
}
})
})
}},
{ID: "209", Name: "ImageGrid", ZhName: "图片网格", Category: chatmsgCategory, Keys: "Tab 聚焦图片,Enter 打开查看器;查看器内 Left/Right 切换,Escape 关闭。",
Code: `if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
fmt.Println("reload", i)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
n float64
loading, fail bool
}
s := ui.Local(c.Root(), "209", func() state { return state{n: 5} })
inputSection(c, "Parameters", func() {
if mujica.NumberInput(c, &s.n, mujica.NumberInputOptions{Min: 1, Max: 9, Label: "Images", StepperOnly: true}).Changed() {
log(fmt.Sprintf("%v images", s.n))
}
inputFlags(c, log, map[string]*bool{"Last loading": &s.loading, "Second failed": &s.fail}, "Last loading", "Second failed")
})
imgs := make([]mujica.GridImage, int(s.n))
for i := range imgs {
imgs[i] = mujica.GridImage{Source: chatmsgPicture(chatmsgColors[i]), Alt: fmt.Sprintf("Portrait %d", i+1)}
}
if s.loading {
imgs[len(imgs)-1] = mujica.GridImage{Alt: "Loading portrait", Loading: true}
}
if s.fail && len(imgs) > 1 {
imgs[1] = mujica.GridImage{Alt: "Lost portrait", Err: errors.New("404")}
}
inputSection(c, "Live", func() {
if i, ok := mujica.ImageGrid(c, imgs).Retried(); ok {
s.fail = false
log(fmt.Sprintf("retry image %d", i))
}
})
}},
{ID: "210", Name: "FileMessage", ZhName: "文件消息", Category: chatmsgCategory, Keys: "Tab 到文件与下载按钮,Enter 或 Space 触发。",
Code: `r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
if r.Downloaded() {
fmt.Println("save treaty.pdf")
}`,
Demo: func(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "210", func() bool { return false })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Disabled": disabled}, "Disabled")
})
inputSection(c, "Live", func() {
ui.Column(c).Gap(8).Disabled(*disabled).Children(func() {
for _, f := range []mujica.FileMessageOptions{{Name: "treaty.pdf", Size: 2621440}, {Name: "portrait.png", Size: 812000},
{Name: "ledger.zip", Size: 73400320}, {Name: "decree.go", Size: 1840}, {Name: "notes", Size: 12}} {
r := mujica.FileMessage(c, f)
if r.Opened() {
log("open " + f.Name)
}
if r.Downloaded() {
log("download " + f.Name)
}
}
})
})
}},
}
// chatmsgWave is a fixed waveform for the voice demo.
var chatmsgWave = func() []float32 {
w := make([]float32, 48)
for i := range w {
w[i] = float32(0.25 + 0.7*float64((i*37)%11)/10)
if w[i] > 1 {
w[i] = 1
}
}
return w
}()
// chatmsgSnippet trims s to n runes for logs.
func chatmsgSnippet(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > n {
return string(r[:n]) + "…"
}
return string(r)
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"image/png"
"os"
"path/filepath"
"slices"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
// chatmsgDemoTester runs the demo of page id and returns its tester and event log.
func chatmsgDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) {
t.Helper()
i := slices.IndexFunc(chatmsgPages, func(p page) bool { return p.ID == id })
if i < 0 {
t.Fatalf("no page %s", id)
}
events := &[]string{}
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
ui.Scroll(c).Fill().Padding(24).Gap(16).Children(func() {
chatmsgPages[i].Demo(c, func(s string) { *events = append(*events, s) })
})
}, 900, 800)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
return tt, events
}
func TestChatmsgDemosRender(t *testing.T) {
if len(chatmsgPages) != 20 {
t.Fatalf("%d chat message pages, want 20", len(chatmsgPages))
}
dir := os.Getenv("CHATMSG_DEMO_DIR")
for _, p := range chatmsgPages {
for _, m := range []mujica.Mode{mujica.Light, mujica.Dark} {
tt, _ := chatmsgDemoTester(t, p.ID, m)
if len(tt.Texts()) == 0 {
t.Errorf("%s: demo shows nothing", p.ID)
}
if dir == "" {
continue
}
f, err := os.Create(filepath.Join(dir, p.ID+"-"+m.String()+".png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, tt.Image()); err != nil {
t.Fatal(err)
}
f.Close()
}
}
}
func TestChatmsgDemosLogEvents(t *testing.T) {
for id, click := range map[string]string{
"202": "Add message", "207": "Copy code", "214": "Regenerate", "216": "Next branch", "217": "Retry",
} {
tt, events := chatmsgDemoTester(t, id, mujica.Light)
if err := tt.Click(click); err != nil {
t.Fatalf("%s: %v", id, err)
}
if len(*events) == 0 {
t.Errorf("%s: clicking %q logged nothing", id, click)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// FileMessageOptions describe a file sent in a chat.
type FileMessageOptions struct {
// Name is the file name; required.
Name string
// Size is the length in bytes.
Size int64
// Type is the MIME type; empty guesses from the name's extension.
Type string
}
// FileMessageResult is a FileMessage's element and this frame's actions.
type FileMessageResult struct {
Element *ui.Element
opened, downloaded bool
}
// Opened reports a click or Enter on the file.
func (r FileMessageResult) Opened() bool { return r.opened }
// Downloaded reports a click on the download button.
func (r FileMessageResult) Downloaded() bool { return r.downloaded }
// FileMessage shows a file's type icon, name and size, with open and download actions.
func FileMessage(c *ui.Context, opts FileMessageOptions) FileMessageResult {
if opts.Name == "" {
panic("mujica: FileMessage needs a Name")
}
if opts.Size < 0 {
panic("mujica: FileMessage Size must not be negative")
}
k := Tokens(c)
u := Density(c).Unit()
size := fileMessageSize(c, opts.Size)
var r FileMessageResult
r.Element = ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u, u, u, u*2).MaxWidth(360).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
open := ui.ButtonBase(c).Grow(1).MinWidth(0).Gap(u * 3).Padding(u).Justify(ui.Start).Radius(theme.ControlRadius).
Label(fmt.Sprintf(msg(c, "file_message.open"), opts.Name, size))
internal.Ring(open, k.Focus, theme.ControlRadius)
if open.Hovered() {
open.Background(k.SurfaceHover)
}
open.Children(func() {
ui.Icon(c, icons.Must(fileMessageIcon(opts.Name, opts.Type))).Size(24, 24).Shrink(0).TextColor(k.AccentText)
ui.Column(c).MinWidth(0).Grow(1).Children(func() {
ui.Text(c, opts.Name).SingleLine().FontWeight(600)
ui.Text(c, size).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
})
if open.Clicked() && !disabled {
r.opened = true
slog.Debug("mujica: file_message open")
}
if iconAction(c, icons.Must("download"), fmt.Sprintf(msg(c, "file_message.download"), opts.Name)).Clicked() && !disabled {
r.downloaded = true
slog.Debug("mujica: file_message download")
}
})
return r
}
// fileMessageSize formats n bytes in binary units.
func fileMessageSize(c *ui.Context, n int64) string {
units := []string{"B", "KB", "MB", "GB", "TB"}
v, i := float64(n), 0
for v >= 1024 && i < len(units)-1 {
v /= 1024
i++
}
if i == 0 {
return fmt.Sprintf(msg(c, "file_message.bytes"), n)
}
return fmt.Sprintf("%.1f %s", v, units[i])
}
// fileMessageKinds maps extensions to type icons.
var fileMessageKinds = func() map[string]string {
m := map[string]string{}
for icon, exts := range map[string]string{
"image": ".png .jpg .jpeg .gif .webp .svg",
"file-archive": ".zip .tar .gz .tgz .7z .rar",
"file-code": ".go .js .ts .py .json .sh .sql .html .css .rs .java .c .h",
"file-text": ".txt .md .pdf .doc .docx .rtf",
} {
for _, e := range strings.Fields(exts) {
m[e] = icon
}
}
return m
}()
// fileMessageIcon picks a type icon from the MIME type, else the extension.
func fileMessageIcon(name, mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.Contains(mime, "zip"):
return "file-archive"
case strings.HasPrefix(mime, "text/"):
return "file-text"
}
if icon, ok := fileMessageKinds[strings.ToLower(path.Ext(name))]; ok {
return icon
}
return "file"
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestFileMessageOpenDownloadAndSize(t *testing.T) {
opened, downloaded, off := 0, 0, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(off).Children(func() {
r := FileMessage(c, FileMessageOptions{Name: "treaty.pdf", Size: 2_621_440})
if r.Opened() {
opened++
}
if r.Downloaded() {
downloaded++
}
})
}, 400, 200)
if !tt.HasText("2.5 MB") || !tt.HasText("treaty.pdf") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Open treaty.pdf, 2.5 MB")
_ = tt.Click("Download treaty.pdf")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if opened != 2 || downloaded != 1 {
t.Fatalf("opened %d downloaded %d", opened, downloaded)
}
off = true
_ = tt.Click("Open treaty.pdf, 2.5 MB")
_ = tt.Click("Download treaty.pdf")
if opened != 2 || downloaded != 1 {
t.Fatalf("disabled file acted: opened %d downloaded %d", opened, downloaded)
}
}
func TestFileMessageIconsAndSizes(t *testing.T) {
for name, want := range map[string]string{"a.png": "image", "b.zip": "file-archive", "c.go": "file-code", "d.md": "file-text", "e.bin": "file", "f.c": "file-code", "g.s": "file"} {
if got := fileMessageIcon(name, ""); got != want {
t.Errorf("%s: icon %s, want %s", name, got, want)
}
}
if fileMessageIcon("x", "image/heic") != "image" {
t.Error("MIME type ignored")
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
for _, n := range []int64{0, 1023, 1024, 5 << 30} {
ui.Text(c, fileMessageSize(c, n))
}
}, 200, 200)
for _, s := range []string{"0 B", "1023 B", "1.0 KB", "5.0 GB"} {
if !tt.HasText(s) {
t.Errorf("missing size %q in %q", s, tt.Texts())
}
}
}
func TestFileMessageNeedsName(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without a name")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
FileMessage(c, FileMessageOptions{})
}, 100, 100)
}
func TestFileMessageSnapshot(t *testing.T) {
snapshot(t, "file_message", 400, 160, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(8).Children(func() {
FileMessage(c, FileMessageOptions{Name: "seating-plan.xlsx", Size: 48_213})
FileMessage(c, FileMessageOptions{Name: "portrait.png", Size: 3_400_000})
})
})
}
+21
View File
@@ -0,0 +1,21 @@
<svg
class="lucide lucide-brain"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 18V5" />
<path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" />
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" />
<path d="M17.997 5.125a4 4 0 0 1 2.526 5.77" />
<path d="M18 18a4 4 0 0 0 2-7.464" />
<path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" />
<path d="M6 18a4 4 0 0 1-2-7.464" />
<path d="M6.003 5.125a4 4 0 0 0-2.526 5.77" />
</svg>

After

Width:  |  Height:  |  Size: 631 B

+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-download"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 15V3" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
</svg>

After

Width:  |  Height:  |  Size: 351 B

+19
View File
@@ -0,0 +1,19 @@
<svg
class="lucide lucide-file-archive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M8 12v-1" />
<path d="M8 18v-2" />
<path d="M8 7V6" />
<circle cx="8" cy="20" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

+17
View File
@@ -0,0 +1,17 @@
<svg
class="lucide lucide-file-code"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M10 12.5 8 15l2 2.5" />
<path d="m14 12.5 2 2.5-2 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 478 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-pause"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="14" y="3" width="5" height="18" rx="1" />
<rect x="5" y="3" width="5" height="18" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 343 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-pencil"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

+14
View File
@@ -0,0 +1,14 @@
<svg
class="lucide lucide-play"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

+18
View File
@@ -0,0 +1,18 @@
<svg
class="lucide lucide-share-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-thumbs-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
<path d="M17 14V2" />
</svg>

After

Width:  |  Height:  |  Size: 435 B

+15
View File
@@ -0,0 +1,15 @@
<svg
class="lucide lucide-thumbs-up"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
<path d="M7 10v12" />
</svg>

After

Width:  |  Height:  |  Size: 434 B

+16
View File
@@ -0,0 +1,16 @@
<svg
class="lucide lucide-volume-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
<path d="M16 9a5 5 0 0 1 0 6" />
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
</svg>

After

Width:  |  Height:  |  Size: 509 B

+17
View File
@@ -0,0 +1,17 @@
<svg
class="lucide lucide-wrap-text"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 16-3 3 3 3" />
<path d="M3 12h14.5a1 1 0 0 1 0 7H13" />
<path d="M3 19h6" />
<path d="M3 5h18" />
</svg>

After

Width:  |  Height:  |  Size: 361 B

+103
View File
@@ -0,0 +1,103 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// GridImage is one picture of an ImageGrid; the caller loads it.
type GridImage struct {
Source ui.ImageSource
Alt string
// Loading and Err show the caller's load in progress or failure.
Loading bool
Err error
}
// ImageGridResult is an ImageGrid's element and a retry the user asked for.
type ImageGridResult struct {
Element *ui.Element
retry int
}
// Retried returns the index of the failed picture whose retry was pressed this frame.
func (r ImageGridResult) Retried() (int, bool) { return r.retry, r.retry >= 0 }
// imageGridState is the viewer opened from the grid.
type imageGridState struct {
open bool
index int
}
// ImageGrid lays out 1 to 9 pictures; a click on a loaded one opens the ImageViewer.
func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult {
n := len(images)
if n < 1 || n > 9 {
panic("mujica: ImageGrid shows 1 to 9 images")
}
k := Tokens(c)
u := Density(c).Unit()
cols := 3
switch n {
case 1:
cols = 1
case 2, 4:
cols = 2
}
cell := float32(120)
if n == 1 {
cell = 280
}
r := ImageGridResult{retry: -1}
r.Element = ui.Grid(c).Columns(cols).Gap(u).AlignSelf(ui.Start)
st := ui.Local(r.Element, "viewer", func() imageGridState { return imageGridState{} })
var viewable []ViewerImage
var at []int
for i, im := range images {
if im.Source != nil && !im.Loading && im.Err == nil {
at = append(at, i)
viewable = append(viewable, ViewerImage{Source: im.Source, Alt: im.Alt})
}
}
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
for i, im := range images {
loaded := im.Source != nil && !im.Loading && im.Err == nil
// Only a loaded picture is a button; a failed one keeps its retry.
var b *ui.Element
if loaded {
b = ui.ButtonBase(c).Label(im.Alt)
internal.Ring(b, k.Focus, theme.ControlRadius)
} else {
b = ui.Box(c)
}
b.Size(cell, cell).Radius(theme.ControlRadius).Clip().Children(func() {
v := Image(c, im.Source, ImageOptions{Width: cell, Height: cell, Crop: true, Alt: im.Alt, Loading: im.Loading, Err: im.Err})
if v.Retry && !disabled {
r.retry = i
slog.Debug("mujica: image_grid retry", "index", i)
}
})
if loaded && b.Clicked() && !disabled {
for vi, ii := range at {
if ii == i {
st.open, st.index = true, vi
}
}
slog.Debug("mujica: image_grid open", "index", i)
}
}
})
if disabled {
st.open = false
}
if st.open && len(viewable) > 0 {
st.index = min(st.index, len(viewable)-1)
ImageViewer(c, &st.open, &st.index, viewable)
}
return r
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"errors"
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
// imageGridPicture is a plain colored picture standing in for a photo.
func imageGridPicture(color string) ui.ImageSource {
return ui.MustParseSVG([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150"><rect width="200" height="150" fill="%s"/></svg>`, color)))
}
func imageGridSet(n int) []GridImage {
colors := []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"}
out := make([]GridImage, n)
for i := range out {
out[i] = GridImage{Source: imageGridPicture(colors[i]), Alt: fmt.Sprintf("Photo %d", i+1)}
}
return out
}
func TestImageGridLayoutsByCount(t *testing.T) {
for n, cols := range map[int]int{1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 9: 3} {
var xs map[float32]bool
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ImageGrid(c, imageGridSet(n))
}, 900, 900)
tt.Frame()
xs = map[float32]bool{}
for i := range n {
r, ok := tt.Find(fmt.Sprintf("Photo %d", i+1))
if !ok {
t.Fatalf("%d images: Photo %d missing", n, i+1)
}
xs[r.X] = true
}
if len(xs) != cols {
t.Errorf("%d images in %d columns, want %d", n, len(xs), cols)
}
}
}
func TestImageGridOpensViewerAndRetries(t *testing.T) {
imgs := imageGridSet(3)
imgs[1] = GridImage{Alt: "Lost", Err: errors.New("404")}
retried := -1
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if i, ok := ImageGrid(c, imgs).Retried(); ok {
retried = i
}
}, 800, 600)
if err := tt.Click("Photo 3"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Image 2 of 2") {
t.Fatalf("viewer not at the third picture (second viewable): %q", tt.Texts())
}
tt.Key(0, ui.KeyEscape)
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
if retried != 1 {
t.Fatalf("retried %d, want 1", retried)
}
}
func TestImageGridCountLimits(t *testing.T) {
for _, n := range []int{0, 10} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%d images: no panic", n)
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ImageGrid(c, make([]GridImage, n))
}, 100, 100)
}()
}
}
func TestImageGridSnapshot(t *testing.T) {
imgs := imageGridSet(5)
imgs[3] = GridImage{Alt: "Loading", Loading: true}
imgs[4] = GridImage{Alt: "Lost", Err: errors.New("404")}
snapshot(t, "image_grid", 420, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { ImageGrid(c, imgs) })
})
}
+305
View File
@@ -0,0 +1,305 @@
package internal
import (
"strconv"
"strings"
)
// BlockKind is the kind of a Markdown block.
type BlockKind uint8
const (
BlockParagraph BlockKind = iota
BlockHeading
BlockList
BlockQuote
BlockCode
BlockTable
BlockRule
)
// MdSpan is a run of inline text with one style.
type MdSpan struct {
Text string
// URL is set for a link.
URL string
Strong, Em, Code, Strike bool
}
// MdItem is one list item.
type MdItem struct {
// Number is the ordinal of an ordered item, 0 for a bullet.
Number int
Depth int
Spans []MdSpan
}
// TableAlign is a table column's alignment.
type TableAlign uint8
const (
AlignLeft TableAlign = iota
AlignCenter
AlignRight
)
// MdBlock is one parsed block.
type MdBlock struct {
Kind BlockKind
// Level is a heading's level, 1 to 6.
Level int
// Spans hold a paragraph's, heading's or quote's text.
Spans []MdSpan
Items []MdItem
// Lang and Code hold a fenced code block; Open is true until its fence closes.
Lang, Code string
Open bool
Header []string
Align []TableAlign
Rows [][]string
}
// Markdown is a document parsed incrementally as its source grows.
type Markdown struct {
src string
Blocks []MdBlock
starts []int
}
// Update parses src and returns the index of the first block parsed anew.
// When src extends the last source, blocks before the last stay as they are.
func (m *Markdown) Update(src string) int {
if src == m.src && m.starts != nil {
return len(m.Blocks)
}
from := 0
if len(m.Blocks) > 0 && strings.HasPrefix(src, m.src) {
from = len(m.Blocks) - 1
}
off := 0
if from > 0 {
off = m.starts[from]
}
m.Blocks, m.starts = m.Blocks[:from], m.starts[:from]
m.src = src
parseBlocks(m, src, off)
return from
}
// mdLine is one source line and its byte offset.
type mdLine struct {
text string
off int
}
func parseBlocks(m *Markdown, src string, off int) {
var lines []mdLine
for off < len(src) {
end := strings.IndexByte(src[off:], '\n')
if end < 0 {
lines = append(lines, mdLine{src[off:], off})
break
}
lines = append(lines, mdLine{strings.TrimRight(src[off:off+end], "\r"), off})
off += end + 1
}
for i := 0; i < len(lines); {
l := lines[i].text
if strings.TrimSpace(l) == "" {
i++
continue
}
start := lines[i].off
var b MdBlock
switch {
case mdFence(l) != "":
b, i = parseFenced(lines, i)
case mdHeadingLevel(l) > 0:
lv := mdHeadingLevel(l)
b = MdBlock{Kind: BlockHeading, Level: lv, Spans: ParseInline(strings.TrimSpace(strings.TrimRight(l[lv:], "# ")))}
i++
case mdIsRule(l):
b = MdBlock{Kind: BlockRule}
i++
case strings.HasPrefix(strings.TrimLeft(l, " "), ">"):
b, i = parseQuote(lines, i)
case mdListMarker(l) != nil:
b, i = parseList(lines, i)
case i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil:
b, i = parseTable(lines, i)
default:
b, i = parseParagraph(lines, i)
}
m.Blocks = append(m.Blocks, b)
m.starts = append(m.starts, start)
}
}
// mdStarts reports whether line l begins a block other than a paragraph.
func mdStarts(lines []mdLine, i int) bool {
l := lines[i].text
return strings.TrimSpace(l) == "" || mdFence(l) != "" || mdHeadingLevel(l) > 0 || mdIsRule(l) ||
strings.HasPrefix(strings.TrimLeft(l, " "), ">") || mdListMarker(l) != nil ||
i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil
}
func parseParagraph(lines []mdLine, i int) (MdBlock, int) {
var parts []string
for ; i < len(lines) && (len(parts) == 0 || !mdStarts(lines, i)); i++ {
parts = append(parts, strings.TrimSpace(lines[i].text))
}
return MdBlock{Kind: BlockParagraph, Spans: ParseInline(strings.Join(parts, " "))}, i
}
// mdFence returns the fence a line opens, "```" or "~~~", or "".
func mdFence(l string) string {
t := strings.TrimLeft(l, " ")
for _, f := range []string{"```", "~~~"} {
if strings.HasPrefix(t, f) {
return f
}
}
return ""
}
func parseFenced(lines []mdLine, i int) (MdBlock, int) {
fence := mdFence(lines[i].text)
b := MdBlock{Kind: BlockCode, Open: true, Lang: strings.ToLower(strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(lines[i].text), fence[:1])))}
var code []string
for i++; i < len(lines); i++ {
if strings.HasPrefix(strings.TrimSpace(lines[i].text), fence) && strings.Trim(strings.TrimSpace(lines[i].text), fence[:1]) == "" {
b.Open = false
i++
break
}
code = append(code, lines[i].text)
}
b.Code = strings.Join(code, "\n")
return b, i
}
func mdHeadingLevel(l string) int {
n := 0
for n < len(l) && l[n] == '#' {
n++
}
if n == 0 || n > 6 || n < len(l) && l[n] != ' ' {
return 0
}
return n
}
func mdIsRule(l string) bool {
t := strings.ReplaceAll(strings.TrimSpace(l), " ", "")
if len(t) < 3 {
return false
}
return strings.Count(t, t[:1]) == len(t) && strings.ContainsAny(t[:1], "-*_")
}
func parseQuote(lines []mdLine, i int) (MdBlock, int) {
var parts []string
for ; i < len(lines); i++ {
t := strings.TrimLeft(lines[i].text, " ")
if !strings.HasPrefix(t, ">") {
break
}
parts = append(parts, strings.TrimSpace(t[1:]))
}
return MdBlock{Kind: BlockQuote, Spans: ParseInline(strings.Join(parts, " "))}, i
}
// mdMarker is a list item's marker.
type mdMarker struct {
number, depth, width int
}
func mdListMarker(l string) *mdMarker {
indent := len(l) - len(strings.TrimLeft(l, " "))
t := l[indent:]
if len(t) >= 2 && strings.ContainsRune("-*+", rune(t[0])) && t[1] == ' ' {
return &mdMarker{depth: indent / 2, width: indent + 2}
}
d := 0
for d < len(t) && t[d] >= '0' && t[d] <= '9' {
d++
}
if d == 0 || d > 9 || d+1 >= len(t) || !strings.ContainsRune(".)", rune(t[d])) || t[d+1] != ' ' {
return nil
}
n, _ := strconv.Atoi(t[:d])
return &mdMarker{number: max(n, 1), depth: indent / 2, width: indent + d + 2}
}
func parseList(lines []mdLine, i int) (MdBlock, int) {
b := MdBlock{Kind: BlockList}
var texts []string
for ; i < len(lines); i++ {
l := lines[i].text
if mk := mdListMarker(l); mk != nil {
b.Items = append(b.Items, MdItem{Number: mk.number, Depth: mk.depth})
texts = append(texts, strings.TrimSpace(l[mk.width:]))
continue
}
if mdStarts(lines, i) {
break
}
// A lazy line continues the item above.
texts[len(texts)-1] += " " + strings.TrimSpace(l)
}
for j := range b.Items {
b.Items[j].Spans = ParseInline(texts[j])
}
return b, i
}
// mdCells splits a table row into trimmed cells.
func mdCells(l string) []string {
t := strings.TrimSpace(l)
t = strings.TrimSuffix(strings.TrimPrefix(t, "|"), "|")
cells := strings.Split(t, "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return cells
}
// mdDelimiter parses a table's delimiter row, or returns nil.
func mdDelimiter(l string) []TableAlign {
if !strings.Contains(l, "-") {
return nil
}
var out []TableAlign
for _, c := range mdCells(l) {
left, right := strings.HasPrefix(c, ":"), strings.HasSuffix(c, ":")
if strings.Trim(c, ":") == "" || strings.Trim(c, ":-") != "" {
return nil
}
switch {
case left && right:
out = append(out, AlignCenter)
case right:
out = append(out, AlignRight)
default:
out = append(out, AlignLeft)
}
}
return out
}
func parseTable(lines []mdLine, i int) (MdBlock, int) {
b := MdBlock{Kind: BlockTable, Header: mdCells(lines[i].text), Align: mdDelimiter(lines[i+1].text)}
n := len(b.Header)
for len(b.Align) < n {
b.Align = append(b.Align, AlignLeft)
}
b.Align = b.Align[:n]
for i += 2; i < len(lines) && strings.Contains(lines[i].text, "|") && strings.TrimSpace(lines[i].text) != ""; i++ {
row := mdCells(lines[i].text)
for len(row) < n {
row = append(row, "")
}
b.Rows = append(b.Rows, row[:n])
}
return b, i
}
+128
View File
@@ -0,0 +1,128 @@
package internal
import "strings"
// mdStyle is the inline style in effect.
type mdStyle struct{ strong, em, strike bool }
// ParseInline splits text into styled spans: emphasis, code, strikethrough and links.
// An opener without its closer stays literal, as while a message streams in.
func ParseInline(s string) []MdSpan {
var out []MdSpan
parseInline(s, mdStyle{}, &out)
return out
}
func mdEmit(out *[]MdSpan, sp MdSpan) {
if sp.Text == "" {
return
}
if n := len(*out); n > 0 {
last := &(*out)[n-1]
if sp.URL == "" && last.URL == "" && last.Strong == sp.Strong && last.Em == sp.Em && last.Code == sp.Code && last.Strike == sp.Strike {
last.Text += sp.Text
return
}
}
*out = append(*out, sp)
}
func parseInline(s string, st mdStyle, out *[]MdSpan) {
plain := func(t string) {
mdEmit(out, MdSpan{Text: t, Strong: st.strong, Em: st.em, Strike: st.strike})
}
for i := 0; i < len(s); {
switch c := s[i]; {
case c == '\\' && i+1 < len(s) && strings.IndexByte("\\`*_~[]()#!>-|", s[i+1]) >= 0:
plain(s[i+1 : i+2])
i += 2
continue
case c == '`':
if end := strings.IndexByte(s[i+1:], '`'); end >= 0 {
mdEmit(out, MdSpan{Text: s[i+1 : i+1+end], Code: true, Strong: st.strong, Em: st.em, Strike: st.strike})
i += end + 2
continue
}
case c == '[':
if text, url, n := mdLink(s[i:]); n > 0 {
mdEmit(out, MdSpan{Text: text, URL: url, Strong: st.strong, Em: st.em, Strike: st.strike})
i += n
continue
}
case c == '*' || c == '_' || c == '~':
if n := mdDelimited(s, i, st, out); n > 0 {
i += n
continue
}
}
j := i + 1
for j < len(s) && strings.IndexByte("\\`[*_~", s[j]) < 0 {
j++
}
plain(s[i:j])
i = j
}
}
// mdDelimited parses emphasis or strikethrough opening at i and returns its length, or 0.
func mdDelimited(s string, i int, st mdStyle, out *[]MdSpan) int {
c := s[i]
n := 1
if i+1 < len(s) && s[i+1] == c {
n = 2
}
if c == '~' && n != 2 {
return 0
}
// Intraword underscores stay literal, as in snake_case.
if c == '_' && i > 0 && mdWord(s[i-1]) {
return 0
}
marker := s[i : i+n]
body := i + n
if body >= len(s) || s[body] == ' ' {
return 0
}
end := -1
for j := body + 1; j+n <= len(s); j++ {
if s[j:j+n] == marker && s[j-1] != ' ' && (n == 2 || j+1 >= len(s) || s[j+1] != c) {
end = j
break
}
}
if end < 0 {
return 0
}
inner := st
switch {
case c == '~':
inner.strike = true
case n == 2:
inner.strong = true
default:
inner.em = true
}
parseInline(s[body:end], inner, out)
return end + n - i
}
func mdWord(b byte) bool {
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
}
// mdLink parses [text](url) at the start of s and returns its length, or 0.
func mdLink(s string) (text, url string, n int) {
close := strings.Index(s, "](")
if close < 1 || strings.IndexByte(s[1:close], '[') >= 0 {
return "", "", 0
}
end := strings.IndexByte(s[close+2:], ')')
if end < 0 {
return "", "", 0
}
url = strings.TrimSpace(s[close+2 : close+2+end])
if url == "" || strings.ContainsAny(url, " \n") {
return "", "", 0
}
return s[1:close], url, close + 3 + end
}
+108
View File
@@ -0,0 +1,108 @@
package internal
import (
"reflect"
"strings"
"testing"
)
const mdSample = "# Title\n\nSome **bold** and *em* text with `code`\nand a [link](https://x.io).\n\n" +
"- one\n- two\n - nested\n3. three\n\n> quoted *line*\n\n```go\nfunc f() {}\n```\n\n" +
"| a | b |\n|:--|--:|\n| 1 | 2 |\n| 3 |\n\n---\n\nend ~~old~~ new"
func TestMarkdownBlocks(t *testing.T) {
var m Markdown
m.Update(mdSample)
var kinds []BlockKind
for _, b := range m.Blocks {
kinds = append(kinds, b.Kind)
}
want := []BlockKind{BlockHeading, BlockParagraph, BlockList, BlockQuote, BlockCode, BlockTable, BlockRule, BlockParagraph}
if !reflect.DeepEqual(kinds, want) {
t.Fatalf("kinds %v, want %v", kinds, want)
}
b := m.Blocks
if b[0].Level != 1 || b[0].Spans[0].Text != "Title" {
t.Errorf("heading %+v", b[0])
}
p := b[1].Spans
if p[1] != (MdSpan{Text: "bold", Strong: true}) || p[3] != (MdSpan{Text: "em", Em: true}) || p[5] != (MdSpan{Text: "code", Code: true}) {
t.Errorf("paragraph spans %+v", p)
}
if p[7] != (MdSpan{Text: "link", URL: "https://x.io"}) {
t.Errorf("link %+v", p[7])
}
it := b[2].Items
if len(it) != 4 || it[2].Depth != 1 || it[2].Spans[0].Text != "nested" || it[3].Number != 3 {
t.Errorf("list items %+v", it)
}
if b[3].Spans[1] != (MdSpan{Text: "line", Em: true}) {
t.Errorf("quote %+v", b[3].Spans)
}
if b[4].Lang != "go" || b[4].Code != "func f() {}" || b[4].Open {
t.Errorf("code %+v", b[4])
}
tb := b[5]
if !reflect.DeepEqual(tb.Header, []string{"a", "b"}) || !reflect.DeepEqual(tb.Align, []TableAlign{AlignLeft, AlignRight}) ||
!reflect.DeepEqual(tb.Rows, [][]string{{"1", "2"}, {"3", ""}}) {
t.Errorf("table %+v", tb)
}
if b[7].Spans[1] != (MdSpan{Text: "old", Strike: true}) {
t.Errorf("strike %+v", b[7].Spans)
}
}
func TestMarkdownInlineLiterals(t *testing.T) {
for in, want := range map[string]string{
"2 * 3 * 4": "2 * 3 * 4",
"snake_case_name": "snake_case_name",
"**unclosed": "**unclosed",
`\*not em\*`: "*not em*",
"[no link]": "[no link]",
} {
sp := ParseInline(in)
if len(sp) != 1 || sp[0].Text != want || sp[0].Em || sp[0].Strong {
t.Errorf("%q gave %+v", in, sp)
}
}
}
// TestMarkdownIncremental streams the sample byte by byte and checks finished blocks are kept.
func TestMarkdownIncremental(t *testing.T) {
var m Markdown
for i := 1; i <= len(mdSample); i++ {
before := append([]MdBlock(nil), m.Blocks...)
from := m.Update(mdSample[:i])
if from < len(before)-1 {
t.Fatalf("at %d reparsed from %d of %d blocks", i, from, len(before))
}
for j := 0; j < from; j++ {
if !reflect.DeepEqual(m.Blocks[j], before[j]) {
t.Fatalf("at %d finished block %d changed", i, j)
}
}
}
var whole Markdown
whole.Update(mdSample)
if !reflect.DeepEqual(m.Blocks, whole.Blocks) {
t.Fatal("streamed parse differs from a whole parse")
}
if m.Update(mdSample) != len(m.Blocks) {
t.Fatal("an unchanged source parsed again")
}
if m.Update("# Other") != 0 || len(m.Blocks) != 1 {
t.Fatal("a replaced source kept old blocks")
}
}
func TestMarkdownOpenFence(t *testing.T) {
var m Markdown
m.Update("```py\nprint(1)\n")
if len(m.Blocks) != 1 || !m.Blocks[0].Open || m.Blocks[0].Code != "print(1)" {
t.Fatalf("open fence %+v", m.Blocks)
}
m.Update("```py\nprint(1)\n```\nafter")
if len(m.Blocks) != 2 || m.Blocks[0].Open || !strings.Contains(m.Blocks[1].Spans[0].Text, "after") {
t.Fatalf("closed fence %+v", m.Blocks)
}
}
+49
View File
@@ -0,0 +1,49 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// LinkPreview is a link's metadata, fetched by the caller.
type LinkPreview struct {
// URL is the link; required.
URL string
// Title is required; Description, Site and Thumbnail may be empty.
Title, Description, Site string
Thumbnail ui.ImageSource
}
// LinkPreviewCard shows a link's site, title, description and thumbnail; Clicked opens it.
func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element {
if p.URL == "" || p.Title == "" {
panic("mujica: LinkPreviewCard needs URL and Title")
}
k := Tokens(c)
u := Density(c).Unit()
b := ui.ButtonBase(c).MaxWidth(420).Gap(u*3).Padding(u*2).AlignItems(ui.Start).Justify(ui.Start).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Label(p.Title).Tooltip(p.URL).Transition(colorFade)
internal.Ring(b, k.Focus, theme.CardRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Column(c).Grow(1).MinWidth(0).Gap(u).Children(func() {
site := p.Site
if site == "" {
site = p.URL
}
ui.Text(c, site).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
ui.Text(c, p.Title).FontWeight(600).TextColor(k.AccentText).MaxLines(2)
if p.Description != "" {
ui.Text(c, p.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).MaxLines(2)
}
})
if p.Thumbnail != nil {
ui.Image(c, p.Thumbnail).Fit(ui.Cover).Size(64, 64).Radius(theme.ControlRadius).Shrink(0)
}
})
return b
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var linkPreviewSample = LinkPreview{
URL: "https://example.com/versailles", Title: "The Palace of Versailles",
Description: "A royal château in the Île-de-France region, seat of the court from 1682.", Site: "example.com",
}
func TestLinkPreviewCardShowsMetadataAndOpens(t *testing.T) {
opened := 0
p := linkPreviewSample
p.Thumbnail = imageGridPicture("#7D2034")
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if LinkPreviewCard(c, p).Clicked() {
opened++
}
}, 500, 200)
for _, s := range []string{"example.com", "The Palace of Versailles", "A royal château"} {
if !tt.HasText(s) {
t.Errorf("missing %q in %q", s, tt.Texts())
}
}
_ = tt.Click("The Palace of Versailles")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if opened != 2 {
t.Fatalf("opened %d", opened)
}
}
func TestLinkPreviewCardSiteFallsBackToURL(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
LinkPreviewCard(c, LinkPreview{URL: "https://a.io/x", Title: "X"})
}, 400, 200)
if !tt.HasText("https://a.io/x") {
t.Fatalf("texts %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("no panic without a title")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
LinkPreviewCard(c, LinkPreview{URL: "https://a.io"})
}, 100, 100)
}
func TestLinkPreviewCardSnapshot(t *testing.T) {
p := linkPreviewSample
p.Thumbnail = imageGridPicture("#AD9164")
snapshot(t, "link_preview_card", 460, 130, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { LinkPreviewCard(c, p) })
})
}
+126
View File
@@ -0,0 +1,126 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// MarkdownResult is a MarkdownView's element and the link the user opened this frame.
type MarkdownResult struct {
Element *ui.Element
link string
}
// Link returns the URL of the link clicked or activated this frame.
func (r MarkdownResult) Link() (string, bool) { return r.link, r.link != "" }
// MarkdownView renders a Markdown subset; a growing src parses only its last block again.
func MarkdownView(c *ui.Context, src string) MarkdownResult {
u := Density(c).Unit()
var r MarkdownResult
r.Element = ui.Column(c).Gap(u * 3).MinWidth(0)
doc := ui.Local(r.Element, "doc", func() *internal.Markdown { return &internal.Markdown{} })
if from := (*doc).Update(src); from < len((*doc).Blocks) {
slog.Debug("mujica: markdown_view parse", "from", from, "blocks", len((*doc).Blocks))
}
r.Element.Children(func() {
for i := range (*doc).Blocks {
b := &(*doc).Blocks[i]
ui.Column(c).Key(i).MinWidth(0).Children(func() {
if url := markdownBlock(c, b); url != "" {
r.link = url
}
})
}
})
if r.link != "" {
slog.Debug("mujica: markdown_view link")
}
return r
}
// markdownBlock builds one block and returns a link opened in it.
func markdownBlock(c *ui.Context, b *internal.MdBlock) string {
k := Tokens(c)
u := Density(c).Unit()
switch b.Kind {
case internal.BlockHeading:
size := []float32{theme.H1Size, theme.H2Size, theme.H3Size}[min(b.Level, 3)-1]
t := ui.RichText(c).Font(theme.SerifFont).FontSize(FontSize(c, size)).FontWeight(600).Role(ui.RoleText)
return markdownSpans(c, t, b.Spans)
case internal.BlockParagraph:
return markdownSpans(c, ui.RichText(c), b.Spans)
case internal.BlockQuote:
var link string
ui.Row(c).Padding(0, 0, 0, u*3).BorderWidth(0, 0, 0, 3).BorderColor(k.Border).Children(func() {
link = markdownSpans(c, ui.RichText(c).TextColor(k.TextMuted).Grow(1), b.Spans)
})
return link
case internal.BlockList:
var link string
ui.Column(c).Gap(u).Role(ui.RoleList).Children(func() {
for _, it := range b.Items {
ui.Row(c).Gap(u*2).AlignItems(ui.Start).Padding(0, 0, 0, float32(it.Depth)*u*5).Role(ui.RoleListItem).Children(func() {
mark := "•"
if it.Number > 0 {
mark = fmt.Sprintf("%d.", it.Number)
}
ui.Text(c, mark).TextColor(k.TextMuted).FontFeatures("tnum").MinWidth(u * 4).Shrink(0)
if l := markdownSpans(c, ui.RichText(c).Grow(1).MinWidth(0), it.Spans); l != "" {
link = l
}
})
}
})
return link
case internal.BlockCode:
CodeBlock(c, b.Code, CodeBlockOptions{Language: b.Lang})
case internal.BlockTable:
align := make([]ui.Align, len(b.Align))
for i, a := range b.Align {
align[i] = []ui.Align{ui.Start, ui.Center, ui.End}[a]
}
TableBlock(c, b.Header, b.Rows, TableBlockOptions{Align: align})
case internal.BlockRule:
ui.Box(c).Height(u * 4).Draw(func(p *ui.Painter, r ui.Rect) {
internal.RuleDiamond(p, r, u*2, 5, k.Ornament)
})
}
return ""
}
// markdownSpans fills paragraph t with styled spans; links are focusable and return their URL when used.
func markdownSpans(c *ui.Context, t *ui.Element, spans []internal.MdSpan) string {
k := Tokens(c)
var link string
t.MinWidth(0).Children(func() {
for _, sp := range spans {
e := ui.Text(c, sp.Text)
if sp.Strong {
e.FontWeight(700)
}
if sp.Em {
e.Italic()
}
if sp.Strike {
e.Strikethrough()
}
if sp.Code {
e.Font(theme.MonoFont).TextBackground(k.SurfaceHover)
}
if sp.URL == "" {
continue
}
e.TextColor(k.AccentText).Underline().Focusable().Role(ui.RoleLink).Cursor(ui.CursorPointer).Tooltip(sp.URL)
if e.Clicked() && !e.IsDisabled() {
link = sp.URL
}
}
})
return link
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
const markdownViewSample = "# Decree\n\nBy order of **the Queen**, *all* guests use `the east door`.\n\n" +
"- Arrive at nine\n- Wear [court dress](https://example.com/dress)\n\n> Silence in the hall.\n\n" +
"```go\nfunc bow() {}\n```\n\n| Room | Floor |\n|---|--:|\n| Hall | 1 |\n\n---\n\nEnd."
func TestMarkdownViewRendersSubset(t *testing.T) {
src := markdownViewSample
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Scroll(c).Fill().Children(func() { MarkdownView(c, src) })
}, 600, 900)
for _, s := range []string{"Decree", "the Queen", "all", "the east door", "Arrive at nine", "court dress",
"Silence in the hall.", "func", "bow", "Room", "Hall", "End."} {
if !tt.HasText(s) {
t.Errorf("missing %q in %q", s, tt.Texts())
}
}
}
func TestMarkdownViewLinkByClickAndKeyboard(t *testing.T) {
var got []string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if url, ok := MarkdownView(c, "See [the guide](https://x.io/guide).").Link(); ok {
got = append(got, url)
}
}, 400, 200)
if err := tt.Click("the guide"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyEnter)
if len(got) != 2 || got[0] != "https://x.io/guide" || got[1] != got[0] {
t.Fatalf("links %q", got)
}
}
func TestMarkdownViewStreamsWithoutReparsingFinishedBlocks(t *testing.T) {
src := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Scroll(c).Fill().Children(func() { MarkdownView(c, src) })
}, 600, 900)
for _, chunk := range strings.SplitAfter(markdownViewSample, " ") {
src += chunk
tt.Frame()
}
if !tt.HasText("End.") || !tt.HasText("Decree") {
t.Fatalf("streamed render incomplete: %q", tt.Texts())
}
}
func TestMarkdownViewSnapshot(t *testing.T) {
snapshot(t, "markdown_view", 560, 720, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { MarkdownView(c, markdownViewSample) })
})
}
+111
View File
@@ -0,0 +1,111 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// MessageAction is one action on a chat message.
type MessageAction int
const (
ActionCopy MessageAction = iota + 1
ActionRegenerate
ActionEdit
ActionLike
ActionDislike
ActionSpeak
ActionShare
)
// MessageFeedback is the user's rating of an assistant message.
type MessageFeedback int
const (
FeedbackNone MessageFeedback = iota
FeedbackLiked
FeedbackDisliked
)
// MessageActionsOptions configure MessageActions.
type MessageActionsOptions struct {
// Feedback holds the rating; nil hides like and dislike.
Feedback *MessageFeedback
}
// MessageActionsResult is the toolbar's element and the action chosen this frame.
type MessageActionsResult struct {
Element *ui.Element
action MessageAction
}
// Action returns the action the user chose this frame.
func (r MessageActionsResult) Action() (MessageAction, bool) { return r.action, r.action != 0 }
// messageActionIcons names each action's icon and copy key.
var messageActionIcons = map[MessageAction][2]string{
ActionCopy: {"copy", "message_actions.copy"},
ActionRegenerate: {"refresh-cw", "message_actions.regenerate"},
ActionEdit: {"pencil", "message_actions.edit"},
ActionLike: {"thumbs-up", "message_actions.like"},
ActionDislike: {"thumbs-down", "message_actions.dislike"},
ActionSpeak: {"volume-2", "message_actions.speak"},
ActionShare: {"share-2", "message_actions.share"},
}
// messageActionsFor lists the actions a role's messages offer.
func messageActionsFor(role MessageRole, rated bool) []MessageAction {
switch role {
case MessageUser:
return []MessageAction{ActionCopy, ActionEdit}
case MessageAssistant:
if rated {
return []MessageAction{ActionCopy, ActionRegenerate, ActionLike, ActionDislike, ActionSpeak, ActionShare}
}
return []MessageAction{ActionCopy, ActionRegenerate, ActionSpeak, ActionShare}
case MessageSystem:
return []MessageAction{ActionCopy}
case MessageError:
return []MessageAction{ActionCopy, ActionRegenerate}
}
panic("mujica: unknown MessageRole")
}
// MessageActions is a toolbar of the actions a role's message offers; arrows move between them.
func MessageActions(c *ui.Context, role MessageRole, opts MessageActionsOptions) MessageActionsResult {
var r MessageActionsResult
r.Element = ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).
Role(ui.RoleToolbar).Label(msg(c, "message_actions.label"))
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
for _, a := range messageActionsFor(role, opts.Feedback != nil) {
ic := messageActionIcons[a]
hit := false
if a == ActionLike || a == ActionDislike {
want := FeedbackLiked
if a == ActionDislike {
want = FeedbackDisliked
}
on := *opts.Feedback == want
if codeBlockToggle(c, ic[0], msg(c, ic[1]), &on) && !disabled {
hit = true
if on {
*opts.Feedback = want
} else {
*opts.Feedback = FeedbackNone
}
}
} else {
hit = iconAction(c, icons.Must(ic[0]), msg(c, ic[1])).Tooltip(msg(c, ic[1])).Clicked() && !disabled
}
if hit {
r.action = a
slog.Debug("mujica: message_actions action", "action", a)
}
}
})
return r
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestMessageActionsByRole(t *testing.T) {
for role, want := range map[MessageRole][]string{
MessageUser: {"Copy", "Edit"},
MessageAssistant: {"Copy", "Regenerate", "Good response", "Bad response", "Read aloud", "Share"},
MessageSystem: {"Copy"},
MessageError: {"Copy", "Regenerate"},
} {
fb := FeedbackNone
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
MessageActions(c, role, MessageActionsOptions{Feedback: &fb})
}, 400, 100)
for _, name := range want {
if _, ok := tt.Find(name); !ok {
t.Errorf("role %v: missing %q", role, name)
}
}
if _, ok := tt.Find("Edit"); ok != (role == MessageUser) {
t.Errorf("role %v: edit shown %v", role, ok)
}
}
}
func TestMessageActionsChooseAndRate(t *testing.T) {
fb := FeedbackNone
var got []MessageAction
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if a, ok := MessageActions(c, MessageAssistant, MessageActionsOptions{Feedback: &fb}).Action(); ok {
got = append(got, a)
}
}, 400, 100)
_ = tt.Click("Regenerate")
_ = tt.Click("Good response")
if fb != FeedbackLiked {
t.Fatalf("feedback %v after like", fb)
}
_ = tt.Click("Bad response")
if fb != FeedbackDisliked {
t.Fatalf("feedback %v after dislike", fb)
}
_ = tt.Click("Bad response")
if fb != FeedbackNone {
t.Fatalf("feedback %v after undoing dislike", fb)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
// Tab returns to the last focused action, Dislike; Right moves to Read aloud.
want := []MessageAction{ActionRegenerate, ActionLike, ActionDislike, ActionDislike, ActionSpeak}
if len(got) != len(want) {
t.Fatalf("actions %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("actions %v, want %v", got, want)
}
}
}
func TestMessageActionsHideRatingAndDisable(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(true).Children(func() {
if _, ok := MessageActions(c, MessageAssistant, MessageActionsOptions{}).Action(); ok {
n++
}
})
}, 400, 100)
if _, ok := tt.Find("Good response"); ok {
t.Fatal("rating shown without Feedback")
}
_ = tt.Click("Copy")
if n != 0 {
t.Fatal("disabled actions fired")
}
}
func TestMessageActionsSnapshot(t *testing.T) {
fb := FeedbackLiked
snapshot(t, "message_actions", 300, 70, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { MessageActions(c, MessageAssistant, MessageActionsOptions{Feedback: &fb}) })
})
}
+115
View File
@@ -0,0 +1,115 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// MessageRole is who a chat message comes from.
type MessageRole int
const (
MessageUser MessageRole = iota
MessageAssistant
MessageSystem
MessageError
)
func (r MessageRole) key() string {
switch r {
case MessageUser:
return "message_bubble.user"
case MessageAssistant:
return "message_bubble.assistant"
case MessageSystem:
return "message_bubble.system"
case MessageError:
return "message_bubble.error"
}
panic("mujica: unknown MessageRole")
}
// MessageBubbleOptions configure a MessageBubble.
type MessageBubbleOptions struct {
// Name shows above the bubble; empty shows none.
Name string
// Avatar builds the picture beside the bubble.
Avatar func()
// MaxWidth caps the bubble in DIPs; zero means 560.
MaxWidth float32
}
// MessageBubble frames one chat message by role: users right, others left, system centered.
func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element {
if opts.MaxWidth < 0 {
panic("mujica: MessageBubble MaxWidth must not be negative")
}
if content == nil {
panic("mujica: MessageBubble needs content")
}
maxW := opts.MaxWidth
if maxW == 0 {
maxW = 560
}
k := Tokens(c)
u := Density(c).Unit()
label := msg(c, role.key())
if opts.Name != "" {
label += ", " + opts.Name
}
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Role(ui.RoleGroup).Label(label)
switch role {
case MessageUser:
row.Justify(ui.End)
case MessageSystem:
row.Justify(ui.Center)
}
row.Children(func() {
if role == MessageSystem {
ui.Row(c).MaxWidth(maxW).Padding(u, u*3).Gap(u * 2).Radius(theme.ControlRadius).
Background(k.SurfaceHover).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).
Children(content)
return
}
if opts.Avatar != nil && role != MessageUser {
opts.Avatar()
}
ui.Column(c).Gap(u).MaxWidth(maxW).Shrink(1).MinWidth(0).AlignItems(messageBubbleAlign(role)).Children(func() {
if opts.Name != "" {
ui.Text(c, opts.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
b := ui.Column(c).Padding(u*2, u*3).Gap(u * 2).Radius(theme.CardRadius).MinWidth(0)
switch role {
case MessageUser:
b.Background(k.Selection)
case MessageError:
b.Background(k.Surface).Border(theme.BorderWidth, k.Danger)
default:
b.Background(k.Surface).Border(theme.BorderWidth, k.Border)
}
b.Children(func() {
if role != MessageError {
content()
return
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0).TextColor(k.Danger)
ui.Column(c).Gap(u * 2).Grow(1).MinWidth(0).Children(content)
})
})
})
if opts.Avatar != nil && role == MessageUser {
opts.Avatar()
}
})
return row
}
func messageBubbleAlign(r MessageRole) ui.Align {
if r == MessageUser {
return ui.End
}
return ui.Start
}
+60
View File
@@ -0,0 +1,60 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestMessageBubbleRolesAlignAndLimit(t *testing.T) {
var user, bot, sys ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(600).Children(func() {
MessageBubble(c, MessageUser, MessageBubbleOptions{Name: "Ada", MaxWidth: 200}, func() {
user = ui.Text(c, "A long user message that must wrap inside the bubble's width limit.").Bounds()
})
MessageBubble(c, MessageAssistant, MessageBubbleOptions{Avatar: func() { Avatar(c, "Bot", AvatarOptions{}) }}, func() {
bot = ui.Text(c, "Hello").Bounds()
})
MessageBubble(c, MessageSystem, MessageBubbleOptions{}, func() { sys = ui.Text(c, "Joined").Bounds() })
MessageBubble(c, MessageError, MessageBubbleOptions{}, func() { ui.Text(c, "Timeout") })
})
}, 600, 400)
tt.Frame()
if user.W > 200 || user.X < 300 {
t.Errorf("user bubble %+v not right-aligned within 200", user)
}
if bot.X > 100 || sys.X < 200 || sys.X > 300 {
t.Errorf("assistant at %v, system at %v", bot.X, sys.X)
}
for _, s := range []string{"Ada", "Bot", "Error"} {
if !tt.HasText(s) {
t.Errorf("missing %q", s)
}
}
}
func TestMessageBubbleBadOptions(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for a negative MaxWidth")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
MessageBubble(c, MessageUser, MessageBubbleOptions{MaxWidth: -1}, func() {})
}, 100, 100)
}
func TestMessageBubbleSnapshot(t *testing.T) {
snapshot(t, "message_bubble", 520, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Children(func() {
MessageBubble(c, MessageUser, MessageBubbleOptions{Name: "Marie"}, func() { ui.Text(c, "Is the ball tonight?") })
MessageBubble(c, MessageAssistant, MessageBubbleOptions{Name: "Herald", Avatar: func() { Avatar(c, "Herald", AvatarOptions{}) }},
func() { ui.Text(c, "Yes, at nine, in the Hall of Mirrors.") })
MessageBubble(c, MessageSystem, MessageBubbleOptions{}, func() { ui.Text(c, "Herald joined the chat") })
MessageBubble(c, MessageError, MessageBubbleOptions{}, func() { ui.Text(c, "The messenger did not return.") })
})
})
}
+57
View File
@@ -0,0 +1,57 @@
package mujica
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
)
// MessageEditorOptions configure a MessageEditor.
type MessageEditorOptions struct {
// Original is the sent text; saving it unchanged is not allowed.
Original string
}
// MessageEditorResult is the editor's element and its outcome this frame.
type MessageEditorResult struct {
Element *ui.Element
saved, canceled bool
}
// Saved reports a save; the caller makes a new branch from the draft.
func (r MessageEditorResult) Saved() bool { return r.saved }
// Canceled reports Cancel or Escape.
func (r MessageEditorResult) Canceled() bool { return r.canceled }
// MessageEditor edits a sent message in *draft; Cmd+Enter saves, Escape cancels.
func MessageEditor(c *ui.Context, draft *string, opts MessageEditorOptions) MessageEditorResult {
if draft == nil {
panic("mujica: MessageEditor needs a draft")
}
u := Density(c).Unit()
var r MessageEditorResult
r.Element = ui.Column(c).Gap(u * 2).MinWidth(0)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
area := TextArea(c, draft, TextAreaOptions{Label: msg(c, "message_editor.label"), MaxHeight: 240})
ok := strings.TrimSpace(*draft) != "" && *draft != opts.Original
escape := area.Input.Shortcut(0, ui.KeyEscape)
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if Button(c, msg(c, "message_editor.cancel"), ButtonOptions{Variant: Secondary}).Clicked() || escape {
r.canceled = !disabled
}
if Button(c, msg(c, "message_editor.save"), ButtonOptions{Disabled: !ok}).Clicked() || area.Submitted() && ok {
r.saved = !disabled
}
})
})
if r.saved {
slog.Debug("mujica: message_editor save", "chars", len(*draft))
}
if r.canceled {
slog.Debug("mujica: message_editor cancel")
}
return r
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func messageEditorTester(draft *string, saves, cancels *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := MessageEditor(c, draft, MessageEditorOptions{Original: "Hello"})
if r.Saved() {
*saves++
}
if r.Canceled() {
*cancels++
}
}, 400, 300)
}
func TestMessageEditorSaveRules(t *testing.T) {
draft, saves, cancels := "Hello", 0, 0
tt := messageEditorTester(&draft, &saves, &cancels)
_ = tt.Click("Save")
_ = tt.Click("Edit message")
tt.Key(ui.Cmd, ui.KeyEnter)
if saves != 0 {
t.Fatal("saved unchanged text")
}
tt.Type(" there")
tt.Key(ui.Cmd, ui.KeyEnter)
_ = tt.Click("Save")
if saves != 2 || draft != "Hello there" {
t.Fatalf("saves %d draft %q", saves, draft)
}
_ = tt.Click("Edit message")
tt.Command("selectAll")
tt.Type(" ")
_ = tt.Click("Save")
if saves != 2 {
t.Fatal("saved blank text")
}
}
func TestMessageEditorCancel(t *testing.T) {
draft, saves, cancels := "Hello", 0, 0
tt := messageEditorTester(&draft, &saves, &cancels)
_ = tt.Click("Cancel")
_ = tt.Click("Edit message")
tt.Key(0, ui.KeyEscape)
if cancels != 2 || saves != 0 {
t.Fatalf("cancels %d saves %d", cancels, saves)
}
}
func TestMessageEditorSnapshot(t *testing.T) {
draft := "Could you seat the ambassador nearer the throne?"
snapshot(t, "message_editor", 420, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { MessageEditor(c, &draft, MessageEditorOptions{Original: "Seat the ambassador."}) })
})
}
+70
View File
@@ -0,0 +1,70 @@
package mujica
import (
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// MessageStatus is where a chat message is in its delivery.
type MessageStatus int
const (
// MessageNoStatus shows no status.
MessageNoStatus MessageStatus = iota
MessageSending
MessageStreaming
MessageSent
MessageFailed
)
// MessageHeaderOptions configure a MessageHeader; zero fields are hidden.
type MessageHeaderOptions struct {
Model string
Time time.Time
Status MessageStatus
}
// MessageHeader shows a message's model, time and status as icon and text.
func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
small := FontSize(c, theme.CaptionSize)
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).FontSize(small).TextColor(k.TextMuted)
row.Children(func() {
if opts.Model != "" {
ui.Text(c, opts.Model).FontWeight(600).TextColor(k.Text).SingleLine()
}
if !opts.Time.IsZero() {
ui.Text(c, opts.Time.Format(msg(c, "message_header.time"))).FontFeatures("tnum").SingleLine()
}
if opts.Status == MessageNoStatus {
return
}
key, icon, color := "", "", k.TextMuted
switch opts.Status {
case MessageSending:
key, icon = "message_header.sending", "clock"
case MessageStreaming:
key = "message_header.streaming"
case MessageSent:
key, icon = "message_header.sent", "check"
case MessageFailed:
key, icon, color = "message_header.failed", "circle-alert", k.Danger
default:
panic("mujica: unknown MessageStatus")
}
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(color).Children(func() {
if icon == "" {
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, key)})
} else {
ui.Icon(c, icons.Must(icon)).Size(12, 12)
}
ui.Text(c, msg(c, key)).SingleLine()
})
})
return row
}
+55
View File
@@ -0,0 +1,55 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestMessageHeaderFieldsAndStatus(t *testing.T) {
at := time.Date(2026, 3, 1, 14, 5, 0, 0, time.UTC)
for status, want := range map[MessageStatus]string{
MessageSending: "Sending", MessageStreaming: "Generating", MessageSent: "Sent", MessageFailed: "Failed",
} {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: EnUS})
MessageHeader(c, MessageHeaderOptions{Model: "claude", Time: at, Status: status})
}, 400, 100)
for _, s := range []string{"claude", "14:05", want} {
if !tt.HasText(s) {
t.Errorf("status %v: missing %q in %q", status, s, tt.Texts())
}
}
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
MessageHeader(c, MessageHeaderOptions{})
}, 400, 100)
if len(tt.Texts()) != 0 {
t.Fatalf("zero header shows %q", tt.Texts())
}
}
func TestMessageHeaderUnknownStatus(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an unknown status")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
MessageHeader(c, MessageHeaderOptions{Status: 99})
}, 100, 100)
}
func TestMessageHeaderSnapshot(t *testing.T) {
at := time.Date(2026, 3, 1, 21, 30, 0, 0, time.UTC)
snapshot(t, "message_header", 360, 150, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Children(func() {
for _, s := range []MessageStatus{MessageSending, MessageStreaming, MessageSent, MessageFailed} {
MessageHeader(c, MessageHeaderOptions{Model: "herald-2", Time: at, Status: s})
}
})
})
}
+108
View File
@@ -0,0 +1,108 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// MessageListState keeps a MessageList's place and what the user has seen.
type MessageListState struct {
list ui.ListState
seen int
started bool
}
// ScrollToEnd shows the newest message and follows new ones again.
func (s *MessageListState) ScrollToEnd() { s.list.ScrollToEnd() }
// AtEnd reports whether the newest message is in view.
func (s *MessageListState) AtEnd() bool { return s.list.AtEnd() }
// MessageListOptions configure a MessageList.
type MessageListOptions struct {
// ID returns message i's stable identity; required.
ID func(i int) string
// Date returns message i's time; set, a separator heads each new day.
Date func(i int) time.Time
// Label returns message i's text for assistive technology and typing to find.
Label func(i int) string
}
// MessageListResult is a MessageList's element and its unread count.
type MessageListResult struct {
Element *ui.Element
unread int
}
// Unread returns how many messages arrived since the user scrolled away from the end.
func (r MessageListResult) Unread() int { return r.unread }
// MessageList shows n messages, building only those in view, and follows the end until the user scrolls up.
func MessageList(c *ui.Context, s *MessageListState, n int, opts MessageListOptions, row func(i int)) MessageListResult {
if opts.ID == nil || row == nil {
panic("mujica: MessageList needs ID and row")
}
if n < 0 {
panic("mujica: MessageList with negative message count")
}
u := Density(c).Unit()
s.list.Key = func(i int) any { return opts.ID(i) }
s.list.Label = opts.Label
s.list.FollowEnd = true
at := !s.started || s.list.AtEnd()
if at || n < s.seen {
s.seen = n
}
s.started = true
r := MessageListResult{unread: n - s.seen}
r.Element = ui.Box(c).Grow(1).MinHeight(0)
r.Element.Children(func() {
ui.List(c, &s.list, n, func(i int) {
ui.Column(c).Padding(u*2, u*4).Gap(u * 2).Children(func() {
if opts.Date != nil && (i == 0 || !messageListSameDay(opts.Date(i-1), opts.Date(i))) {
messageListDay(c, opts.Date(i))
}
row(i)
})
}).Fill().Justify(ui.End).Label(msg(c, "message_list.label"))
jump := ScrollToBottomButton(c, !at, r.unread)
if jump.Clicked() && !r.Element.IsDisabled() {
s.list.ScrollToEnd()
s.seen = n
slog.Debug("mujica: message_list jump", "unread", r.unread)
}
})
return r
}
func messageListSameDay(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
// messageListDay is a separator naming the day: today, yesterday or the date.
func messageListDay(c *ui.Context, t time.Time) {
k := Tokens(c)
u := Density(c).Unit()
label := t.Format(msg(c, "message_list.date"))
now := c.Now().In(t.Location())
switch {
case messageListSameDay(t, now):
label = msg(c, "message_list.today")
case messageListSameDay(t, now.AddDate(0, 0, -1)):
label = msg(c, "message_list.yesterday")
}
ui.Row(c).Gap(u*3).AlignItems(ui.Center).Padding(u*2, 0).Role(ui.RoleText).Label(label).Children(func() {
rule := func() {
ui.Box(c).Grow(1).Height(theme.BorderWidth).Background(k.Border)
}
rule()
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
rule()
})
}
+127
View File
@@ -0,0 +1,127 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
// messageListApp is a chat of n messages with stable IDs.
type messageListApp struct {
ids []string
state MessageListState
built int
res MessageListResult
}
func newMessageListApp(n int) *messageListApp {
a := &messageListApp{}
for i := range n {
a.ids = append(a.ids, fmt.Sprintf("m%d", i))
}
return a
}
func (a *messageListApp) view(c *ui.Context) {
Use(c, Settings{})
a.built = 0
day := time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)
a.res = MessageList(c, &a.state, len(a.ids), MessageListOptions{
ID: func(i int) string { return a.ids[i] },
Date: func(i int) time.Time { return day.Add(time.Duration(i/3) * 24 * time.Hour) },
}, func(i int) {
a.built++
ui.Text(c, "Message "+a.ids[i]).Padding(8)
})
}
func TestMessageListVirtualizedAndFollowsEnd(t *testing.T) {
a := newMessageListApp(100000)
tt := ui.NewTester(a.view, 400, 300)
tt.Frame()
if a.built == 0 || a.built > 60 {
t.Fatalf("built %d rows of 100000", a.built)
}
if !tt.HasText("Message m99999") || !a.state.AtEnd() {
t.Fatal("list did not start at the newest message")
}
a.ids = append(a.ids, "m100000")
tt.Frame()
tt.Frame()
if !tt.HasText("Message m100000") || a.res.Unread() != 0 {
t.Fatalf("new message not followed, unread %d", a.res.Unread())
}
}
func TestMessageListStopsFollowingAndCountsUnread(t *testing.T) {
a := newMessageListApp(50)
tt := ui.NewTester(a.view, 400, 300)
tt.Frame()
tt.Scroll(200, 150, 0, -2000)
tt.Frame()
if a.state.AtEnd() {
t.Fatal("still at end after scrolling up")
}
a.ids = append(a.ids, "n1", "n2")
tt.Frame()
tt.Frame()
if tt.HasText("Message n2") || a.res.Unread() != 2 {
t.Fatalf("followed while scrolled up, unread %d", a.res.Unread())
}
if err := tt.Click("Scroll to latest, 2 new"); err != nil {
t.Fatal(err)
}
tt.Frame()
tt.Frame()
if !tt.HasText("Message n2") || a.res.Unread() != 0 {
t.Fatalf("jump did not reach the end, unread %d", a.res.Unread())
}
}
func TestMessageListKeepsPlaceByID(t *testing.T) {
a := newMessageListApp(50)
tt := ui.NewTester(a.view, 400, 300)
tt.Frame()
tt.Scroll(200, 150, 0, -600)
tt.Frame()
r, _ := tt.Find("Message m40")
// Older messages load above; the one in view stays put.
a.ids = append([]string{"old1", "old2", "old3"}, a.ids...)
tt.Frame()
r2, ok := tt.Find("Message m40")
if !ok || r2.Y != r.Y {
t.Fatalf("m40 moved from %v to %v (found %v)", r.Y, r2.Y, ok)
}
}
func TestMessageListDaySeparators(t *testing.T) {
a := newMessageListApp(6)
tt := ui.NewTester(a.view, 400, 600)
tt.Frame()
for _, d := range []string{"Mar 1, 2026", "Mar 2, 2026"} {
if !tt.HasText(d) {
t.Errorf("missing separator %q in %q", d, tt.Texts())
}
}
}
func TestMessageListNeedsID(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without ID")
}
}()
var s MessageListState
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
MessageList(c, &s, 1, MessageListOptions{}, func(int) {})
}, 200, 100)
}
func TestMessageListSnapshot(t *testing.T) {
snapshot(t, "message_list", 420, 320, func(c *ui.Context) {
(*ui.Local(c.Root(), "app", func() *messageListApp { return newMessageListApp(6) })).view(c)
})
}
+57
View File
@@ -0,0 +1,57 @@
package mujica
func init() {
addMessages(map[string][2]string{
"message_bubble.user": {"You", "你"},
"message_bubble.assistant": {"Assistant", "助手"},
"message_bubble.system": {"System", "系统"},
"message_bubble.error": {"Error", "错误"},
"message_header.time": {"15:04", "15:04"},
"message_header.sending": {"Sending", "发送中"},
"message_header.streaming": {"Generating", "生成中"},
"message_header.sent": {"Sent", "已发送"},
"message_header.failed": {"Failed", "失败"},
"message_list.label": {"Messages", "消息"},
"message_list.date": {"Jan 2, 2006", "2006年1月2日"},
"message_list.today": {"Today", "今天"},
"message_list.yesterday": {"Yesterday", "昨天"},
"scroll_to_bottom_button.label": {"Scroll to latest", "回到最新"},
"scroll_to_bottom_button.unread": {"Scroll to latest, %d new", "回到最新,%d 条新消息"},
"code_block.plain": {"text", "文本"},
"code_block.wrap": {"Wrap lines", "自动换行"},
"code_block.copy": {"Copy code", "复制代码"},
"code_block.copied": {"Copied", "已复制"},
"code_block.collapse": {"Collapse", "折叠"},
"code_block.expand": {"Show %d lines", "展开 %d 行"},
"table_block.title": {"Table", "表格"},
"table_block.copy": {"Copy as TSV", "复制为 TSV"},
"file_message.open": {"Open %s, %s", "打开 %s,%s"},
"file_message.download": {"Download %s", "下载 %s"},
"file_message.bytes": {"%d B", "%d 字节"},
"audio_message.play": {"Play", "播放"},
"audio_message.pause": {"Pause", "暂停"},
"audio_message.seek": {"Playback position", "播放位置"},
"quote_reply.label": {"Reply to %s: %s", "回复 %s:%s"},
"message_actions.label": {"Message actions", "消息操作"},
"message_actions.copy": {"Copy", "复制"},
"message_actions.regenerate": {"Regenerate", "重新生成"},
"message_actions.edit": {"Edit", "编辑"},
"message_actions.like": {"Good response", "赞"},
"message_actions.dislike": {"Bad response", "踩"},
"message_actions.speak": {"Read aloud", "朗读"},
"message_actions.share": {"Share", "分享"},
"message_editor.label": {"Edit message", "编辑消息"},
"message_editor.cancel": {"Cancel", "取消"},
"message_editor.save": {"Save", "保存"},
"branch_navigator.label": {"Branch %d of %d", "第 %d 个分支,共 %d 个"},
"branch_navigator.previous": {"Previous branch", "上一个分支"},
"branch_navigator.next": {"Next branch", "下一个分支"},
"error_message.title": {"Something went wrong", "出错了"},
"error_message.retry": {"Retry", "重试"},
"error_message.wait": {"Retry in %d s", "%d 秒后重试"},
"error_message.limited": {"Rate limited", "已限流"},
"thinking_block.done": {"Thought for %d s", "思考了 %d 秒"},
"thinking_block.thinking": {"Thinking… %d s", "思考中… %d 秒"},
"typing_indicator.label": {"%s is typing…", "%s 正在输入…"},
})
}
+33
View File
@@ -0,0 +1,33 @@
package mujica
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// QuoteReply shows the author and a one-line excerpt of a quoted message; Clicked jumps to it.
func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element {
if author == "" {
panic("mujica: QuoteReply needs an author")
}
k := Tokens(c)
u := Density(c).Unit()
line := strings.Join(strings.Fields(excerpt), " ")
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MaxWidth(420).Gap(u*2).Padding(u, u*3, u, u*2).Justify(ui.Start).
Radius(theme.ControlRadius).Background(k.SurfaceHover).BorderWidth(0, 0, 0, 3).BorderColor(k.AccentText).
Label(fmt.Sprintf(msg(c, "quote_reply.label"), author, line)).Transition(colorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.Selection)
}
b.Children(func() {
ui.Text(c, author).FontWeight(600).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Shrink(0)
ui.Text(c, line).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().MinWidth(0).Shrink(1)
})
return b
}
+53
View File
@@ -0,0 +1,53 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestQuoteReplyJumpsAndFlattensExcerpt(t *testing.T) {
jumps, off := 0, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(off).Children(func() {
if QuoteReply(c, "Marie", "Let them\n eat cake.").Clicked() {
jumps++
}
})
}, 400, 200)
if !tt.HasText("Let them eat cake.") {
t.Fatalf("excerpt %q", tt.Texts())
}
_ = tt.Click("Reply to Marie: Let them eat cake.")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if jumps != 2 {
t.Fatalf("jumps %d", jumps)
}
off = true
_ = tt.Click("Reply to Marie: Let them eat cake.")
if jumps != 2 {
t.Fatal("disabled quote jumped")
}
}
func TestQuoteReplyNeedsAuthor(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without an author")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
QuoteReply(c, "", "x")
}, 100, 100)
}
func TestQuoteReplySnapshot(t *testing.T) {
snapshot(t, "quote_reply", 420, 70, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
QuoteReply(c, "Herald", "The ball begins at nine in the Hall of Mirrors, and every guest must arrive by the east door.")
})
})
}
+47
View File
@@ -0,0 +1,47 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ScrollToBottomButton shows while away is true, with the unread count; click it to return to the end.
// While hidden it returns an empty element whose Clicked is false.
func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element {
if unread < 0 {
panic("mujica: ScrollToBottomButton unread must not be negative")
}
if !away {
return ui.Box(c).Absolute().Invisible()
}
k := Tokens(c)
u := Density(c).Unit()
label := msg(c, "scroll_to_bottom_button.label")
if unread > 0 {
label = fmt.Sprintf(msg(c, "scroll_to_bottom_button.unread"), unread)
}
wrap := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 3).Justify(ui.Center).PassThrough()
var b *ui.Element
wrap.Children(func() {
b = ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*3).Gap(u*2).
Radius(theme.MinHitSize/2).Label(label).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
TextColor(k.Text).Shadow(0, 2, 8, 0, ui.RGBA(0, 0, 0, 0.14)).Transition(colorFade)
b.Opacity(overlayEnter(c, b))
internal.Ring(b, k.Focus, theme.MinHitSize/2)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Icon(c, icons.Must("arrow-down")).Size(16, 16)
if unread > 0 {
ui.Text(c, fmt.Sprint(unread)).FontFeatures("tnum").FontWeight(600).TextColor(k.AccentText).SingleLine()
}
})
})
return b
}
+51
View File
@@ -0,0 +1,51 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestScrollToBottomButtonShowsWhenAway(t *testing.T) {
away, unread, clicks := false, 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Box(c).Fill().Children(func() {
if ScrollToBottomButton(c, away, unread).Clicked() {
clicks++
}
})
}, 300, 200)
if _, ok := tt.Find("Scroll to latest"); ok {
t.Fatal("shown while at the bottom")
}
away, unread = true, 4
tt.Frame()
if !tt.HasText("4") {
t.Fatalf("unread count missing: %q", tt.Texts())
}
_ = tt.Click("Scroll to latest, 4 new")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if clicks != 2 {
t.Fatalf("clicks %d", clicks)
}
}
func TestScrollToBottomButtonNegativeUnread(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for negative unread")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ScrollToBottomButton(c, true, -1)
}, 100, 100)
}
func TestScrollToBottomButtonSnapshot(t *testing.T) {
snapshot(t, "scroll_to_bottom_button", 240, 80, func(c *ui.Context) {
ui.Box(c).Fill().Children(func() { ScrollToBottomButton(c, true, 12) })
})
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"strings"
"time"
"unicode/utf8"
"github.com/egoist/mygo/ui"
)
// StreamingTextOptions configure a StreamingText.
type StreamingTextOptions struct {
// Streaming shows the cursor after the text while more is coming.
Streaming bool
// Smooth reveals new text gradually instead of at once.
Smooth bool
// Rate is the smooth reveal speed in characters per second; zero means 80.
Rate float64
}
// streamingState is how much of the text shows, and since when.
type streamingState struct {
text string
shown float64
at time.Time
}
// StreamingText shows text that grows as it streams; reduced motion shows it at once.
func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element {
if opts.Rate < 0 {
panic("mujica: StreamingText Rate must not be negative")
}
rate := opts.Rate
if rate == 0 {
rate = 80
}
k := Tokens(c)
e := ui.Box(c).Label(text)
st := ui.Local(e, "stream", func() streamingState {
return streamingState{text: text, shown: float64(utf8.RuneCountInString(text)), at: c.Now()}
})
total := float64(utf8.RuneCountInString(text))
if !strings.HasPrefix(text, st.text) {
// Replaced text starts over from what it shares.
st.shown = min(st.shown, float64(utf8.RuneCountInString(streamingCommon(st.text, text))))
}
st.text = text
now := c.Now()
if !opts.Smooth || Motion(c, time.Second) == 0 {
st.shown = total
} else if st.shown < total {
st.shown = min(total, st.shown+now.Sub(st.at).Seconds()*rate)
c.AnimationFrame()
}
st.at = now
shown := text
if n := int(st.shown); n < int(total) {
shown = string([]rune(text)[:n])
}
spans := []ui.Span{{Text: shown}}
if opts.Streaming {
spans = append(spans, ui.Span{Text: "▍", Color: k.AccentText})
}
return e.Children(func() { ui.RichText(c, spans...) })
}
// streamingCommon returns the longest common prefix of a and b, on rune boundaries.
func streamingCommon(a, b string) string {
n := 0
for n < len(a) && n < len(b) && a[n] == b[n] {
n++
}
for n > 0 && n < len(a) && !utf8.RuneStart(a[n]) {
n--
}
return a[:n]
}
+80
View File
@@ -0,0 +1,80 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func streamingTester(text *string, opts StreamingTextOptions, reduce bool) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
StreamingText(c, *text, opts)
}, 400, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
tt.Frame()
return tt
}
// streamingShown returns the visible text of the stream.
func streamingShown(tt *ui.Tester) string {
texts := tt.Texts()
return texts[len(texts)-1]
}
func TestStreamingTextSmoothReveal(t *testing.T) {
text := "Hello"
tt := streamingTester(&text, StreamingTextOptions{Smooth: true, Streaming: true, Rate: 1}, false)
if !strings.HasPrefix(streamingShown(tt), "Hello") || !strings.HasSuffix(streamingShown(tt), "▍") {
t.Fatalf("first text %q", streamingShown(tt))
}
text += ", court"
tt.Frame()
if s := streamingShown(tt); strings.Contains(s, "court") {
t.Fatalf("1 char/s revealed %q at once", s)
}
}
func TestStreamingTextInstantAndReducedMotion(t *testing.T) {
for _, tc := range []struct {
opts StreamingTextOptions
reduce bool
}{{StreamingTextOptions{}, false}, {StreamingTextOptions{Smooth: true, Rate: 1}, true}} {
text := "Hello"
tt := streamingTester(&text, tc.opts, tc.reduce)
text += ", court"
tt.Frame()
if s := streamingShown(tt); s != "Hello, court" {
t.Errorf("%+v reduce %v: shows %q", tc.opts, tc.reduce, s)
}
}
}
func TestStreamingTextReplacedRestarts(t *testing.T) {
text := "abcdef"
tt := streamingTester(&text, StreamingTextOptions{Smooth: true, Rate: 1}, false)
text = "abXYZ"
tt.Frame()
if s := streamingShown(tt); s != "ab" {
t.Fatalf("replaced text shows %q, want the shared prefix", s)
}
}
func TestStreamingTextNegativeRate(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for a negative rate")
}
}()
text := ""
streamingTester(&text, StreamingTextOptions{Rate: -1}, false)
}
func TestStreamingTextSnapshot(t *testing.T) {
snapshot(t, "streaming_text", 360, 90, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
StreamingText(c, "The herald reads the decree, word by word", StreamingTextOptions{Streaming: true, Smooth: true})
})
})
}
+125
View File
@@ -0,0 +1,125 @@
package mujica
import (
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TableBlockOptions configure a TableBlock.
type TableBlockOptions struct {
// Align sets each column's alignment: ui.Start, ui.Center or ui.End; missing columns start.
Align []ui.Align
}
// TableBlock shows a small table inside a message; it scrolls sideways and copies as TSV.
func TableBlock(c *ui.Context, header []string, rows [][]string, opts TableBlockOptions) CopyResult {
n := len(header)
if n == 0 {
panic("mujica: TableBlock needs a header")
}
if len(opts.Align) > n {
panic("mujica: TableBlock has more alignments than columns")
}
for _, row := range rows {
if len(row) != n {
panic("mujica: TableBlock row width differs from the header")
}
}
k := Tokens(c)
u := Density(c).Unit()
var r CopyResult
r.Element = ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).Clip().MinWidth(0)
copiedAt := ui.Local(r.Element, "copied", func() time.Time { return time.Time{} })
r.Element.Children(func() {
ui.Row(c).Padding(0, u, 0, u*3).AlignItems(ui.Center).Background(k.SurfaceHover).Children(func() {
ui.Text(c, msg(c, "table_block.title")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1)
icon, name := "copy", msg(c, "table_block.copy")
if left := codeBlockCopiedFor - c.Now().Sub(*copiedAt); left > 0 {
icon, name = "check", msg(c, "code_block.copied")
c.After(left)
}
if iconAction(c, icons.Must(icon), name).Clicked() && !r.Element.IsDisabled() {
c.WriteClipboard(tableBlockTSV(header, rows))
*copiedAt, r.copied = c.Now(), true
c.Announce(msg(c, "code_block.copied"))
slog.Debug("mujica: table_block copy", "rows", len(rows))
}
})
tracks := make([]ui.Track, n)
for i := range tracks {
tracks[i] = ui.FitContent()
}
sc := ui.ScrollHorizontal(c).AlignItems(ui.Start)
// Rows span the block: the grid is at least last frame's scroll width.
w := sc.Bounds().W
internal.Remeasure(c, sc, w, sc.Bounds().H)
sc.Children(func() {
ui.Grid(c).ColumnTracks(tracks...).MinWidth(w).Children(func() {
for i, h := range header {
tableBlockCell(c, h, tableBlockAlign(opts.Align, i), true, false)
}
for ri, row := range rows {
for i, cell := range row {
tableBlockCell(c, cell, tableBlockAlign(opts.Align, i), false, ri == len(rows)-1)
}
}
})
})
})
return r
}
func tableBlockAlign(a []ui.Align, i int) ui.Align {
if i < len(a) {
return a[i]
}
return ui.Start
}
func tableBlockCell(c *ui.Context, s string, align ui.Align, head, last bool) {
k := Tokens(c)
u := Density(c).Unit()
cell := ui.Box(c).Padding(u*2, u*3).Role(ui.RoleCell)
if !last {
cell.BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border)
}
if head {
cell.Role(ui.RoleColumnHeader)
}
cell.Children(func() {
t := ui.Text(c, tableBlockClean.Replace(s)).NoWrap().TextAlign(align)
if head {
t.FontWeight(600)
}
})
}
// tableBlockClean turns a cell's tabs and newlines into spaces.
var tableBlockClean = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ")
// tableBlockTSV joins cells with tabs and rows with newlines.
func tableBlockTSV(header []string, rows [][]string) string {
clean := tableBlockClean
var b strings.Builder
line := func(cells []string) {
for i, s := range cells {
if i > 0 {
b.WriteByte('\t')
}
b.WriteString(clean.Replace(s))
}
b.WriteByte('\n')
}
line(header)
for _, row := range rows {
line(row)
}
return b.String()
}
+58
View File
@@ -0,0 +1,58 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var (
tableBlockHeader = []string{"Guest", "Title", "Seat"}
tableBlockRows = [][]string{{"Marie", "Queen", "1"}, {"Louis", "King", "2"}, {"Axel", "Count\tof Fersen", "14"}}
)
func TestTableBlockCopiesTSVAndScrolls(t *testing.T) {
copies := 0
var w float32
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(160).Children(func() {
r := TableBlock(c, tableBlockHeader, tableBlockRows, TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}})
w = r.Element.Bounds().W
if r.Copied() {
copies++
}
})
}, 400, 300)
tt.Frame()
if w > 160 {
t.Fatalf("table widened its message to %v", w)
}
if err := tt.Click("Copy as TSV"); err != nil {
t.Fatal(err)
}
want := "Guest\tTitle\tSeat\nMarie\tQueen\t1\nLouis\tKing\t2\nAxel\tCount of Fersen\t14\n"
if tt.Clipboard() != want || copies != 1 {
t.Fatalf("copies %d clipboard %q", copies, tt.Clipboard())
}
}
func TestTableBlockBadRows(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for a short row")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
TableBlock(c, []string{"a", "b"}, [][]string{{"1"}}, TableBlockOptions{})
}, 100, 100)
}
func TestTableBlockSnapshot(t *testing.T) {
snapshot(t, "table_block", 400, 220, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
TableBlock(c, tableBlockHeader, tableBlockRows, TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more