From 455f5f2a051a52d71dc884eb1ad03598fdfe6b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 05:02:36 -0400 Subject: [PATCH] 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. --- chat/attachmentchip.go | 72 ++++++++++ chat/callcontrols.go | 54 ++++++++ chat/channellist.go | 53 ++++++++ chat/chat.go | 90 ++++++++++++ chat/chat_test.go | 287 +++++++++++++++++++++++++++++++++++++++ chat/chatlist.go | 59 ++++++++ chat/chatlog.go | 51 +++++++ chat/composer.go | 50 +++++++ chat/controls_test.go | 249 +++++++++++++++++++++++++++++++++ chat/dateseparator.go | 38 ++++++ chat/emoji.go | 49 +++++++ chat/emojipicker.go | 72 ++++++++++ chat/letter.go | 53 ++++++++ chat/linkpreview.go | 61 +++++++++ chat/lists_test.go | 145 ++++++++++++++++++++ chat/mailbox.go | 66 +++++++++ chat/mailreader.go | 28 ++++ chat/memberlist.go | 53 ++++++++ chat/mentioninput.go | 60 ++++++++ chat/notificationbell.go | 62 +++++++++ chat/poll.go | 93 +++++++++++++ chat/reactions.go | 68 ++++++++++ chat/readreceipt.go | 42 ++++++ chat/speechbubble.go | 32 +++++ chat/stickerpicker.go | 37 +++++ chat/systemmessage.go | 36 +++++ chat/typingindicator.go | 38 ++++++ chat/unreaddivider.go | 18 +++ chat/usercard.go | 63 +++++++++ chat/voicemeter.go | 45 ++++++ data/data_test.go | 24 +++- data/list.go | 9 +- data/searchresults.go | 2 +- data/transfer.go | 2 +- display/bubble.go | 14 +- display/emoji.go | 7 +- gallery/demos_chat.go | 194 ++++++++++++++++++++++++++ gallery/demos_data.go | 2 +- gallery/demos_mail.go | 146 ++++++++++++++++++++ gallery/fx_demo_test.go | 34 +++++ gallery/registry.go | 2 +- game/questlog.go | 2 +- game/screens_test.go | 6 +- input/inert_test.go | 33 +++++ input/textarea.go | 7 +- layout/layout_test.go | 67 +++++++++ layout/scrollarea.go | 23 ++-- pixel/editor.go | 11 +- pixel/editor_test.go | 14 ++ pixel/messages.go | 8 ++ 50 files changed, 2697 insertions(+), 34 deletions(-) create mode 100644 chat/attachmentchip.go create mode 100644 chat/callcontrols.go create mode 100644 chat/channellist.go create mode 100644 chat/chat.go create mode 100644 chat/chat_test.go create mode 100644 chat/chatlist.go create mode 100644 chat/chatlog.go create mode 100644 chat/composer.go create mode 100644 chat/controls_test.go create mode 100644 chat/dateseparator.go create mode 100644 chat/emoji.go create mode 100644 chat/emojipicker.go create mode 100644 chat/letter.go create mode 100644 chat/linkpreview.go create mode 100644 chat/lists_test.go create mode 100644 chat/mailbox.go create mode 100644 chat/mailreader.go create mode 100644 chat/memberlist.go create mode 100644 chat/mentioninput.go create mode 100644 chat/notificationbell.go create mode 100644 chat/poll.go create mode 100644 chat/reactions.go create mode 100644 chat/readreceipt.go create mode 100644 chat/speechbubble.go create mode 100644 chat/stickerpicker.go create mode 100644 chat/systemmessage.go create mode 100644 chat/typingindicator.go create mode 100644 chat/unreaddivider.go create mode 100644 chat/usercard.go create mode 100644 chat/voicemeter.go create mode 100644 gallery/demos_chat.go create mode 100644 gallery/demos_mail.go create mode 100644 input/inert_test.go diff --git a/chat/attachmentchip.go b/chat/attachmentchip.go new file mode 100644 index 0000000..4b2c1d4 --- /dev/null +++ b/chat/attachmentchip.go @@ -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]) +} diff --git a/chat/callcontrols.go b/chat/callcontrols.go new file mode 100644 index 0000000..adad40c --- /dev/null +++ b/chat/callcontrols.go @@ -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 +} diff --git a/chat/channellist.go b/chat/channellist.go new file mode 100644 index 0000000..4aa183e --- /dev/null +++ b/chat/channellist.go @@ -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) + } + }) + })) +} diff --git a/chat/chat.go b/chat/chat.go new file mode 100644 index 0000000..6bc3388 --- /dev/null +++ b/chat/chat.go @@ -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) + }) +} diff --git a/chat/chat_test.go b/chat/chat_test.go new file mode 100644 index 0000000..2f50ab0 --- /dev/null +++ b/chat/chat_test.go @@ -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") +} diff --git a/chat/chatlist.go b/chat/chatlist.go new file mode 100644 index 0000000..b9c4e7c --- /dev/null +++ b/chat/chatlist.go @@ -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{}) + } + }) + })) +} diff --git a/chat/chatlog.go b/chat/chatlog.go new file mode 100644 index 0000000..9661b01 --- /dev/null +++ b/chat/chatlog.go @@ -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() + }) +} diff --git a/chat/composer.go b/chat/composer.go new file mode 100644 index 0000000..0449015 --- /dev/null +++ b/chat/composer.go @@ -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 +} diff --git a/chat/controls_test.go b/chat/controls_test.go new file mode 100644 index 0000000..3bb341c --- /dev/null +++ b/chat/controls_test.go @@ -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{}) }) + }) +} diff --git a/chat/dateseparator.go b/chat/dateseparator.go new file mode 100644 index 0000000..d4a6bb7 --- /dev/null +++ b/chat/dateseparator.go @@ -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())) +} diff --git a/chat/emoji.go b/chat/emoji.go new file mode 100644 index 0000000..9ac39e3 --- /dev/null +++ b/chat/emoji.go @@ -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)) +} diff --git a/chat/emojipicker.go b/chat/emojipicker.go new file mode 100644 index 0000000..9b0fe8d --- /dev/null +++ b/chat/emojipicker.go @@ -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] +} diff --git a/chat/letter.go b/chat/letter.go new file mode 100644 index 0000000..af89307 --- /dev/null +++ b/chat/letter.go @@ -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) + }) +} diff --git a/chat/linkpreview.go b/chat/linkpreview.go new file mode 100644 index 0000000..54b1826 --- /dev/null +++ b/chat/linkpreview.go @@ -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 +} diff --git a/chat/lists_test.go b/chat/lists_test.go new file mode 100644 index 0000000..9c2dac4 --- /dev/null +++ b/chat/lists_test.go @@ -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) + } +} diff --git a/chat/mailbox.go b/chat/mailbox.go new file mode 100644 index 0000000..3d38be5 --- /dev/null +++ b/chat/mailbox.go @@ -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 +} diff --git a/chat/mailreader.go b/chat/mailreader.go new file mode 100644 index 0000000..8440453 --- /dev/null +++ b/chat/mailreader.go @@ -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) + }) +} diff --git a/chat/memberlist.go b/chat/memberlist.go new file mode 100644 index 0000000..10b9b97 --- /dev/null +++ b/chat/memberlist.go @@ -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}) + }) + } + } + }) +} diff --git a/chat/mentioninput.go b/chat/mentioninput.go new file mode 100644 index 0000000..252f18f --- /dev/null +++ b/chat/mentioninput.go @@ -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 +} diff --git a/chat/notificationbell.go b/chat/notificationbell.go new file mode 100644 index 0000000..3aa510b --- /dev/null +++ b/chat/notificationbell.go @@ -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()} +} diff --git a/chat/poll.go b/chat/poll.go new file mode 100644 index 0000000..0ef9caa --- /dev/null +++ b/chat/poll.go @@ -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 +} diff --git a/chat/reactions.go b/chat/reactions.go new file mode 100644 index 0000000..73e6e53 --- /dev/null +++ b/chat/reactions.go @@ -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 +} diff --git a/chat/readreceipt.go b/chat/readreceipt.go new file mode 100644 index 0000000..d3424aa --- /dev/null +++ b/chat/readreceipt.go @@ -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) + } + }) +} diff --git a/chat/speechbubble.go b/chat/speechbubble.go new file mode 100644 index 0000000..caeefe1 --- /dev/null +++ b/chat/speechbubble.go @@ -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}) + }) +} diff --git a/chat/stickerpicker.go b/chat/stickerpicker.go new file mode 100644 index 0000000..d52d617 --- /dev/null +++ b/chat/stickerpicker.go @@ -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 +} diff --git a/chat/systemmessage.go b/chat/systemmessage.go new file mode 100644 index 0000000..43dae17 --- /dev/null +++ b/chat/systemmessage.go @@ -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) + }) +} diff --git a/chat/typingindicator.go b/chat/typingindicator.go new file mode 100644 index 0000000..7e83635 --- /dev/null +++ b/chat/typingindicator.go @@ -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) +} diff --git a/chat/unreaddivider.go b/chat/unreaddivider.go new file mode 100644 index 0000000..ac671e7 --- /dev/null +++ b/chat/unreaddivider.go @@ -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) +} diff --git a/chat/usercard.go b/chat/usercard.go new file mode 100644 index 0000000..a3e644c --- /dev/null +++ b/chat/usercard.go @@ -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 +} diff --git a/chat/voicemeter.go b/chat/voicemeter.go new file mode 100644 index 0000000..3960d2c --- /dev/null +++ b/chat/voicemeter.go @@ -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) + } + }) +} diff --git a/data/data_test.go b/data/data_test.go index 99500b8..9041345 100644 --- a/data/data_test.go +++ b/data/data_test.go @@ -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) + } +} diff --git a/data/list.go b/data/list.go index 26bbff5..a6752c0 100644 --- a/data/list.go +++ b/data/list.go @@ -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) }) diff --git a/data/searchresults.go b/data/searchresults.go index 76d4af7..cf5bea8 100644 --- a/data/searchresults.go +++ b/data/searchresults.go @@ -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}) diff --git a/data/transfer.go b/data/transfer.go index f466b1d..dc61d2a 100644 --- a/data/transfer.go +++ b/data/transfer.go @@ -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)) } diff --git a/display/bubble.go b/display/bubble.go index 8203bee..b9793d8 100644 --- a/display/bubble.go +++ b/display/bubble.go @@ -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) diff --git a/display/emoji.go b/display/emoji.go index f493b89..56cc3ed 100644 --- a/display/emoji.go +++ b/display/emoji.go @@ -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 } diff --git a/gallery/demos_chat.go b/gallery/demos_chat.go new file mode 100644 index 0000000..1dc55eb --- /dev/null +++ b/gallery/demos_chat.go @@ -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) + } +} diff --git a/gallery/demos_data.go b/gallery/demos_data.go index 53d8a9d..c28378a 100644 --- a/gallery/demos_data.go +++ b/gallery/demos_data.go @@ -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)) diff --git a/gallery/demos_mail.go b/gallery/demos_mail.go new file mode 100644 index 0000000..3f925b3 --- /dev/null +++ b/gallery/demos_mail.go @@ -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) + } +} diff --git a/gallery/fx_demo_test.go b/gallery/fx_demo_test.go index c9e2c55..77c7695 100644 --- a/gallery/fx_demo_test.go +++ b/gallery/fx_demo_test.go @@ -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]) + } + } + } +} diff --git a/gallery/registry.go b/gallery/registry.go index 9829354..286ed18 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/game/questlog.go b/game/questlog.go index 11420bd..e2ac74b 100644 --- a/game/questlog.go +++ b/game/questlog.go @@ -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 { diff --git a/game/screens_test.go b/game/screens_test.go index cdea945..ef69714 100644 --- a/game/screens_test.go +++ b/game/screens_test.go @@ -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() { diff --git a/input/inert_test.go b/input/inert_test.go new file mode 100644 index 0000000..1ae5d30 --- /dev/null +++ b/input/inert_test.go @@ -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") + } +} diff --git a/input/textarea.go b/input/textarea.go index 0827b39..6bab95f 100644 --- a/input/textarea.go +++ b/input/textarea.go @@ -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 { diff --git a/layout/layout_test.go b/layout/layout_test.go index 9968f40..c9a4bef 100644 --- a/layout/layout_test.go +++ b/layout/layout_test.go @@ -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") + } +} diff --git a/layout/scrollarea.go b/layout/scrollarea.go index 42183aa..b3801fd 100644 --- a/layout/scrollarea.go +++ b/layout/scrollarea.go @@ -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) } diff --git a/pixel/editor.go b/pixel/editor.go index a2bb4bc..900bcdf 100644 --- a/pixel/editor.go +++ b/pixel/editor.go @@ -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 } diff --git a/pixel/editor_test.go b/pixel/editor_test.go index 48fb824..a603115 100644 --- a/pixel/editor_test.go +++ b/pixel/editor_test.go @@ -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() diff --git a/pixel/messages.go b/pixel/messages.go index 654c180..ccd785a 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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": "日 一 二 三 四 五 六", }, }