diff --git a/audio_message.go b/audio_message.go
new file mode 100644
index 0000000..a97bb08
--- /dev/null
+++ b/audio_message.go
@@ -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)
+}
diff --git a/audio_message_test.go b/audio_message_test.go
new file mode 100644
index 0000000..682e821
--- /dev/null
+++ b/audio_message_test.go
@@ -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})
+ })
+ })
+}
diff --git a/branch_navigator.go b/branch_navigator.go
new file mode 100644
index 0000000..b08f1b3
--- /dev/null
+++ b/branch_navigator.go
@@ -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
+}
diff --git a/branch_navigator_test.go b/branch_navigator_test.go
new file mode 100644
index 0000000..eee74eb
--- /dev/null
+++ b/branch_navigator_test.go
@@ -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) })
+ })
+}
diff --git a/chat_container.go b/chat_container.go
new file mode 100644
index 0000000..a3ad762
--- /dev/null
+++ b/chat_container.go
@@ -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
+}
diff --git a/chat_container_test.go b/chat_container_test.go
new file mode 100644
index 0000000..ac9f9ca
--- /dev/null
+++ b/chat_container_test.go
@@ -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)
+ })
+}
diff --git a/code_block.go b/code_block.go
new file mode 100644
index 0000000..3143923
--- /dev/null
+++ b/code_block.go
@@ -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()
+}
diff --git a/code_block_test.go b/code_block_test.go
new file mode 100644
index 0000000..59d1995
--- /dev/null
+++ b/code_block_test.go
@@ -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})
+ })
+ })
+}
diff --git a/docs/components/201-chat-container.md b/docs/components/201-chat-container.md
new file mode 100644
index 0000000..2c5b04d
--- /dev/null
+++ b/docs/components/201-chat-container.md
@@ -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)包裹。
diff --git a/docs/components/202-message-list.md b/docs/components/202-message-list.md
new file mode 100644
index 0000000..0e2bc60
--- /dev/null
+++ b/docs/components/202-message-list.md
@@ -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 在显示时测量,未显示消息按已测高度估算,长距离跳转时滚动条可能微调。
diff --git a/docs/components/203-message-bubble.md b/docs/components/203-message-bubble.md
new file mode 100644
index 0000000..96349f0
--- /dev/null
+++ b/docs/components/203-message-bubble.md
@@ -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 中或气泡下方。
diff --git a/docs/components/204-message-header.md b/docs/components/204-message-header.md
new file mode 100644
index 0000000..f545f94
--- /dev/null
+++ b/docs/components/204-message-header.md
@@ -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 的日期分隔显示。
diff --git a/docs/components/205-streaming-text.md b/docs/components/205-streaming-text.md
new file mode 100644
index 0000000..552c2cf
--- /dev/null
+++ b/docs/components/205-streaming-text.md
@@ -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),它的增量解析同样适合流式输出。
diff --git a/docs/components/206-markdown-view.md b/docs/components/206-markdown-view.md
new file mode 100644
index 0000000..e1b35b0
--- /dev/null
+++ b/docs/components/206-markdown-view.md
@@ -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 标题、链接引用定义,列表项内不支持多段落。
+- 表格单元格按纯文本显示,不解析行内样式。
+- 源文本被整体替换(不是在末尾追加)时重新解析全部块。
diff --git a/docs/components/207-code-block.md b/docs/components/207-code-block.md
new file mode 100644
index 0000000..87b6375
--- /dev/null
+++ b/docs/components/207-code-block.md
@@ -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 触发。
+
+## 限制
+
+- 着色是逐行的词法着色,不做语法分析;块注释可跨行。
+- 不做虚拟化,适合消息中的代码片段,不适合上万行文件。
diff --git a/docs/components/208-table-block.md b/docs/components/208-table-block.md
new file mode 100644
index 0000000..5891e29
--- /dev/null
+++ b/docs/components/208-table-block.md
@@ -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 起)。
diff --git a/docs/components/209-image-grid.md b/docs/components/209-image-grid.md
new file mode 100644
index 0000000..ae3ffa4
--- /dev/null
+++ b/docs/components/209-image-grid.md
@@ -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。
+
+## 限制
+
+- 查看器只包含已加载的图片,序号按已加载图片计算。
diff --git a/docs/components/210-file-message.md b/docs/components/210-file-message.md
new file mode 100644
index 0000000..5bfc0f5
--- /dev/null
+++ b/docs/components/210-file-message.md
@@ -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 触发。
+
+## 限制
+
+- 组件不读取文件、不下载:打开与下载由调用方完成。
diff --git a/docs/components/211-audio-message.md b/docs/components/211-audio-message.md
new file mode 100644
index 0000000..f8c8879
--- /dev/null
+++ b/docs/components/211-audio-message.md
@@ -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 到开头与结尾。
+
+## 限制
+
+- 不解码音频、不生成波形:采样由调用方提供。
diff --git a/docs/components/212-link-preview-card.md b/docs/components/212-link-preview-card.md
new file mode 100644
index 0000000..c96bc48
--- /dev/null
+++ b/docs/components/212-link-preview-card.md
@@ -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 触发。
+
+## 限制
+
+- 组件不联网:标题、描述与缩略图都由调用方提供。
diff --git a/docs/components/213-quote-reply.md b/docs/components/213-quote-reply.md
new file mode 100644
index 0000000..6f7857b
--- /dev/null
+++ b/docs/components/213-quote-reply.md
@@ -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 触发。辅助技术读到“回复 作者:摘要”。
+
+## 限制
+
+- 只显示一行摘要,不渲染原消息的格式。
diff --git a/docs/components/214-message-actions.md b/docs/components/214-message-actions.md
new file mode 100644
index 0000000..6828031
--- /dev/null
+++ b/docs/components/214-message-actions.md
@@ -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 触发。
+
+## 限制
+
+- 不内置复制内容与朗读:调用方收到操作后自行处理。
diff --git a/docs/components/215-message-editor.md b/docs/components/215-message-editor.md
new file mode 100644
index 0000000..5d6b0d1
--- /dev/null
+++ b/docs/components/215-message-editor.md
@@ -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 依次到文本框、取消、保存。
+
+## 限制
+
+- 不保存历史:分支与原文由调用方保存。
diff --git a/docs/components/216-branch-navigator.md b/docs/components/216-branch-navigator.md
new file mode 100644
index 0000000..8ab7bcc
--- /dev/null
+++ b/docs/components/216-branch-navigator.md
@@ -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 序列,避免到达首末分支时焦点停在禁用按钮上。
diff --git a/docs/components/217-error-message.md b/docs/components/217-error-message.md
new file mode 100644
index 0000000..441891c
--- /dev/null
+++ b/docs/components/217-error-message.md
@@ -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。
diff --git a/docs/components/218-thinking-block.md b/docs/components/218-thinking-block.md
new file mode 100644
index 0000000..fb2ca98
--- /dev/null
+++ b/docs/components/218-thinking-block.md
@@ -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 展开与折叠。
+
+## 限制
+
+- 思考中每秒重绘一次以更新秒数;折叠且完成后不再重绘。
diff --git a/docs/components/219-scroll-to-bottom-button.md b/docs/components/219-scroll-to-bottom-button.md
new file mode 100644
index 0000000..1cd7550
--- /dev/null
+++ b/docs/components/219-scroll-to-bottom-button.md
@@ -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 条新消息”。
+
+## 限制
+
+- 按钮以绝对定位贴在父元素底部,父元素需要占满滚动区域。
diff --git a/docs/components/220-typing-indicator.md b/docs/components/220-typing-indicator.md
new file mode 100644
index 0000000..b0094d6
--- /dev/null
+++ b/docs/components/220-typing-indicator.md
@@ -0,0 +1,35 @@
+# 220 TypingIndicator 正在输入
+
+## 用途
+
+对方正在输入的提示:三个依次明暗变化的圆点;减少动画时改为静态文字“某某 正在输入…”。
+
+## 最小示例
+
+```go
+mujica.TypingIndicator(c, "Herald")
+```
+
+签名:`func TypingIndicator(c *ui.Context, name string) *ui.Element`
+
+## 参数
+
+| 字段 | 类型 | 默认值 | 含义 |
+| --- | --- | --- | --- |
+| `name` | `string` | 必填 | 正在输入的一方;为空 panic。 |
+
+## 状态
+
+动画 / 静态文字;状态角色,辅助技术读到“某某 正在输入…”。
+
+## 事件
+
+无。
+
+## 键盘操作
+
+不获取焦点。
+
+## 限制
+
+- 动画只在显示时请求下一帧,移出界面后停止重绘。
diff --git a/error_message.go b/error_message.go
new file mode 100644
index 0000000..64847d3
--- /dev/null
+++ b/error_message.go
@@ -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
+}
diff --git a/error_message_test.go b/error_message_test.go
new file mode 100644
index 0000000..08afeac
--- /dev/null
+++ b/error_message_test.go
@@ -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{}) })
+ })
+}
diff --git a/example_chatmsg_test.go b/example_chatmsg_test.go
new file mode 100644
index 0000000..63b8ed2
--- /dev/null
+++ b/example_chatmsg_test.go
@@ -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(``))
+ 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)
+}
diff --git a/examples/gallery/pages_chatmsg.go b/examples/gallery/pages_chatmsg.go
index 0172452..10d6b85 100644
--- a/examples/gallery/pages_chatmsg.go
+++ b/examples/gallery/pages_chatmsg.go
@@ -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})
+ })
+ }},
+}
diff --git a/examples/gallery/pages_chatmsg_actions.go b/examples/gallery/pages_chatmsg_actions.go
new file mode 100644
index 0000000..4d84d48
--- /dev/null
+++ b/examples/gallery/pages_chatmsg_actions.go
@@ -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")
+ }
+ })
+ }},
+}
diff --git a/examples/gallery/pages_chatmsg_blocks.go b/examples/gallery/pages_chatmsg_blocks.go
new file mode 100644
index 0000000..3356e64
--- /dev/null
+++ b/examples/gallery/pages_chatmsg_blocks.go
@@ -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(``, 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)
+}
diff --git a/examples/gallery/pages_chatmsg_test.go b/examples/gallery/pages_chatmsg_test.go
new file mode 100644
index 0000000..7a2bf62
--- /dev/null
+++ b/examples/gallery/pages_chatmsg_test.go
@@ -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)
+ }
+ }
+}
diff --git a/file_message.go b/file_message.go
new file mode 100644
index 0000000..451d76c
--- /dev/null
+++ b/file_message.go
@@ -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"
+}
diff --git a/file_message_test.go b/file_message_test.go
new file mode 100644
index 0000000..3ec84cf
--- /dev/null
+++ b/file_message_test.go
@@ -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})
+ })
+ })
+}
diff --git a/icons/svg/brain.svg b/icons/svg/brain.svg
new file mode 100644
index 0000000..5a030d0
--- /dev/null
+++ b/icons/svg/brain.svg
@@ -0,0 +1,21 @@
+
diff --git a/icons/svg/download.svg b/icons/svg/download.svg
new file mode 100644
index 0000000..4da2f2a
--- /dev/null
+++ b/icons/svg/download.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/file-archive.svg b/icons/svg/file-archive.svg
new file mode 100644
index 0000000..c9ea9ef
--- /dev/null
+++ b/icons/svg/file-archive.svg
@@ -0,0 +1,19 @@
+
diff --git a/icons/svg/file-code.svg b/icons/svg/file-code.svg
new file mode 100644
index 0000000..34c065e
--- /dev/null
+++ b/icons/svg/file-code.svg
@@ -0,0 +1,17 @@
+
diff --git a/icons/svg/pause.svg b/icons/svg/pause.svg
new file mode 100644
index 0000000..12b6f06
--- /dev/null
+++ b/icons/svg/pause.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/pencil.svg b/icons/svg/pencil.svg
new file mode 100644
index 0000000..f0e3f38
--- /dev/null
+++ b/icons/svg/pencil.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/play.svg b/icons/svg/play.svg
new file mode 100644
index 0000000..6eb03bb
--- /dev/null
+++ b/icons/svg/play.svg
@@ -0,0 +1,14 @@
+
diff --git a/icons/svg/share-2.svg b/icons/svg/share-2.svg
new file mode 100644
index 0000000..7825a02
--- /dev/null
+++ b/icons/svg/share-2.svg
@@ -0,0 +1,18 @@
+
diff --git a/icons/svg/thumbs-down.svg b/icons/svg/thumbs-down.svg
new file mode 100644
index 0000000..6e74b23
--- /dev/null
+++ b/icons/svg/thumbs-down.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/thumbs-up.svg b/icons/svg/thumbs-up.svg
new file mode 100644
index 0000000..30dca62
--- /dev/null
+++ b/icons/svg/thumbs-up.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/volume-2.svg b/icons/svg/volume-2.svg
new file mode 100644
index 0000000..d403d95
--- /dev/null
+++ b/icons/svg/volume-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/wrap-text.svg b/icons/svg/wrap-text.svg
new file mode 100644
index 0000000..d5ce615
--- /dev/null
+++ b/icons/svg/wrap-text.svg
@@ -0,0 +1,17 @@
+
diff --git a/image_grid.go b/image_grid.go
new file mode 100644
index 0000000..8161285
--- /dev/null
+++ b/image_grid.go
@@ -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
+}
diff --git a/image_grid_test.go b/image_grid_test.go
new file mode 100644
index 0000000..e5e65fd
--- /dev/null
+++ b/image_grid_test.go
@@ -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(``, 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) })
+ })
+}
diff --git a/internal/markdown.go b/internal/markdown.go
new file mode 100644
index 0000000..8608cac
--- /dev/null
+++ b/internal/markdown.go
@@ -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
+}
diff --git a/internal/markdown_inline.go b/internal/markdown_inline.go
new file mode 100644
index 0000000..db9e160
--- /dev/null
+++ b/internal/markdown_inline.go
@@ -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
+}
diff --git a/internal/markdown_test.go b/internal/markdown_test.go
new file mode 100644
index 0000000..373baeb
--- /dev/null
+++ b/internal/markdown_test.go
@@ -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)
+ }
+}
diff --git a/link_preview_card.go b/link_preview_card.go
new file mode 100644
index 0000000..697ace7
--- /dev/null
+++ b/link_preview_card.go
@@ -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
+}
diff --git a/link_preview_card_test.go b/link_preview_card_test.go
new file mode 100644
index 0000000..527f4e9
--- /dev/null
+++ b/link_preview_card_test.go
@@ -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) })
+ })
+}
diff --git a/markdown_view.go b/markdown_view.go
new file mode 100644
index 0000000..d06a6e7
--- /dev/null
+++ b/markdown_view.go
@@ -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
+}
diff --git a/markdown_view_test.go b/markdown_view_test.go
new file mode 100644
index 0000000..9f39b2f
--- /dev/null
+++ b/markdown_view_test.go
@@ -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) })
+ })
+}
diff --git a/message_actions.go b/message_actions.go
new file mode 100644
index 0000000..ba28375
--- /dev/null
+++ b/message_actions.go
@@ -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
+}
diff --git a/message_actions_test.go b/message_actions_test.go
new file mode 100644
index 0000000..6c41739
--- /dev/null
+++ b/message_actions_test.go
@@ -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}) })
+ })
+}
diff --git a/message_bubble.go b/message_bubble.go
new file mode 100644
index 0000000..a4377ae
--- /dev/null
+++ b/message_bubble.go
@@ -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
+}
diff --git a/message_bubble_test.go b/message_bubble_test.go
new file mode 100644
index 0000000..df5676c
--- /dev/null
+++ b/message_bubble_test.go
@@ -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.") })
+ })
+ })
+}
diff --git a/message_editor.go b/message_editor.go
new file mode 100644
index 0000000..06439b5
--- /dev/null
+++ b/message_editor.go
@@ -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
+}
diff --git a/message_editor_test.go b/message_editor_test.go
new file mode 100644
index 0000000..1a75412
--- /dev/null
+++ b/message_editor_test.go
@@ -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."}) })
+ })
+}
diff --git a/message_header.go b/message_header.go
new file mode 100644
index 0000000..f45adde
--- /dev/null
+++ b/message_header.go
@@ -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
+}
diff --git a/message_header_test.go b/message_header_test.go
new file mode 100644
index 0000000..61f95b6
--- /dev/null
+++ b/message_header_test.go
@@ -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})
+ }
+ })
+ })
+}
diff --git a/message_list.go b/message_list.go
new file mode 100644
index 0000000..fc5bf1c
--- /dev/null
+++ b/message_list.go
@@ -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()
+ })
+}
diff --git a/message_list_test.go b/message_list_test.go
new file mode 100644
index 0000000..5aaf0a4
--- /dev/null
+++ b/message_list_test.go
@@ -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)
+ })
+}
diff --git a/messages_chatmsg.go b/messages_chatmsg.go
new file mode 100644
index 0000000..2ebff69
--- /dev/null
+++ b/messages_chatmsg.go
@@ -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 正在输入…"},
+ })
+}
diff --git a/quote_reply.go b/quote_reply.go
new file mode 100644
index 0000000..ad82de1
--- /dev/null
+++ b/quote_reply.go
@@ -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
+}
diff --git a/quote_reply_test.go b/quote_reply_test.go
new file mode 100644
index 0000000..b9aa5b5
--- /dev/null
+++ b/quote_reply_test.go
@@ -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.")
+ })
+ })
+}
diff --git a/scroll_to_bottom_button.go b/scroll_to_bottom_button.go
new file mode 100644
index 0000000..19ac548
--- /dev/null
+++ b/scroll_to_bottom_button.go
@@ -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
+}
diff --git a/scroll_to_bottom_button_test.go b/scroll_to_bottom_button_test.go
new file mode 100644
index 0000000..2ea4271
--- /dev/null
+++ b/scroll_to_bottom_button_test.go
@@ -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) })
+ })
+}
diff --git a/streaming_text.go b/streaming_text.go
new file mode 100644
index 0000000..8c658f4
--- /dev/null
+++ b/streaming_text.go
@@ -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]
+}
diff --git a/streaming_text_test.go b/streaming_text_test.go
new file mode 100644
index 0000000..a8499d3
--- /dev/null
+++ b/streaming_text_test.go
@@ -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})
+ })
+ })
+}
diff --git a/table_block.go b/table_block.go
new file mode 100644
index 0000000..55ccfc7
--- /dev/null
+++ b/table_block.go
@@ -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()
+}
diff --git a/table_block_test.go b/table_block_test.go
new file mode 100644
index 0000000..c5bc804
--- /dev/null
+++ b/table_block_test.go
@@ -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}})
+ })
+ })
+}
diff --git a/testdata/snapshots/darwin/audio_message-dark.png b/testdata/snapshots/darwin/audio_message-dark.png
new file mode 100644
index 0000000..f987222
Binary files /dev/null and b/testdata/snapshots/darwin/audio_message-dark.png differ
diff --git a/testdata/snapshots/darwin/audio_message-light.png b/testdata/snapshots/darwin/audio_message-light.png
new file mode 100644
index 0000000..8c92e8e
Binary files /dev/null and b/testdata/snapshots/darwin/audio_message-light.png differ
diff --git a/testdata/snapshots/darwin/branch_navigator-dark.png b/testdata/snapshots/darwin/branch_navigator-dark.png
new file mode 100644
index 0000000..a2341da
Binary files /dev/null and b/testdata/snapshots/darwin/branch_navigator-dark.png differ
diff --git a/testdata/snapshots/darwin/branch_navigator-light.png b/testdata/snapshots/darwin/branch_navigator-light.png
new file mode 100644
index 0000000..9179751
Binary files /dev/null and b/testdata/snapshots/darwin/branch_navigator-light.png differ
diff --git a/testdata/snapshots/darwin/chat_container-dark.png b/testdata/snapshots/darwin/chat_container-dark.png
new file mode 100644
index 0000000..850e4c2
Binary files /dev/null and b/testdata/snapshots/darwin/chat_container-dark.png differ
diff --git a/testdata/snapshots/darwin/chat_container-light.png b/testdata/snapshots/darwin/chat_container-light.png
new file mode 100644
index 0000000..503f7a3
Binary files /dev/null and b/testdata/snapshots/darwin/chat_container-light.png differ
diff --git a/testdata/snapshots/darwin/code_block-dark.png b/testdata/snapshots/darwin/code_block-dark.png
new file mode 100644
index 0000000..6c41a35
Binary files /dev/null and b/testdata/snapshots/darwin/code_block-dark.png differ
diff --git a/testdata/snapshots/darwin/code_block-light.png b/testdata/snapshots/darwin/code_block-light.png
new file mode 100644
index 0000000..7c3b9cf
Binary files /dev/null and b/testdata/snapshots/darwin/code_block-light.png differ
diff --git a/testdata/snapshots/darwin/error_message-dark.png b/testdata/snapshots/darwin/error_message-dark.png
new file mode 100644
index 0000000..ba389db
Binary files /dev/null and b/testdata/snapshots/darwin/error_message-dark.png differ
diff --git a/testdata/snapshots/darwin/error_message-light.png b/testdata/snapshots/darwin/error_message-light.png
new file mode 100644
index 0000000..04a1344
Binary files /dev/null and b/testdata/snapshots/darwin/error_message-light.png differ
diff --git a/testdata/snapshots/darwin/file_message-dark.png b/testdata/snapshots/darwin/file_message-dark.png
new file mode 100644
index 0000000..f3438b9
Binary files /dev/null and b/testdata/snapshots/darwin/file_message-dark.png differ
diff --git a/testdata/snapshots/darwin/file_message-light.png b/testdata/snapshots/darwin/file_message-light.png
new file mode 100644
index 0000000..f0b5f9c
Binary files /dev/null and b/testdata/snapshots/darwin/file_message-light.png differ
diff --git a/testdata/snapshots/darwin/image_grid-dark.png b/testdata/snapshots/darwin/image_grid-dark.png
new file mode 100644
index 0000000..c28e23f
Binary files /dev/null and b/testdata/snapshots/darwin/image_grid-dark.png differ
diff --git a/testdata/snapshots/darwin/image_grid-light.png b/testdata/snapshots/darwin/image_grid-light.png
new file mode 100644
index 0000000..a622891
Binary files /dev/null and b/testdata/snapshots/darwin/image_grid-light.png differ
diff --git a/testdata/snapshots/darwin/link_preview_card-dark.png b/testdata/snapshots/darwin/link_preview_card-dark.png
new file mode 100644
index 0000000..904852b
Binary files /dev/null and b/testdata/snapshots/darwin/link_preview_card-dark.png differ
diff --git a/testdata/snapshots/darwin/link_preview_card-light.png b/testdata/snapshots/darwin/link_preview_card-light.png
new file mode 100644
index 0000000..b1e45ba
Binary files /dev/null and b/testdata/snapshots/darwin/link_preview_card-light.png differ
diff --git a/testdata/snapshots/darwin/markdown_view-dark.png b/testdata/snapshots/darwin/markdown_view-dark.png
new file mode 100644
index 0000000..a4d43d3
Binary files /dev/null and b/testdata/snapshots/darwin/markdown_view-dark.png differ
diff --git a/testdata/snapshots/darwin/markdown_view-light.png b/testdata/snapshots/darwin/markdown_view-light.png
new file mode 100644
index 0000000..0550c31
Binary files /dev/null and b/testdata/snapshots/darwin/markdown_view-light.png differ
diff --git a/testdata/snapshots/darwin/message_actions-dark.png b/testdata/snapshots/darwin/message_actions-dark.png
new file mode 100644
index 0000000..878585a
Binary files /dev/null and b/testdata/snapshots/darwin/message_actions-dark.png differ
diff --git a/testdata/snapshots/darwin/message_actions-light.png b/testdata/snapshots/darwin/message_actions-light.png
new file mode 100644
index 0000000..f31f9f0
Binary files /dev/null and b/testdata/snapshots/darwin/message_actions-light.png differ
diff --git a/testdata/snapshots/darwin/message_bubble-dark.png b/testdata/snapshots/darwin/message_bubble-dark.png
new file mode 100644
index 0000000..71d3f9a
Binary files /dev/null and b/testdata/snapshots/darwin/message_bubble-dark.png differ
diff --git a/testdata/snapshots/darwin/message_bubble-light.png b/testdata/snapshots/darwin/message_bubble-light.png
new file mode 100644
index 0000000..fd189cc
Binary files /dev/null and b/testdata/snapshots/darwin/message_bubble-light.png differ
diff --git a/testdata/snapshots/darwin/message_editor-dark.png b/testdata/snapshots/darwin/message_editor-dark.png
new file mode 100644
index 0000000..b61c1ac
Binary files /dev/null and b/testdata/snapshots/darwin/message_editor-dark.png differ
diff --git a/testdata/snapshots/darwin/message_editor-light.png b/testdata/snapshots/darwin/message_editor-light.png
new file mode 100644
index 0000000..e609072
Binary files /dev/null and b/testdata/snapshots/darwin/message_editor-light.png differ
diff --git a/testdata/snapshots/darwin/message_header-dark.png b/testdata/snapshots/darwin/message_header-dark.png
new file mode 100644
index 0000000..547af06
Binary files /dev/null and b/testdata/snapshots/darwin/message_header-dark.png differ
diff --git a/testdata/snapshots/darwin/message_header-light.png b/testdata/snapshots/darwin/message_header-light.png
new file mode 100644
index 0000000..cc7c41b
Binary files /dev/null and b/testdata/snapshots/darwin/message_header-light.png differ
diff --git a/testdata/snapshots/darwin/message_list-dark.png b/testdata/snapshots/darwin/message_list-dark.png
new file mode 100644
index 0000000..0850170
Binary files /dev/null and b/testdata/snapshots/darwin/message_list-dark.png differ
diff --git a/testdata/snapshots/darwin/message_list-light.png b/testdata/snapshots/darwin/message_list-light.png
new file mode 100644
index 0000000..1853340
Binary files /dev/null and b/testdata/snapshots/darwin/message_list-light.png differ
diff --git a/testdata/snapshots/darwin/quote_reply-dark.png b/testdata/snapshots/darwin/quote_reply-dark.png
new file mode 100644
index 0000000..dae6d61
Binary files /dev/null and b/testdata/snapshots/darwin/quote_reply-dark.png differ
diff --git a/testdata/snapshots/darwin/quote_reply-light.png b/testdata/snapshots/darwin/quote_reply-light.png
new file mode 100644
index 0000000..9c4e543
Binary files /dev/null and b/testdata/snapshots/darwin/quote_reply-light.png differ
diff --git a/testdata/snapshots/darwin/scroll_to_bottom_button-dark.png b/testdata/snapshots/darwin/scroll_to_bottom_button-dark.png
new file mode 100644
index 0000000..2213efe
Binary files /dev/null and b/testdata/snapshots/darwin/scroll_to_bottom_button-dark.png differ
diff --git a/testdata/snapshots/darwin/scroll_to_bottom_button-light.png b/testdata/snapshots/darwin/scroll_to_bottom_button-light.png
new file mode 100644
index 0000000..31fd1d7
Binary files /dev/null and b/testdata/snapshots/darwin/scroll_to_bottom_button-light.png differ
diff --git a/testdata/snapshots/darwin/streaming_text-dark.png b/testdata/snapshots/darwin/streaming_text-dark.png
new file mode 100644
index 0000000..4e3abd4
Binary files /dev/null and b/testdata/snapshots/darwin/streaming_text-dark.png differ
diff --git a/testdata/snapshots/darwin/streaming_text-light.png b/testdata/snapshots/darwin/streaming_text-light.png
new file mode 100644
index 0000000..cadc899
Binary files /dev/null and b/testdata/snapshots/darwin/streaming_text-light.png differ
diff --git a/testdata/snapshots/darwin/table_block-dark.png b/testdata/snapshots/darwin/table_block-dark.png
new file mode 100644
index 0000000..cc38f31
Binary files /dev/null and b/testdata/snapshots/darwin/table_block-dark.png differ
diff --git a/testdata/snapshots/darwin/table_block-light.png b/testdata/snapshots/darwin/table_block-light.png
new file mode 100644
index 0000000..54fdc1d
Binary files /dev/null and b/testdata/snapshots/darwin/table_block-light.png differ
diff --git a/testdata/snapshots/darwin/thinking_block-dark.png b/testdata/snapshots/darwin/thinking_block-dark.png
new file mode 100644
index 0000000..a33397b
Binary files /dev/null and b/testdata/snapshots/darwin/thinking_block-dark.png differ
diff --git a/testdata/snapshots/darwin/thinking_block-light.png b/testdata/snapshots/darwin/thinking_block-light.png
new file mode 100644
index 0000000..4f62d55
Binary files /dev/null and b/testdata/snapshots/darwin/thinking_block-light.png differ
diff --git a/testdata/snapshots/darwin/typing_indicator-dark.png b/testdata/snapshots/darwin/typing_indicator-dark.png
new file mode 100644
index 0000000..50032a9
Binary files /dev/null and b/testdata/snapshots/darwin/typing_indicator-dark.png differ
diff --git a/testdata/snapshots/darwin/typing_indicator-light.png b/testdata/snapshots/darwin/typing_indicator-light.png
new file mode 100644
index 0000000..d9aa0ce
Binary files /dev/null and b/testdata/snapshots/darwin/typing_indicator-light.png differ
diff --git a/thinking_block.go b/thinking_block.go
new file mode 100644
index 0000000..e859cc7
--- /dev/null
+++ b/thinking_block.go
@@ -0,0 +1,50 @@
+package mujica
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// ThinkingBlockOptions configure a ThinkingBlock.
+type ThinkingBlockOptions struct {
+ // Thinking shows the spinner and a running timer from Started.
+ Thinking bool
+ Started time.Time
+ // Elapsed is the final thinking time, shown once Thinking is false.
+ Elapsed time.Duration
+}
+
+// ThinkingBlock folds a model's reasoning under a header with its time; *open's first value sets the default.
+func ThinkingBlock(c *ui.Context, open *bool, opts ThinkingBlockOptions, content func()) ChoiceResult {
+ if opts.Elapsed < 0 || opts.Thinking && opts.Started.IsZero() {
+ panic("mujica: ThinkingBlock needs Started while Thinking and a non-negative Elapsed")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ secs := int(opts.Elapsed.Round(time.Second).Seconds())
+ title := fmt.Sprintf(msg(c, "thinking_block.done"), secs)
+ if opts.Thinking {
+ run := c.Now().Sub(opts.Started)
+ secs = int(max(run, 0) / time.Second)
+ title = fmt.Sprintf(msg(c, "thinking_block.thinking"), secs)
+ c.After(time.Second - run%time.Second)
+ }
+ return Collapsible(c, open, CollapsibleOptions{Title: title, Trigger: func() {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
+ if opts.Thinking {
+ Spinner(c, SpinnerOptions{Size: 14, Label: title})
+ } else {
+ ui.Icon(c, icons.Must("brain")).Size(16, 16)
+ }
+ ui.Text(c, title).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
+ })
+ }}, func() {
+ ui.Column(c).Gap(u*2).Padding(0, 0, 0, u*3).BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.Border).
+ TextColor(k.TextMuted).Children(content)
+ })
+}
diff --git a/thinking_block_test.go b/thinking_block_test.go
new file mode 100644
index 0000000..0688685
--- /dev/null
+++ b/thinking_block_test.go
@@ -0,0 +1,71 @@
+package mujica
+
+import (
+ "testing"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestThinkingBlockFoldsAndTimes(t *testing.T) {
+ open, thinking := false, true
+ started := time.Now().Add(-5 * time.Second)
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ThinkingBlock(c, &open, ThinkingBlockOptions{Thinking: thinking, Started: started, Elapsed: 12 * time.Second}, func() {
+ ui.Text(c, "Weigh the guest list")
+ })
+ }, 400, 200)
+ if !tt.HasText("Thinking… 5 s") || tt.HasText("Weigh the guest list") {
+ t.Fatalf("collapsed thinking: %q", tt.Texts())
+ }
+ _ = tt.Click("Thinking… 5 s")
+ if !open || !tt.HasText("Weigh the guest list") {
+ t.Fatal("click did not open the reasoning")
+ }
+ thinking = false
+ tt.Frame()
+ if !tt.HasText("Thought for 12 s") {
+ t.Fatalf("finished header %q", tt.Texts())
+ }
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeySpace)
+ if open {
+ t.Fatal("Space did not fold it")
+ }
+}
+
+func TestThinkingBlockDefaultOpen(t *testing.T) {
+ open := true
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ThinkingBlock(c, &open, ThinkingBlockOptions{Elapsed: time.Second}, func() { ui.Text(c, "Notes") })
+ }, 400, 200)
+ if !tt.HasText("Notes") {
+ t.Fatal("default open block hides its content")
+ }
+}
+
+func TestThinkingBlockNeedsStart(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for Thinking without Started")
+ }
+ }()
+ open := false
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ThinkingBlock(c, &open, ThinkingBlockOptions{Thinking: true}, func() {})
+ }, 100, 100)
+}
+
+func TestThinkingBlockSnapshot(t *testing.T) {
+ open := true
+ snapshot(t, "thinking_block", 420, 150, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Children(func() {
+ ThinkingBlock(c, &open, ThinkingBlockOptions{Elapsed: 14 * time.Second}, func() {
+ ui.Text(c, "The guest list has forty names; the hall seats thirty-six. Four must dine in the antechamber.")
+ })
+ })
+ })
+}
diff --git a/typing_indicator.go b/typing_indicator.go
new file mode 100644
index 0000000..8657c60
--- /dev/null
+++ b/typing_indicator.go
@@ -0,0 +1,44 @@
+package mujica
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// typingPeriod is one cycle of the dots, in milliseconds.
+const typingPeriod = 1200
+
+// TypingIndicator shows that name is typing: three fading dots, or static text under reduced motion.
+func TypingIndicator(c *ui.Context, name string) *ui.Element {
+ if name == "" {
+ panic("mujica: TypingIndicator needs a name")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ label := fmt.Sprintf(msg(c, "typing_indicator.label"), name)
+ still := c.Preferences().ReduceMotion
+ row := ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u*3).Radius(theme.CardRadius).
+ Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleStatus).Label(label).AlignSelf(ui.Start)
+ row.Children(func() {
+ if still {
+ ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
+ return
+ }
+ ui.Box(c).Size(30, 12).Draw(func(p *ui.Painter, r ui.Rect) {
+ // Repaints only while shown: the painter asks for the next frame.
+ p.AnimationFrame()
+ phase := float64(p.Now().UnixMilli()%typingPeriod) / typingPeriod
+ for i := range 3 {
+ a := 0.35 + 0.65*(0.5+0.5*math.Cos(2*math.Pi*(phase-float64(i)/6)))
+ cx := internal.Snap(p, r.X+5+float32(i)*10)
+ p.Fill(ui.Rect{X: cx - 3, Y: r.Y + r.H/2 - 3, W: 6, H: 6}, k.TextMuted.Alpha(float32(a)), 3)
+ }
+ })
+ })
+ return row
+}
diff --git a/typing_indicator_test.go b/typing_indicator_test.go
new file mode 100644
index 0000000..963d98f
--- /dev/null
+++ b/typing_indicator_test.go
@@ -0,0 +1,49 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestTypingIndicatorMotionAndStatic(t *testing.T) {
+ for _, reduce := range []bool{false, true} {
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ TypingIndicator(c, "Herald")
+ }, 300, 100)
+ tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
+ tt.Frame()
+ if _, ok := tt.Find("Herald is typing…"); !ok {
+ t.Fatalf("reduce %v: not named", reduce)
+ }
+ shown := 0
+ for _, s := range tt.Texts() {
+ if s == "Herald is typing…" {
+ shown++
+ }
+ }
+ // The status label always; the static text only under reduced motion.
+ if want := map[bool]int{false: 1, true: 2}[reduce]; shown != want {
+ t.Errorf("reduce %v: %d labels, want %d", reduce, shown, want)
+ }
+ }
+}
+
+func TestTypingIndicatorNeedsName(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic without a name")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ TypingIndicator(c, "")
+ }, 100, 100)
+}
+
+func TestTypingIndicatorSnapshot(t *testing.T) {
+ snapshot(t, "typing_indicator", 240, 60, func(c *ui.Context) {
+ ui.Column(c).Padding(12).Children(func() { TypingIndicator(c, "Herald") })
+ })
+}