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

+72
View File
@@ -0,0 +1,72 @@
package chat
import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AttachmentChipOptions struct {
// Width caps the name in dots; zero means 100.
Width int
Disabled bool
}
type AttachmentChipResult struct {
Element *ui.Element
Removed bool
}
// kinds pick a file's icon by its extension.
var kinds = map[string]*pixel.Sprite{
".png": icon.Image, ".jpg": icon.Image, ".gif": icon.Image, ".mp3": icon.Music, ".wav": icon.Music, ".ogg": icon.Music,
".mp4": icon.Video, ".mov": icon.Video, ".go": icon.Code, ".js": icon.Code, ".py": icon.Code,
".txt": icon.FileText, ".md": icon.FileText, ".pdf": icon.FileText,
}
// AttachmentChip shows a file's icon, name, size and ×.
func AttachmentChip(c *ui.Context, name string, size int64, o AttachmentChipOptions) AttachmentChipResult {
k := pixel.K(c)
if size < 0 {
panic(fmt.Sprintf("chat: attachment %q has size %d", name, size))
}
sp, ok := kinds[strings.ToLower(filepath.Ext(name))]
if !ok {
sp = icon.File
}
var res AttachmentChipResult
res.Element = ui.Row(c).Gap(k.D(3)).Padding(k.D(2), k.D(2), k.D(2), k.D(4)).AlignItems(ui.Center).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Face)
}).Children(func() {
display.Icon(c, sp, display.IconOptions{Slot: pixel.Faint})
pixel.Text(c, line(name, pixel.Or("AttachmentChip Width", o.Width, 100)), pixel.TextOptions{})
pixel.Text(c, bytes(size), pixel.TextOptions{Slot: pixel.Faint})
if input.IconButton(c, icon.Close, k.Msg("remove")+" "+name, input.IconButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled}).Clicked {
res.Removed = true
slog.Debug("8bit: attachment removed", "name", name)
}
})
return res
}
// bytes prints n in binary units, tenths below ten.
func bytes(n int64) string {
v, units := float64(n), []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for v >= 1024 && i < len(units)-1 {
v, i = v/1024, i+1
}
if i > 0 && v < 10 {
return fmt.Sprintf("%.1f %s", v, units[i])
}
return fmt.Sprintf("%.0f %s", v, units[i])
}
+54
View File
@@ -0,0 +1,54 @@
package chat
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CallControlsOptions struct {
Disabled bool
}
type CallControlsResult struct {
Element *ui.Element
Changed, HungUp bool
}
// CallControls toggles mute and camera, and hangs up.
func CallControls(c *ui.Context, text string, muted, camera *bool, o CallControlsOptions) CallControlsResult {
k := pixel.K(c)
var res CallControlsResult
// A slash crosses a sprite that is off.
button := func(base func() *ui.Element, sp *pixel.Sprite, fill, ink pixel.Slot, off bool) bool {
shell, b, st := pixel.Shell(c, func() *ui.Element { return base().Disabled(o.Disabled).FocusRing(false) })
shell.Size(k.D(22), k.D(22)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, 22, 22, pixel.Raised, fill)
g.Sprite(5, 5, sp, 0, ink, 1)
if off {
g.Line(4, 17, 17, 4, pixel.Bad)
}
g.Finish(st)
})
return b.Changed() || b.Clicked()
}
res.Element = ui.Row(c).Gap(k.D(4)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text).Children(func() {
if button(func() *ui.Element { return ui.ToggleBase(c, muted).Label(k.Msg("mute")) }, icon.Mic, pixel.Face, pixel.Ink, *muted) {
res.Changed = true
slog.Debug("8bit: call mute", "label", text, "muted", *muted)
}
if button(func() *ui.Element { return ui.ToggleBase(c, camera).Label(k.Msg("camera")) }, icon.Camera, pixel.Face, pixel.Ink, !*camera) {
res.Changed = true
slog.Debug("8bit: call camera", "label", text, "on", *camera)
}
if button(func() *ui.Element { return ui.ButtonBase(c).Label(k.Msg("hang_up")) }, icon.Phone, pixel.Bad, pixel.AccentInk, false) {
res.HungUp = true
slog.Debug("8bit: call hung up", "label", text)
}
})
return res
}
+53
View File
@@ -0,0 +1,53 @@
package chat
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Channel is a # channel; Unread marks new messages.
type Channel struct {
Name string
Unread bool
}
type ChannelListResult struct {
Element *ui.Element
// Submitted is Enter or a double click on a row.
Changed, Submitted bool
}
type ChannelListOptions struct {
// Width and Height are dots; zeros mean 140 by 120.
Width, Height int
Disabled bool
}
// ChannelList lists # channels; unread ones glow with a dot.
func ChannelList(c *ui.Context, text string, chans []Channel, selected *int, o ChannelListOptions) ChannelListResult {
k := pixel.K(c)
w := pixel.Or("ChannelList Width", o.Width, 140)
room := w - 14 - 8 - 10
name := func(i int) string {
if chans[i].Unread {
return chans[i].Name + ", " + k.Msg("new_messages")
}
return chans[i].Name
}
return ChannelListResult(list(c, k, text, len(chans), selected, w, pixel.Or("ChannelList Height", o.Height, 120), o.Disabled, name, func(i int) {
ch := chans[i]
ink := pixel.Faint
if ch.Unread {
ink = pixel.Ink
}
name := "# " + line(ch.Name, room)
ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Text(0, 0, name, ink, 1)
if ch.Unread {
g.Disc(g.W-5, pixel.LineHeight/2, 2, pixel.Accent)
}
})
}))
}
+90
View File
@@ -0,0 +1,90 @@
// Package chat draws messages, channels, mail and calls.
package chat
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Message is a chat line; Mine sits on the right.
type Message struct {
From, Text string
At time.Time
Mine bool
}
// listed is a chat list's choice; Submitted is Enter.
type listed struct {
Element *ui.Element
Changed, Submitted bool
}
// list is a data.List of n named rows.
func list(c *ui.Context, k pixel.Kit, text string, n int, selected *int, w, h int, disabled bool, name func(i int) string, row func(i int)) listed {
if *selected < -1 || *selected >= n {
panic(fmt.Sprintf("chat: %s selects %d of %d rows", text, *selected, n))
}
var r data.ListResult
e := ui.Box(c).Width(k.D(w)).Children(func() {
r = data.List(c, text, n, selected, data.ListOptions{Height: h, Disabled: disabled}, name, row)
})
return listed{Element: e, Changed: r.Changed, Submitted: r.Submitted}
}
// para wraps s to w dots, hugging its lines.
func para(c *ui.Context, k pixel.Kit, s string, w, maxLines int, ink pixel.Slot) *ui.Element {
lines := pixel.Wrap(s, w, maxLines)
lw := 0
for _, l := range lines {
lw = max(lw, pixel.Measure(l))
}
return ui.Box(c).Role(ui.RoleText).Label(s).Size(k.D(lw), k.D(len(lines)*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, l := range lines {
g.Text(0, i*pixel.LineHeight, l, ink, 1)
}
})
}
// line is s cut to w dots with an ellipsis.
func line(s string, w int) string { return pixel.Wrap(s, w, 1)[0] }
// clock prints t's time of day.
func clock(t time.Time) string { return t.Format("15:04") }
// tag draws s in micro letters, or the font.
func tag(g pixel.Canvas, x, y int, s string, ink pixel.Slot) {
if pixel.MicroOK(s) {
g.Micro(x, y+3, s, ink, 1)
return
}
g.Text(x, y, s, ink, 1)
}
// tagW is tag's width for s.
func tagW(s string) int {
if pixel.MicroOK(s) {
return pixel.MicroWidth(s)
}
return pixel.Measure(s)
}
// rule centers label on a line across w dots.
func rule(c *ui.Context, k pixel.Kit, label string, w int, ink, edge pixel.Slot) *ui.Element {
tw := tagW(label)
if w < tw+16 {
panic(fmt.Sprintf("chat: a %d-dot rule cannot hold %q", w, label))
}
return ui.Box(c).Role(ui.RoleText).Label(label).Size(k.D(w), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
x, y := (w-tw)/2, pixel.LineHeight/2
g.HLine(0, y, x-4, edge)
g.HLine(x+tw+4, y, w-x-tw-4, edge)
tag(g, x, 0, label, ink)
})
}
+287
View File
@@ -0,0 +1,287 @@
package chat_test
import (
"bytes"
"image"
"log/slog"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chat"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
// newClock starts on Wednesday 2 September 2026, mid-morning.
func newClock() *clock { return &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)} }
// world is the settings a test view runs under.
type world struct {
clk *clock
reduce, zh, inert bool
}
// view builds on a fake clock, two pixels a dot.
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
s := pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert}
if wd.zh {
s.Locale = pixel.Zh
}
pixel.Use(c, s)
build(c)
}, w, h)
if wd.reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
}
return tt
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatalf("%v in %q", err, tt.Texts())
}
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
// count is how many dots of a box hold s.
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
n, want := 0, pixel.SpringDay.Colors[s]
for j := y; j < y+h; j++ {
for i := x; i < x+w; i++ {
if c := img.RGBAAt(2*i, 2*j); c.R == want.R && c.G == want.G && c.B == want.B {
n++
}
}
}
return n
}
func mustPanic(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s did not panic", name)
}
}()
fn()
}
func TestSpeechBubbleSidesNameAndTime(t *testing.T) {
wd := &world{clk: newClock()}
at := wd.clk.t
tt := view(300, 200, wd, func(c *ui.Context) {
ui.Column(c).Width(pixel.K(c).D(140)).Children(func() {
chat.SpeechBubble(c, chat.Message{From: "Aria", Text: "Hi there", At: at}, chat.SpeechBubbleOptions{Width: 80})
chat.SpeechBubble(c, chat.Message{From: "You", Text: "Hello", At: at, Mine: true}, chat.SpeechBubbleOptions{Width: 80})
})
})
theirs, mine := find(t, tt, "Hi there"), find(t, tt, "Hello")
if theirs.X > 40 || mine.X < 140 {
t.Fatalf("theirs at %v, mine at %v: theirs sit left, yours right", theirs.X, mine.X)
}
find(t, tt, "Aria")
if !tt.HasText("10:30") {
t.Fatalf("no time in %q", tt.Texts())
}
// Yours fill Accent; theirs Lit.
img := tt.Image()
if count(img, int(mine.X/2), int(mine.Y/2), 20, 6, pixel.Accent) == 0 || count(img, int(theirs.X/2), int(theirs.Y/2), 20, 6, pixel.Accent) != 0 {
t.Fatal("only your bubble is Accent")
}
}
func TestChatLogFollowsItsEndOnlyWhileThere(t *testing.T) {
wd := &world{clk: newClock()}
n := 8
tt := view(400, 300, wd, func(c *ui.Context) {
chat.ChatLog(c, "Log", chat.ChatLogOptions{Width: 160, Height: 80}, func() {
for i := range n {
pixel.Text(c, "line "+strings.Repeat("I", i+1), pixel.TextOptions{})
}
})
})
// Find clips to the view; whole lines are 32 pixels.
shown := func(label string) bool {
r, l := find(t, tt, label), find(t, tt, "Log")
return r.H == 32 && r.Y >= l.Y && r.Y+r.H <= l.Y+l.H
}
if !shown("line IIIIIIII") {
t.Fatal("the log did not open at its end")
}
n++
tt.Frame()
if !shown("line IIIIIIIII") {
t.Fatal("at the end, the log did not follow a new line")
}
// Scrolled up, a new line leaves the view alone.
for range 4 {
click(t, tt, "SCROLL UP")
}
n++
tt.Frame()
if shown("line IIIIIIIIII") {
t.Fatal("scrolled up, the log jumped to a new line")
}
}
func TestChatLogKeepsFollowingThroughInert(t *testing.T) {
wd := &world{clk: newClock()}
n := 8
tt := view(400, 300, wd, func(c *ui.Context) {
chat.ChatLog(c, "Log", chat.ChatLogOptions{Width: 160, Height: 80}, func() {
for i := range n {
pixel.Text(c, "line "+strings.Repeat("I", i+1), pixel.TextOptions{})
}
})
})
shown := func(label string) bool {
r, l := find(t, tt, label), find(t, tt, "Log")
return r.H == 32 && r.Y >= l.Y && r.Y+r.H <= l.Y+l.H
}
// A line landing while inert shows once the log wakes.
wd.inert = true
n++
tt.Frame()
tt.Frame()
wd.inert = false
tt.Frame()
if !shown("line IIIIIIIII") {
t.Fatal("after Inert the log did not catch up with its end")
}
n++
tt.Frame()
if !shown("line IIIIIIIIII") {
t.Fatal("after Inert the log stopped following")
}
}
func TestFieldsLogChangesWithoutText(t *testing.T) {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(old)
draft, mention := "", ""
tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
chat.Composer(c, "Message", &draft, chat.ComposerOptions{})
chat.MentionInput(c, "Mention", &mention, []string{"Aria"}, chat.MentionInputOptions{})
})
click(t, tt, "Message")
tt.Type("hi")
click(t, tt, "Mention")
tt.Type("yo")
out := buf.String()
for _, want := range []string{"composer changed", "mention input changed", "runes=2"} {
if !strings.Contains(out, want) {
t.Errorf("no %q in the logs:\n%s", want, out)
}
}
if strings.Contains(out, "hi") || strings.Contains(out, "yo") {
t.Error("the logs carry what was typed")
}
}
func TestComposerSendsOnEnterOrSend(t *testing.T) {
wd := &world{clk: newClock()}
draft, sent := "", []string{}
tt := view(400, 200, wd, func(c *ui.Context) {
if r := chat.Composer(c, "Message", &draft, chat.ComposerOptions{}); r.Sent != "" {
sent = append(sent, r.Sent)
}
})
// Empty, SEND ▶ does nothing.
click(t, tt, "SEND ▶")
click(t, tt, "Message")
tt.Type("hi")
tt.Key(ui.Shift, ui.KeyEnter)
tt.Type("all")
tt.Key(0, ui.KeyEnter)
if len(sent) != 1 || sent[0] != "hi\nall" || draft != "" {
t.Fatalf("sent %q, draft %q", sent, draft)
}
tt.Type("bye")
click(t, tt, "SEND ▶")
if len(sent) != 2 || sent[1] != "bye" || draft != "" {
t.Fatalf("sent %q, draft %q", sent, draft)
}
// The field keeps the focus; next words land there.
tt.Type("again")
if draft != "again" {
t.Fatalf("after sending, typing gave %q", draft)
}
}
func TestTypingIndicatorHopsUnlessReduced(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
tt := view(200, 100, wd, func(c *ui.Context) { chat.TypingIndicator(c, "Aria", chat.TypingIndicatorOptions{}) })
r := find(t, tt, "Aria IS TYPING")
before := count(tt.Image(), int(r.X/2), int(r.Y/2), 17, 3, pixel.Faint)
moved := false
for range 4 {
wd.clk.t = wd.clk.t.Add(time.Second / 6)
tt.Frame()
moved = moved || count(tt.Image(), int(r.X/2), int(r.Y/2), 17, 3, pixel.Faint) != before
}
if moved == reduce {
t.Fatalf("reduce %v: dots hopped %v", reduce, moved)
}
}
}
func TestDateSeparatorNamesTheDay(t *testing.T) {
for _, tc := range []struct {
zh bool
days int
want string
}{{false, 0, "TODAY"}, {false, -1, "YESTERDAY"}, {false, -7, "WED 26 AUG"}, {true, -7, "8月26日 周三"}, {true, 0, "今天"}} {
wd := &world{clk: newClock(), zh: tc.zh}
day := wd.clk.t.AddDate(0, 0, tc.days)
tt := view(400, 100, wd, func(c *ui.Context) { chat.DateSeparator(c, day, chat.DateSeparatorOptions{}) })
find(t, tt, tc.want)
}
// At midnight today turns into yesterday by itself.
wd := &world{clk: newClock()}
wd.clk.t = time.Date(2026, 9, 2, 23, 59, 59, 0, time.UTC)
day := wd.clk.t
pixel.ResetDue()
tt := view(400, 100, wd, func(c *ui.Context) { chat.DateSeparator(c, day, chat.DateSeparatorOptions{}) })
if until := time.Until(pixel.Due()); until <= 0 || until > 2*time.Second {
t.Fatalf("the next build is due in %v, not at midnight", until)
}
wd.clk.t = wd.clk.t.Add(2 * time.Second)
tt.Frame()
find(t, tt, "YESTERDAY")
mustPanic(t, "narrow", func() {
view(100, 100, wd, func(c *ui.Context) { chat.UnreadDivider(c, chat.UnreadDividerOptions{Width: 30}) })
})
}
func TestDividerAndSystemLines(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(500, 100, wd, func(c *ui.Context) {
chat.UnreadDivider(c, chat.UnreadDividerOptions{})
chat.SystemMessage(c, "PLAYER 2 HAS JOINED", chat.SystemMessageOptions{})
})
r := find(t, tt, "NEW MESSAGES")
if count(tt.Image(), int(r.X/2), int(r.Y/2), 200, 16, pixel.Bad) < 100 {
t.Fatal("the unread rule is not Bad")
}
find(t, tt, "PLAYER 2 HAS JOINED")
}
+59
View File
@@ -0,0 +1,59 @@
package chat
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Conversation is a ChatList row; Unread counts new lines.
type Conversation struct {
Name, Last string
Unread int
}
type ChatListResult struct {
Element *ui.Element
// Submitted is Enter or a double click on a row.
Changed, Submitted bool
}
type ChatListOptions struct {
// Width and Height are dots; zeros mean 200 by 120.
Width, Height int
Disabled bool
}
// ChatList lists conversations: avatar, last line, unread badge.
func ChatList(c *ui.Context, text string, convs []Conversation, selected *int, o ChatListOptions) ChatListResult {
k := pixel.K(c)
w := pixel.Or("ChatList Width", o.Width, 200)
// Rows lose the ▶ gutter, the avatar and the badge.
room := w - 14 - 16 - 4 - 22
name := func(i int) string {
cv := convs[i]
if cv.Unread > 0 {
return fmt.Sprintf("%s: %s, "+k.Msg("unread"), cv.Name, cv.Last, cv.Unread)
}
return cv.Name + ": " + cv.Last
}
return ChatListResult(list(c, k, text, len(convs), selected, w, pixel.Or("ChatList Height", o.Height, 120), o.Disabled, name, func(i int) {
cv := convs[i]
if cv.Unread < 0 {
panic("chat: ChatList unread count is negative")
}
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).FillWidth().Children(func() {
display.Avatar(c, cv.Name, display.AvatarOptions{})
ui.Column(c).Grow(1).Children(func() {
pixel.Text(c, line(cv.Name, room), pixel.TextOptions{})
pixel.Text(c, line(cv.Last, room), pixel.TextOptions{Slot: pixel.Faint})
})
if cv.Unread > 0 {
display.Badge(c, cv.Unread, display.BadgeOptions{})
}
})
}))
}
+51
View File
@@ -0,0 +1,51 @@
package chat
import (
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ChatLogOptions struct {
// Width and Height are dots; zeros mean 220 by 140.
Width, Height int
}
type logKey struct{}
// logState is the scroll and the end it last reached.
type logState struct {
scroll ui.ScrollState
end float32
}
// ChatLog scrolls messages and follows the end while there.
func ChatLog(c *ui.Context, text string, o ChatLogOptions, content func()) *ui.Element {
k := pixel.K(c)
w, h := pixel.Or("ChatLog Width", o.Width, 220), pixel.Or("ChatLog Height", o.Height, 140)
box := ui.Box(c).Size(k.D(w), k.D(h))
st := ui.Local(box, logKey{}, func() logState { return logState{scroll: ui.ScrollState{Y: math.MaxFloat32}} })
// Content grew under a view at its old end: follow.
grew := func() bool { return st.scroll.MaxY > st.end && st.scroll.Y >= st.end }
// Inert freezes the view; the end waits until it wakes.
frozen := pixel.Current(c).Inert
if !frozen {
if grew() {
st.scroll.Y = st.scroll.MaxY
}
st.end = st.scroll.MaxY
}
return box.Draw(func(p *ui.Painter, r ui.Rect) {
// Layout has just measured the content; follow it next frame.
if !frozen && grew() {
c.Invalidate()
}
}).Children(func() {
layout.ScrollArea(c, &st.scroll, layout.ScrollAreaOptions{Label: text}, func() {
ui.Column(c).FillWidth().Padding(k.D(4)).Gap(k.D(4)).Children(content)
}).Element.Fill()
})
}
+50
View File
@@ -0,0 +1,50 @@
package chat
import (
"log/slog"
"strings"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ComposerOptions struct {
Placeholder string
// Width is in dots, zero 160; Lines zero means 2.
Width, Lines int
Disabled bool
}
type ComposerResult struct {
Element *ui.Element
Changed bool
// Sent is the text sent this frame, else empty.
Sent string
}
// Composer: Enter or SEND ▶ sends; Shift+Enter breaks lines.
func Composer(c *ui.Context, text string, value *string, o ComposerOptions) ComposerResult {
k := pixel.K(c)
lines := pixel.Or("Composer Lines", o.Lines, 2)
var res ComposerResult
res.Element = ui.Row(c).Gap(k.D(4)).AlignItems(ui.End).Children(func() {
f := input.TextArea(c, text, value, input.TextAreaOptions{Placeholder: o.Placeholder, Width: o.Width, Lines: lines, Disabled: o.Disabled, EnterSends: true})
empty := strings.TrimSpace(*value) == ""
b := input.Button(c, k.Msg("send"), input.ButtonOptions{Disabled: o.Disabled || empty})
res.Changed = f.Changed
if f.Changed {
slog.Debug("8bit: composer changed", "label", text, "runes", utf8.RuneCountInString(*value))
}
if (f.Submitted || b.Clicked) && !empty && !o.Disabled {
res.Sent, *value = *value, ""
// The cleared field shows next frame and keeps focus.
f.Input.Focus()
c.Invalidate()
slog.Debug("8bit: composer sent", "label", text, "runes", utf8.RuneCountInString(res.Sent))
}
})
return res
}
+249
View File
@@ -0,0 +1,249 @@
package chat_test
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chat"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestReactionsToggleYours(t *testing.T) {
wd := &world{clk: newClock()}
rs := []chat.Reaction{{Emoji: "love", Count: 3}, {Emoji: "fire", Count: 1, Mine: true}}
toggled := -1
tt := view(300, 100, wd, func(c *ui.Context) {
if r := chat.Reactions(c, "Reactions", rs, chat.ReactionsOptions{}); r.Toggled >= 0 {
toggled = r.Toggled
}
})
click(t, tt, "love 3")
if !rs[0].Mine || rs[0].Count != 4 || toggled != 0 {
t.Fatalf("after a click: %+v, toggled %d", rs[0], toggled)
}
click(t, tt, "fire 1")
if rs[1].Mine || rs[1].Count != 0 || toggled != 1 {
t.Fatalf("taking yours back: %+v", rs[1])
}
for name, bad := range map[string]chat.Reaction{"negative": {Emoji: "love", Count: -1}, "mine at zero": {Emoji: "love", Mine: true}, "no such emoji": {Emoji: "nope", Count: 1}} {
mustPanic(t, name, func() {
view(300, 100, wd, func(c *ui.Context) { chat.Reactions(c, "R", []chat.Reaction{bad}, chat.ReactionsOptions{}) })
})
}
}
func TestEmojiPickerPicksByKeysAcrossCategories(t *testing.T) {
wd := &world{clk: newClock()}
picked := ""
tt := view(400, 200, wd, func(c *ui.Context) {
if r := chat.EmojiPicker(c, "Emoji", chat.EmojiPickerOptions{}); r.Picked != "" {
picked = r.Picked
}
})
// A click picks the face under it: the grid's middle.
click(t, tt, "Emoji")
if picked != "dead" {
t.Fatalf("the click picked %q", picked)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
// A row of eight below grin is angry.
if picked != "angry" {
t.Fatalf("picked %q", picked)
}
// From the last face, a shorter category starts over.
tt.Key(0, ui.KeyEnd)
click(t, tt, "ITEMS")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if picked != "coin" {
t.Fatalf("the items tab picked %q", picked)
}
}
func TestStickerPickerPicksBigFaces(t *testing.T) {
wd := &world{clk: newClock()}
picked := ""
tt := view(400, 400, wd, func(c *ui.Context) {
if r := chat.StickerPicker(c, "Stickers", chat.StickerPickerOptions{}); r.Picked != "" {
picked = r.Picked
}
})
if r := find(t, tt, "Stickers"); r.W != 2*4*40 {
t.Fatalf("four 40-dot stickers a row, got %v wide", r.W)
}
click(t, tt, "Stickers")
if picked != "sleep" {
t.Fatalf("the click picked %q", picked)
}
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if picked != "love" {
t.Fatalf("picked %q", picked)
}
}
func TestMentionInputCompletesMembers(t *testing.T) {
wd := &world{clk: newClock()}
v, mentioned := "", ""
tt := view(400, 200, wd, func(c *ui.Context) {
if r := chat.MentionInput(c, "Mention", &v, []string{"Aria", "Arthur", "Brom"}, chat.MentionInputOptions{}); r.Mentioned != "" {
mentioned = r.Mentioned
}
})
click(t, tt, "Mention")
tt.Type("hi @ar")
find(t, tt, "@Aria @Arthur")
tt.Key(0, ui.KeyTab)
if v != "hi @Aria " || mentioned != "Aria" {
t.Fatalf("value %q, mentioned %q", v, mentioned)
}
// Past a space the word is done: nothing is suggested.
tt.Type("ok")
if _, ok := tt.Find("@Aria @Arthur"); ok {
t.Fatal("suggestions outlived the mention")
}
}
func TestNotificationBellRingsOnlyWhenNewsArrives(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
n, clicks := 0, 0
tt := view(200, 100, wd, func(c *ui.Context) {
if chat.NotificationBell(c, "Bell", n, chat.NotificationBellOptions{}).Clicked {
clicks++
}
})
bell := find(t, tt, "Bell")
rest := count(tt.Image(), int(bell.X/2), int(bell.Y/2)+5, 3, 12, pixel.Ink)
swung := func() bool { return count(tt.Image(), int(bell.X/2), int(bell.Y/2)+5, 3, 12, pixel.Ink) != rest }
n = 3
tt.Frame()
if swung() == reduce {
t.Fatalf("reduce %v: the bell swung %v on news", reduce, !reduce)
}
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
n = 1
tt.Frame()
if swung() {
t.Fatalf("reduce %v: fewer notes rang the bell", reduce)
}
click(t, tt, "Bell")
if clicks != 1 {
t.Fatal("the bell did not report its click")
}
}
mustPanic(t, "negative", func() {
view(100, 100, &world{clk: newClock()}, func(c *ui.Context) { chat.NotificationBell(c, "Bell", -1, chat.NotificationBellOptions{}) })
})
}
func TestCallControlsToggleAndHangUp(t *testing.T) {
wd := &world{clk: newClock()}
muted, camera, hung := false, true, 0
tt := view(300, 100, wd, func(c *ui.Context) {
if chat.CallControls(c, "Call", &muted, &camera, chat.CallControlsOptions{}).HungUp {
hung++
}
})
click(t, tt, "MUTE")
click(t, tt, "CAMERA")
click(t, tt, "HANG UP")
if !muted || camera || hung != 1 {
t.Fatalf("muted %v, camera %v, hung up %d", muted, camera, hung)
}
}
func TestVoiceMeterLightsBars(t *testing.T) {
wd := &world{clk: newClock()}
for _, tc := range []struct {
level float64
good, warn, hot int
}{{0, 0, 0, 0}, {0.5, 4, 0, 0}, {1, 6, 1, 1}} {
tt := view(100, 50, wd, func(c *ui.Context) { chat.VoiceMeter(c, "Voice", tc.level, chat.VoiceMeterOptions{}) })
img := tt.Image()
// Each lit bar shows one dot on the bottom row.
if g, w, b := count(img, 0, 11, 23, 1, pixel.Good)/2, count(img, 0, 11, 23, 1, pixel.Warn)/2, count(img, 0, 11, 23, 1, pixel.Bad)/2; g != tc.good || w != tc.warn || b != tc.hot {
t.Fatalf("level %v lit %d, %d, %d bars", tc.level, g, w, b)
}
}
for _, bad := range []float64{-0.1, 1.1, math.NaN()} {
mustPanic(t, "level", func() {
view(100, 50, wd, func(c *ui.Context) { chat.VoiceMeter(c, "Voice", bad, chat.VoiceMeterOptions{}) })
})
}
}
func TestAttachmentChipSizesAndRemoves(t *testing.T) {
wd := &world{clk: newClock()}
removed := 0
tt := view(500, 200, wd, func(c *ui.Context) {
for _, f := range []struct {
name string
size int64
}{{"map.png", 2_516_582}, {"song.mp3", 12 << 20}, {"a.txt", 812}, {"b.bin", 1024}} {
if chat.AttachmentChip(c, f.name, f.size, chat.AttachmentChipOptions{}).Removed {
removed++
}
}
})
for _, s := range []string{"2.4 MB", "12 MB", "812 B", "1.0 KB"} {
find(t, tt, s)
}
click(t, tt, "REMOVE a.txt")
if removed != 1 {
t.Fatal("× did not remove")
}
}
func TestLinkPreviewOpensItsURL(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(500, 200, wd, func(c *ui.Context) {
chat.LinkPreview(c, "https://example.com/a", "A long title that wraps over two lines here", "example.com", chat.LinkPreviewOptions{})
})
r := find(t, tt, "A long title that wraps over two lines here")
if r.H < 2*44 {
t.Fatalf("a two-line title needs room for the site: %v high", r.H)
}
click(t, tt, "A long title that wraps over two lines here")
tt.Key(0, ui.KeyEnter)
if got := tt.OpenedURLs(); len(got) != 2 || got[0] != "https://example.com/a" {
t.Fatalf("opened %q", got)
}
}
func TestPollMovesYourVote(t *testing.T) {
wd := &world{clk: newClock()}
opts := []chat.PollOption{{Text: "Mage", Votes: 2}, {Text: "Knight", Votes: 1}}
mine, voted := -1, -1
tt := view(400, 200, wd, func(c *ui.Context) {
if r := chat.Poll(c, "Class?", opts, &mine, chat.PollOptions{}); r.Voted >= 0 {
voted = r.Voted
}
})
click(t, tt, "Knight, 1")
if mine != 1 || opts[1].Votes != 2 || voted != 1 {
t.Fatalf("first vote: mine %d, %+v", mine, opts)
}
click(t, tt, "Mage, 2")
if mine != 0 || opts[0].Votes != 3 || opts[1].Votes != 1 {
t.Fatalf("moved vote: mine %d, %+v", mine, opts)
}
voted = -1
click(t, tt, "Mage, 3")
if voted != -1 || opts[0].Votes != 3 {
t.Fatal("voting again for your choice counted twice")
}
find(t, tt, "4 VOTES")
mustPanic(t, "lone option", func() {
one := -1
view(300, 100, wd, func(c *ui.Context) { chat.Poll(c, "Q", opts[:1], &one, chat.PollOptions{}) })
})
}
+38
View File
@@ -0,0 +1,38 @@
package chat
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DateSeparatorOptions struct {
// Width is in dots; zero means 200.
Width int
}
// DateSeparator rules a line with TODAY, YESTERDAY or the date.
func DateSeparator(c *ui.Context, day time.Time, o DateSeparatorOptions) *ui.Element {
k := pixel.K(c)
now := k.Now()
// At midnight today becomes yesterday.
k.At(c, time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()))
return rule(c, k, dayName(k, day.In(now.Location()), now), pixel.Or("DateSeparator Width", o.Width, 200), pixel.Faint, pixel.Edge)
}
// dayName is TODAY, YESTERDAY, or the locale's short date.
func dayName(k pixel.Kit, day, now time.Time) string {
same := func(a, b time.Time) bool { return a.Year() == b.Year() && a.YearDay() == b.YearDay() }
switch {
case same(day, now):
return k.Msg("today")
case same(day, now.AddDate(0, 0, -1)):
return k.Msg("yesterday")
}
wd, months := strings.Fields(k.Msg("days")), strings.Fields(k.Msg("months"))
return fmt.Sprintf(k.Msg("day_date"), wd[day.Weekday()], day.Day(), months[day.Month()-1], int(day.Month()))
}
+49
View File
@@ -0,0 +1,49 @@
package chat
import (
"fmt"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// glyph is a 12×12 emoji and its # dots' slot.
type glyph struct {
sp *pixel.Sprite
ink pixel.Slot
}
// sets are the emoji by category, keyed by message.
var sets = []struct {
key string
names []string
}{
{"faces", display.EmojiNames},
{"nature", []string{"sun", "moon", "cloud", "snow", "bolt", "drop", "leaf", "flower", "fire", "star", "sparkle", "heart"}},
{"items", []string{"coin", "gem", "potion", "sword", "shield", "chest", "crown", "trophy", "skull", "gift", "key", "scroll"}},
}
var things = map[string]glyph{
"sun": {icon.Sun, pixel.Warn}, "moon": {icon.Moon, pixel.Warn}, "cloud": {icon.Cloud, pixel.Faint},
"snow": {icon.Snowflake, pixel.Info}, "bolt": {icon.Bolt, pixel.Warn}, "drop": {icon.Drop, pixel.Info},
"leaf": {icon.Leaf, pixel.Good}, "flower": {icon.Flower, pixel.Accent}, "fire": {icon.Flame, pixel.Bad},
"star": {icon.Star, pixel.Warn}, "sparkle": {icon.Sparkle, pixel.Warn}, "heart": {icon.Heart, pixel.Bad},
"coin": {icon.Coin, pixel.Warn}, "gem": {icon.Gem, pixel.Info}, "potion": {icon.Potion, pixel.Magic},
"sword": {icon.Sword, pixel.Edge}, "shield": {icon.Shield, pixel.Info}, "chest": {icon.Chest, pixel.Warn},
"crown": {icon.Crown, pixel.Warn}, "trophy": {icon.Trophy, pixel.Warn}, "skull": {icon.Skull, pixel.Ink},
"gift": {icon.Gift, pixel.Accent}, "key": {icon.Key, pixel.Warn}, "scroll": {icon.Scroll, pixel.Faint},
}
// emoji is the face or thing named name.
func emoji(name string) glyph {
if g, ok := things[name]; ok {
return g
}
for _, n := range display.EmojiNames {
if n == name {
return glyph{display.EmojiSprite(name), pixel.Ink}
}
}
panic(fmt.Sprintf("chat: no emoji %q", name))
}
+72
View File
@@ -0,0 +1,72 @@
package chat
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type EmojiPickerOptions struct {
Disabled bool
}
type EmojiPickerResult struct {
Element *ui.Element
// Picked is the emoji chosen this frame, else empty.
Picked string
}
type pickerKey struct{}
// pickerState is the open category and the cursor in it.
type pickerState struct{ set, cur int }
// EmojiPicker picks an emoji from one category's grid.
func EmojiPicker(c *ui.Context, text string, o EmojiPickerOptions) EmojiPickerResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(3))
st := ui.Local(col, pickerKey{}, func() pickerState { return pickerState{} })
var res EmojiPickerResult
res.Element = col.Children(func() {
tabs := make([]string, len(sets))
for i, s := range sets {
tabs[i] = k.Msg(s.key)
}
if input.Segmented(c, k.Msg("category"), tabs, &st.set, input.SegmentedOptions{Disabled: o.Disabled}).Changed {
st.cur = 0
}
names := sets[st.set].names
if picked := grid(c, k, text, names, 8, 1, &st.cur, o.Disabled); picked != "" {
res.Picked = picked
slog.Debug("8bit: emoji picked", "label", text, "emoji", picked)
}
})
return res
}
// grid lays out names as a Pad of emoji.
func grid(c *ui.Context, k pixel.Kit, text string, names []string, cols, sc int, cur *int, disabled bool) string {
cell := 12*sc + 4
shell, base, st, picked := pixel.Pad(c, text, cols, len(names), cell, cell, cur, disabled)
base.Description(names[*cur])
at := *cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, n := range names {
x, y := i%cols*cell, i/cols*cell
if i == at && st.Focus {
g.Cursor(x, y, cell, cell)
}
art := emoji(n)
g.Sprite(x+2, y+2, art.sp, 0, art.ink, sc)
}
g.Finish(st)
})
if picked < 0 {
return ""
}
return names[picked]
}
+53
View File
@@ -0,0 +1,53 @@
package chat
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type LetterOptions struct {
// Signature closes the letter in Accent.
Signature string
// Width is in dots; zero means 180.
Width int
}
// Letter sets a body on parchment with a signature.
func Letter(c *ui.Context, body string, o LetterOptions) *ui.Element {
k := pixel.K(c)
w := pixel.Or("Letter Width", o.Width, 180)
if w < 40 {
panic("chat: a Letter needs 40 dots")
}
lines := pixel.Wrap(body, w-20, 0)
h := 20 + len(lines)*pixel.LineHeight
if o.Signature != "" {
h += pixel.LineHeight + 4
}
label := body
if o.Signature != "" {
label += " — " + o.Signature
}
return ui.Box(c).Role(ui.RoleText).Label(label).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, w, h, pixel.Face)
// The edge darkens outward in three dither rings.
for i, level := range []int{12, 7, 3} {
g.Veil(i, i, w-2*i, 1, pixel.Shade, level)
g.Veil(i, h-1-i, w-2*i, 1, pixel.Shade, level)
g.Veil(i, i+1, 1, h-2-2*i, pixel.Shade, level)
g.Veil(w-1-i, i+1, 1, h-2-2*i, pixel.Shade, level)
}
for i, l := range lines {
g.Text(10, 10+i*pixel.LineHeight, l, pixel.Ink, 1)
}
if o.Signature == "" {
return
}
y, sw := 14+len(lines)*pixel.LineHeight, pixel.Measure(o.Signature)
g.Text(w-10-sw, y, o.Signature, pixel.Accent, 1)
g.HLine(w-12-sw, y+pixel.LineHeight-2, sw+2, pixel.Accent)
g.Dot(w-9, y+pixel.LineHeight-3, pixel.Accent)
})
}
+61
View File
@@ -0,0 +1,61 @@
package chat
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type LinkPreviewOptions struct {
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type LinkPreviewResult struct {
Element *ui.Element
Clicked bool
}
// LinkPreview: thumbnail, title and site; a click opens url.
func LinkPreview(c *ui.Context, url, title, site string, o LinkPreviewOptions) LinkPreviewResult {
k := pixel.K(c)
w := pixel.Or("LinkPreview Width", o.Width, 200)
if w < 100 {
panic("chat: a LinkPreview needs 100 dots")
}
lines := pixel.Wrap(title, w-56, 2)
// The site sits under the title; the card fits both.
sy := 4 + len(lines)*pixel.LineHeight
h := max(40, sy+12)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleLink).Label(title).Description(url).Disabled(o.Disabled).Cursor(ui.CursorPointer).FocusRing(false)
})
shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
fill := pixel.Face
if st.Hover {
fill = pixel.Lit
}
g.Frame(0, 0, w, h, pixel.Window, fill)
// The thumbnail is a placeholder: dithered, with a picture icon.
g.Frame(4, 4, 40, 32, pixel.Sunken, pixel.Well)
g.Dither(6, 6, 36, 28, pixel.Well, pixel.Shade, 4)
g.Sprite(18, 14, icon.Image, 0, pixel.Faint, 1)
for i, l := range lines {
g.Text(50, 3+i*pixel.LineHeight, l, pixel.Ink, 1)
}
tag(g, 50, sy, line(site, w-56), pixel.Faint)
g.Finish(st)
})
res := LinkPreviewResult{Element: shell}
if base.Clicked() {
c.OpenURL(url)
res.Clicked = true
slog.Debug("8bit: link opened", "url", url)
}
return res
}
+145
View File
@@ -0,0 +1,145 @@
package chat_test
import (
"testing"
"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/pixel"
)
func TestChatListBadgesAndChooses(t *testing.T) {
wd := &world{clk: newClock()}
convs := []chat.Conversation{{Name: "Aria", Last: "Ready?", Unread: 2}, {Name: "Brom", Last: "Snacks."}, {Name: "Guild", Last: "Raid at eight", Unread: 120}}
sel, changed, submitted := 0, 0, 0
tt := view(500, 300, wd, func(c *ui.Context) {
r := chat.ChatList(c, "Chats", convs, &sel, chat.ChatListOptions{})
if r.Changed {
changed++
}
if r.Submitted {
submitted++
}
})
find(t, tt, "2")
find(t, tt, "99+")
click(t, tt, "Brom")
if sel != 1 || changed != 1 {
t.Fatalf("clicking Brom chose %d, changed %d", sel, changed)
}
// The chosen row is Face, readable in any palette.
if r := find(t, tt, "Brom"); count(tt.Image(), int(r.X/2)+30, int(r.Y/2), 40, 8, pixel.Face) < 200 || count(tt.Image(), int(r.X/2)+30, int(r.Y/2), 40, 8, pixel.AccentLit) != 0 {
t.Fatal("the chosen row is not Face")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if sel != 2 || submitted != 1 {
t.Fatalf("Down and Enter: row %d, submitted %d", sel, submitted)
}
// Rows are named; typing a name chooses its row.
find(t, tt, "Aria: Ready?, 2 UNREAD")
tt.Key(0, ui.KeyB)
if sel != 1 {
t.Fatalf("typing b chose row %d", sel)
}
mustPanic(t, "row", func() {
bad := 5
view(300, 200, wd, func(c *ui.Context) { chat.ChatList(c, "Chats", convs, &bad, chat.ChatListOptions{}) })
})
}
func TestChannelListMarksUnread(t *testing.T) {
wd := &world{clk: newClock()}
chans := []chat.Channel{{Name: "general", Unread: true}, {Name: "quests"}}
// None is chosen, so no ▶ adds its Accent.
sel := -1
tt := view(400, 200, wd, func(c *ui.Context) { chat.ChannelList(c, "Channels", chans, &sel, chat.ChannelListOptions{}) })
unread, read := find(t, tt, "general, NEW MESSAGES"), find(t, tt, "quests")
// Unread channels get an Accent dot; read ones are Faint.
img := tt.Image()
if count(img, int(unread.X/2), int(unread.Y/2), int(unread.W/2), 16, pixel.Accent) == 0 || count(img, int(read.X/2), int(read.Y/2), int(read.W/2), 16, pixel.Accent) != 0 {
t.Fatal("only unread channels carry a dot")
}
if count(img, int(read.X/2), int(read.Y/2), 40, 16, pixel.Faint) == 0 {
t.Fatal("a read channel is Faint")
}
}
func TestMemberListGroupsByPresence(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(400, 300, wd, func(c *ui.Context) {
chat.MemberList(c, "Members", []chat.Member{{Name: "Ari", Status: display.Offline}, {Name: "Bo", Status: display.Busy}, {Name: "Cy", Status: display.Online}}, chat.MemberListOptions{})
})
on, off := find(t, tt, "ONLINE · 2"), find(t, tt, "OFFLINE · 1")
if bo, ari := find(t, tt, "Bo"), find(t, tt, "Ari"); !(on.Y < bo.Y && bo.Y < off.Y && off.Y < ari.Y) {
t.Fatal("online members come first, each under its heading")
}
find(t, tt, "BUSY")
}
func TestUserCardActions(t *testing.T) {
wd := &world{clk: newClock()}
clicked := -1
tt := view(400, 200, wd, func(c *ui.Context) {
if r := chat.UserCard(c, "Aria", chat.UserCardOptions{Title: "Mage", Actions: []string{"MESSAGE", "INVITE"}}); r.Clicked >= 0 {
clicked = r.Clicked
}
})
find(t, tt, "Mage")
click(t, tt, "INVITE")
if clicked != 1 {
t.Fatalf("INVITE reported %d", clicked)
}
}
func TestMailboxOpensMail(t *testing.T) {
wd := &world{clk: newClock()}
mails := []chat.Mail{{From: "Guild", Subject: "Rank up", At: wd.clk.t}, {From: "Shop", Subject: "Sale", At: wd.clk.t, Read: true}}
sel, opened := -1, -1
tt := view(500, 300, wd, func(c *ui.Context) {
if r := chat.Mailbox(c, "Inbox", mails, &sel, chat.MailboxOptions{}); r.Opened >= 0 {
opened = r.Opened
}
})
click(t, tt, "Guild: Rank up, NEW MESSAGES")
tt.Key(0, ui.KeyEnter)
if opened != 0 || !mails[0].Read {
t.Fatalf("Enter opened %d, read %v", opened, mails[0].Read)
}
}
func TestMailReaderAndLetter(t *testing.T) {
wd := &world{clk: newClock()}
m := chat.Mail{From: "Guild", Subject: "Rank up", Body: "Well done.", At: wd.clk.t}
tt := view(500, 400, wd, func(c *ui.Context) {
chat.MailReader(c, m, chat.MailReaderOptions{})
chat.Letter(c, "Wear the scarf.", chat.LetterOptions{Signature: "Grandpa"})
})
for _, s := range []string{"FROM", "Guild", "SUBJECT", "Rank up", "DATE", "2026-09-02 10:30", "Well done."} {
find(t, tt, s)
}
r := find(t, tt, "Wear the scarf. — Grandpa")
// The parchment darkens to its rim.
img := tt.Image()
if count(img, int(r.X/2), int(r.Y/2)+4, 1, int(r.H/2)-8, pixel.Shade) == 0 || count(img, int(r.X/2)+10, int(r.Y/2)+30, 20, 1, pixel.Shade) != 0 {
t.Fatal("only the letter's edge is dithered")
}
mustPanic(t, "narrow letter", func() {
view(100, 100, wd, func(c *ui.Context) { chat.Letter(c, "x", chat.LetterOptions{Width: 30}) })
})
}
func TestReadReceiptSentAndRead(t *testing.T) {
wd := &world{clk: newClock()}
tt := view(200, 100, wd, func(c *ui.Context) {
chat.ReadReceipt(c, false, chat.ReadReceiptOptions{})
chat.ReadReceipt(c, true, chat.ReadReceiptOptions{})
})
sent, read := find(t, tt, "SENT"), find(t, tt, "READ")
img := tt.Image()
if a, b := count(img, int(sent.X/2), int(sent.Y/2), 8, 5, pixel.Faint), count(img, int(read.X/2), int(read.Y/2), 8, 5, pixel.Info); b <= a || a == 0 {
t.Fatalf("one check of %d dots, two of %d", a, b)
}
}
+66
View File
@@ -0,0 +1,66 @@
package chat
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Mail is a letter; Read opens its envelope.
type Mail struct {
From, Subject, Body string
At time.Time
Read bool
}
type MailboxOptions struct {
// Width and Height are dots; zeros mean 220 by 120.
Width, Height int
Disabled bool
}
type MailboxResult struct {
Element *ui.Element
Changed bool
// Opened is the mail opened this frame, else -1.
Opened int
}
// Mailbox lists envelopes; Enter or a double click opens.
func Mailbox(c *ui.Context, text string, mails []Mail, selected *int, o MailboxOptions) MailboxResult {
k := pixel.K(c)
w := pixel.Or("Mailbox Width", o.Width, 220)
room := w - 14 - 16 - 30
name := func(i int) string {
m := mails[i]
if !m.Read {
return m.From + ": " + m.Subject + ", " + k.Msg("new_messages")
}
return m.From + ": " + m.Subject
}
r := list(c, k, text, len(mails), selected, w, pixel.Or("Mailbox Height", o.Height, 120), o.Disabled, name, func(i int) {
m := mails[i]
env, ink, from := icon.MailOpen, pixel.Faint, pixel.Faint
if !m.Read {
env, ink, from = icon.Mail, pixel.Accent, pixel.Ink
}
ui.Box(c).FillWidth().Height(k.D(2 * pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 4, env, 0, ink, 1)
g.Text(16, 0, line(m.From, room), from, 1)
g.Text(16, pixel.LineHeight, line(m.Subject, room+28), pixel.Faint, 1)
t := clock(m.At)
g.Micro(g.W-pixel.MicroWidth(t)-2, 3, t, pixel.Faint, 1)
})
})
res := MailboxResult{Element: r.Element, Changed: r.Changed, Opened: -1}
if r.Submitted && *selected >= 0 {
mails[*selected].Read, res.Opened = true, *selected
slog.Debug("8bit: mailbox opened", "label", text, "mail", *selected)
}
return res
}
+28
View File
@@ -0,0 +1,28 @@
package chat
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MailReaderOptions struct {
// Width is in dots; zero means 220.
Width int
}
// MailReader shows a mail's headers, a rule, its body.
func MailReader(c *ui.Context, m Mail, o MailReaderOptions) *ui.Element {
k := pixel.K(c)
w := pixel.Or("MailReader Width", o.Width, 220)
return ui.Column(c).Width(k.D(w)).Gap(k.D(4)).Children(func() {
head := [][2]string{{k.Msg("from"), m.From}, {k.Msg("subject"), m.Subject}, {k.Msg("date"), m.At.Format("2006-01-02 15:04")}}
data.DescriptionList(c, head, data.DescriptionListOptions{TermWidth: 50})
ui.Box(c).FillWidth().Height(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.HLine(0, 1, g.W, pixel.Edge)
})
para(c, k, m.Body, w, 0, pixel.Ink)
})
}
+53
View File
@@ -0,0 +1,53 @@
package chat
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Member is a name and how present they are.
type Member struct {
Name string
Status display.Status
}
type MemberListOptions struct {
// Width is in dots; zero means 140.
Width int
}
// MemberList groups members online and offline, with status dots.
func MemberList(c *ui.Context, text string, members []Member, o MemberListOptions) *ui.Element {
k := pixel.K(c)
w := pixel.Or("MemberList Width", o.Width, 140)
var on, off []Member
for _, m := range members {
if m.Status == display.Offline {
off = append(off, m)
} else {
on = append(on, m)
}
}
return ui.Column(c).Width(k.D(w)).Gap(k.D(2)).Role(ui.RoleList).Label(text).Children(func() {
for _, g := range []struct {
key string
ms []Member
ink pixel.Slot
}{{"online", on, pixel.Ink}, {"offline", off, pixel.Faint}} {
if len(g.ms) == 0 {
continue
}
pixel.Text(c, fmt.Sprintf("%s · %d", k.Msg(g.key), len(g.ms)), pixel.TextOptions{Slot: pixel.Faint})
for _, m := range g.ms {
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Padding(0, 0, 0, k.D(4)).Children(func() {
display.StatusDot(c, m.Status, display.StatusDotOptions{})
pixel.Text(c, line(m.Name, w-15), pixel.TextOptions{Slot: g.ink})
})
}
}
})
}
+60
View File
@@ -0,0 +1,60 @@
package chat
import (
"log/slog"
"strings"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MentionInputOptions struct {
Placeholder string
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type MentionInputResult struct {
Element *ui.Element
Changed, Submitted, Accepted bool
// Mentioned is the member just completed, else empty.
Mentioned string
}
// MentionInput suggests members after @; Tab or Enter completes.
func MentionInput(c *ui.Context, text string, value *string, members []string, o MentionInputOptions) MentionInputResult {
k := pixel.K(c)
w := pixel.Or("MentionInput Width", o.Width, 160)
// The word after the last @ picks matching members.
var whole, names []string
if at := strings.LastIndex(*value, "@"); at >= 0 && !strings.ContainsAny((*value)[at:], " \n") {
typed := strings.ToLower((*value)[at+1:])
for _, m := range members {
if strings.HasPrefix(strings.ToLower(m), typed) {
whole, names = append(whole, (*value)[:at+1]+m+" "), append(names, m)
}
}
}
var res MentionInputResult
res.Element = ui.Column(c).Gap(k.D(1)).Children(func() {
r := input.Autocomplete(c, text, value, whole, input.AutocompleteOptions{Placeholder: o.Placeholder, Width: w, Disabled: o.Disabled})
res.Changed, res.Submitted, res.Accepted = r.Changed, r.Submitted, r.Accepted
if r.Changed {
slog.Debug("8bit: mention input changed", "label", text, "runes", utf8.RuneCountInString(*value))
}
for i, s := range whole {
if r.Accepted && s == *value {
res.Mentioned = names[i]
slog.Debug("8bit: mention", "label", text, "member", names[i])
}
}
if len(names) > 0 && !r.Accepted {
pixel.Text(c, line("@"+strings.Join(names, " @"), w), pixel.TextOptions{Slot: pixel.Faint})
}
})
return res
}
+62
View File
@@ -0,0 +1,62 @@
package chat
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type NotificationBellOptions struct {
Disabled bool
}
type NotificationBellResult struct {
Element *ui.Element
Clicked bool
}
type bellKey struct{}
// bellState is the last count and how often it rose.
type bellState struct{ last, rises int }
// swings tilt the ringing bell a dot a step.
var swings = [...]int{-1, 1, -1, 1, 0}
// NotificationBell counts news and rings when the count rises.
func NotificationBell(c *ui.Context, text string, n int, o NotificationBellOptions) NotificationBellResult {
k := pixel.K(c)
if n < 0 {
panic(fmt.Sprintf("chat: NotificationBell count %d", n))
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Label(text).Description(fmt.Sprintf(k.Msg("new_count"), n)).Disabled(o.Disabled).FocusRing(false)
})
s := ui.Local(shell, bellKey{}, func() bellState { return bellState{last: n} })
if n > s.last {
s.rises++
slog.Debug("8bit: bell rings", "label", text, "count", n)
}
s.last = n
dx := swings[k.Shot(c, shell, s.rises, 10, len(swings)-1)]
count := strconv.Itoa(n)
if n > 99 {
count = "99+"
}
shell.Size(k.D(18+pixel.MicroWidth(count)), k.D(18)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(2+dx, 5, icon.Bell, 0, pixel.Ink, 1)
if n > 0 {
bw := pixel.MicroWidth(count) + 5
g.Frame(g.W-bw, 0, bw, 9, pixel.Notched, pixel.Bad)
g.Micro(g.W-bw+3, 2, count, pixel.AccentInk, 1)
}
g.Finish(st)
})
return NotificationBellResult{Element: shell, Clicked: base.Clicked()}
}
+93
View File
@@ -0,0 +1,93 @@
package chat
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// PollOption is a choice and its votes.
type PollOption struct {
Text string
Votes int
}
type PollOptions struct {
// Width is in dots; zero means 180.
Width int
Disabled bool
}
type PollResult struct {
Element *ui.Element
// Voted is the option voted for this frame, else -1.
Voted int
}
// Poll shows vote bars; a click moves your vote there.
func Poll(c *ui.Context, question string, opts []PollOption, mine *int, o PollOptions) PollResult {
k := pixel.K(c)
w := pixel.Or("Poll Width", o.Width, 180)
if len(opts) < 2 || *mine < -1 || *mine >= len(opts) {
panic(fmt.Sprintf("chat: Poll %q has %d options and vote %d", question, len(opts), *mine))
}
total := 0
for i, op := range opts {
if op.Votes < 0 || i == *mine && op.Votes == 0 {
panic(fmt.Sprintf("chat: Poll option %q has %d votes", op.Text, op.Votes))
}
total += op.Votes
}
res := PollResult{Voted: -1}
res.Element = ui.Column(c).Width(k.D(w)).Gap(k.D(3)).Children(func() {
para(c, k, question, w, 0, pixel.Ink)
ui.Column(c).Gap(k.D(2)).FocusGroup(ui.Vertical).Role(ui.RoleGroup).Label(question).Children(func() {
for i := range opts {
if bar(c, k, opts[i], total, i == *mine, w, o.Disabled) {
if *mine >= 0 {
opts[*mine].Votes--
}
opts[i].Votes++
*mine, res.Voted = i, i
slog.Debug("8bit: poll vote", "poll", question, "option", opts[i].Text)
}
}
})
pixel.Text(c, fmt.Sprintf(k.Msg("votes"), total), pixel.TextOptions{Slot: pixel.Faint})
})
return res
}
// bar fills an option's share; ▶ marks your vote.
func bar(c *ui.Context, k pixel.Kit, op PollOption, total int, mine bool, w int, disabled bool) bool {
label := fmt.Sprintf("%s, %d", op.Text, op.Votes)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Label(label).Disabled(disabled).FocusRing(false)
})
n := strconv.Itoa(op.Votes)
shell.Size(k.D(w), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, w, g.H, pixel.Sunken, pixel.Well)
fill := 0
if total > 0 {
fill = (w - 4) * op.Votes / total
}
g.Rect(2, 2, fill, g.H-4, pixel.Accent)
// Over the bar the words take the bar's own ink.
words := func(ink, mark pixel.Slot) {
if mine {
g.Text(3, 2, "▶", mark, 1)
}
g.Text(14, 2, line(op.Text, w-30), ink, 1)
g.Micro(w-4-pixel.MicroWidth(n), 7, n, ink, 1)
}
g.Clip(2, 0, fill, g.H, func() { words(pixel.AccentInk, pixel.AccentInk) })
g.Clip(2+fill, 0, w-4-fill, g.H, func() { words(pixel.Ink, pixel.Accent) })
g.Finish(st)
})
return base.Clicked() && !mine
}
+68
View File
@@ -0,0 +1,68 @@
package chat
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Reaction is an emoji count; Mine marks your own.
type Reaction struct {
Emoji string
Count int
Mine bool
}
type ReactionsOptions struct {
Disabled bool
}
type ReactionsResult struct {
Element *ui.Element
// Toggled is the chip clicked this frame, else -1.
Toggled int
}
// Reactions shows emoji chips with counts; a click toggles yours.
func Reactions(c *ui.Context, text string, rs []Reaction, o ReactionsOptions) ReactionsResult {
k := pixel.K(c)
res := ReactionsResult{Toggled: -1}
res.Element = ui.Row(c).Gap(k.D(2)).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(text).Children(func() {
for i := range rs {
r := &rs[i]
if r.Count < 0 || r.Mine && r.Count == 0 {
panic(fmt.Sprintf("chat: reaction %q counts %d, mine %v", r.Emoji, r.Count, r.Mine))
}
art := emoji(r.Emoji)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ToggleBase(c, &r.Mine).Label(fmt.Sprintf("%s %d", r.Emoji, r.Count)).Disabled(o.Disabled).FocusRing(false)
})
if base.Changed() {
if r.Mine {
r.Count++
} else {
r.Count--
}
res.Toggled = i
slog.Debug("8bit: reaction toggled", "label", text, "emoji", r.Emoji, "mine", r.Mine)
}
n, mine := strconv.Itoa(r.Count), r.Mine
shell.Size(k.D(20+pixel.MicroWidth(n)), k.D(16)).Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
fill, ink := pixel.Face, pixel.Ink
if mine {
fill, ink = pixel.Accent, pixel.AccentInk
}
g.Frame(0, 0, g.W, g.H, pixel.Notched, fill)
g.Sprite(2, 2, art.sp, 0, art.ink, 1)
g.Micro(16, 6, n, ink, 1)
g.Finish(st)
})
}
})
return res
}
+42
View File
@@ -0,0 +1,42 @@
package chat
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ReadReceiptOptions struct {
// Scale is 1–4; zero means 1.
Scale int
}
var check = pixel.MustSprite(`
....#
...##
#.##.
###..
.#...
`)
// ReadReceipt shows one check once sent, two once read.
func ReadReceipt(c *ui.Context, read bool, o ReadReceiptOptions) *ui.Element {
k := pixel.K(c)
sc := pixel.Or("ReadReceipt Scale", o.Scale, 1)
if sc > 4 {
panic(fmt.Sprintf("chat: ReadReceipt Scale %d outside 1–4", sc))
}
label, ink, w := k.Msg("sent"), pixel.Faint, check.W
if read {
label, ink, w = k.Msg("seen"), pixel.Info, check.W+3
}
return ui.Box(c).Role(ui.RoleImage).Label(label).Size(k.D(w*sc), k.D(check.H*sc)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 0, check, 0, ink, sc)
if read {
g.Sprite(3*sc, 0, check, 0, ink, sc)
}
})
}
+32
View File
@@ -0,0 +1,32 @@
package chat
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type SpeechBubbleOptions struct {
// Width wraps the text; zero means 140.
Width int
}
// SpeechBubble shows who said what, when; tail their side.
func SpeechBubble(c *ui.Context, m Message, o SpeechBubbleOptions) *ui.Element {
k := pixel.K(c)
w := pixel.Or("SpeechBubble Width", o.Width, 140)
// Yours sit right on Accent, written in AccentInk.
side, tail, fill, ink := ui.Start, display.Left, pixel.Lit, pixel.Ink
if m.Mine {
side, tail, fill, ink = ui.End, display.Right, pixel.Accent, pixel.AccentInk
}
return ui.Column(c).AlignSelf(side).AlignItems(side).Gap(k.D(1)).Children(func() {
pixel.Text(c, line(m.From, w), pixel.TextOptions{Slot: pixel.Faint})
display.Bubble(c, display.BubbleOptions{Tail: tail, At: 4, Fill: fill}, func() {
para(c, k, m.Text, w, 0, ink)
})
text.MicroText(c, clock(m.At), text.MicroTextOptions{Slot: pixel.Faint})
})
}
+37
View File
@@ -0,0 +1,37 @@
package chat
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type StickerPickerOptions struct {
Disabled bool
}
type StickerPickerResult struct {
Element *ui.Element
// Picked is the sticker chosen this frame, else empty.
Picked string
}
type stickerKey struct{}
// StickerPicker picks a big face sticker from a grid.
func StickerPicker(c *ui.Context, text string, o StickerPickerOptions) StickerPickerResult {
k := pixel.K(c)
box := ui.Box(c)
cur := ui.Local(box, stickerKey{}, func() int { return 0 })
var res StickerPickerResult
res.Element = box.Children(func() {
if picked := grid(c, k, text, display.EmojiNames, 4, 3, cur, o.Disabled); picked != "" {
res.Picked = picked
slog.Debug("8bit: sticker picked", "label", text, "sticker", picked)
}
})
return res
}
+36
View File
@@ -0,0 +1,36 @@
package chat
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SystemMessageOptions struct {
// Slot inks the line; zero means Faint.
Slot pixel.Slot
}
// diamond flanks a system message.
var diamond = pixel.MustSprite(`
.#.
###
.#.
`)
// SystemMessage centers a notice, such as PLAYER 2 HAS JOINED.
func SystemMessage(c *ui.Context, text string, o SystemMessageOptions) *ui.Element {
k := pixel.K(c)
ink := o.Slot
if ink == pixel.Ink {
ink = pixel.Faint
}
w := tagW(text) + 16
return ui.Box(c).AlignSelf(ui.Center).Role(ui.RoleText).Label(text).Size(k.D(w), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
y := (pixel.LineHeight - diamond.H) / 2
g.Sprite(0, y, diamond, 0, pixel.Accent, 1)
tag(g, 8, 0, text, ink)
g.Sprite(w-diamond.W, y, diamond, 0, pixel.Accent, 1)
})
}
+38
View File
@@ -0,0 +1,38 @@
package chat
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TypingIndicatorOptions struct {
// Slot inks the dots; zero means Faint.
Slot pixel.Slot
}
// TypingIndicator bounces three dots in a bubble while name types.
func TypingIndicator(c *ui.Context, name string, o TypingIndicatorOptions) *ui.Element {
k := pixel.K(c)
ink := o.Slot
if ink == pixel.Ink {
ink = pixel.Faint
}
return display.Bubble(c, display.BubbleOptions{Tail: display.Left, At: 4}, func() {
ui.Box(c).Role(ui.RoleStatus).Label(fmt.Sprintf(k.Msg("typing"), name)).Size(k.D(17), k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Each dot hops in turn, then a rest step.
hop := g.Step(6) % 4
for i := range 3 {
y := 3
if hop == i+1 {
y = 0
}
g.Rect(i*7, y, 3, 3, ink)
}
})
}).AlignSelf(ui.Start)
}
+18
View File
@@ -0,0 +1,18 @@
package chat
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type UnreadDividerOptions struct {
// Width is in dots; zero means 200.
Width int
}
// UnreadDivider rules NEW MESSAGES in Bad above the first unread.
func UnreadDivider(c *ui.Context, o UnreadDividerOptions) *ui.Element {
k := pixel.K(c)
return rule(c, k, k.Msg("new_messages"), pixel.Or("UnreadDivider Width", o.Width, 200), pixel.Bad, pixel.Bad)
}
+63
View File
@@ -0,0 +1,63 @@
package chat
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type UserCardOptions struct {
Title string
Status display.Status
// Actions label the buttons along the bottom.
Actions []string
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type UserCardResult struct {
Element *ui.Element
// Clicked is the action clicked this frame, else -1.
Clicked int
}
// UserCard shows an avatar, name, title and action buttons.
func UserCard(c *ui.Context, name string, o UserCardOptions) UserCardResult {
k := pixel.K(c)
w := pixel.Or("UserCard Width", o.Width, 160)
res := UserCardResult{Clicked: -1}
res.Element = ui.Column(c).Width(k.D(w)).Padding(k.D(6)).Gap(k.D(5)).Role(ui.RoleGroup).Label(name).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
display.Avatar(c, name, display.AvatarOptions{Scale: 2})
ui.Column(c).Gap(k.D(1)).Children(func() {
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
pixel.Text(c, line(name, w-62), pixel.TextOptions{})
display.StatusDot(c, o.Status, display.StatusDotOptions{})
})
if o.Title != "" {
pixel.Text(c, line(o.Title, w-50), pixel.TextOptions{Slot: pixel.Faint})
}
})
})
if len(o.Actions) == 0 {
return
}
ui.Row(c).Gap(k.D(3)).FocusGroup(ui.Horizontal).Children(func() {
for i, a := range o.Actions {
if input.Button(c, a, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
res.Clicked = i
slog.Debug("8bit: usercard action", "name", name, "action", a)
}
}
})
})
return res
}
+45
View File
@@ -0,0 +1,45 @@
package chat
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type VoiceMeterOptions struct {
// Bars is how many level bars; zero means 8.
Bars int
}
// VoiceMeter lights bars for 0–1: green, amber, then red.
func VoiceMeter(c *ui.Context, text string, level float64, o VoiceMeterOptions) *ui.Element {
k := pixel.K(c)
if math.IsNaN(level) || level < 0 || level > 1 {
panic(fmt.Sprintf("chat: VoiceMeter level %v outside 0–1", level))
}
n := pixel.Or("VoiceMeter Bars", o.Bars, 8)
if n < 2 {
panic(fmt.Sprintf("chat: VoiceMeter needs two bars, got %d", n))
}
lit := int(math.Round(level * float64(n)))
return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(fmt.Sprintf("%d%%", int(math.Round(level*100)))).Size(k.D(3*n-1), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range n {
h := 3 + i*9/(n-1)
s := pixel.Well
switch {
case i >= lit:
case i == n-1:
s = pixel.Bad
case i >= n*3/4:
s = pixel.Warn
default:
s = pixel.Good
}
g.Rect(3*i, 12-h, 2, h, s)
}
})
}
+21 -3
View File
@@ -79,7 +79,7 @@ func keeps(t *testing.T, img *image.RGBA) {
var heroes = []string{"Aria", "Brom", "Cyra", "Dax", "Eko"}
func party(c *ui.Context, sel *int, o data.ListOptions, changes, sent *int) {
r := data.List(c, "Party", len(heroes), sel, o, func(i int) {
r := data.List(c, "Party", len(heroes), sel, o, func(i int) string { return heroes[i] }, func(i int) {
pixel.Text(c, heroes[i], pixel.TextOptions{})
})
if r.Changed {
@@ -309,7 +309,7 @@ func TestListsKeepWholeDotsWhenScrolledByHalfADot(t *testing.T) {
}
tt := view(600, 300, newClock(), false, func(c *ui.Context) {
ui.Row(c).Children(func() {
data.List(c, "Rows", len(many), &sel, data.ListOptions{Height: 60}, func(i int) {
data.List(c, "Rows", len(many), &sel, data.ListOptions{Height: 60}, func(i int) string { return many[i] }, func(i int) {
pixel.Text(c, many[i], pixel.TextOptions{})
}).Element.Width(100)
data.Table(c, "Grid", []data.Column{{Title: "Row"}}, len(many), &sort, &row, data.TableOptions{Height: 60}, func(r, _ int) string { return many[r] })
@@ -334,7 +334,7 @@ func TestOffListsIgnoreTheWheel(t *testing.T) {
sel, sort, row, file, tile := 0, data.Sort{}, 0, "row 0", 0
tt := view(800, 300, newClock(), inert, func(c *ui.Context) {
ui.Row(c).Children(func() {
data.List(c, "Rows", len(many), &sel, data.ListOptions{Height: 60, Disabled: !inert}, func(i int) {
data.List(c, "Rows", len(many), &sel, data.ListOptions{Height: 60, Disabled: !inert}, func(i int) string { return many[i] }, func(i int) {
pixel.Text(c, many[i], pixel.TextOptions{})
})
data.Table(c, "Grid", []data.Column{{Title: "Row"}}, len(many), &sort, &row, data.TableOptions{Height: 60, Disabled: !inert}, func(r, _ int) string { return many[r] })
@@ -376,3 +376,21 @@ func TestInertInfiniteListIgnoresTheWheel(t *testing.T) {
t.Fatalf("the wheel moved an inert list to %q; %d asks", tt.Texts(), asks)
}
}
func TestListNamesRowsForReadersAndTyping(t *testing.T) {
fruit := []string{"Apple", "Banana", "Cherry"}
sel := 0
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.List(c, "Fruit", len(fruit), &sel, data.ListOptions{}, func(i int) string { return fruit[i] + " row" }, func(i int) {
pixel.Text(c, fruit[i], pixel.TextOptions{})
})
})
find(t, tt, "Banana row")
// Type-ahead reads key presses, as a keyboard sends them.
click(t, tt, "Apple")
tt.Key(0, ui.KeyC)
tt.Key(0, ui.KeyH)
if sel != 2 {
t.Fatalf("typing ch chose row %d", sel)
}
}
+5 -4
View File
@@ -23,12 +23,13 @@ type ListResult struct {
type listKey struct{}
// List is a virtual list; ▶ marks the chosen row.
func List(c *ui.Context, text string, n int, selected *int, o ListOptions, row func(i int)) ListResult {
func List(c *ui.Context, text string, n int, selected *int, o ListOptions, name func(i int) string, row func(i int)) ListResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
box := ui.Box(c)
st := ui.Local(box, listKey{}, func() ui.ListState { return ui.ListState{} })
st.Selected = selected
// Names read aloud and let typing choose a row.
st.Selected, st.Label = selected, name
if off {
st.Selected = nil
}
@@ -36,11 +37,11 @@ func List(c *ui.Context, text string, n int, selected *int, o ListOptions, row f
box.Children(func() {
e = ui.List(c, st, n, pixel.Lazy(c, func(i int) {
on := i == *selected
// The chosen row is AccentLit under a ▶.
// The chosen row is Face: Ink reads on it always.
ui.Row(c).FillWidth().Padding(k.D(1), k.D(2), k.D(1), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.AccentLit)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.Sprite(3, (g.H-pointer.H)/2, pointer, 0, pixel.Accent, 1)
}
}).Children(func() { row(i) })
+1 -1
View File
@@ -30,7 +30,7 @@ func SearchResults(c *ui.Context, label, query string, hits []Hit, selected *int
var res ListResult
col.Children(func() {
pixel.Text(c, fmt.Sprintf(k.Msg("found"), len(hits)), pixel.TextOptions{Slot: pixel.Faint})
res = List(c, label, len(hits), selected, ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) {
res = List(c, label, len(hits), selected, ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) string { return hits[i].Title + ", " + hits[i].Text }, func(i int) {
ui.Column(c).Children(func() {
text.Highlight(c, hits[i].Title, query, text.HighlightOptions{})
text.Highlight(c, hits[i].Text, query, text.HighlightOptions{Slot: pixel.Faint})
+1 -1
View File
@@ -33,7 +33,7 @@ func Transfer(c *ui.Context, text string, left, right *[]string, o TransferOptio
// side lists one side's items, its choice kept in range.
side := func(name string, items []string, at *int) {
*at = max(0, min(*at, len(items)-1))
List(c, name, len(items), at, ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) {
List(c, name, len(items), at, ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) string { return items[i] }, func(i int) {
pixel.Text(c, items[i], pixel.TextOptions{})
}).Element.Width(k.D(100))
}
+8 -6
View File
@@ -22,6 +22,8 @@ type BubbleOptions struct {
Tail Side
// At offsets the tail along its side; zero means 8.
At int
// Fill is the bubble's slot; zero means Lit.
Fill pixel.Slot
}
const tailLen = 5
@@ -32,7 +34,7 @@ func Bubble(c *ui.Context, o BubbleOptions, children func()) *ui.Element {
panic(fmt.Sprintf("display: unknown Side %d", o.Tail))
}
k := pixel.K(c)
at := pixel.Or("Bubble At", o.At, 8)
at, fill := pixel.Or("Bubble At", o.At, 8), slotOr(o.Fill, pixel.Lit)
pad := [4]int{6, 8, 6, 8}
pad[[...]int{Bottom: 2, Top: 0, Left: 3, Right: 1}[o.Tail]] += tailLen
return ui.Column(c).Padding(k.D(pad[0]), k.D(pad[1]), k.D(pad[2]), k.D(pad[3])).Draw(func(p *ui.Painter, r ui.Rect) {
@@ -51,22 +53,22 @@ func Bubble(c *ui.Context, o BubbleOptions, children func()) *ui.Element {
if w < 6 || h < 6 {
return
}
g.Frame(x, y, w, h, pixel.Notched, pixel.Lit)
g.Frame(x, y, w, h, pixel.Notched, fill)
for i := range tailLen {
n := tailLen - i
switch o.Tail {
case Bottom:
g.HLine(x+at-1, y+h-1+i, n+1, pixel.Edge)
g.HLine(x+at, y+h-1+i, n-1, pixel.Lit)
g.HLine(x+at, y+h-1+i, n-1, fill)
case Top:
g.HLine(x+at-1, y-i, n+1, pixel.Edge)
g.HLine(x+at, y-i, n-1, pixel.Lit)
g.HLine(x+at, y-i, n-1, fill)
case Left:
g.VLine(x-i, y+at-1, n+1, pixel.Edge)
g.VLine(x-i, y+at, n-1, pixel.Lit)
g.VLine(x-i, y+at, n-1, fill)
case Right:
g.VLine(x+w-1+i, y+at-1, n+1, pixel.Edge)
g.VLine(x+w-1+i, y+at, n-1, pixel.Lit)
g.VLine(x+w-1+i, y+at, n-1, fill)
}
}
}).Children(children)
+6 -1
View File
@@ -72,9 +72,14 @@ type EmojiOptions struct {
// Emoji draws one of sixteen hand-placed faces by name.
func Emoji(c *ui.Context, name string, o EmojiOptions) *ui.Element {
return Icon(c, EmojiSprite(name), IconOptions{Scale: o.Scale, Label: name})
}
// EmojiSprite is the face named name.
func EmojiSprite(name string) *pixel.Sprite {
sp, ok := emoji[name]
if !ok {
panic(fmt.Sprintf("display: no emoji %q", name))
}
return Icon(c, sp, IconOptions{Scale: o.Scale, Label: name})
return sp
}
+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 }
+1 -1
View File
@@ -32,7 +32,7 @@ func QuestLog(c *ui.Context, text string, quests []Quest, tracked *int, o QuestL
row := ui.Row(c).Gap(k.D(10)).AlignItems(ui.Start)
res := QuestLogResult{Element: row}
row.Children(func() {
r := data.List(c, text, len(quests), tracked, data.ListOptions{Height: 6 * pixel.LineHeight, Disabled: o.Disabled}, func(i int) {
r := data.List(c, text, len(quests), tracked, data.ListOptions{Height: 6 * pixel.LineHeight, Disabled: o.Disabled}, func(i int) string { return quests[i].Title }, func(i int) {
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
mark, ink := icon.Flag, pixel.Accent
switch {
+3 -3
View File
@@ -27,14 +27,14 @@ func TestQuestLogTracksTheChosenQuest(t *testing.T) {
shown := func(title string) int {
return len(slices.DeleteFunc(tt.Texts(), func(s string) bool { return s != title }))
}
// The tracked title shows in the list and the tracker.
if shown("Slime trouble") != 2 || shown("Lost cat") != 1 || !tt.HasText("TRACKING") {
// Rows are named after their quests; the tracker repeats one.
if shown("Slime trouble") != 3 || shown("Lost cat") != 2 || !tt.HasText("TRACKING") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("Lost cat"); err != nil {
t.Fatal(err)
}
if tracked != 1 || changes != 1 || shown("Lost cat") != 2 {
if tracked != 1 || changes != 1 || shown("Lost cat") != 3 {
t.Fatalf("clicking Lost cat tracked %d", tracked)
}
panics(t, "an objective past its need", func() {
+33
View File
@@ -0,0 +1,33 @@
package input_test
import (
"bytes"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestTextAreaHoldsStillWhenInert(t *testing.T) {
v, inert := "1\n2\n3\n4\n5\n6\n7\n8", false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert})
input.TextArea(c, "Notes", &v, input.TextAreaOptions{Lines: 2})
}, 400, 200)
r, ok := tt.Find("Notes")
if !ok {
t.Fatal("no Notes")
}
inert = true
tt.Frame()
before := tt.Image()
// The caret keeps the view at the end; wheel upward.
tt.Move(r.X+10, r.Y+10)
tt.Scroll(r.X+10, r.Y+10, 0, -80)
tt.Frame()
if !bytes.Equal(before.Pix, tt.Image().Pix) {
t.Fatal("the wheel scrolled an inert text area")
}
}
+5 -2
View File
@@ -16,6 +16,8 @@ type TextAreaOptions struct {
// Width is zero for 160 dots, Lines zero for 4.
Width, Lines int
Disabled bool
// EnterSends submits on Enter; Shift+Enter breaks lines.
EnterSends bool
}
type areaKey struct{}
@@ -36,7 +38,8 @@ func TextArea(c *ui.Context, text string, value *string, o TextAreaOptions) Text
lines := pixel.Or("TextArea Lines", o.Lines, 4)
col := ui.Column(c).Width(k.D(pixel.Or("TextArea Width", o.Width, 160))).Gap(k.D(2))
st := ui.Local(col, areaKey{}, func() areaState { return areaState{line: -1} })
if o.Disabled {
// Disabled or inert, the view holds still.
if o.Disabled || pixel.Current(c).Inert {
st.scroll.Y = st.held
}
var ed pixel.Edit
@@ -46,7 +49,7 @@ func TextArea(c *ui.Context, text string, value *string, o TextAreaOptions) Text
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
}).Children(func() {
ed = pixel.Editor(c, value, pixel.EditorOptions{Multiline: true, Label: text, Placeholder: o.Placeholder, MaxLength: o.MaxLength, Disabled: o.Disabled})
ed = pixel.Editor(c, value, pixel.EditorOptions{Multiline: true, Label: text, Placeholder: o.Placeholder, MaxLength: o.MaxLength, Disabled: o.Disabled, EnterSends: o.EnterSends})
})
// Keep the caret's line in view after it moves.
if ed.Line != st.line || *value != st.text {
+67
View File
@@ -383,3 +383,70 @@ func boolInt(b bool) int {
}
return 0
}
func TestScrollAreaHoldsStillWhenInert(t *testing.T) {
s, inert := ui.ScrollState{}, false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert})
layout.ScrollArea(c, &s, layout.ScrollAreaOptions{Label: "List"}, func() {
for range 40 {
ui.Box(c).Height(32)
}
}).Element.Size(200, 100)
}, 300, 200)
tt.Frame()
r := box(t, tt, "List")
if err := tt.Click("List"); err != nil {
t.Fatal(err)
}
bar := func() []uint8 {
return tt.Image().SubImage(image.Rect(int(r.X+r.W), int(r.Y), int(r.X+r.W)+20, int(r.Y+r.H))).(*image.RGBA).Pix
}
looks := bar()
inert = true
tt.Frame()
// Inert keeps the look; only Disabled dims the bar.
if string(bar()) != string(looks) {
t.Fatal("an inert scroll area changed its look")
}
// Keys, the wheel and the arrows all leave it be.
tt.Key(0, ui.KeyDown)
tt.Move(r.X+20, r.Y+30)
tt.Scroll(r.X+20, r.Y+30, 0, 80)
if err := tt.Click("SCROLL DOWN"); err != nil {
t.Fatal(err)
}
if s.Y != 0 {
t.Fatalf("an inert scroll area moved to %v", s.Y)
}
}
func TestInertScrollAreaLetsTabPass(t *testing.T) {
s, inert, clicks := ui.ScrollState{}, false, 0
tt := ui.NewTester(func(c *ui.Context) {
use(c)
set := pixel.Current(c)
set.Inert = inert
pixel.Scope(c, set, func() {
layout.ScrollArea(c, &s, layout.ScrollAreaOptions{Label: "List"}, func() {
for range 40 {
ui.Box(c).Height(32)
}
}).Element.Size(200, 100)
})
if ui.ButtonBase(c).Label("Next").Size(60, 20).Clicked() {
clicks++
}
}, 300, 200)
if err := tt.Click("List"); err != nil {
t.Fatal(err)
}
inert = true
tt.Frame()
// Parked on the root, Tab passes the inert view.
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if clicks != 1 {
t.Fatal("Tab stuck on an inert scroll area")
}
}
+15 -8
View File
@@ -44,21 +44,28 @@ func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children
row := ui.Row(c).AlignItems(ui.Stretch).Reverse()
last := ui.Local(row, scrollKey{}, func() float32 { return s.Y })
view := row.Bounds().H
// Disabled freezes the view: any move is undone before paint.
if o.Disabled {
// Off freezes the view: any move is undone before paint.
inert := pixel.Current(c).Inert
if o.Disabled || inert {
s.Y = *last
}
var v *ui.Element
row.Children(func() {
scrollBar(c, k, s, view, o.Disabled)
scrollBar(c, k, s, view, o.Disabled || inert, o.Disabled)
// A disabled view is a new element; the focus leaves.
v = ui.Scroll(c).Key(o.Disabled).TrackScroll(s).Grow(1).Label(o.Label).FocusRing(false)
if o.Disabled {
switch {
case o.Disabled:
v.Role(ui.RoleGroup).Description(k.Msg("disabled"))
} else {
case inert:
// Out of the Tab order, so Tab moves past it.
v.Role(ui.RoleScroll)
default:
v.Role(ui.RoleScroll).Focusable()
}
v.Children(children)
// Inert parks the focus and gives it back after.
pixel.Park(c, v, inert)
})
focus := v.FocusVisible()
row.Draw(func(p *ui.Painter, r ui.Rect) {
@@ -83,11 +90,11 @@ func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children
return res
}
// scrollBar draws arrows, track and thumb, and moves s.
func scrollBar(c *ui.Context, k pixel.Kit, s *ui.ScrollState, view float32, disabled bool) {
// scrollBar moves s unless off; dim draws it disabled.
func scrollBar(c *ui.Context, k pixel.Kit, s *ui.ScrollState, view float32, disabled, dim bool) {
clamp := func(y float32) { s.Y = max(0, min(y, s.MaxY)) }
ui.Column(c).Width(k.D(barW)).DrawOver(func(p *ui.Painter, r ui.Rect) {
if disabled {
if dim {
g := k.Canvas(p, r)
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
+9 -2
View File
@@ -17,6 +17,8 @@ type EditorOptions struct {
Placeholder string
Label string
Disabled bool
// EnterSends submits a multi-line field on Enter; Shift+Enter breaks lines.
EnterSends bool
}
type Edit struct {
@@ -33,6 +35,7 @@ type editLine struct{ start, end int }
type editEnv struct {
multi bool
sends bool
maxLen int
mask rune
width int
@@ -47,6 +50,9 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit {
if o.MaxLength < 0 {
panic(fmt.Sprintf("pixel: MaxLength %d", o.MaxLength))
}
if o.EnterSends && !o.Multiline {
panic("pixel: EnterSends needs a multi-line field")
}
shell, base, _ := Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(o.Label).Disabled(o.Disabled)
})
@@ -58,7 +64,7 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit {
base.Description(*value)
}
width, check := WrapWidth(c, shell)
env := editEnv{multi: o.Multiline, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: c.WriteClipboard}
env := editEnv{multi: o.Multiline, sends: o.EnterSends, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: c.WriteClipboard}
shown := s.view(o.Mask, true)
caret := s.caret + s.composeCaret
lines := editLayout(shown, width, o.Multiline)
@@ -403,7 +409,8 @@ func (s *editState) key(ev ui.InputEvent, e editEnv) bool {
s.eraseOr(s.caret, s.caret+1)
}
case ui.KeyEnter:
if !e.multi || m != 0 {
brk := e.multi && m == 0 && (shift || !e.sends)
if !brk {
s.submitted = true
return true
}
+14
View File
@@ -145,6 +145,20 @@ func TestEditorMultiline(t *testing.T) {
}
}
func TestEditorEnterSends(t *testing.T) {
h := newEdit(t, "hi", 200, pixel.EditorOptions{Multiline: true, EnterSends: true})
h.focus()
h.tt.Key(ui.Shift, ui.KeyEnter)
h.tt.Type("yo")
h.want("hi\nyo")
h.tt.Key(0, ui.KeyEnter)
if h.submits != 1 {
t.Fatalf("Enter submitted %d times", h.submits)
}
h.want("hi\nyo")
mustPanic(t, "one line", func() { newEdit(t, "", 200, pixel.EditorOptions{EnterSends: true}).tt.Frame() })
}
func TestEditorLimitsAndMask(t *testing.T) {
h := newEdit(t, "", 200, pixel.EditorOptions{MaxLength: 5})
h.focus()
+8
View File
@@ -30,6 +30,10 @@ var messages = map[Locale]map[string]string{
"buy": "BUY", "sell": "SELL", "done": "DONE", "tracking": "TRACKING",
"new_game": "NEW GAME", "file": "FILE %d", "press_start": "PRESS START", "continue_game": "CONTINUE", "the_end": "THE END",
"tip": "TIP", "you_got": "YOU GOT: %s!", "unlocked": "UNLOCKED %d/%d", "rank": "RANK", "craft": "CRAFT",
"send": "SEND ▶", "typing": "%s IS TYPING", "today": "TODAY", "new_messages": "NEW MESSAGES", "sent": "SENT", "seen": "READ",
"from": "FROM", "subject": "SUBJECT", "date": "DATE", "faces": "FACES", "nature": "NATURE", "items": "ITEMS",
"mute": "MUTE", "camera": "CAMERA", "hang_up": "HANG UP", "new_count": "%d NEW", "votes": "%d VOTES", "unread": "%d UNREAD",
"day_date": "%[1]s %[2]d %[3]s", "category": "CATEGORY", "days": "SUN MON TUE WED THU FRI SAT",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -58,6 +62,10 @@ var messages = map[Locale]map[string]string{
"buy": "购买", "sell": "出售", "done": "完成", "tracking": "追踪中",
"new_game": "新游戏", "file": "存档 %d", "press_start": "按下开始", "continue_game": "继续", "the_end": "完",
"tip": "提示", "you_got": "获得了 %s!", "unlocked": "已解锁 %d/%d", "rank": "评价", "craft": "合成",
"send": "发送 ▶", "typing": "%s 正在输入", "today": "今天", "new_messages": "新消息", "sent": "已发送", "seen": "已读",
"from": "发件人", "subject": "主题", "date": "日期", "faces": "表情", "nature": "万物", "items": "道具",
"mute": "静音", "camera": "摄像头", "hang_up": "挂断", "new_count": "%d 条新", "votes": "%d 票", "unread": "%d 未读",
"day_date": "%[4]d月%[2]d日 周%[1]s", "category": "类别", "days": "日 一 二 三 四 五 六",
},
}