Chat Messages: components 201-220
This commit is contained in:
121 files changed
+5737
-1
No files matched your search
@@ -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})
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user