Chat Messages: components 201-220

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:25:10 -04:00
1 parent 6f0c02d339
commit 9677fad17c
121 files changed
+5737 -1

No files matched your search

+259 -1
View File
@@ -1,3 +1,261 @@
package main
var chatmsgPages = []page{}
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
const chatmsgCategory = "Chat Messages"
var chatmsgPages = append(append(chatmsgListPages, chatmsgBlockPages...), chatmsgActionPages...)
// chatmsgReplies are the lines the demo assistant sends.
var chatmsgReplies = []string{
"The ball begins at nine, in the Hall of Mirrors.",
"Guests arrive by the **east door**; carriages wait in the *lower court*.",
"Seating follows rank: dukes nearest the throne, then the envoys.",
"Shall I send the invitations tonight?",
}
// chatmsgDemo is a chat whose messages the demo adds.
type chatmsgDemo struct {
ids []string
texts []string
list mujica.MessageListState
next int
sidebar bool
dated bool
}
func newChatmsgDemo(n int) *chatmsgDemo {
d := &chatmsgDemo{sidebar: true, dated: true}
for range n {
d.add()
}
return d
}
func (d *chatmsgDemo) add() {
d.ids = append(d.ids, fmt.Sprintf("msg-%d", d.next))
d.texts = append(d.texts, chatmsgReplies[d.next%len(chatmsgReplies)])
d.next++
}
// day puts three messages on each day, ending today.
func (d *chatmsgDemo) day(i int) time.Time {
last := (len(d.ids) - 1) / 3
return time.Now().AddDate(0, 0, i/3-last)
}
// row shows message i as a bubble, users and the assistant taking turns.
func (d *chatmsgDemo) row(c *ui.Context, i int) {
role := mujica.MessageAssistant
if i%2 == 0 {
role = mujica.MessageUser
}
mujica.MessageBubble(c, role, mujica.MessageBubbleOptions{}, func() { mujica.MarkdownView(c, d.texts[i]) })
}
// chatmsgControls adds messages to a demo chat and logs it.
func chatmsgControls(c *ui.Context, log func(string), d *chatmsgDemo) {
ui.Row(c).Gap(8).Children(func() {
if mujica.Button(c, "Add message", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
d.add()
log(fmt.Sprintf("message %s added", d.ids[len(d.ids)-1]))
}
if mujica.Button(c, "Add 10", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for range 10 {
d.add()
}
log(fmt.Sprintf("10 messages added, %d in all", len(d.ids)))
}
if mujica.Button(c, "Jump to latest", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
d.list.ScrollToEnd()
log("scrolled to end")
}
})
}
var chatmsgListPages = []page{
{ID: "201", Name: "ChatContainer", ZhName: "对话容器", Category: chatmsgCategory,
Keys: "Tab 在侧栏、消息与输入区之间移动;消息区滚轮与 PageUp/PageDown 滚动;上翻后出现回到最新按钮。",
Code: `mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{
List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }},
Input: func() { ui.Text(c, "Composer goes here") },
}, func(i int) { ui.Text(c, msgs[i]) })`,
Demo: func(c *ui.Context, log func(string)) {
d := *ui.Local(c.Root(), "201", func() *chatmsgDemo { return newChatmsgDemo(6) })
draft := ui.Local(c.Root(), "201.draft", func() string { return "" })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Sidebar": &d.sidebar}, "Sidebar")
chatmsgControls(c, log, d)
})
inputSection(c, "Live", func() {
ui.Box(c).Height(420).Border(1, mujica.Tokens(c).Border).Clip().Children(func() {
opts := mujica.ChatContainerOptions{
List: mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }},
Input: func() {
r := mujica.TextInput(c, draft, mujica.TextInputOptions{Label: "Message", Placeholder: "Write to the herald"})
if r.Submitted() && strings.TrimSpace(*draft) != "" {
d.add()
d.texts[len(d.texts)-1] = *draft
log("sent a message")
*draft = ""
}
},
}
if d.sidebar {
opts.Sidebar = func() {
ui.Column(c).Padding(12).Gap(8).Children(func() {
for _, s := range []string{"The ball", "Seating", "Envoys"} {
ui.Text(c, s)
}
})
}
}
r := mujica.ChatContainer(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) })
if r.Unread() > 0 {
ui.Text(c, fmt.Sprintf("%d unread", r.Unread())).Absolute().Top(8).Right(12)
}
})
})
}},
{ID: "202", Name: "MessageList", ZhName: "消息列表", Category: chatmsgCategory,
Keys: "滚轮与方向键滚动;上翻后停止跟随;Tab 到回到最新按钮,Enter 回到底部。",
Code: `r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{
ID: func(i int) string { return ids[i] },
}, func(i int) { ui.Text(c, "Message "+ids[i]) })
fmt.Println("unread", r.Unread())`,
Demo: func(c *ui.Context, log func(string)) {
d := *ui.Local(c.Root(), "202", func() *chatmsgDemo { return newChatmsgDemo(30) })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Date separators": &d.dated}, "Date separators")
chatmsgControls(c, log, d)
ui.Row(c).Children(func() {
if mujica.Button(c, "Load 100,000", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for len(d.ids) < 100000 {
d.add()
}
log("100,000 messages loaded")
}
})
})
inputSection(c, "Live", func() {
opts := mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }, Label: func(i int) string { return d.texts[i] }}
if d.dated {
opts.Date = d.day
}
ui.Column(c).Height(360).Border(1, mujica.Tokens(c).Border).Children(func() {
r := mujica.MessageList(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) })
ui.Text(c, fmt.Sprintf("%d messages, %d unread, at end %v", len(d.ids), r.Unread(), d.list.AtEnd())).Padding(8)
})
})
}},
{ID: "203", Name: "MessageBubble", ZhName: "消息气泡", Category: chatmsgCategory,
Keys: "气泡不获取焦点;内容中的链接与按钮按 Tab 顺序访问。",
Code: `mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() {
ui.Text(c, "The ball begins at nine.")
})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
role mujica.MessageRole
avatar, named bool
width float64
}
s := ui.Local(c.Root(), "203", func() state { return state{role: mujica.MessageAssistant, avatar: true, named: true, width: 360} })
roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"},
{Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}}
inputSection(c, "Parameters", func() {
if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() {
log(fmt.Sprintf("role %d", s.role))
}
inputFlags(c, log, map[string]*bool{"Avatar": &s.avatar, "Name": &s.named}, "Avatar", "Name")
if mujica.NumberInput(c, &s.width, mujica.NumberInputOptions{Min: 160, Max: 640, Step: 40, Label: "MaxWidth"}).Changed() {
log(fmt.Sprintf("MaxWidth %v", s.width))
}
})
inputSection(c, "Live", func() {
opts := mujica.MessageBubbleOptions{MaxWidth: float32(s.width)}
if s.named {
opts.Name = "Herald"
}
if s.avatar {
opts.Avatar = func() { mujica.Avatar(c, "Herald", mujica.AvatarOptions{}) }
}
mujica.MessageBubble(c, s.role, opts, func() { ui.Text(c, chatmsgReplies[0]+" "+chatmsgReplies[2]) })
})
inputSection(c, "States", func() {
for _, r := range roles {
mujica.MessageBubble(c, r.Value, mujica.MessageBubbleOptions{Name: r.Label}, func() { ui.Text(c, r.Label+" message") })
}
})
}},
{ID: "204", Name: "MessageHeader", ZhName: "消息头", Category: chatmsgCategory, Keys: "不获取焦点。",
Code: `mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})`,
Demo: func(c *ui.Context, log func(string)) {
status := ui.Local(c.Root(), "204", func() mujica.MessageStatus { return mujica.MessageSent })
items := []mujica.RadioItem[mujica.MessageStatus]{{Value: mujica.MessageNoStatus, Label: "None"}, {Value: mujica.MessageSending, Label: "Sending"},
{Value: mujica.MessageStreaming, Label: "Streaming"}, {Value: mujica.MessageSent, Label: "Sent"}, {Value: mujica.MessageFailed, Label: "Failed"}}
inputSection(c, "Parameters", func() {
if mujica.RadioGroup(c, status, items, mujica.RadioGroupOptions{Horizontal: true, Label: "Status"}).Changed() {
log(fmt.Sprintf("status %d", *status))
}
})
inputSection(c, "Live", func() {
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: *status})
})
inputSection(c, "States", func() {
for _, it := range items[1:] {
mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: it.Value})
}
})
}},
{ID: "205", Name: "StreamingText", ZhName: "流式文本", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时直接显示全部文本。",
Code: `mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
text string
smooth, streaming, auto bool
rate float64
at time.Time
}
s := ui.Local(c.Root(), "205", func() state { return state{smooth: true, streaming: true, rate: 80} })
const full = "By order of the Queen, the ball begins at nine. Guests arrive by the east door, and carriages wait in the lower court until midnight."
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Smooth": &s.smooth, "Streaming cursor": &s.streaming, "Stream": &s.auto}, "Smooth", "Streaming cursor", "Stream")
if mujica.NumberInput(c, &s.rate, mujica.NumberInputOptions{Min: 10, Max: 400, Step: 10, Label: "Rate"}).Changed() {
log(fmt.Sprintf("Rate %v", s.rate))
}
ui.Row(c).Children(func() {
if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.text = ""
log("stream restarted")
}
})
})
// The demo's own source appends a word every 150 ms while streaming.
if s.auto && len(s.text) < len(full) && c.Now().Sub(s.at) > 150*time.Millisecond {
rest := full[len(s.text):]
n := strings.IndexByte(rest[1:], ' ') + 2
if n <= 1 {
n = len(rest)
}
s.text += rest[:n]
s.at = c.Now()
if len(s.text) == len(full) {
log("stream finished")
}
}
if s.auto && len(s.text) < len(full) {
c.After(150 * time.Millisecond)
}
inputSection(c, "Live", func() {
mujica.StreamingText(c, s.text, mujica.StreamingTextOptions{Streaming: s.streaming && len(s.text) < len(full), Smooth: s.smooth, Rate: s.rate})
})
}},
}
+292
View File
@@ -0,0 +1,292 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var chatmsgActionPages = []page{
{ID: "211", Name: "AudioMessage", ZhName: "语音消息", Category: chatmsgCategory, Keys: "Tab 到播放按钮与波形;波形上 Left/Right 跳转,Home、End 到首尾。",
Code: `r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos})
if d, ok := r.Seek(); ok {
pos = d
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
pos time.Duration
playing bool
at time.Time
}
s := ui.Local(c.Root(), "211", func() state { return state{pos: 12 * time.Second} })
const length = 47 * time.Second
// The demo plays: it moves the position while playing.
if s.playing {
s.pos += c.Now().Sub(s.at)
if s.pos >= length {
s.pos, s.playing = length, false
log("playback ended")
}
c.After(100 * time.Millisecond)
}
s.at = c.Now()
inputSection(c, "Live", func() {
r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Position: s.pos, Playing: s.playing, Waveform: chatmsgWave})
if r.Toggled() {
s.playing = !s.playing
if s.pos >= length {
s.pos = 0
}
log(fmt.Sprintf("playing %v", s.playing))
}
if d, ok := r.Seek(); ok {
s.pos = d
log(fmt.Sprintf("seek to %v", d.Round(time.Second)))
}
})
inputSection(c, "States", func() {
ui.Column(c).Disabled(true).Children(func() {
mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Waveform: chatmsgWave})
})
})
}},
{ID: "212", Name: "LinkPreviewCard", ZhName: "链接预览卡片", Category: chatmsgCategory, Keys: "Tab 聚焦卡片,Enter 或 Space 打开。",
Code: `p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"}
if mujica.LinkPreviewCard(c, p).Clicked() {
fmt.Println("open", p.URL)
}`,
Demo: func(c *ui.Context, log func(string)) {
thumb := ui.Local(c.Root(), "212", func() bool { return true })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Thumbnail": thumb}, "Thumbnail")
})
p := mujica.LinkPreview{URL: "https://example.com/versailles", Title: "The Palace of Versailles", Site: "example.com",
Description: "A royal château in the Île-de-France region, seat of the French court from 1682 until the Revolution."}
if *thumb {
p.Thumbnail = chatmsgPicture("#AD9164")
}
inputSection(c, "Live", func() {
if mujica.LinkPreviewCard(c, p).Clicked() {
log("open " + p.URL)
}
if mujica.LinkPreviewCard(c, mujica.LinkPreview{URL: "https://example.com/hall", Title: "Hall of Mirrors"}).Clicked() {
log("open https://example.com/hall")
}
})
}},
{ID: "213", Name: "QuoteReply", ZhName: "引用回复", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 跳转。",
Code: `if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() {
fmt.Println("jump to the quoted message")
}`,
Demo: func(c *ui.Context, log func(string)) {
inputSection(c, "Live", func() {
for _, q := range [][2]string{{"Marie", "Let them eat cake."}, {"Herald", chatmsgMarkdown}} {
if mujica.QuoteReply(c, q[0], q[1]).Clicked() {
log("jump to " + q[0] + ": " + chatmsgSnippet(q[1], 24))
}
}
})
}},
{ID: "214", Name: "MessageActions", ZhName: "消息操作", Category: chatmsgCategory, Keys: "Tab 进入工具栏,Left/Right 移动,Enter 或 Space 触发。",
Code: `if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok {
fmt.Println("action", a)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
role mujica.MessageRole
rating mujica.MessageFeedback
rated bool
}
s := ui.Local(c.Root(), "214", func() state { return state{role: mujica.MessageAssistant, rated: true} })
names := map[mujica.MessageAction]string{mujica.ActionCopy: "copy", mujica.ActionRegenerate: "regenerate", mujica.ActionEdit: "edit",
mujica.ActionLike: "like", mujica.ActionDislike: "dislike", mujica.ActionSpeak: "speak", mujica.ActionShare: "share"}
inputSection(c, "Parameters", func() {
roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"},
{Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}}
if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() {
log(fmt.Sprintf("role %d", s.role))
}
inputFlags(c, log, map[string]*bool{"Rating": &s.rated}, "Rating")
})
inputSection(c, "Live", func() {
opts := mujica.MessageActionsOptions{}
if s.rated {
opts.Feedback = &s.rating
}
if a, ok := mujica.MessageActions(c, s.role, opts).Action(); ok {
log(fmt.Sprintf("action %s, rating %d", names[a], s.rating))
}
})
}},
{ID: "215", Name: "MessageEditor", ZhName: "消息编辑器", Category: chatmsgCategory, Keys: "Cmd/Ctrl+Enter 保存,Escape 取消。",
Code: `if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() {
fmt.Println("new branch from", draft)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
original, draft string
editing bool
branches []string
index int
}
s := ui.Local(c.Root(), "215", func() state {
return state{original: "Seat the ambassador.", branches: []string{"Seat the ambassador."}}
})
inputSection(c, "Live", func() {
if !s.editing {
mujica.MessageBubble(c, mujica.MessageUser, mujica.MessageBubbleOptions{}, func() { ui.Text(c, s.branches[s.index]) })
ui.Row(c).Gap(8).Justify(ui.End).Children(func() {
if mujica.BranchNavigator(c, &s.index, len(s.branches)).Changed() {
log(fmt.Sprintf("branch %d", s.index+1))
}
if mujica.Button(c, "Edit", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
s.editing, s.draft = true, s.branches[s.index]
log("editing")
}
})
return
}
r := mujica.MessageEditor(c, &s.draft, mujica.MessageEditorOptions{Original: s.branches[s.index]})
if r.Saved() {
s.branches = append(s.branches, s.draft)
s.index, s.editing = len(s.branches)-1, false
log(fmt.Sprintf("saved as branch %d", len(s.branches)))
}
if r.Canceled() {
s.editing = false
log("edit canceled")
}
})
}},
{ID: "216", Name: "BranchNavigator", ZhName: "分支切换", Category: chatmsgCategory, Keys: "Tab 聚焦,Left/Right 切换,Home、End 到首末。",
Code: `if mujica.BranchNavigator(c, &branch, 3).Changed() {
fmt.Println("show branch", branch)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
index int
count float64
disabled bool
}
s := ui.Local(c.Root(), "216", func() state { return state{index: 1, count: 3} })
inputSection(c, "Parameters", func() {
if mujica.NumberInput(c, &s.count, mujica.NumberInputOptions{Min: 1, Max: 9, StepperOnly: true, Label: "Branches"}).Changed() {
s.index = min(s.index, int(s.count)-1)
log(fmt.Sprintf("%v branches", s.count))
}
inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled")
})
inputSection(c, "Live", func() {
ui.Row(c).Disabled(s.disabled).Children(func() {
if mujica.BranchNavigator(c, &s.index, int(s.count)).Changed() {
log(fmt.Sprintf("branch %d of %v", s.index+1, s.count))
}
})
})
}},
{ID: "217", Name: "ErrorMessage", ZhName: "错误消息", Category: chatmsgCategory, Keys: "Tab 到错误文本(可选择复制)与重试按钮。",
Code: `opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)}
if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() {
fmt.Println("send again")
}`,
Demo: func(c *ui.Context, log func(string)) {
until := ui.Local(c.Root(), "217", func() time.Time { return time.Time{} })
inputSection(c, "Parameters", func() {
ui.Row(c).Children(func() {
if mujica.Button(c, "Rate limit for 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
*until = c.Now().Add(10 * time.Second)
log("rate limited for 10 s")
}
})
})
inputSection(c, "Live", func() {
opts := mujica.ErrorMessageOptions{RetryAt: *until}
if mujica.ErrorMessage(c, `429 Too Many Requests: {"type":"rate_limit_error","message":"Number of requests exceeded"}`, opts).Retried() {
log("retry")
}
if mujica.ErrorMessage(c, "dial tcp 10.0.0.7:443: connection refused", mujica.ErrorMessageOptions{Title: "Could not reach the server"}).Retried() {
log("retry connection")
}
})
}},
{ID: "218", Name: "ThinkingBlock", ZhName: "思考过程", Category: chatmsgCategory, Keys: "Tab 聚焦标题,Enter 或 Space 展开与折叠。",
Code: `mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() {
ui.Text(c, "Weigh the guest list.")
})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
open, thinking bool
started time.Time
elapsed time.Duration
}
s := ui.Local(c.Root(), "218", func() state { return state{elapsed: 14 * time.Second} })
inputSection(c, "Parameters", func() {
label := "Start thinking"
if s.thinking {
label = "Finish thinking"
}
ui.Row(c).Children(func() {
if mujica.Button(c, label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
if s.thinking {
s.elapsed = c.Now().Sub(s.started)
} else {
s.started = c.Now()
}
s.thinking = !s.thinking
log(fmt.Sprintf("thinking %v", s.thinking))
}
})
})
inputSection(c, "Live", func() {
opts := mujica.ThinkingBlockOptions{Thinking: s.thinking, Started: s.started, Elapsed: s.elapsed}
if mujica.ThinkingBlock(c, &s.open, opts, func() {
ui.Text(c, "Forty guests, thirty-six seats. The envoys outrank the counts; four counts dine in the antechamber.")
}).Changed() {
log(fmt.Sprintf("open %v", s.open))
}
})
}},
{ID: "219", Name: "ScrollToBottomButton", ZhName: "回到底部按钮", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 回到底部。",
Code: `ui.Box(c).Fill().Children(func() {
if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() {
list.ScrollToEnd()
}
})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
away bool
unread float64
}
s := ui.Local(c.Root(), "219", func() state { return state{away: true, unread: 3} })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Away from bottom": &s.away}, "Away from bottom")
if mujica.NumberInput(c, &s.unread, mujica.NumberInputOptions{Min: 0, Max: 99, Label: "Unread"}).Changed() {
log(fmt.Sprintf("unread %v", s.unread))
}
})
inputSection(c, "Live", func() {
ui.Box(c).Height(120).Background(mujica.Tokens(c).Surface).Border(1, mujica.Tokens(c).Border).Children(func() {
if mujica.ScrollToBottomButton(c, s.away, int(s.unread)).Clicked() {
s.away, s.unread = false, 0
log("back to the bottom")
}
})
})
}},
{ID: "220", Name: "TypingIndicator", ZhName: "正在输入", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时显示静态文字。",
Code: `mujica.TypingIndicator(c, "Herald")`,
Demo: func(c *ui.Context, log func(string)) {
typing := ui.Local(c.Root(), "220", func() bool { return true })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Typing": typing}, "Typing")
})
inputSection(c, "Live", func() {
if *typing {
mujica.TypingIndicator(c, "Herald")
}
})
}},
}
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"errors"
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
const chatmsgMarkdown = "## The Decree\n\nBy order of **the Queen**, *all* guests use `the east door`. See [the protocol](https://example.com/protocol).\n\n" +
"1. Arrive at nine\n2. Bow to the throne\n - dukes first\n\n> Silence in the Hall of Mirrors.\n\n" +
"```python\ndef bow(guest):\n return f\"{guest} bows\" # deeply\n```\n\n| Guest | Seat |\n|---|--:|\n| Marie | 1 |\n| Axel | 14 |\n\n---\n\nVive la reine."
// chatmsgPicture is a flat colored picture for image demos.
func chatmsgPicture(color string) ui.ImageSource {
return ui.MustParseSVG([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="%s"/><circle cx="120" cy="90" r="40" fill="#FBF7EF" opacity="0.5"/></svg>`, color)))
}
var chatmsgColors = []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"}
var chatmsgBlockPages = []page{
{ID: "206", Name: "MarkdownView", ZhName: "Markdown 视图", Category: chatmsgCategory, Keys: "Tab 到链接,Enter 激活;代码块与表格按钮按 Tab 顺序访问。",
Code: `if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok {
fmt.Println("open", url)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
src string
stream bool
at time.Time
}
s := ui.Local(c.Root(), "206", func() state { return state{src: chatmsgMarkdown} })
inputSection(c, "Parameters", func() {
ui.Row(c).Children(func() {
if mujica.Button(c, "Stream it again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.src, s.stream = "", true
log("streaming started")
}
})
mujica.TextArea(c, &s.src, mujica.TextAreaOptions{Label: "Markdown source", MaxHeight: 160})
})
if s.stream && len(s.src) < len(chatmsgMarkdown) && c.Now().Sub(s.at) > 40*time.Millisecond {
s.src = chatmsgMarkdown[:min(len(chatmsgMarkdown), len(s.src)+6)]
s.at = c.Now()
if len(s.src) == len(chatmsgMarkdown) {
s.stream = false
log("streaming finished; finished blocks were never parsed again")
}
}
if s.stream {
c.After(40 * time.Millisecond)
}
inputSection(c, "Live", func() {
if url, ok := mujica.MarkdownView(c, s.src).Link(); ok {
log("link " + url)
}
})
}},
{ID: "207", Name: "CodeBlock", ZhName: "代码块", Category: chatmsgCategory, Keys: "Tab 到换行、复制、折叠按钮;Space 或 Enter 触发。",
Code: `mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
lang string
numbers, wrap bool
}
s := ui.Local(c.Root(), "207", func() state { return state{lang: "go", numbers: true} })
samples := map[string]string{
"go": "// greet the court\nfunc greet(name string) int {\n\treturn len(\"Your Majesty, \" + name + \", the ball awaits you in the Hall of Mirrors\") + 42\n}",
"python": "def bow(guest):\n # deeply, always\n return f\"{guest} bows\" * 3",
"sql": "SELECT name, seat FROM guests WHERE rank > 2 ORDER BY seat LIMIT 10; -- the envoys",
}
inputSection(c, "Parameters", func() {
langs := []mujica.RadioItem[string]{{Value: "go", Label: "Go"}, {Value: "python", Label: "Python"}, {Value: "sql", Label: "SQL"}}
if mujica.RadioGroup(c, &s.lang, langs, mujica.RadioGroupOptions{Horizontal: true, Label: "Language"}).Changed() {
log("language " + s.lang)
}
inputFlags(c, log, map[string]*bool{"LineNumbers": &s.numbers, "Wrap at first": &s.wrap}, "LineNumbers", "Wrap at first")
})
inputSection(c, "Live", func() {
ui.Column(c).Key(s.lang + fmt.Sprint(s.wrap)).Children(func() {
if mujica.CodeBlock(c, samples[s.lang], mujica.CodeBlockOptions{Language: s.lang, LineNumbers: s.numbers, Wrap: s.wrap}).Copied() {
log("code copied")
}
})
})
inputSection(c, "States", func() {
mujica.CodeBlock(c, samples["go"], mujica.CodeBlockOptions{Language: "go", Collapsed: true})
})
}},
{ID: "208", Name: "TableBlock", ZhName: "表格块", Category: chatmsgCategory, Keys: "Tab 到复制按钮,Enter 或 Space 复制为 TSV。",
Code: `if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() {
fmt.Println("copied as TSV")
}`,
Demo: func(c *ui.Context, log func(string)) {
wide := ui.Local(c.Root(), "208", func() bool { return false })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Wide table": wide}, "Wide table")
})
header := []string{"Guest", "Title", "Seat"}
rows := [][]string{{"Marie", "Queen of France", "1"}, {"Axel", "Count of Fersen", "14"}}
if *wide {
header = append(header, "Arrival", "Carriage", "Retinue", "Notes")
for i := range rows {
rows[i] = append(rows[i], "21:00", "Gilded coach", "Twelve", "Seated near the orchestra, away from the draughts")
}
}
inputSection(c, "Live", func() {
ui.Column(c).MaxWidth(520).Children(func() {
if mujica.TableBlock(c, header, rows, mujica.TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}}).Copied() {
log("table copied as TSV")
}
})
})
}},
{ID: "209", Name: "ImageGrid", ZhName: "图片网格", Category: chatmsgCategory, Keys: "Tab 聚焦图片,Enter 打开查看器;查看器内 Left/Right 切换,Escape 关闭。",
Code: `if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok {
fmt.Println("reload", i)
}`,
Demo: func(c *ui.Context, log func(string)) {
type state struct {
n float64
loading, fail bool
}
s := ui.Local(c.Root(), "209", func() state { return state{n: 5} })
inputSection(c, "Parameters", func() {
if mujica.NumberInput(c, &s.n, mujica.NumberInputOptions{Min: 1, Max: 9, Label: "Images", StepperOnly: true}).Changed() {
log(fmt.Sprintf("%v images", s.n))
}
inputFlags(c, log, map[string]*bool{"Last loading": &s.loading, "Second failed": &s.fail}, "Last loading", "Second failed")
})
imgs := make([]mujica.GridImage, int(s.n))
for i := range imgs {
imgs[i] = mujica.GridImage{Source: chatmsgPicture(chatmsgColors[i]), Alt: fmt.Sprintf("Portrait %d", i+1)}
}
if s.loading {
imgs[len(imgs)-1] = mujica.GridImage{Alt: "Loading portrait", Loading: true}
}
if s.fail && len(imgs) > 1 {
imgs[1] = mujica.GridImage{Alt: "Lost portrait", Err: errors.New("404")}
}
inputSection(c, "Live", func() {
if i, ok := mujica.ImageGrid(c, imgs).Retried(); ok {
s.fail = false
log(fmt.Sprintf("retry image %d", i))
}
})
}},
{ID: "210", Name: "FileMessage", ZhName: "文件消息", Category: chatmsgCategory, Keys: "Tab 到文件与下载按钮,Enter 或 Space 触发。",
Code: `r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440})
if r.Downloaded() {
fmt.Println("save treaty.pdf")
}`,
Demo: func(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "210", func() bool { return false })
inputSection(c, "Parameters", func() {
inputFlags(c, log, map[string]*bool{"Disabled": disabled}, "Disabled")
})
inputSection(c, "Live", func() {
ui.Column(c).Gap(8).Disabled(*disabled).Children(func() {
for _, f := range []mujica.FileMessageOptions{{Name: "treaty.pdf", Size: 2621440}, {Name: "portrait.png", Size: 812000},
{Name: "ledger.zip", Size: 73400320}, {Name: "decree.go", Size: 1840}, {Name: "notes", Size: 12}} {
r := mujica.FileMessage(c, f)
if r.Opened() {
log("open " + f.Name)
}
if r.Downloaded() {
log("download " + f.Name)
}
}
})
})
}},
}
// chatmsgWave is a fixed waveform for the voice demo.
var chatmsgWave = func() []float32 {
w := make([]float32, 48)
for i := range w {
w[i] = float32(0.25 + 0.7*float64((i*37)%11)/10)
if w[i] > 1 {
w[i] = 1
}
}
return w
}()
// chatmsgSnippet trims s to n runes for logs.
func chatmsgSnippet(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > n {
return string(r[:n]) + "…"
}
return string(r)
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"image/png"
"os"
"path/filepath"
"slices"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
// chatmsgDemoTester runs the demo of page id and returns its tester and event log.
func chatmsgDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) {
t.Helper()
i := slices.IndexFunc(chatmsgPages, func(p page) bool { return p.ID == id })
if i < 0 {
t.Fatalf("no page %s", id)
}
events := &[]string{}
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
ui.Scroll(c).Fill().Padding(24).Gap(16).Children(func() {
chatmsgPages[i].Demo(c, func(s string) { *events = append(*events, s) })
})
}, 900, 800)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
return tt, events
}
func TestChatmsgDemosRender(t *testing.T) {
if len(chatmsgPages) != 20 {
t.Fatalf("%d chat message pages, want 20", len(chatmsgPages))
}
dir := os.Getenv("CHATMSG_DEMO_DIR")
for _, p := range chatmsgPages {
for _, m := range []mujica.Mode{mujica.Light, mujica.Dark} {
tt, _ := chatmsgDemoTester(t, p.ID, m)
if len(tt.Texts()) == 0 {
t.Errorf("%s: demo shows nothing", p.ID)
}
if dir == "" {
continue
}
f, err := os.Create(filepath.Join(dir, p.ID+"-"+m.String()+".png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, tt.Image()); err != nil {
t.Fatal(err)
}
f.Close()
}
}
}
func TestChatmsgDemosLogEvents(t *testing.T) {
for id, click := range map[string]string{
"202": "Add message", "207": "Copy code", "214": "Regenerate", "216": "Next branch", "217": "Retry",
} {
tt, events := chatmsgDemoTester(t, id, mujica.Light)
if err := tt.Click(click); err != nil {
t.Fatalf("%s: %v", id, err)
}
if len(*events) == 0 {
t.Errorf("%s: clicking %q logged nothing", id, click)
}
}
}