Add chat components 321-345; named List rows, inert scroll views
Speech bubbles, a chat log that follows its end, a composer that sends on Enter, typing dots, chat and channel lists, read receipts, reactions, emoji and sticker pickers, mentions, date and unread rules, system lines, member lists, user cards, a mailbox, mail reader and letter, a notification bell, call controls, a voice meter, attachment chips, link previews and polls. pixel's editor takes EnterSends; display's Bubble takes a Fill and exports EmojiSprite. data.List names its rows, which also lets typing choose one. layout.ScrollArea and input.TextArea hold still under Inert, and an inert scroll view leaves the Tab order until it wakes.
This commit is contained in:
50 files changed
+2697
-34
No files matched your search
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user