Add chat components 321-345; named List rows, inert scroll views

Speech bubbles, a chat log that follows its end, a composer that sends
on Enter, typing dots, chat and channel lists, read receipts,
reactions, emoji and sticker pickers, mentions, date and unread rules,
system lines, member lists, user cards, a mailbox, mail reader and
letter, a notification bell, call controls, a voice meter, attachment
chips, link previews and polls.

pixel's editor takes EnterSends; display's Bubble takes a Fill and
exports EmojiSprite. data.List names its rows, which also lets typing
choose one. layout.ScrollArea and input.TextArea hold still under
Inert, and an inert scroll view leaves the Tab order until it wakes.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 05:02:36 -04:00
1 parent 8be66c09f2
commit 455f5f2a05
50 files changed
+2697 -34

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
package gallery
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chat"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var chatEntries = []Entry{
{321, "SpeechBubble", "聊天气泡", "chat", "Their words on the left, yours on the right, tails pointing home.", demoSpeechBubble},
{322, "ChatLog", "聊天记录", "chat", "Scrolls with the talk; at the end, it stays at the end.", demoChatLog},
{323, "Composer", "输入栏", "chat", "Enter sends, Shift+Enter breaks the line. SEND ▶ works too.", demoComposer},
{324, "TypingIndicator", "正在输入", "chat", "Three dots hop while someone types. Suspense!", demoTypingIndicator},
{325, "ChatList", "会话列表", "chat", "Who said what last, and how much you missed.", demoChatList},
{326, "ReadReceipt", "已读回执", "chat", "One check: sent. Two checks: seen. No excuses.", demoReadReceipt},
{327, "Reactions", "表情回应", "chat", "Click a chip to add your face to the pile, again to take it back.", demoReactions},
{328, "EmojiPicker", "表情选择", "chat", "Faces, the world and loot, eight to a row.", demoEmojiPicker},
{329, "MentionInput", "提及输入", "chat", "Type @ and a few letters; Tab finishes the name.", demoMentionInput},
{330, "DateSeparator", "日期分隔", "chat", "TODAY, YESTERDAY, then plain dates. Turns over at midnight.", demoDateSeparator},
{331, "UnreadDivider", "未读分隔", "chat", "Everything under the red line is news.", demoUnreadDivider},
{332, "SystemMessage", "系统消息", "chat", "Announcements from the game itself.", demoSystemMessage},
{333, "ChannelList", "频道列表", "chat", "Bright channels with a dot have something new.", demoChannelList},
}
type chatDemo struct {
draft, mention string
msgs []chat.Message
conv, channel int
reactions []chat.Reaction
mails []chat.Mail
mail, vote int
news int
muted, camera bool
level float64
files []string
polls []chat.PollOption
}
func chatState(c *ui.Context) *chatDemo {
now := pixel.K(c).Now()
return ui.Local(c.Root(), demoKey("chat"), func() chatDemo {
return chatDemo{
msgs: []chat.Message{
{From: "Aria", Text: "Ready for the boss?", At: now.Add(-6 * time.Minute)},
{From: "You", Text: "Potions packed. Lead the way!", At: now.Add(-5 * time.Minute), Mine: true},
{From: "Brom", Text: "I brought snacks.", At: now.Add(-4 * time.Minute)},
},
reactions: []chat.Reaction{{Emoji: "love", Count: 3}, {Emoji: "laugh", Count: 1, Mine: true}, {Emoji: "fire", Count: 5}},
mails: mailbag(now),
mail: -1, vote: 1, camera: true, level: 0.6,
files: []string{"world-map.png", "battle-theme.mp3", "notes.txt"},
polls: []chat.PollOption{{Text: "Mage", Votes: 4}, {Text: "Knight", Votes: 6}, {Text: "Rogue", Votes: 3}, {Text: "Bard", Votes: 1}},
}
})
}
func demoSpeechBubble(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := chatState(c)
ui.Column(c).Width(k.D(220)).Gap(k.D(4)).Children(func() {
chat.SpeechBubble(c, s.msgs[0], chat.SpeechBubbleOptions{})
chat.SpeechBubble(c, s.msgs[1], chat.SpeechBubbleOptions{})
})
}
func demoChatLog(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := chatState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
chat.ChatLog(c, "Party chat", chat.ChatLogOptions{Height: 120}, func() {
chat.DateSeparator(c, k.Now(), chat.DateSeparatorOptions{Width: 190})
chat.SystemMessage(c, "BROM HAS JOINED", chat.SystemMessageOptions{})
for i, m := range s.msgs {
if i == 2 {
chat.UnreadDivider(c, chat.UnreadDividerOptions{Width: 190})
}
chat.SpeechBubble(c, m, chat.SpeechBubbleOptions{Width: 120})
}
})
if small(c, "SAY HI", false) {
s.msgs = append(s.msgs, chat.Message{From: "Mittens", Text: fmt.Sprintf("Mew #%d!", len(s.msgs)), At: pixel.K(c).Now()})
log(fmt.Sprintf("%d messages", len(s.msgs)))
}
})
}
func demoComposer(c *ui.Context, log func(string)) {
s := chatState(c)
r := chat.Composer(c, "Message", &s.draft, chat.ComposerOptions{Placeholder: "Say something…"})
if r.Changed {
log(fmt.Sprintf("draft %q", s.draft))
}
if r.Sent != "" {
log("sent: " + r.Sent)
}
}
func demoTypingIndicator(c *ui.Context, log func(string)) {
chat.TypingIndicator(c, "Aria", chat.TypingIndicatorOptions{})
}
func demoChatList(c *ui.Context, log func(string)) {
s := chatState(c)
convs := []chat.Conversation{{Name: "Aria", Last: "Ready for the boss?", Unread: 2}, {Name: "Brom", Last: "I brought snacks."},
{Name: "Guild Hall", Last: "Raid at eight. Bring torches.", Unread: 12}, {Name: "Mittens", Last: "Mew."}}
r := chat.ChatList(c, "Chats", convs, &s.conv, chat.ChatListOptions{})
if r.Changed {
log("chat with " + convs[s.conv].Name)
}
if r.Submitted {
log("open " + convs[s.conv].Name)
}
}
func demoReadReceipt(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(10)).AlignItems(ui.Center).Children(func() {
for _, r := range []struct {
read bool
text string
}{{false, "sent"}, {true, "read"}} {
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
chat.ReadReceipt(c, r.read, chat.ReadReceiptOptions{Scale: 2})
pixel.Text(c, r.text, pixel.TextOptions{Slot: pixel.Faint})
})
}
})
}
func demoReactions(c *ui.Context, log func(string)) {
s := chatState(c)
if r := chat.Reactions(c, "Reactions", s.reactions, chat.ReactionsOptions{}); r.Toggled >= 0 {
re := s.reactions[r.Toggled]
log(fmt.Sprintf("%s: %d, yours %v", re.Emoji, re.Count, re.Mine))
}
}
func demoEmojiPicker(c *ui.Context, log func(string)) {
if r := chat.EmojiPicker(c, "Emoji", chat.EmojiPickerOptions{}); r.Picked != "" {
log("picked " + r.Picked)
}
}
func demoMentionInput(c *ui.Context, log func(string)) {
s := chatState(c)
r := chat.MentionInput(c, "Mention", &s.mention, []string{"Aria", "Arthur", "Brom", "Mittens"}, chat.MentionInputOptions{Placeholder: "Try @ar"})
if r.Changed {
log(fmt.Sprintf("text %q", s.mention))
}
if r.Mentioned != "" {
log("mentioned " + r.Mentioned)
}
if r.Submitted {
log("sent: " + s.mention)
}
}
func demoDateSeparator(c *ui.Context, log func(string)) {
k := pixel.K(c)
now := k.Now()
ui.Column(c).Gap(k.D(4)).Children(func() {
for _, d := range []int{0, -1, -40} {
chat.DateSeparator(c, now.AddDate(0, 0, d), chat.DateSeparatorOptions{})
}
})
}
func demoUnreadDivider(c *ui.Context, log func(string)) {
chat.UnreadDivider(c, chat.UnreadDividerOptions{})
}
func demoSystemMessage(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Width(k.D(200)).Gap(k.D(4)).Children(func() {
chat.SystemMessage(c, "PLAYER 2 HAS JOINED", chat.SystemMessageOptions{})
chat.SystemMessage(c, "The tavern is now open", chat.SystemMessageOptions{Slot: pixel.Good})
})
}
func demoChannelList(c *ui.Context, log func(string)) {
s := chatState(c)
chans := []chat.Channel{{Name: "general", Unread: true}, {Name: "quests"}, {Name: "trading", Unread: true}, {Name: "off-topic"}}
r := chat.ChannelList(c, "Channels", chans, &s.channel, chat.ChannelListOptions{})
if r.Changed {
log("# " + chans[s.channel].Name)
}
if r.Submitted {
log("open # " + chans[s.channel].Name)
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ var horde = strings.Fields("Slime Bat Goblin Ghost Golem Imp Kobold Lich Mimic O
func demoList(c *ui.Context, log func(string)) {
s := dataState(c)
r := data.List(c, "Monsters", len(horde), &s.list, data.ListOptions{}, func(i int) {
r := data.List(c, "Monsters", len(horde), &s.list, data.ListOptions{}, func(i int) string { return horde[i] }, func(i int) {
pixel.Text(c, horde[i], pixel.TextOptions{})
})
r.Element.Width(pixel.K(c).D(140))
+146
View File
@@ -0,0 +1,146 @@
package gallery
import (
"fmt"
"slices"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chat"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var mailEntries = []Entry{
{334, "MemberList", "成员列表", "chat", "Who is around, who is away, who logged off for dinner.", demoMemberList},
{335, "UserCard", "用户卡", "chat", "A face, a name, a title and things to do with them.", demoUserCard},
{336, "Mailbox", "信箱", "chat", "Closed envelopes are new. Enter opens one.", demoMailbox},
{337, "MailReader", "读信", "chat", "Who, what, when; then the letter itself.", demoMailReader},
{338, "Letter", "信笺", "chat", "Parchment with worn, dithered edges and a signature.", demoLetter},
{339, "NotificationBell", "通知铃", "chat", "Rings in steps when news arrives. Click to read it all.", demoNotificationBell},
{340, "CallControls", "通话控制", "chat", "Mute, camera, and the big red button.", demoCallControls},
{341, "VoiceMeter", "语音电平", "chat", "Green when you talk, red when you shout.", demoVoiceMeter},
{342, "AttachmentChip", "附件", "chat", "A file's icon, name and size; × takes it off.", demoAttachmentChip},
{343, "LinkPreview", "链接预览", "chat", "Title, site and a thumbnail to come. A click opens it.", demoLinkPreview},
{344, "Poll", "投票", "chat", "Click to vote; click another to change your mind.", demoPoll},
{345, "StickerPicker", "贴纸选择", "chat", "Big faces for big feelings.", demoStickerPicker},
}
// mailbag is the demo inbox.
func mailbag(now time.Time) []chat.Mail {
return []chat.Mail{
{From: "The Guild", Subject: "Your rank went up!", Body: "Congratulations, adventurer. Collect your new badge at the front desk.", At: now.Add(-time.Hour)},
{From: "Aria", Subject: "Re: boss fight", Body: "Same time tomorrow? Bring more potions this time.", At: now.Add(-3 * time.Hour)},
{From: "Shopkeeper", Subject: "Sale on shields", Body: "Two for one, today only.", At: now.Add(-26 * time.Hour), Read: true},
}
}
func demoMemberList(c *ui.Context, log func(string)) {
chat.MemberList(c, "Members", []chat.Member{{Name: "Aria", Status: display.Online}, {Name: "Brom", Status: display.Away},
{Name: "Mittens", Status: display.Busy}, {Name: "Arthur", Status: display.Offline}, {Name: "Old Sage", Status: display.Offline}}, chat.MemberListOptions{})
}
func demoUserCard(c *ui.Context, log func(string)) {
actions := []string{"MESSAGE", "INVITE"}
if r := chat.UserCard(c, "Aria", chat.UserCardOptions{Title: "Level 12 Mage", Actions: actions}); r.Clicked >= 0 {
log(actions[r.Clicked])
}
}
func demoMailbox(c *ui.Context, log func(string)) {
s := chatState(c)
r := chat.Mailbox(c, "Inbox", s.mails, &s.mail, chat.MailboxOptions{})
if r.Changed {
log("mail " + s.mails[s.mail].Subject)
}
if r.Opened >= 0 {
log("opened " + s.mails[r.Opened].Subject)
}
}
func demoMailReader(c *ui.Context, log func(string)) {
chat.MailReader(c, mailbag(pixel.K(c).Now())[0], chat.MailReaderOptions{})
}
func demoLetter(c *ui.Context, log func(string)) {
chat.Letter(c, "Dear hero, the cave is colder than it looks. Wear the red scarf. Come home for supper.", chat.LetterOptions{Signature: "Grandpa"})
}
func demoNotificationBell(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := chatState(c)
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
if chat.NotificationBell(c, "Notifications", s.news, chat.NotificationBellOptions{}).Clicked {
s.news = 0
log("all read")
}
if small(c, "NEWS!", false) {
s.news++
log(fmt.Sprintf("%d new", s.news))
}
})
}
func demoCallControls(c *ui.Context, log func(string)) {
s := chatState(c)
r := chat.CallControls(c, "Call", &s.muted, &s.camera, chat.CallControlsOptions{})
if r.Changed {
log(fmt.Sprintf("muted %v, camera %v", s.muted, s.camera))
}
if r.HungUp {
log("hung up")
}
}
func demoVoiceMeter(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := chatState(c)
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
chat.VoiceMeter(c, "Voice", s.level, chat.VoiceMeterOptions{})
if input.Slider(c, "Level", &s.level, input.SliderOptions{}).Changed {
log(fmt.Sprintf("level %.2f", s.level))
}
})
}
func demoAttachmentChip(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := chatState(c)
sizes := map[string]int64{"world-map.png": 2_516_582, "battle-theme.mp3": 4_404_019, "notes.txt": 812}
ui.Column(c).Gap(k.D(3)).AlignItems(ui.Start).Children(func() {
// Keys keep each chip's state as earlier ones go.
for _, f := range slices.Clone(s.files) {
ui.Box(c).Key(f).Children(func() {
if chat.AttachmentChip(c, f, sizes[f], chat.AttachmentChipOptions{}).Removed {
s.files = slices.DeleteFunc(s.files, func(x string) bool { return x == f })
log("removed " + f)
}
})
}
if len(s.files) == 0 && small(c, "RESTORE", false) {
s.files = []string{"world-map.png", "battle-theme.mp3", "notes.txt"}
log("restored")
}
})
}
func demoLinkPreview(c *ui.Context, log func(string)) {
if chat.LinkPreview(c, "https://mygo.egoist.dev/docs/ui", "MyGo UI: native widgets, written in Go", "mygo.egoist.dev", chat.LinkPreviewOptions{}).Clicked {
log("opened mygo.egoist.dev")
}
}
func demoPoll(c *ui.Context, log func(string)) {
s := chatState(c)
if r := chat.Poll(c, "Best class for the dungeon?", s.polls, &s.vote, chat.PollOptions{}); r.Voted >= 0 {
log("voted " + s.polls[r.Voted].Text)
}
}
func demoStickerPicker(c *ui.Context, log func(string)) {
if r := chat.StickerPicker(c, "Stickers", chat.StickerPickerOptions{}); r.Picked != "" {
log("sticker " + r.Picked)
}
}
+34
View File
@@ -1,6 +1,7 @@
package gallery
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
@@ -84,3 +85,36 @@ func TestHomeSceneFollowsThePaletteStrip(t *testing.T) {
t.Fatal("the strip does not mark the palette in use")
}
}
func TestChatDemosLogTheirActions(t *testing.T) {
for _, tc := range []struct {
path string
steps []string
}{
{"/c/322-chatlog", []string{"SAY HI"}}, {"/c/323-composer", []string{"Message", "type:hi", "Enter"}},
{"/c/325-chatlist", []string{"Brom"}}, {"/c/327-reactions", []string{"fire 5"}}, {"/c/329-mentioninput", []string{"Mention", "type:@a"}},
{"/c/328-emojipicker", []string{"Emoji"}}, {"/c/333-channellist", []string{"quests", "Enter"}}, {"/c/335-usercard", []string{"INVITE"}},
{"/c/336-mailbox", []string{"Aria: Re: boss fight, NEW MESSAGES", "Enter"}}, {"/c/339-notificationbell", []string{"NEWS!", "Notifications"}},
{"/c/340-callcontrols", []string{"MUTE", "HANG UP"}}, {"/c/342-attachmentchip", []string{"REMOVE notes.txt"}},
{"/c/343-linkpreview", []string{"MyGo UI: native widgets, written in Go"}}, {"/c/344-poll", []string{"Bard, 1"}},
{"/c/345-stickerpicker", []string{"Stickers"}},
} {
a, tt := open(tc.path)
slug := tc.path[3:]
for _, step := range tc.steps {
before := len(a.logs[slug])
switch {
case step == "Enter":
tt.Key(0, ui.KeyEnter)
case strings.HasPrefix(step, "type:"):
tt.Type(step[5:])
default:
click(t, tt, step)
}
// Focusing a field changes nothing worth a line.
if len(a.logs[slug]) <= before && step != "Message" && step != "Mention" {
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
}
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries)
func Entries() []Entry { return entries }