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") }) + }) +}