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": "日 一 二 三 四 五 六", }, }