Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
package messaging
import (
"log/slog"
"runtime"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/media"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CallState is what the caller's call reports.
type CallState struct {
Muted, CameraOff, Sharing bool
}
// CallControlsOptions are the callbacks; every one is required.
type CallControlsOptions struct {
OnMute, OnCamera, OnShare func(on bool)
OnHangUp func()
Disabled bool
}
type callKey struct {
key ui.Key
letter string
shift bool
}
var (
callMute = callKey{ui.KeyD, "D", false}
callCamera = callKey{ui.KeyE, "E", false}
callShare = callKey{ui.KeyS, "S", true}
callHangUp = callKey{ui.KeyH, "H", true}
)
// pressed reports the shortcut anywhere in the window.
func (k callKey) pressed(c *ui.Context) bool {
mods := ui.Cmd
if k.shift {
mods |= ui.Shift
}
return c.Shortcut(mods, k.key)
}
// String formats the platform’s shortcut label.
func (k callKey) String() string {
mac := runtime.GOOS == "darwin"
keys := []string{"Mod"}
if k.shift {
keys = append(keys, "Shift")
}
out := ""
for i, name := range append(keys, k.letter) {
face, _ := display.KbdKey(name, mac)
if i > 0 && !mac {
out += "+"
}
out += face
}
return out
}
// CallControls provides call actions and window shortcuts.
func CallControls(c *ui.Context, st CallState, opts CallControlsOptions) *ui.Element {
if opts.OnMute == nil || opts.OnCamera == nil || opts.OnShare == nil || opts.OnHangUp == nil {
panic("mujica: CallControls needs every callback")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
row := ui.Row(c).Gap(u*2).Padding(u*2, u*3).AlignItems(ui.Center).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).
Label(core.Msg(c, "call_controls.label")).Disabled(opts.Disabled)
live := !row.IsDisabled()
report := func(name string, on bool, fn func(bool)) {
slog.Debug("mujica: call_controls "+name, "on", on)
fn(on)
}
row.Children(func() {
toggle := func(off, on *ui.SVG, offKey, onKey string, state bool, key callKey, name string, fn func(bool)) {
icon, label := off, core.Msg(c, offKey)
if state {
icon, label = on, core.Msg(c, onKey)
}
b := media.MediaToggleAction(c, icon, label, state).Size(40, 40).Tooltip(label + " (" + key.String() + ")")
if live && b.Clicked() {
report(name, !state, fn)
}
}
toggle(core.MediaMic, msgMicOff, "call_controls.mute", "call_controls.unmute", st.Muted, callMute, "mute", opts.OnMute)
toggle(core.MediaCamera, msgVideoOff, "call_controls.cameraOff", "call_controls.cameraOn", st.CameraOff, callCamera, "camera", opts.OnCamera)
toggle(msgShare, msgShare, "call_controls.share", "call_controls.stopShare", st.Sharing, callShare, "share", opts.OnShare)
hang := input.Button(c, core.Msg(c, "call_controls.hangUp"), input.ButtonOptions{Variant: input.Danger, Icon: msgHangUp}).
Tooltip(core.Msg(c, "call_controls.hangUp") + " (" + callHangUp.String() + ")")
if live && hang.Clicked() {
slog.Debug("mujica: call_controls hang up")
opts.OnHangUp()
}
})
if live {
switch {
case callMute.pressed(c):
report("mute", !st.Muted, opts.OnMute)
case callCamera.pressed(c):
report("camera", !st.CameraOff, opts.OnCamera)
case callShare.pressed(c):
report("share", !st.Sharing, opts.OnShare)
case callHangUp.pressed(c):
slog.Debug("mujica: call_controls hang up")
opts.OnHangUp()
}
}
return row
}
+64
View File
@@ -0,0 +1,64 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestCallControlsClicksAndShortcuts(t *testing.T) {
st := CallState{Muted: true}
var k callCalls
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
CallControls(c, st, k.opts())
input.Button(c, "Elsewhere", input.ButtonOptions{})
})
}, 500, 200)
for _, name := range []string{"Unmute", "Turn camera off", "Share screen", "Leave"} {
if err := tt.Click(name); err != nil {
t.Fatal(err)
}
}
if len(k.mute) != 1 || k.mute[0] || len(k.camera) != 1 || !k.camera[0] || len(k.share) != 1 || !k.share[0] || k.hang != 1 {
t.Fatalf("clicks %+v", k)
}
if err := tt.Click("Elsewhere"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Cmd, ui.KeyD)
tt.Key(ui.Cmd, ui.KeyE)
tt.Key(ui.Cmd|ui.Shift, ui.KeyS)
tt.Key(ui.Cmd|ui.Shift, ui.KeyH)
if len(k.mute) != 2 || len(k.camera) != 2 || len(k.share) != 2 || k.hang != 2 {
t.Fatalf("shortcuts %+v", k)
}
}
func TestCallControlsDisabled(t *testing.T) {
var k callCalls
o := k.opts()
o.Disabled = true
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
CallControls(c, CallState{}, o)
}, 500, 200)
_ = tt.Click("Mute")
tt.Key(ui.Cmd, ui.KeyD)
tt.Key(ui.Cmd|ui.Shift, ui.KeyH)
if len(k.mute) != 0 || k.hang != 0 {
t.Fatalf("disabled controls reported %+v", k)
}
mediaPanics(t, "no callbacks", func(c *ui.Context) { CallControls(c, CallState{}, CallControlsOptions{}) })
}
func TestCallControlsSnapshot(t *testing.T) {
var k callCalls
snapshot(t, "call_controls", 420, 120, func(c *ui.Context) {
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { CallControls(c, CallState{Muted: true, Sharing: true}, k.opts()) })
})
}
+218
View File
@@ -0,0 +1,218 @@
package messaging
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Channel supplies channel or direct-conversation data.
type Channel struct {
ID, Name string
// Direct marks a private conversation, shown with the person's presence.
Direct bool
Presence PresenceStatus
Unread int
Muted bool
Pinned bool
}
// ChannelGroup is a titled, collapsible section of channels.
type ChannelGroup struct {
ID, Title string
Channels []Channel
}
// ChannelListOptions configure a ChannelList.
type ChannelListOptions struct {
// Height defaults to 360 DIP.
Height float32
// Revision invalidates grouped rows after in-place data edits.
Revision int
Disabled bool
}
type channelListKey struct{}
type channelIndex struct{ group, item int }
type channelListState struct {
list ui.ListState
folded map[string]bool
first *ChannelGroup
count, revision int
rows []channelIndex
}
// ChannelList chooses a channel from collapsible, virtualized groups.
func ChannelList(c *ui.Context, selected *string, groups []ChannelGroup, opts ChannelListOptions) core.ChoiceResult {
if opts.Height < 0 {
panic("mujica: ChannelList Height must be nonnegative")
}
k := core.Tokens(c)
d := core.Density(c)
col := ui.Column(c).Disabled(opts.Disabled)
st := ui.Local(col, channelListKey{}, func() channelListState {
return channelListState{folded: map[string]bool{}}
})
var first *ChannelGroup
if len(groups) > 0 {
first = &groups[0]
}
if st.rows == nil || st.first != first || st.count != len(groups) || st.revision != opts.Revision {
st.first, st.count, st.revision = first, len(groups), opts.Revision
channelRows(st, groups)
}
st.list.Key = func(i int) any {
r := st.rows[i]
if r.item < 0 {
return "group:" + groups[r.group].ID
}
return "channel:" + groups[r.group].Channels[r.item].ID
}
h := opts.Height
if h == 0 {
h = 360
}
h = min(h, float32(len(st.rows))*d.ControlHeight())
old := *selected
col.Children(func() {
ui.List(c, &st.list, len(st.rows), func(i int) {
r := st.rows[i]
g := groups[r.group]
if r.item < 0 {
open := !st.folded[g.ID]
if channelHeader(c, k, g, open) && !col.IsDisabled() {
st.folded[g.ID] = open
st.first = nil
st.count = -1
c.Invalidate()
slog.Debug("mujica: channel_list fold", "group", g.ID, "open", !open)
}
return
}
ch := g.Channels[r.item]
if channelRow(c, k, ch, ch.ID == *selected) && !col.IsDisabled() {
*selected = ch.ID
}
}).Height(h).FocusGroup(ui.Vertical).Role(ui.RoleTree).Label(core.Msg(c, "channel_list.label"))
})
changed := *selected != old
if changed {
slog.Debug("mujica: channel_list select", "id", *selected)
}
return core.NewChoiceResult(col, changed)
}
func channelRows(st *channelListState, groups []ChannelGroup) {
seen, groupIDs := map[string]bool{}, map[string]bool{}
rows := []channelIndex{}
for gi, g := range groups {
if g.ID == "" || g.Title == "" || groupIDs[g.ID] {
panic("mujica: ChannelList needs unique group IDs and titles")
}
groupIDs[g.ID] = true
rows = append(rows, channelIndex{gi, -1})
for _, ch := range g.Channels {
if ch.ID == "" || ch.Name == "" || seen[ch.ID] || ch.Unread < 0 {
panic("mujica: ChannelList needs unique channel IDs, names and nonnegative Unread")
}
ch.Presence.key()
seen[ch.ID] = true
}
if st.folded[g.ID] {
continue
}
for _, pinned := range []bool{true, false} {
for ci, ch := range g.Channels {
if ch.Pinned == pinned {
rows = append(rows, channelIndex{gi, ci})
}
}
}
}
st.rows = rows
}
func channelHeader(c *ui.Context, k theme.Tokens, g ChannelGroup, open bool) bool {
u := core.Density(c).Unit()
b := ui.ButtonBase(c).MinHeight(core.Density(c).ControlHeight()).Justify(ui.Start).Gap(u).Padding(0, u*2).Radius(theme.ControlRadius).
Label(g.Title).Role(ui.RoleTreeItem).TextColor(k.TextMuted).Transition(core.ColorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
arrow := "chevron-down"
if !open {
arrow = "chevron-right"
}
b.Children(func() {
ui.Icon(c, icons.Must(arrow)).Size(14, 14)
ui.Text(c, g.Title).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine()
})
if b.IsDisabled() {
return false
}
toggled := b.Clicked()
if open && b.Shortcut(0, ui.KeyLeft) || !open && b.Shortcut(0, ui.KeyRight) {
toggled = true
}
return toggled
}
// channelRow is one channel button; true means it was chosen.
func channelRow(c *ui.Context, k theme.Tokens, ch Channel, on bool) bool {
d := core.Density(c)
u := d.Unit()
label := ch.Name
if ch.Unread > 0 {
label = fmt.Sprintf(core.Msg(c, "channel_list.unread"), ch.Name, ch.Unread)
}
b := ui.ButtonBase(c).Key(ch.ID).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(u*2).Padding(0, u*2, 0, u*5).
Radius(theme.ControlRadius).Label(label).Role(ui.RoleTreeItem).Selected(on).Background(ui.Transparent).
TextColor(k.Text).Transition(core.ColorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case on:
b.Background(k.Selection).BorderWidth(0, 0, 0, 3).BorderColor(k.AccentText)
case b.Hovered():
b.Background(k.SurfaceHover)
}
if ch.Muted && !on {
b.TextColor(k.TextMuted)
}
b.Children(func() {
ui.Box(c).Width(16).Center().Shrink(0).Children(func() {
if ch.Direct {
OnlineStatus(c, ch.Presence, OnlineStatusOptions{HideText: true, Size: 9})
} else {
ui.Text(c, "#").TextColor(k.TextMuted)
}
})
name := ui.Text(c, ch.Name).SingleLine().Grow(1).MinWidth(0)
if ch.Unread > 0 && !ch.Muted {
name.FontWeight(600)
}
if ch.Pinned {
ui.Icon(c, msgPin).Size(13, 13).TextColor(k.TextMuted).Label(core.Msg(c, "channel_list.pinned"))
}
if ch.Muted {
ui.Icon(c, msgBellOff).Size(13, 13).TextColor(k.TextMuted).Label(core.Msg(c, "channel_list.muted"))
}
if ch.Unread > 0 {
tone := display.BadgeAccent
if ch.Muted {
tone = display.BadgeNeutral
}
display.Badge(c, display.BadgeOptions{Count: ch.Unread, Tone: tone}, nil)
}
})
return b.Clicked()
}
+67
View File
@@ -0,0 +1,67 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestChannelListSelectFoldAndOrder(t *testing.T) {
sel, n := "", 0
tt := channelTester(&sel, false, &n)
if err := tt.Click("random, 120 unread"); err != nil {
t.Fatal(err)
}
if sel != "random" || n != 1 || !tt.HasText("99+") {
t.Fatalf("selected %q, changes %d", sel, n)
}
pinned, _ := tt.Find("rehearsal")
general, _ := tt.Find("general, 3 unread")
if pinned.Y >= general.Y {
t.Fatal("pinned channel not first")
}
if err := tt.Click("Direct messages"); err != nil {
t.Fatal(err)
}
if tt.HasText("Uika") {
t.Fatal("folded group still shows its channels")
}
}
func TestChannelListKeyboard(t *testing.T) {
sel, n := "", 0
tt := channelTester(&sel, false, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if sel != "rehearsal" {
t.Fatalf("Down, Enter chose %q", sel)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyLeft)
if tt.HasText("general") {
t.Fatal("Left on a header did not fold it")
}
tt.Key(0, ui.KeyRight)
if !tt.HasText("general") {
t.Fatal("Right did not unfold")
}
}
func TestChannelListDisabledAndBad(t *testing.T) {
sel, n := "", 0
tt := channelTester(&sel, true, &n)
_ = tt.Click("rehearsal")
if sel != "" || n != 0 {
t.Fatal("disabled list chose")
}
dup := []ChannelGroup{{ID: "a", Title: "A", Channels: []Channel{{ID: "x", Name: "x"}, {ID: "x", Name: "y"}}}}
mediaPanics(t, "duplicate", func(c *ui.Context) { ChannelList(c, &sel, dup, ChannelListOptions{}) })
}
func TestChannelListSnapshot(t *testing.T) {
sel := "general"
snapshot(t, "channel_list", 300, 300, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(260).Children(func() { ChannelList(c, &sel, channelTestGroups, ChannelListOptions{}) })
})
}
+88
View File
@@ -0,0 +1,88 @@
package messaging
import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chat"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ChatEntry is one message of a person-to-person chat.
type ChatEntry struct {
// ID is unique and stable; Author is required.
ID, Author string
Image *ui.Bitmap
Time time.Time
Text string
Edited bool
// Own marks the user's own messages, shown on the right.
Own bool
}
// ChatMessageOptions configure a ChatMessage.
type ChatMessageOptions struct {
// Continued hides repeated author details.
Continued bool
// Reply builds a quoted message above the text, as QuoteReply.
Reply func()
// Reactions builds the reaction row below the text.
Reactions func()
}
// chatMergeGap is how close two messages must be to merge.
const chatMergeGap = 5 * time.Minute
// ChatContinues merges matching authors within five minutes.
func ChatContinues(prev, cur ChatEntry) bool {
d := cur.Time.Sub(prev.Time)
return prev.Author == cur.Author && prev.Own == cur.Own && d >= 0 && d < chatMergeGap
}
// ChatMessage displays text, timestamps, edits and interaction slots.
func ChatMessage(c *ui.Context, m ChatEntry, opts ChatMessageOptions) *ui.Element {
if m.Author == "" {
panic("mujica: ChatMessage needs an Author")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
role := chat.MessageAssistant
if m.Own {
role = chat.MessageUser
}
bo := chat.MessageBubbleOptions{}
const avatar = 32
if !opts.Continued {
bo.Name = m.Author
bo.Avatar = func() { display.Avatar(c, m.Author, display.AvatarOptions{Image: m.Image, Size: avatar}) }
} else {
bo.Avatar = func() { ui.Box(c).Width(avatar).Shrink(0) }
}
e := chat.MessageBubble(c, role, bo, func() {
if opts.Reply != nil {
opts.Reply()
}
ui.Text(c, m.Text).Selectable().FocusRing(false).LineHeight(theme.LineHeight)
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
if !m.Time.IsZero() {
ui.Text(c, m.Time.Format(core.Msg(c, "chat_message.time"))).FontSize(core.FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
}
if m.Edited {
ui.Text(c, core.Msg(c, "chat_message.edited")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).
Italic().SingleLine()
}
})
if opts.Reactions != nil {
ui.Row(c).Gap(u).Wrap().Children(opts.Reactions)
}
})
if opts.Continued {
e.Label(m.Author)
e.Margin(-u*2, 0, 0, 0)
}
return e
}
+64
View File
@@ -0,0 +1,64 @@
package messaging
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chat"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
)
func TestChatContinues(t *testing.T) {
at := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
a := ChatEntry{Author: "Uika", Time: at}
for _, tc := range []struct {
b ChatEntry
want bool
}{
{ChatEntry{Author: "Uika", Time: at.Add(time.Minute)}, true},
{ChatEntry{Author: "Uika", Time: at.Add(6 * time.Minute)}, false},
{ChatEntry{Author: "Mutsumi", Time: at.Add(time.Minute)}, false},
{ChatEntry{Author: "Uika", Time: at.Add(-time.Minute)}, false},
} {
if got := ChatContinues(a, tc.b); got != tc.want {
t.Errorf("%+v: %v", tc.b, got)
}
}
}
func TestChatMessageSlotsAndMerge(t *testing.T) {
at := time.Date(2026, 10, 5, 21, 7, 0, 0, time.UTC)
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(500).Children(func() {
ChatMessage(c, ChatEntry{ID: "1", Author: "Uika", Time: at, Text: "Rehearsal at nine."}, ChatMessageOptions{
Reply: func() { chat.QuoteReply(c, "Sakiko", "When do we start?") },
Reactions: func() { display.Tag(c, "👍 2", display.TagOptions{}) },
})
ChatMessage(c, ChatEntry{ID: "2", Author: "Uika", Time: at, Text: "Bring the masks.", Edited: true}, ChatMessageOptions{Continued: true})
})
}, 600, 400)
if mediaTextCount(tt, "U") != 1 || mediaTextCount(tt, "Assistant, Uika") != 1 {
t.Fatalf("continued message repeated the author: %v", tt.Texts())
}
for _, s := range []string{"21:07", "(edited)", "When do we start?", "👍 2"} {
if !tt.HasText(s) {
t.Fatalf("no %q in %v", s, tt.Texts())
}
}
}
func TestChatMessageSnapshot(t *testing.T) {
at := time.Date(2026, 10, 5, 21, 7, 0, 0, time.UTC)
snapshot(t, "chat_message", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(8).Width(480).Children(func() {
ChatMessage(c, ChatEntry{Author: "Uika Misumi", Time: at, Text: "Rehearsal moves to nine."}, ChatMessageOptions{
Reply: func() { chat.QuoteReply(c, "Sakiko", "When do we start?") }})
ChatMessage(c, ChatEntry{Author: "Uika Misumi", Time: at, Text: "Bring the masks.", Edited: true}, ChatMessageOptions{Continued: true})
ChatMessage(c, ChatEntry{Author: "Me", Time: at, Text: "Understood.", Own: true}, ChatMessageOptions{})
})
})
}
+2
View File
@@ -0,0 +1,2 @@
// Package messaging holds MujicaUI messaging, calls and mail.
package messaging
+71
View File
@@ -0,0 +1,71 @@
package messaging
type emoji struct {
char, name string
toned bool
}
// emojiCategory is a tab of the picker.
type emojiCategory struct {
key string
icon string
emojis []emoji
}
// emojiTones are the Fitzpatrick modifiers, after the default yellow.
var emojiTones = []string{"", "\U0001F3FB", "\U0001F3FC", "\U0001F3FD", "\U0001F3FE", "\U0001F3FF"}
var emojiCategories = []emojiCategory{
{"emoji_picker.smileys", "😀", []emoji{
{"😀", "grinning face", false}, {"😄", "smiling face with open mouth", false}, {"😂", "face with tears of joy", false},
{"🙂", "slightly smiling face", false}, {"😉", "winking face", false}, {"😊", "smiling face with smiling eyes", false},
{"😍", "smiling face with heart eyes", false}, {"😘", "face blowing a kiss", false}, {"🤔", "thinking face", false},
{"😐", "neutral face", false}, {"🙄", "face with rolling eyes", false}, {"😴", "sleeping face", false},
{"😎", "smiling face with sunglasses", false}, {"🤩", "star struck", false}, {"😢", "crying face", false},
{"😡", "pouting face", false}, {"😱", "face screaming in fear", false}, {"🥳", "partying face", false},
}},
{"emoji_picker.people", "👋", []emoji{
{"👋", "waving hand", true}, {"👍", "thumbs up", true}, {"👎", "thumbs down", true}, {"👏", "clapping hands", true},
{"🙌", "raising hands", true}, {"🙏", "folded hands", true}, {"👌", "ok hand", true}, {"✌️", "victory hand", false},
{"🤝", "handshake", false}, {"💪", "flexed biceps", true}, {"✍️", "writing hand", false}, {"👀", "eyes", false},
{"🧑", "person", true}, {"👑", "crown", false}, {"🎭", "performing arts masks", false}, {"💃", "woman dancing", true},
}},
{"emoji_picker.nature", "🌹", []emoji{
{"🌹", "rose", false}, {"🥀", "wilted flower", false}, {"🌸", "cherry blossom", false}, {"🌙", "crescent moon", false},
{"⭐", "star", false}, {"🔥", "fire", false}, {"🌧️", "cloud with rain", false}, {"❄️", "snowflake", false},
{"🐈‍⬛", "black cat", false}, {"🦇", "bat", false}, {"🐦‍⬛", "black bird", false}, {"🕊️", "dove", false},
{"🍂", "fallen leaf", false}, {"🌲", "evergreen tree", false}, {"🌊", "water wave", false}, {"🌈", "rainbow", false},
}},
{"emoji_picker.food", "🍷", []emoji{
{"🍷", "wine glass", false}, {"🥂", "clinking glasses", false}, {"☕", "hot beverage", false}, {"🍵", "teacup", false},
{"🍰", "shortcake", false}, {"🍫", "chocolate bar", false}, {"🍓", "strawberry", false}, {"🍇", "grapes", false},
{"🍎", "red apple", false}, {"🧀", "cheese wedge", false}, {"🥖", "baguette", false}, {"🍞", "bread", false},
}},
{"emoji_picker.activities", "🎻", []emoji{
{"🎻", "violin", false}, {"🎹", "musical keyboard", false}, {"🎸", "guitar", false}, {"🥁", "drum", false},
{"🎤", "microphone", false}, {"🎵", "musical note", false}, {"🎶", "musical notes", false}, {"🎉", "party popper", false},
{"🏆", "trophy", false}, {"🎨", "artist palette", false}, {"♟️", "chess pawn", false}, {"🎬", "clapper board", false},
}},
{"emoji_picker.travel", "🏰", []emoji{
{"🏰", "castle", false}, {"⛪", "church", false}, {"🗼", "tower", false}, {"🌃", "night with stars", false},
{"🚂", "locomotive", false}, {"✈️", "airplane", false}, {"🚗", "automobile", false}, {"⛵", "sailboat", false},
{"🗺️", "world map", false}, {"🏔️", "snow capped mountain", false}, {"🌅", "sunrise", false}, {"🎡", "ferris wheel", false},
}},
{"emoji_picker.objects", "🕯️", []emoji{
{"🕯️", "candle", false}, {"🗝️", "old key", false}, {"📜", "scroll", false}, {"✉️", "envelope", false},
{"📎", "paperclip", false}, {"📌", "pushpin", false}, {"📅", "calendar", false}, {"💡", "light bulb", false},
{"🔔", "bell", false}, {"🔒", "locked", false}, {"💎", "gem stone", false}, {"⏳", "hourglass", false},
}},
{"emoji_picker.symbols", "❤️", []emoji{
{"❤️", "red heart", false}, {"🖤", "black heart", false}, {"💔", "broken heart", false}, {"✨", "sparkles", false},
{"✅", "check mark button", false}, {"❌", "cross mark", false}, {"❗", "exclamation mark", false}, {"❓", "question mark", false},
{"💯", "hundred points", false}, {"♦️", "diamond suit", false}, {"⚜️", "fleur de lis", false}, {"🔺", "red triangle", false},
}},
}
func emojiToned(e emoji, tone int) string {
if !e.toned || tone == 0 {
return e.char
}
return e.char + emojiTones[tone]
}
+199
View File
@@ -0,0 +1,199 @@
package messaging
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// EmojiPickerOptions configure an EmojiPicker.
type EmojiPickerOptions struct {
// Recent stores up to 24 emoji, newest first.
Recent *[]string
// Tone stores the selected skin tone, from 0 through 5.
Tone *int
Disabled bool
}
// EmojiResult exposes the element and this frame’s chosen emoji.
type EmojiResult struct {
Element *ui.Element
// Picked contains the chosen emoji, or an empty string.
Picked string
}
// emojiRecentMax is how many recent emoji are kept.
const emojiRecentMax = 24
type emojiState struct {
tab int
query string
recent bool
}
type emojiKey struct{}
// EmojiPicker supports categories, search, history and skin tones.
func EmojiPicker(c *ui.Context, opts EmojiPickerOptions) EmojiResult {
if opts.Tone != nil && (*opts.Tone < 0 || *opts.Tone >= len(emojiTones)) {
panic("mujica: EmojiPicker Tone outside 0..5")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
tone := 0
if opts.Tone != nil {
tone = *opts.Tone
}
var r EmojiResult
col := ui.Column(c).Width(8*36+u*4).Gap(u*2).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(core.Msg(c, "emoji_picker.label")).Disabled(opts.Disabled)
r.Element = col
recent := opts.Recent != nil && len(*opts.Recent) > 0
st := ui.Local(col, emojiKey{}, func() emojiState { return emojiState{recent: recent} })
if recent != st.recent {
if recent {
st.tab++
} else {
st.tab = max(0, st.tab-1)
}
st.recent = recent
}
col.Children(func() {
input.TextInput(c, &st.query, input.TextInputOptions{Placeholder: core.Msg(c, "emoji_picker.search"), Label: core.Msg(c, "emoji_picker.search"),
Leading: func() { ui.Icon(c, icons.Must("search")).Size(14, 14).TextColor(k.TextMuted) }, Clearable: true})
if opts.Tone != nil {
emojiToneRow(c, k, opts.Tone)
}
if strings.TrimSpace(st.query) == "" {
emojiTabs(c, k, &st.tab, recent)
}
ui.Scroll(c).Height(36*5 + u*2).Children(func() {
grid := ui.Row(c).Wrap().FocusGroup(ui.Horizontal | ui.Vertical).Role(ui.RoleList)
grid.Children(func() {
for _, e := range emojiShown(c, st, opts.Recent, recent) {
if s := emojiCell(c, k, e, tone); s != "" && !col.IsDisabled() {
r.Picked = s
}
}
})
})
})
if r.Picked != "" {
if opts.Recent != nil {
rec := slices.DeleteFunc(slices.Clone(*opts.Recent), func(s string) bool { return s == r.Picked })
*opts.Recent = slices.Insert(rec, 0, r.Picked)[:min(emojiRecentMax, len(rec)+1)]
}
slog.Debug("mujica: emoji_picker pick", "emoji", r.Picked)
}
return r
}
func emojiShown(c *ui.Context, st *emojiState, recentList *[]string, recent bool) []emoji {
if q := strings.ToLower(strings.TrimSpace(st.query)); q != "" {
var out []emoji
for _, cat := range emojiCategories {
for _, e := range cat.emojis {
if strings.Contains(e.name, q) || strings.Contains(emojiLabel(c, e), q) {
out = append(out, e)
}
}
}
if len(out) == 0 {
ui.Text(c, core.Msg(c, "emoji_picker.none")).TextColor(core.Tokens(c).TextMuted).Padding(core.Density(c).Unit() * 2)
}
return out
}
tab := st.tab
if recent {
if tab == 0 {
out := make([]emoji, len(*recentList))
for i, s := range *recentList {
out[i] = emoji{char: s, name: s}
}
return out
}
tab--
}
return emojiCategories[min(tab, len(emojiCategories)-1)].emojis
}
// emojiTabs picks a category; Recent leads when there is history.
func emojiTabs(c *ui.Context, k theme.Tokens, tab *int, recent bool) {
type tabItem struct{ face, key string }
var tabs []tabItem
if recent {
tabs = append(tabs, tabItem{"🕘", "emoji_picker.recent"})
}
for _, cat := range emojiCategories {
tabs = append(tabs, tabItem{cat.icon, cat.key})
}
*tab = min(*tab, len(tabs)-1)
ui.Row(c).FocusGroup(ui.Horizontal).Role(ui.RoleTabList).BorderWidth(0, 0, 1, 0).BorderColor(k.Border).Children(func() {
for i, t := range tabs {
b := ui.ButtonBase(c).Key(t.key).Size(32, 32).Radius(theme.ControlRadius).Role(ui.RoleTab).Label(core.Msg(c, t.key)).
Selected(i == *tab).Background(ui.Transparent).Tooltip(core.Msg(c, t.key))
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case i == *tab:
b.Background(k.Selection).BorderWidth(0, 0, 2, 0).BorderColor(k.AccentText)
case b.Hovered():
b.Background(k.SurfaceHover)
}
b.Children(func() { ui.Text(c, t.face).FontSize(core.FontSize(c, 16)) })
if b.Clicked() && !b.IsDisabled() {
*tab = i
}
}
})
}
func emojiToneRow(c *ui.Context, k theme.Tokens, tone *int) {
ui.Row(c).Gap(2).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleRadioGroup).Label(core.Msg(c, "emoji_picker.tone")).Children(func() {
ui.Text(c, core.Msg(c, "emoji_picker.tone")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(0, 6, 0, 0)
for i := range emojiTones {
b := ui.ButtonBase(c).Key(i).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio).
Label(fmt.Sprintf(core.Msg(c, "emoji_picker.toneN"), i)).Selected(i == *tone).Background(ui.Transparent)
internal.Ring(b, k.Focus, theme.ControlRadius)
if i == *tone {
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() { ui.Text(c, emojiToned(emoji{char: "👋", toned: true}, i)).FontSize(core.FontSize(c, 15)) })
if b.Clicked() && !b.IsDisabled() {
*tone = i
slog.Debug("mujica: emoji_picker tone", "tone", i)
}
}
})
}
func emojiCell(c *ui.Context, k theme.Tokens, e emoji, tone int) string {
s := emojiToned(e, tone)
b := ui.ButtonBase(c).Key(e.char).Size(36, 36).Radius(theme.ControlRadius).Label(emojiLabel(c, e)).Tooltip(emojiLabel(c, e)).Transition(core.ColorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() || b.Focused() {
b.Background(k.SurfaceHover)
}
b.Children(func() { ui.Text(c, s).FontSize(core.FontSize(c, 20)) })
if b.Clicked() {
return s
}
return ""
}
func emojiLabel(c *ui.Context, e emoji) string {
if e.name == e.char {
return e.char
}
return core.Msg(c, "emoji_picker.name_"+strings.ReplaceAll(e.name, " ", "_"))
}
+144
View File
@@ -0,0 +1,144 @@
package messaging
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestEmojiPickerPickSearchToneRecent(t *testing.T) {
var recent []string
tone := 0
var picked []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := EmojiPicker(c, EmojiPickerOptions{Recent: &recent, Tone: &tone}); r.Picked != "" {
picked = append(picked, r.Picked)
}
}, 400, 500)
if err := tt.Click("People"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Skin tone 3"); err != nil {
t.Fatal(err)
}
if err := tt.Click("thumbs up"); err != nil {
t.Fatal(err)
}
if len(picked) != 1 || picked[0] != "👍\U0001F3FD" || tone != 3 {
t.Fatalf("picked %q tone %d", picked, tone)
}
if err := tt.Click("Search emoji"); err != nil {
t.Fatal(err)
}
tt.Type("wine")
if err := tt.Click("wine glass"); err != nil {
t.Fatal(err)
}
if !slices.Equal(recent, []string{"🍷", "👍\U0001F3FD"}) {
t.Fatalf("recent %q", recent)
}
if err := tt.Click("Search emoji"); err != nil {
t.Fatal(err)
}
tt.Type("zzzz")
if !tt.HasText("No emoji found") {
t.Fatal("no empty search state")
}
}
func TestEmojiPickerKeyboardAndDisabled(t *testing.T) {
var got string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := EmojiPicker(c, EmojiPickerOptions{}); r.Picked != "" {
got = r.Picked
}
}, 400, 500)
if err := tt.Click("grinning face"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if got != "😄" {
t.Fatalf("Right, Enter picked %q", got)
}
got = ""
off := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := EmojiPicker(c, EmojiPickerOptions{Disabled: true}); r.Picked != "" {
got = r.Picked
}
}, 400, 500)
_ = off.Click("grinning face")
if got != "" {
t.Fatal("disabled picker picked")
}
bad := 9
mediaPanics(t, "tone", func(c *ui.Context) { EmojiPicker(c, EmojiPickerOptions{Tone: &bad}) })
}
func TestEmojiPickerSnapshot(t *testing.T) {
recent := []string{"🌹", "🍷"}
tone := 0
snapshot(t, "emoji_picker", 360, 400, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() { EmojiPicker(c, EmojiPickerOptions{Recent: &recent, Tone: &tone}) })
})
}
func TestEmojiPickerKeepsCategoryAfterFirstPick(t *testing.T) {
var recent []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
EmojiPicker(c, EmojiPickerOptions{Recent: &recent})
}, 400, 400)
if err := tt.Click("People"); err != nil {
t.Fatal(err)
}
if err := tt.Click("thumbs up"); err != nil {
t.Fatal(err)
}
if _, ok := tt.Find("waving hand"); !ok {
t.Fatal("first pick changed the category")
}
if err := tt.Click("Recent"); err != nil {
t.Fatal(err)
}
if _, ok := tt.Find("👍"); !ok {
t.Fatal("recent pick missing")
}
}
func TestEmojiPickerChineseSearch(t *testing.T) {
picked := ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: core.ZhCN})
if r := EmojiPicker(c, EmojiPickerOptions{}); r.Picked != "" {
picked = r.Picked
}
}, 400, 400)
if err := tt.Click("搜索表情"); err != nil {
t.Fatal(err)
}
tt.Type("小提琴")
if err := tt.Click("小提琴"); err != nil {
t.Fatal(err)
}
if picked != "🎻" {
t.Fatal("localized search did not choose violin")
}
}
func TestEmojiPickerInitialHistory(t *testing.T) {
recent := []string{"🌹", "🍷"}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
EmojiPicker(c, EmojiPickerOptions{Recent: &recent})
}, 400, 400)
if got := mediaTextCount(tt, "🌹"); got != 3 {
t.Fatalf("initial history rose occurrences=%d, want 3", got)
}
}
+247
View File
@@ -0,0 +1,247 @@
package messaging_test
import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/messaging"
)
func ExampleChannelList() {
groups := []messaging.ChannelGroup{{ID: "ch", Title: "Channels", Channels: []messaging.Channel{
{ID: "general", Name: "general", Unread: 3},
{ID: "rehearsal", Name: "rehearsal", Pinned: true},
}}}
selected := "general"
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if messaging.ChannelList(c, &selected, groups, messaging.ChannelListOptions{}).Changed() {
_ = selected // show that channel
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleChatMessage() {
prev := messaging.ChatEntry{ID: "1", Author: "Uika", Time: time.Now(), Text: "Rehearsal at nine."}
m := messaging.ChatEntry{ID: "2", Author: "Uika", Time: time.Now(), Text: "Bring the masks.", Edited: true}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.ChatMessage(c, prev, messaging.ChatMessageOptions{})
messaging.ChatMessage(c, m, messaging.ChatMessageOptions{
Continued: messaging.ChatContinues(prev, m),
Reactions: func() { display.Tag(c, "👍 2", display.TagOptions{}) },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleUnreadDivider() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.UnreadDivider(c, 3)
}
ui.Render(view, 400, 300, 1)
}
func ExampleReadReceipt() {
readers := []display.AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.ReadReceipt(c, messaging.ReceiptRead, messaging.ReadReceiptOptions{Readers: readers})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMemberList() {
var s messaging.MemberListState
members := []messaging.Member{
{ID: "1", Name: "Sakiko", Presence: messaging.PresenceOnline, Role: "Leader"},
{ID: "2", Name: "Umiri", Presence: messaging.PresenceOffline},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := messaging.MemberList(c, &s, members, messaging.MemberListOptions{}); r.Opened != "" {
_ = r.Opened // show that member's profile
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleUserProfileCard() {
profile := messaging.UserProfile{Name: "Sakiko Togawa", Presence: messaging.PresenceBusy, Role: "Keyboard", Status: "In rehearsal"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.UserProfileCard(c, profile, messaging.UserProfileCardOptions{Actions: func() {
input.Button(c, "Message", input.ButtonOptions{})
}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOnlineStatus() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.OnlineStatus(c, messaging.PresenceAway, messaging.OnlineStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStatusSetter() {
status := messaging.CustomStatus{Emoji: "🎻", Text: "Rehearsing"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if messaging.StatusSetter(c, &status, messaging.StatusSetterOptions{}).Changed() {
_ = status // publish it; clear it once status.Until passes
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCallControls() {
st := messaging.CallState{}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.CallControls(c, st, messaging.CallControlsOptions{
OnMute: func(on bool) { st.Muted = on },
OnCamera: func(off bool) { st.CameraOff = off },
OnShare: func(on bool) { st.Sharing = on },
OnHangUp: func() {}, // leave the call
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleVideoCallGrid() {
people := []messaging.CallParticipant{
{ID: "1", Name: "Sakiko", Speaking: true}, // Frame: the latest decoded frame
{ID: "2", Name: "Uika", Muted: true},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.VideoCallGrid(c, people, messaging.VideoCallGridOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleIncomingCallDialog() {
ringing := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.IncomingCallDialog(c, &ringing, messaging.IncomingCall{Caller: "Sakiko", Video: true}, messaging.IncomingCallOptions{
OnAccept: func(video bool) {}, // join, with the camera on when video
OnDecline: func() {},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleEmojiPicker() {
var recent []string
tone := 0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := messaging.EmojiPicker(c, messaging.EmojiPickerOptions{Recent: &recent, Tone: &tone}); r.Picked != "" {
_ = r.Picked // insert it
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePinnedMessages() {
pins := []messaging.ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := messaging.PinnedMessages(c, pins, messaging.PinnedMessagesOptions{})
if r.Unpinned != "" {
pins = pins[:0] // unpin it
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleThreadPanel() {
open := true
root := messaging.ChatEntry{ID: "r", Author: "Sakiko", Text: "Set list for Friday?"}
var replies []messaging.ChatEntry
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := messaging.ThreadPanel(c, &open, root, replies, messaging.ThreadPanelOptions{}); r.Sent != "" {
replies = append(replies, messaging.ChatEntry{ID: r.Sent, Author: "Me", Text: r.Sent, Own: true})
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMailList() {
var s data.ListState[string]
mails := []messaging.MailItem{{ID: "1", From: "Sakiko", Subject: "Second act", Unread: true, Attachments: 1}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := messaging.MailList(c, &s, mails, messaging.MailListOptions{}); r.Starred != "" {
mails[0].Starred = !mails[0].Starred
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMailReader() {
m := messaging.Mail{From: "Sakiko", Subject: "Second act", Body: "The score is attached.\n> Can you send it?",
Attachments: []messaging.MailAttachment{{Name: "score.pdf", Size: 2048}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := messaging.MailReader(c, m); r.Link != "" {
c.OpenURL(r.Link)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMailComposer() {
draft := messaging.MailDraft{To: []string{"uika@example.com"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := messaging.MailComposer(c, &draft, messaging.MailComposerOptions{
OnSaveDraft: func(d messaging.MailDraft) {}, // store d
})
if r.Sent {
draft = messaging.MailDraft{}
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleRecipientInput() {
var to []string
contacts := []messaging.Contact{{Name: "Uika Misumi", Email: "uika@example.com"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.RecipientInput(c, &to, messaging.RecipientInputOptions{Contacts: contacts, Label: "To"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleQuotedText() {
open := false
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
messaging.QuotedText(c, "> Can you send the score?", &open)
}
ui.Render(view, 400, 300, 1)
}
func ExampleSnoozePicker() {
var until time.Time
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if messaging.SnoozePicker(c, &until, messaging.SnoozePickerOptions{}).Changed() {
_ = until // hide the mail until then
}
}
ui.Render(view, 400, 300, 1)
}
+61
View File
@@ -0,0 +1,61 @@
package messaging
import (
"image"
"image/color"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// mediaPanics fails t unless fn panics.
func mediaPanics(t *testing.T, name string, fn func(c *ui.Context)) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
fn(c)
}, 400, 300)
tt.Frame()
}
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
func mediaTestBitmap(w, h int) *ui.Bitmap { return ui.NewBitmap(mediaTestImage(w, h)) }
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
// mediaTestImage is a w×h picture whose left half is wine and right half ivory.
func mediaTestImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
c := color.RGBA{125, 32, 52, 255}
if x >= w/2 {
c = color.RGBA{242, 235, 221, 255}
}
if y < h/8 {
c = color.RGBA{173, 145, 100, 255}
}
img.Set(x, y, c)
}
}
return img
}
+95
View File
@@ -0,0 +1,95 @@
package messaging
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// IncomingCall is who calls; Caller is required.
type IncomingCall struct {
Caller string
Image *ui.Bitmap
// Video offers answering with the camera on.
Video bool
}
// IncomingCallOptions configure an IncomingCallDialog.
type IncomingCallOptions struct {
// FocusAccept focuses Accept instead of the default Decline.
FocusAccept bool
Disabled bool
// OnAccept answers; OnDecline refuses or handles Escape.
OnAccept func(video bool)
OnDecline func()
}
// IncomingCallDialog presents call actions with configurable initial focus.
func IncomingCallDialog(c *ui.Context, open *bool, call IncomingCall, opts IncomingCallOptions) {
if call.Caller == "" || opts.OnAccept == nil || opts.OnDecline == nil {
panic("mujica: IncomingCallDialog needs Caller, OnAccept and OnDecline")
}
site, focus := core.OverlaySite(c)
if opts.Disabled || site.IsDisabled() {
*open = false
}
if !*open {
focus.Closed(c, site)
return
}
core.OverlayLayer(c, open)
k := core.Tokens(c)
u := core.Density(c).Unit()
title := fmt.Sprintf(core.Msg(c, "incoming_call_dialog.title"), call.Caller)
shown, accepted, video, declined, backClicked := true, false, false, false, false
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
backClicked = back.Clicked() // a click outside neither answers nor refuses
p := core.OverlayEnter(c, panel)
back.Background(overlay.OverlayScrim(k).Alpha(p)).Padding(16)
overlay.DialogPanel(c, panel, 420, false, p)
panel.Role(ui.RoleAlertDialog).Label(title).AlignItems(ui.Center)
ui.Scroll(c).Shrink(1).FillWidth().Gap(u * 3).AlignItems(ui.Center).Children(func() {
display.Avatar(c, call.Caller, display.AvatarOptions{Image: call.Image, Size: 72})
ui.Text(c, call.Caller).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).TextAlign(ui.Center)
kind := core.Msg(c, "incoming_call_dialog.voice")
if call.Video {
kind = core.Msg(c, "incoming_call_dialog.video")
}
ui.Text(c, kind).TextColor(k.TextMuted)
})
ui.Row(c).Shrink(0).Gap(u*3).Wrap().Justify(ui.Center).Margin(u*2, 0, 0, 0).Children(func() {
decline := input.Button(c, core.Msg(c, "incoming_call_dialog.decline"), input.ButtonOptions{Variant: input.Danger, Icon: msgHangUp, Label: core.Msg(c, "incoming_call_dialog.decline")})
accept := input.Button(c, core.Msg(c, "incoming_call_dialog.accept"), input.ButtonOptions{Icon: msgPhone, Label: core.Msg(c, "incoming_call_dialog.accept")})
if opts.FocusAccept {
accept.AutoFocus()
} else {
decline.AutoFocus()
}
declined, accepted = decline.Clicked(), accept.Clicked()
if call.Video && input.Button(c, core.Msg(c, "incoming_call_dialog.acceptVideo"), input.ButtonOptions{Variant: input.Secondary, Icon: core.MediaCamera, Label: core.Msg(c, "incoming_call_dialog.acceptVideo")}).Clicked() {
accepted, video = true, true
}
})
})
focus.Shown(panel)
if !shown && !accepted && !backClicked {
declined = true // Escape
}
switch {
case accepted:
*open = false
slog.Debug("mujica: incoming_call accept", "video", video)
opts.OnAccept(video)
case declined:
*open = false
slog.Debug("mujica: incoming_call decline")
opts.OnDecline()
}
}
+95
View File
@@ -0,0 +1,95 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestIncomingCallDefaultFocusAndEscape(t *testing.T) {
open := false
accepted, declined := 0, 0
opts := IncomingCallOptions{OnAccept: func(bool) { accepted++ }, OnDecline: func() { declined++ }}
tt := incomingTester(&open, opts, IncomingCall{Caller: "Uika"})
if err := tt.Click("Ring"); err != nil {
t.Fatal(err)
}
if !tt.Focused("Decline") || !tt.HasText("Uika") {
t.Fatalf("Decline not focused: %v", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if declined != 1 || open {
t.Fatalf("Enter on the default declined %d", declined)
}
open = true
tt.Frame()
tt.Key(0, ui.KeyEscape)
if declined != 2 || accepted != 0 || open {
t.Fatalf("Escape declined %d", declined)
}
}
func TestIncomingCallFocusAcceptAndVideo(t *testing.T) {
open := true
var videos []bool
opts := IncomingCallOptions{FocusAccept: true, OnAccept: func(v bool) { videos = append(videos, v) }, OnDecline: func() {}}
tt := incomingTester(&open, opts, IncomingCall{Caller: "Sakiko", Video: true})
if !tt.Focused("Accept") {
t.Fatal("Accept not focused")
}
if err := tt.Click("With video"); err != nil {
t.Fatal(err)
}
if len(videos) != 1 || !videos[0] || open {
t.Fatalf("accepted %v", videos)
}
mediaPanics(t, "no caller", func(c *ui.Context) { IncomingCallDialog(c, &open, IncomingCall{}, opts) })
}
func TestIncomingCallSnapshot(t *testing.T) {
open := true
opts := IncomingCallOptions{OnAccept: func(bool) {}, OnDecline: func() {}}
snapshot(t, "incoming_call_dialog", 520, 400, func(c *ui.Context) {
IncomingCallDialog(c, &open, IncomingCall{Caller: "Sakiko Togawa", Video: true}, opts)
})
}
func TestIncomingCallDisabledAncestorCloses(t *testing.T) {
open, disabled, events := true, false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
IncomingCallDialog(c, &open, IncomingCall{Caller: "Caller"}, IncomingCallOptions{
OnAccept: func(bool) { events++ }, OnDecline: func() { events++ },
})
})
}, 400, 300)
disabled = true
tt.Frame()
tt.Key(0, ui.KeyEnter)
if open || events != 0 || tt.HasText("Decline") {
t.Fatalf("disabled dialog: open=%v events=%d", open, events)
}
}
func TestIncomingCallSmallWindowKeyboardActions(t *testing.T) {
open, accepted := true, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
IncomingCallDialog(c, &open, IncomingCall{Caller: "Caller", Video: true}, IncomingCallOptions{
OnAccept: func(bool) { accepted++ }, OnDecline: func() {},
})
}, 280, 220)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Key(0, ui.KeyTab)
r, ok := tt.Find("Accept")
if !ok || r.W < 28 || r.H < 28 || r.X < 0 || r.Y < 0 || r.X+r.W > 280 || r.Y+r.H > 220 {
t.Fatalf("accept bounds %+v", r)
}
tt.Key(0, ui.KeyEnter)
if open || accepted != 1 {
t.Fatalf("accept callbacks=%d open=%v", accepted, open)
}
}
+216
View File
@@ -0,0 +1,216 @@
package messaging
import (
"fmt"
"log/slog"
"net/mail"
"slices"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MailDraft is a mail being written.
type MailDraft struct {
To, Cc, Bcc []string
Subject, Body string
Attachments []MailAttachment
}
// MailComposerOptions configure a MailComposer.
type MailComposerOptions struct {
Contacts []Contact
// OnSaveDraft saves rested edits or manual requests; required.
OnSaveDraft func(MailDraft)
// OnAttach asks the caller to pick files; nil hides Attach.
OnAttach func()
Disabled bool
}
// MailComposerResult is the composer's element and this frame's events.
type MailComposerResult struct {
Element *ui.Element
// Sent requests sending; Discarded requests discarding the draft.
Sent, Discarded bool
}
const mailSaveDelay = 2 * time.Second
type composerState struct {
cc, bcc bool
dirty bool
edited time.Time
saved time.Time
last MailDraft
started bool
}
type composerKey struct{}
// MailComposer edits mail fields and reports draft save requests.
func MailComposer(c *ui.Context, d *MailDraft, opts MailComposerOptions) MailComposerResult {
if opts.OnSaveDraft == nil {
panic("mujica: MailComposer needs OnSaveDraft")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r MailComposerResult
col := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(core.Msg(c, "mail_composer.label")).Disabled(opts.Disabled)
r.Element = col
st := ui.Local(col, composerKey{}, func() *composerState { return &composerState{} })
s := *st
live := !col.IsDisabled()
if !live {
copy := composerCopy(*d)
d = &copy
}
if !s.started {
s.started, s.last = true, composerCopy(*d)
s.cc, s.bcc = len(d.Cc) > 0, len(d.Bcc) > 0
}
s.cc = s.cc || len(d.Cc) > 0
s.bcc = s.bcc || len(d.Bcc) > 0
col.Children(func() {
field := func(key string, list *[]string, extra func()) {
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
label := ui.Text(c, core.Msg(c, key)).Width(56).TextColor(k.TextMuted).Margin(u*2, 0, 0, 0).SingleLine()
parts := RecipientInput(c, list, RecipientInputOptions{Contacts: opts.Contacts, Label: core.Msg(c, key), Disabled: opts.Disabled})
parts.Element.Grow(1).MinWidth(0)
if label.Clicked() && !parts.Input.IsDisabled() {
parts.Input.Focus()
}
if extra != nil {
extra()
}
})
}
field("mail_composer.to", &d.To, func() {
if !s.cc && input.Button(c, core.Msg(c, "mail_composer.cc"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
s.cc = true
}
if !s.bcc && input.Button(c, core.Msg(c, "mail_composer.bcc"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
s.bcc = true
}
})
if s.cc {
field("mail_composer.cc", &d.Cc, nil)
}
if s.bcc {
field("mail_composer.bcc", &d.Bcc, nil)
}
input.TextInput(c, &d.Subject, input.TextInputOptions{Placeholder: core.Msg(c, "mail_composer.subject"), Label: core.Msg(c, "mail_composer.subject"), Disabled: opts.Disabled})
if input.TextArea(c, &d.Body, input.TextAreaOptions{Label: core.Msg(c, "mail_composer.body"), MinHeight: 160, Disabled: opts.Disabled}).Submitted() &&
composerReady(*d) {
r.Sent = true
}
if len(d.Attachments) > 0 {
composerAttachments(c, k, d, opts.Disabled)
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ready := composerReady(*d)
if input.Button(c, core.Msg(c, "mail_composer.send"), input.ButtonOptions{Icon: msgSend, Disabled: !ready}).Clicked() {
r.Sent = true
}
if opts.OnAttach != nil && input.Button(c, core.Msg(c, "mail_composer.attach"), input.ButtonOptions{Variant: input.Secondary, Icon: msgPaperclip}).Clicked() {
slog.Debug("mujica: mail_composer attach")
opts.OnAttach()
}
ui.Box(c).Grow(1)
composerStatus(c, k, s)
if input.Button(c, core.Msg(c, "mail_composer.discard"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("trash-2")}).Clicked() {
r.Discarded = true
}
})
})
if live && col.Shortcut(ui.Cmd, ui.KeyEnter) && composerReady(*d) {
r.Sent = true
}
if live {
composerAutosave(c, s, *d, opts.OnSaveDraft, col.Shortcut(ui.Cmd, ui.KeyS))
}
if !live {
r.Sent, r.Discarded = false, false
}
if r.Sent || r.Discarded {
slog.Debug("mujica: mail_composer done", "sent", r.Sent)
}
return r
}
func composerAutosave(c *ui.Context, s *composerState, d MailDraft, save func(MailDraft), now bool) {
if !composerSame(s.last, d) {
s.last, s.dirty, s.edited = composerCopy(d), true, c.Now()
}
if !s.dirty && !now {
return
}
if wait := mailSaveDelay - c.Now().Sub(s.edited); wait > 0 && !now {
c.After(wait)
return
}
s.dirty, s.saved = false, c.Now()
slog.Debug("mujica: mail_composer save draft")
save(composerCopy(d))
}
// composerStatus says whether the draft is saved.
func composerStatus(c *ui.Context, k theme.Tokens, s *composerState) {
text := ""
switch {
case s.dirty:
text = core.Msg(c, "mail_composer.unsaved")
case !s.saved.IsZero():
text = fmt.Sprintf(core.Msg(c, "mail_composer.saved"), s.saved.Format("15:04"))
}
if text != "" {
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
}
}
// composerAttachments lists attached files with remove buttons.
func composerAttachments(c *ui.Context, k theme.Tokens, d *MailDraft, disabled bool) {
ui.Row(c).Gap(core.Density(c).Unit() * 2).Wrap().Children(func() {
for i, a := range d.Attachments {
var v display.TagView
ui.Box(c).Key(fmt.Sprint(i, a.Name)).Children(func() {
v = display.Tag(c, a.Name, display.TagOptions{Icon: "file", Closable: true, Disabled: disabled})
})
if v.Closed {
d.Attachments = slices.Delete(slices.Clone(d.Attachments), i, i+1)
return
}
}
})
}
// composerReady reports at least one recipient and only valid addresses.
func composerReady(d MailDraft) bool {
if len(d.To)+len(d.Cc)+len(d.Bcc) == 0 {
return false
}
for _, list := range [][]string{d.To, d.Cc, d.Bcc} {
for _, a := range list {
if _, err := mail.ParseAddress(a); err != nil {
return false
}
}
}
return true
}
func composerCopy(d MailDraft) MailDraft {
d.To, d.Cc, d.Bcc = slices.Clone(d.To), slices.Clone(d.Cc), slices.Clone(d.Bcc)
d.Attachments = slices.Clone(d.Attachments)
return d
}
func composerSame(a, b MailDraft) bool {
return a.Subject == b.Subject && a.Body == b.Body && slices.Equal(a.To, b.To) && slices.Equal(a.Cc, b.Cc) &&
slices.Equal(a.Bcc, b.Bcc) && slices.Equal(a.Attachments, b.Attachments)
}
+190
View File
@@ -0,0 +1,190 @@
package messaging
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestMailComposerDisabledDefersSave(t *testing.T) {
draft := MailDraft{To: []string{"a@example.com"}}
disabled, saves := false, 0
var state *composerState
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
r := MailComposer(c, &draft, MailComposerOptions{OnSaveDraft: func(MailDraft) { saves++ }})
state = *ui.Local(r.Element, composerKey{}, func() *composerState { panic("missing state") })
})
}, 600, 600)
if err := tt.Click("Subject"); err != nil {
t.Fatal(err)
}
tt.Type("Draft")
disabled = true
state.edited = time.Now().Add(-10 * time.Second)
tt.Frame()
if saves != 0 {
t.Fatalf("disabled save callbacks: %d", saves)
}
disabled = false
tt.Frame()
if saves != 1 {
t.Fatalf("resumed save callbacks: %d", saves)
}
}
func TestMailComposerManualSaveAndFocus(t *testing.T) {
draft := MailDraft{To: []string{"a@example.com"}}
saves, sent := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if MailComposer(c, &draft, MailComposerOptions{OnSaveDraft: func(MailDraft) { saves++ }}).Sent {
sent++
}
input.TextInput(c, new(string), input.TextInputOptions{Label: "Other"})
}, 600, 700)
if err := tt.Click("Subject"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Cmd, ui.KeyS)
if saves != 1 {
t.Fatalf("manual save callbacks: %d", saves)
}
if err := tt.Click("Other"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Cmd, ui.KeyEnter)
if sent != 0 {
t.Fatalf("composer submitted from another field: %d", sent)
}
}
func TestMailComposerAutosaveAndSend(t *testing.T) {
d := MailDraft{To: []string{"uika@example.com"}}
var saves []MailDraft
sent := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(20).Width(560).Children(func() {
if MailComposer(c, &d, MailComposerOptions{OnSaveDraft: func(m MailDraft) { saves = append(saves, m) }}).Sent {
sent++
}
})
}, 700, 700)
if err := tt.Click("Subject"); err != nil {
t.Fatal(err)
}
tt.Type("Second act")
if len(saves) != 0 || !tt.HasText("Unsaved changes") {
t.Fatal("saved before the draft rested")
}
tt.Key(ui.Cmd, ui.KeyS)
if len(saves) != 1 || saves[0].Subject != "Second act" {
t.Fatalf("Cmd+S saves %+v", saves)
}
if err := tt.Click("Cc"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Message"); err != nil {
t.Fatal(err)
}
tt.Type("The score is attached.")
tt.Key(ui.Cmd, ui.KeyEnter)
if sent != 1 || d.Body != "The score is attached." {
t.Fatalf("sent %d body %q", sent, d.Body)
}
}
func TestMailComposerNeedsRecipientsAndDisabled(t *testing.T) {
d := MailDraft{Subject: "x"}
sent := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if MailComposer(c, &d, MailComposerOptions{OnSaveDraft: func(MailDraft) {}}).Sent {
sent++
}
}, 700, 700)
_ = tt.Click("Send")
tt.Key(ui.Cmd, ui.KeyEnter)
if sent != 0 {
t.Fatal("sent without recipients")
}
mediaPanics(t, "no save callback", func(c *ui.Context) { MailComposer(c, &d, MailComposerOptions{}) })
}
func TestMailComposerSnapshot(t *testing.T) {
d := MailDraft{To: []string{"Uika Misumi <uika@example.com>"}, Subject: "The second act", Body: "The score is attached.",
Attachments: []MailAttachment{{Name: "score.pdf", Size: 2048}}}
snapshot(t, "mail_composer", 600, 460, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(560).Children(func() {
MailComposer(c, &d, MailComposerOptions{OnSaveDraft: func(MailDraft) {}, OnAttach: func() {}})
})
})
}
func TestMailComposerAttachmentsAndAddressFields(t *testing.T) {
d := MailDraft{To: []string{"uika@example.com"}}
attachments, discards := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := MailComposer(c, &d, MailComposerOptions{OnSaveDraft: func(MailDraft) {}, OnAttach: func() {
d.Attachments = append(d.Attachments, MailAttachment{Name: "score.pdf", Size: 2048})
attachments++
}})
if r.Discarded {
discards++
}
}, 700, 700)
for _, label := range []string{"Cc", "Bcc", "Attach"} {
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
if attachments != 1 || len(d.Attachments) != 1 {
t.Fatal("attachment callback did not update draft")
}
if err := tt.Click("Cc"); err != nil {
t.Fatal(err)
}
tt.Type("cc@example.com")
tt.Key(0, ui.KeyEnter)
if err := tt.Click("Bcc"); err != nil {
t.Fatal(err)
}
tt.Type("bcc@example.com")
tt.Key(0, ui.KeyEnter)
if !slices.Equal(d.Cc, []string{"cc@example.com"}) || !slices.Equal(d.Bcc, []string{"bcc@example.com"}) {
t.Fatal("address fields did not commit")
}
if err := tt.Click("Remove score.pdf"); err != nil {
t.Fatal(err)
}
if len(d.Attachments) != 0 {
t.Fatal("attachment removal failed")
}
if err := tt.Click("Discard"); err != nil {
t.Fatal(err)
}
if discards != 1 {
t.Fatalf("discard events=%d", discards)
}
}
func TestMailComposerLoadedAddressFields(t *testing.T) {
d := MailDraft{}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
MailComposer(c, &d, MailComposerOptions{OnSaveDraft: func(MailDraft) {}})
}, 700, 700)
d = MailDraft{Cc: []string{"cc@example.com"}, Bcc: []string{"bcc@example.com"}}
tt.Frame()
if !tt.HasText("cc@example.com") || !tt.HasText("bcc@example.com") {
t.Fatal("loaded recipients remain hidden")
}
}
+142
View File
@@ -0,0 +1,142 @@
package messaging
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/media"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MailItem supplies a unique ID and mail preview data.
type MailItem struct {
ID, From, Subject, Snippet string
Time time.Time
Unread, Starred bool
Attachments int
}
// MailListOptions configure a MailList.
type MailListOptions struct {
// Height is the list's height; zero means 420.
Height float32
Disabled bool
}
// MailListResult is a MailList's element and this frame's events.
type MailListResult struct {
Element *ui.Element
changed bool
// Opened contains the ID activated by Enter or double-click.
Opened string
// Starred is the ID whose star the user toggled.
Starred string
}
// Changed reports a new selection; s.Selection holds the chosen IDs.
func (r MailListResult) Changed() bool { return r.changed }
// MailList virtualizes previews, selection and star actions.
func MailList(c *ui.Context, s *data.ListState[string], mails []MailItem, opts MailListOptions) MailListResult {
if opts.Height < 0 {
panic("mujica: MailList Height must not be negative")
}
h := opts.Height
if h == 0 {
h = 420
}
k := core.Tokens(c)
u := core.Density(c).Unit()
small := core.FontSize(c, theme.CaptionSize)
var r MailListResult
box := ui.Column(c).Height(h).Disabled(opts.Disabled)
r.Element = box
box.Children(func() {
v := data.List(c, s, len(mails), data.ListOptions[string]{
Key: func(i int) string { return mails[i].ID },
Label: func(i int) string { return mailLabel(c, mails[i]) },
Multiple: true,
Empty: core.Msg(c, "mail_list.empty"),
}, func(i int) {
m := mails[i]
if m.ID == "" {
panic("mujica: MailList item without an ID")
}
ui.Box(c).Width(8).Shrink(0).Children(func() {
if m.Unread {
ui.Box(c).Size(8, 8).Radius(4).Background(k.AccentText)
}
})
star := media.MediaToggleAction(c, icons.Must("star"), fmt.Sprintf(core.Msg(c, "mail_list.star"), m.Subject), m.Starred)
if m.Starred {
star.TextColor(k.Warning)
}
if star.Clicked() && !star.IsDisabled() {
r.Starred = m.ID
}
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Padding(u, 0).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
from := ui.Text(c, m.From).SingleLine().Grow(1).MinWidth(0)
if m.Unread {
from.FontWeight(700)
}
if m.Attachments > 0 {
ui.Icon(c, msgPaperclip).Size(13, 13).TextColor(k.TextMuted).Label(fmt.Sprintf(core.Msg(c, "mail_list.attachments"), m.Attachments))
}
if !m.Time.IsZero() {
ui.Text(c, mailTime(c, m.Time)).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
}
})
subj := ui.Text(c, m.Subject).SingleLine()
if m.Unread {
subj.FontWeight(700)
}
ui.Text(c, m.Snippet).FontSize(small).TextColor(k.TextMuted).SingleLine()
})
})
v.Element.Grow(1)
r.changed = v.Changed()
if v.Element.IsDisabled() {
return
}
if id, ok := s.Selected(); ok {
if v.Submitted() {
r.Opened = id
}
if v.Element.Shortcut(0, ui.KeyS) {
r.Starred = id
}
}
})
if r.Opened != "" || r.Starred != "" {
slog.Debug("mujica: mail_list action", "opened", r.Opened, "starred", r.Starred)
}
return r
}
// mailLabel reads a row: unread, starred, from, subject.
func mailLabel(c *ui.Context, m MailItem) string {
s := m.From + ", " + m.Subject
if m.Starred {
s = core.Msg(c, "mail_list.starred") + ", " + s
}
if m.Unread {
s = core.Msg(c, "mail_list.unread") + ", " + s
}
return s
}
// mailTime shows today's mails by time, older ones by date.
func mailTime(c *ui.Context, t time.Time) string {
now := c.Now().In(t.Location())
if y, m, d := t.Date(); y == now.Year() && m == now.Month() && d == now.Day() {
return t.Format("15:04")
}
return t.Format(core.Msg(c, "mail_list.date"))
}
+93
View File
@@ -0,0 +1,93 @@
package messaging
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
)
func TestMailListSelectStarOpen(t *testing.T) {
var s data.ListState[string]
mails := mailTestItems(6)
var opened, starred []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := MailList(c, &s, mails, MailListOptions{})
if r.Opened != "" {
opened = append(opened, r.Opened)
}
if r.Starred != "" {
starred = append(starred, r.Starred)
}
}, 600, 500)
if err := tt.Click("Subject 0"); err != nil {
t.Fatal(err)
}
r2, _ := tt.Find("Subject 2")
tt.ClickAtWith(ui.Cmd, r2.X+4, r2.Y+4)
if s.Selection.Len() != 2 || !s.Selection.Has("m0") || !s.Selection.Has("m2") {
t.Fatalf("selection %d", s.Selection.Len())
}
if err := tt.Click("Star Subject 3"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyS)
tt.Key(0, ui.KeyEnter)
if len(starred) != 2 || starred[0] != "m3" || len(opened) != 1 {
t.Fatalf("starred %v opened %v", starred, opened)
}
if _, ok := tt.Find("Unread, Sender 0, Subject 0"); !ok {
t.Fatal("unread not named")
}
}
func TestMailListBuildsOnlyVisible(t *testing.T) {
var s data.ListState[string]
mails := mailTestItems(100000)
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
MailList(c, &s, mails, MailListOptions{})
}, 600, 500)
tt.Frame()
built := 0
for _, x := range tt.Texts() {
if strings.HasPrefix(x, "Subject ") {
built++
}
}
if built == 0 || built > 40 {
t.Fatalf("built %d rows of 100000", built)
}
}
func TestMailListDisabled(t *testing.T) {
var s data.ListState[string]
starred := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if MailList(c, &s, mailTestItems(3), MailListOptions{Disabled: true}).Starred != "" {
starred++
}
}, 600, 400)
_ = tt.Click("Star Subject 0")
_ = tt.Click("Subject 1")
if starred != 0 || s.Selection.Len() != 0 {
t.Fatal("disabled list reacted")
}
}
func TestMailListSnapshot(t *testing.T) {
mails := []MailItem{
{ID: "1", From: "Sakiko Togawa", Subject: "Second act", Snippet: "The score is attached.", Unread: true, Attachments: 2},
{ID: "2", From: "Uika Misumi", Subject: "Costumes", Snippet: "Fittings on Thursday.", Starred: true},
{ID: "3", From: "Umiri Yahata", Subject: "Venue", Snippet: "The hall is booked."},
}
snapshot(t, "mail_list", 480, 260, func(c *ui.Context) {
s := ui.Local(c.Root(), "mail", func() *data.ListState[string] { return &data.ListState[string]{} })
ui.Column(c).Padding(20).Width(440).Children(func() { MailList(c, *s, mails, MailListOptions{Height: 220}) })
})
}
+138
View File
@@ -0,0 +1,138 @@
package messaging
import (
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chat"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MailFormat selects plain text or safe Markdown.
type MailFormat int
const (
MailPlain MailFormat = iota
// MailMarkdown uses MarkdownView’s safe subset.
MailMarkdown
)
// MailAttachment is a file sent with a mail.
type MailAttachment struct {
Name string
Size int64
Type string
}
// Mail supplies headers, body and attachments.
type Mail struct {
From string
To, Cc []string
Subject string
Date time.Time
Body string
Format MailFormat
Attachments []MailAttachment
}
// MailReaderResult is a MailReader's element and this frame's events.
type MailReaderResult struct {
Element *ui.Element
// Link contains the activated URL, or an empty string.
Link string
// Opened and Downloaded are attachment indexes, -1 for none.
Opened, Downloaded int
}
type mailQuotesKey struct{}
// MailReader displays headers, body, folded quotes and attachments.
func MailReader(c *ui.Context, m Mail) MailReaderResult {
if m.From == "" || m.Subject == "" {
panic("mujica: MailReader needs From and Subject")
}
if m.Format != MailPlain && m.Format != MailMarkdown {
panic("mujica: unknown MailFormat")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
r := MailReaderResult{Opened: -1, Downloaded: -1}
col := ui.Column(c).Gap(u * 4).MinWidth(0).Role(ui.RoleGroup).Label(m.Subject)
r.Element = col
small := core.FontSize(c, theme.CaptionSize)
col.Children(func() {
ui.Text(c, m.Subject).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).FontWeight(600)
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
display.Avatar(c, m.From, display.AvatarOptions{Size: 36})
ui.Column(c).Gap(2).Grow(1).MinWidth(0).Children(func() {
ui.Text(c, m.From).FontWeight(600).Selectable().FocusRing(false)
for _, line := range [][2]string{{"mail_reader.to", strings.Join(m.To, ", ")}, {"mail_reader.cc", strings.Join(m.Cc, ", ")}} {
if line[1] != "" {
ui.Text(c, core.Msg(c, line[0])+" "+line[1]).FontSize(small).TextColor(k.TextMuted).Selectable().FocusRing(false)
}
}
})
if !m.Date.IsZero() {
ui.Text(c, core.MessagingDate(c, m.Date, "mail_reader.date")).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
}
})
ui.Box(c).Height(1).Background(k.Border)
if m.Format == MailMarkdown {
if url, ok := chat.MarkdownView(c, m.Body).Link(); ok {
r.Link = url
}
} else {
mailPlainBody(c, ui.Local(col, mailQuotesKey{}, func() map[string]bool { return map[string]bool{} }), m.Body)
}
if len(m.Attachments) > 0 {
ui.Text(c, core.Msg(c, "mail_reader.attachments")).FontSize(small).FontWeight(600).TextColor(k.TextMuted)
ui.Row(c).Gap(u * 2).Wrap().Children(func() {
for i, a := range m.Attachments {
f := chat.FileMessage(c, chat.FileMessageOptions(a))
f.Element.Key(i)
if f.Opened() {
r.Opened = i
}
if f.Downloaded() {
r.Downloaded = i
}
}
})
}
})
if r.Link != "" || r.Opened >= 0 || r.Downloaded >= 0 {
slog.Debug("mujica: mail_reader action", "opened", r.Opened, "downloaded", r.Downloaded)
}
return r
}
// mailPlainBody shows plain text, folding each run of quoted lines.
func mailPlainBody(c *ui.Context, open *map[string]bool, body string) {
lines := strings.Split(body, "\n")
ui.Column(c).Gap(core.Density(c).Unit() * 2).MinWidth(0).Children(func() {
for i := 0; i < len(lines); {
j := i
quoted := strings.HasPrefix(lines[i], ">")
for j < len(lines) && strings.HasPrefix(lines[j], ">") == quoted {
j++
}
chunk := strings.Join(lines[i:j], "\n")
ui.Box(c).Key(i).Children(func() {
if quoted {
// Fold state is kept per quote text.
o := (*open)[chunk]
QuotedText(c, chunk, &o)
(*open)[chunk] = o
} else if strings.TrimSpace(chunk) != "" {
ui.Text(c, strings.Trim(chunk, "\n")).Selectable().FocusRing(false).LineHeight(theme.LineHeight)
}
})
i = j
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package messaging
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestMailReaderPlainQuotesAndAttachments(t *testing.T) {
m := Mail{From: "Sakiko Togawa", To: []string{"Uika"}, Subject: "Second act", Body: "See the score.\n> Can you send the score?\n> Thanks",
Attachments: []MailAttachment{{Name: "score.pdf", Size: 2048}}}
opened := -1
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(500).Children(func() {
if r := MailReader(c, m); r.Opened >= 0 {
opened = r.Opened
}
})
}, 600, 600)
if !tt.HasText("See the score.") || tt.HasText("Can you send the score?") || !tt.HasText("Show quoted text (2 lines)") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Show quoted text (2 lines)"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Can you send the score?\nThanks") {
t.Fatalf("quote not unfolded: %v", tt.Texts())
}
if err := tt.Click("score.pdf"); err != nil {
t.Fatal(err)
}
if opened != 0 {
t.Fatalf("opened %d", opened)
}
}
func TestMailReaderMarkdownIsSafe(t *testing.T) {
m := Mail{From: "A", Subject: "B", Format: MailMarkdown, Body: "**Bold** and [the hall](https://example.com)\n\n<script>alert(1)</script>"}
link := ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(500).Children(func() {
if r := MailReader(c, m); r.Link != "" {
link = r.Link
}
})
}, 600, 400)
if err := tt.Click("the hall"); err != nil {
t.Fatal(err)
}
if link != "https://example.com" || len(tt.OpenedURLs()) != 0 {
t.Fatalf("link %q opened %v", link, tt.OpenedURLs())
}
if !tt.HasText("<script>alert(1)</script>") {
t.Fatal("HTML was not shown as text")
}
mediaPanics(t, "no subject", func(c *ui.Context) { MailReader(c, Mail{From: "A"}) })
}
func TestMailReaderSnapshot(t *testing.T) {
m := Mail{From: "Sakiko Togawa", To: []string{"Uika Misumi", "Mutsumi Wakaba"}, Cc: []string{"Umiri Yahata"}, Subject: "The second act",
Date: time.Date(2026, 10, 5, 21, 7, 0, 0, time.UTC), Body: "The score is attached.\nRehearsal is at nine.\n> Can you send it tonight?",
Attachments: []MailAttachment{{Name: "score.pdf", Size: 2 << 20}}}
snapshot(t, "mail_reader", 560, 420, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(520).Children(func() { MailReader(c, m) })
})
}
+195
View File
@@ -0,0 +1,195 @@
package messaging
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Member supplies a unique ID, name, presence and role.
type Member struct {
ID, Name string
Image *ui.Bitmap
Presence PresenceStatus
// Role, as "Owner"; empty shows none.
Role string
}
// MemberListOptions configure a MemberList.
type MemberListOptions struct {
// Height is the list's height; zero means 360.
Height float32
Disabled bool
}
// MemberListState keeps a MemberList's place and choice.
type MemberListState struct {
rows ui.ListState
cursor int
// Selected is the chosen member's ID; empty is none.
Selected string
cache memberRows
looked string
lookedGen int
}
type memberRows struct {
first *Member
n int
rows []int
online int
offline int
gen int
}
// MemberListResult is a MemberList's element and this frame's events.
type MemberListResult struct {
Element *ui.Element
changed bool
// Opened contains the ID activated by Enter or double-click.
Opened string
}
// Changed reports a new selection.
func (r MemberListResult) Changed() bool { return r.changed }
// MemberList virtualizes online and offline groups.
func MemberList(c *ui.Context, s *MemberListState, members []Member, opts MemberListOptions) MemberListResult {
if opts.Height < 0 {
panic("mujica: MemberList Height must not be negative")
}
h := opts.Height
if h == 0 {
h = 360
}
memberGroup(s, members)
k := core.Tokens(c)
d := core.Density(c)
rows := s.cache.rows
if s.looked != s.Selected || s.lookedGen != s.cache.gen {
s.looked, s.lookedGen, s.cursor = s.Selected, s.cache.gen, -1
for i, r := range rows {
if s.Selected != "" && r >= 0 && members[r].ID == s.Selected {
s.cursor = i
break
}
}
}
s.rows.Selected = &s.cursor
s.rows.Header = func(i int) bool { return rows[i] < 0 }
s.rows.Key = func(i int) any {
if rows[i] < 0 {
return rows[i]
}
return members[rows[i]].ID
}
s.rows.Label = func(i int) string {
if rows[i] < 0 {
return memberHeader(c, s, rows[i])
}
return members[rows[i]].Name
}
var r MemberListResult
box := ui.Column(c).Height(h).Disabled(opts.Disabled)
r.Element = box
box.Children(func() {
if len(rows) == 0 {
ui.Text(c, core.Msg(c, "member_list.empty")).TextColor(k.TextMuted)
return
}
restore := data.DataSelectTheme(c, 0)
l := ui.List(c, &s.rows, len(rows), func(i int) {
if rows[i] < 0 {
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).AlignItems(ui.Center).Background(k.Surface).Children(func() {
ui.Text(c, memberHeader(c, s, rows[i])).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.TextMuted)
})
return
}
m := members[rows[i]]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Box(c).Shrink(0).Children(func() {
display.Avatar(c, m.Name, display.AvatarOptions{Image: m.Image, Size: 28})
ui.Box(c).Absolute().Right(-2).Bottom(-2).Padding(1.5).Radius(6).Background(k.Surface).Children(func() {
OnlineStatus(c, m.Presence, OnlineStatusOptions{HideText: true, Size: 9})
})
})
t := ui.Text(c, m.Name).SingleLine().Grow(1).MinWidth(0)
if m.Presence == PresenceOffline {
t.TextColor(k.TextMuted)
}
if m.Role != "" {
display.Badge(c, display.BadgeOptions{Text: m.Role, Tone: display.BadgeNeutral}, nil)
}
if i == s.cursor {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
})
})
restore()
l.Grow(1).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Padding(d.Unit())
if l.IsDisabled() {
return
}
if l.Changed() && s.cursor >= 0 && rows[s.cursor] >= 0 {
s.Selected, r.changed = members[rows[s.cursor]].ID, true
s.looked = s.Selected
}
if l.Submitted() && s.cursor >= 0 && rows[s.cursor] >= 0 {
r.Opened = members[rows[s.cursor]].ID
}
})
if r.changed || r.Opened != "" {
slog.Debug("mujica: member_list choose", "id", s.Selected, "opened", r.Opened)
}
return r
}
func memberHeader(c *ui.Context, s *MemberListState, row int) string {
if row == -1 {
return fmt.Sprintf(core.Msg(c, "member_list.online"), s.cache.online)
}
return fmt.Sprintf(core.Msg(c, "member_list.offline"), s.cache.offline)
}
// memberGroup rebuilds the sections only when members changed.
func memberGroup(s *MemberListState, members []Member) {
var first *Member
if len(members) > 0 {
first = &members[0]
}
if s.cache.rows != nil && s.cache.first == first && s.cache.n == len(members) {
return
}
seen := make(map[string]bool, len(members))
on, off := []int{-1}, []int{-2}
for i, m := range members {
if m.ID == "" || m.Name == "" || seen[m.ID] {
panic("mujica: MemberList members need unique IDs and names: " + m.ID)
}
seen[m.ID] = true
m.Presence.key()
if m.Presence == PresenceOffline {
off = append(off, i)
} else {
on = append(on, i)
}
}
s.cache = memberRows{first: first, n: len(members), online: len(on) - 1, offline: len(off) - 1, gen: s.cache.gen + 1}
if len(on) > 1 {
s.cache.rows = append(s.cache.rows, on...)
}
if len(off) > 1 {
s.cache.rows = append(s.cache.rows, off...)
}
if s.cache.rows == nil {
s.cache.rows = []int{}
}
slog.Debug("mujica: member_list grouped", "online", s.cache.online, "offline", s.cache.offline)
}
+101
View File
@@ -0,0 +1,101 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestMemberListGroupsAndChoose(t *testing.T) {
var s MemberListState
members := memberTestList(8)
opened := ""
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := MemberList(c, &s, members, MemberListOptions{}); r.Opened != "" {
opened = r.Opened
}
}, 400, 500)
if !tt.HasText("Online — 6") || !tt.HasText("Offline — 2") || !tt.HasText("Owner") {
t.Fatalf("texts %v", tt.Texts())
}
on, _ := tt.Find("Member 1")
off, _ := tt.Find("Member 0")
if off.Y <= on.Y {
t.Fatal("offline member above the online section")
}
if err := tt.Click("Member 1"); err != nil {
t.Fatal(err)
}
if s.Selected != "m1" {
t.Fatalf("selected %q", s.Selected)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if s.Selected != "m2" || opened != "m2" {
t.Fatalf("keyboard chose %q, opened %q", s.Selected, opened)
}
}
func TestMemberListBuildsOnlyVisibleAndGroupsOnce(t *testing.T) {
var s MemberListState
members := memberTestList(100000)
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
MemberList(c, &s, members, MemberListOptions{})
}, 400, 500)
gen := s.cache.gen
tt.Frame()
tt.Frame()
built := 0
for _, x := range tt.Texts() {
if len(x) > 7 && x[:7] == "Member " {
built++
}
}
if built == 0 || built > 60 || s.cache.gen != gen {
t.Fatalf("built %d rows, regrouped %d times", built, s.cache.gen-gen)
}
}
func TestMemberListSnapshot(t *testing.T) {
members := []Member{{ID: "1", Name: "Sakiko Togawa", Presence: PresenceOnline, Role: "Leader"}, {ID: "2", Name: "Uika Misumi", Presence: PresenceBusy},
{ID: "3", Name: "Mutsumi Wakaba", Presence: PresenceAway}, {ID: "4", Name: "Umiri Yahata", Presence: PresenceOffline}}
snapshot(t, "member_list", 340, 300, func(c *ui.Context) {
s := ui.Local(c.Root(), "members", func() *MemberListState { return &MemberListState{} })
ui.Column(c).Padding(20).Width(300).Children(func() { MemberList(c, *s, members, MemberListOptions{Height: 260}) })
})
}
func TestMemberListEmptyDisabledAndReplacement(t *testing.T) {
members := []Member{{ID: "1", Name: "One", Presence: PresenceOnline}}
var s MemberListState
disabled, events := true, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
r := MemberList(c, &s, members, MemberListOptions{})
if r.Changed() || r.Opened != "" {
events++
}
})
}, 400, 400)
_ = tt.Click("One")
tt.Key(0, ui.KeyEnter)
if s.Selected != "" || events != 0 {
t.Fatal("disabled member changed")
}
disabled = false
members = []Member{{ID: "1", Name: "One", Presence: PresenceOffline}}
tt.Frame()
if !tt.HasText("Offline — 1") || tt.HasText("Online — 1") {
t.Fatal("replacement kept stale groups")
}
members = nil
tt.Frame()
if !tt.HasText("No members") {
t.Fatal("empty state missing")
}
}
+28
View File
@@ -0,0 +1,28 @@
package messaging
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
type callCalls struct {
mute, camera, share []bool
hang int
}
func (k *callCalls) opts() CallControlsOptions {
return CallControlsOptions{OnMute: func(on bool) { k.mute = append(k.mute, on) }, OnCamera: func(on bool) { k.camera = append(k.camera, on) },
OnShare: func(on bool) { k.share = append(k.share, on) }, OnHangUp: func() { k.hang++ }}
}
func incomingTester(open *bool, opts IncomingCallOptions, call IncomingCall) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.Button(c, "Ring", input.ButtonOptions{}).Clicked() {
*open = true
}
IncomingCallDialog(c, open, call, opts)
}, 600, 500)
}
+19
View File
@@ -0,0 +1,19 @@
package messaging
import (
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var (
msgBellOff = core.MediaSVG(`<path d="M8.7 3A6 6 0 0 1 18 8a21 21 0 0 0 .6 5M17 17H3s3-2 3-9a4.7 4.7 0 0 1 .3-1.7M10.3 21a1.9 1.9 0 0 0 3.4 0M2 2l20 20"/>`)
msgPin = core.MediaSVG(`<path d="M12 17v5M9 3h6l-1 6 3 3v2H7v-2l3-3z"/>`)
msgPaperclip = core.MediaSVG(`<path d="m21 11-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7"/>`)
msgMicOff = core.MediaSVG(`<path d="M2 2l20 20M15 9.3V5a3 3 0 0 0-5.7-1.3M9 9v3a3 3 0 0 0 5.1 2.1M19 10a7 7 0 0 1-1.1 3.8M5 10a7 7 0 0 0 11.5 5.4M12 17v5M8 22h8"/>`)
msgVideoOff = core.MediaSVG(`<path d="M2 2l20 20M10.7 6H14a2 2 0 0 1 2 2v3.3l1 1 5-3.3v9M16 16v0a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2"/>`)
msgShare = core.MediaSVG(`<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v7"/>`)
msgPhone = core.MediaSVG(`<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 1.9.7 2.8a2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.8.7a2 2 0 0 1 1.7 2z"/>`)
msgHangUp = core.MediaSVG(`<path d="M3 14.5c5-5 13-5 18 0l-2.2 2.2-3.6-1.6v-2.6a10 10 0 0 0-6.4 0v2.6l-3.6 1.6z"/>`)
msgSpeaking = core.MediaSVG(`<path d="M4 10v4M8 7v10M12 4v16M16 7v10M20 10v4"/>`)
msgSend = core.MediaSVG(`<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>`)
msgClose = core.MediaSVG(`<path d="M18 6 6 18M6 6l12 12"/>`)
)
+32
View File
@@ -0,0 +1,32 @@
package messaging
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func mailTestItems(n int) []MailItem {
at := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
out := make([]MailItem, n)
for i := range out {
out[i] = MailItem{ID: fmt.Sprint("m", i), From: fmt.Sprint("Sender ", i), Subject: fmt.Sprint("Subject ", i),
Snippet: "A short preview", Time: at, Unread: i%2 == 0, Starred: i == 1, Attachments: i % 3}
}
return out
}
var recipientContacts = []Contact{{Name: "Uika Misumi", Email: "uika@example.com"}, {Name: "Umiri Yahata", Email: "umiri@example.com"},
{Name: "Sakiko Togawa", Email: "sakiko@example.com"}}
func recipientTester(list *[]string) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(20).Width(400).Children(func() {
RecipientInput(c, list, RecipientInputOptions{Contacts: recipientContacts, Label: "To"})
})
}, 520, 400)
}
+38
View File
@@ -0,0 +1,38 @@
package messaging
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var channelTestGroups = []ChannelGroup{
{ID: "ch", Title: "Channels", Channels: []Channel{
{ID: "general", Name: "general", Unread: 3},
{ID: "rehearsal", Name: "rehearsal", Pinned: true},
{ID: "random", Name: "random", Unread: 120, Muted: true},
}},
{ID: "dm", Title: "Direct messages", Channels: []Channel{{ID: "uika", Name: "Uika", Direct: true, Presence: PresenceOnline}}},
}
func channelTester(sel *string, disabled bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(260).Children(func() {
if ChannelList(c, sel, channelTestGroups, ChannelListOptions{Disabled: disabled}).Changed() {
*n++
}
})
}, 320, 480)
}
func memberTestList(n int) []Member {
out := make([]Member, n)
for i := range out {
out[i] = Member{ID: fmt.Sprint("m", i), Name: fmt.Sprint("Member ", i), Presence: PresenceStatus(i % 4)}
}
out[0].Role = "Owner"
return out
}
+49
View File
@@ -0,0 +1,49 @@
package messaging
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestMessagingLargeListsBounded(t *testing.T) {
const count = 100000
channels := make([]Channel, count)
pins := make([]ChatEntry, count)
for i := range count {
channels[i] = Channel{ID: fmt.Sprint(i), Name: fmt.Sprint("Channel ", i)}
pins[i] = ChatEntry{ID: fmt.Sprint(i), Author: "Author", Text: fmt.Sprint("Pinned ", i)}
}
for _, name := range []string{"channels", "pins"} {
t.Run(name, func(t *testing.T) {
selected := ""
groups := []ChannelGroup{{ID: "g", Title: "Group", Channels: channels}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Height(280).Children(func() {
if name == "channels" {
ChannelList(c, &selected, groups, ChannelListOptions{Height: 240})
} else {
PinnedMessages(c, pins, PinnedMessagesOptions{Height: 240})
}
})
}, 400, 300)
prefix := "Channel "
if name == "pins" {
prefix = "Pinned "
}
n := 0
for _, text := range tt.Texts() {
if len(text) >= len(prefix) && text[:len(prefix)] == prefix {
n++
}
}
if n == 0 || n > 60 {
t.Fatalf("built %d of %d", n, count)
}
})
}
}
+36
View File
@@ -0,0 +1,36 @@
package messaging
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// mediaTextCount counts texts equal to s.
func mediaTextCount(tt *ui.Tester, s string) int {
n := 0
for _, x := range tt.Texts() {
if x == s {
n++
}
}
return n
}
func TestMessagingChineseWeekdays(t *testing.T) {
date := time.Date(2026, 10, 6, 15, 4, 0, 0, time.UTC)
st := CustomStatus{Until: date}
var until time.Time
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{Locale: core.ZhCN})
StatusSetter(c, &st, StatusSetterOptions{Location: time.UTC})
SnoozePicker(c, &until, SnoozePickerOptions{Now: time.Date(2026, 10, 5, 10, 0, 0, 0, time.UTC), Location: time.UTC})
MailReader(c, Mail{Subject: "Test", From: "a@example.com", Date: date})
}, 900, 900)
if !tt.HasText("2026年10月6日 星期二 15:04") || !tt.HasText("星期二 15:04") || !tt.HasText("星期二 09:00") {
t.Fatalf("weekday texts=%v", tt.Texts())
}
}
+88
View File
@@ -0,0 +1,88 @@
package messaging
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PresenceStatus is a person's availability. The zero value is offline.
type PresenceStatus int
const (
PresenceOffline PresenceStatus = iota
PresenceOnline
PresenceAway
PresenceBusy
)
func (p PresenceStatus) key() string {
switch p {
case PresenceOffline:
return "online_status.offline"
case PresenceOnline:
return "online_status.online"
case PresenceAway:
return "online_status.away"
case PresenceBusy:
return "online_status.busy"
}
panic(fmt.Sprintf("mujica: unknown PresenceStatus %d", int(p)))
}
// OnlineStatusOptions configure an OnlineStatus.
type OnlineStatusOptions struct {
// HideText keeps the shape, accessible name and tooltip.
HideText bool
// Size is the shape's diameter; zero means 10.
Size float32
}
// OnlineStatus shows presence as a distinct shape plus its name.
func OnlineStatus(c *ui.Context, p PresenceStatus, opts OnlineStatusOptions) *ui.Element {
name := core.Msg(c, p.key())
if opts.Size < 0 {
panic("mujica: OnlineStatus Size must not be negative")
}
size := opts.Size
if size == 0 {
size = 10
}
k := core.Tokens(c)
row := ui.Row(c).Gap(core.Density(c).Unit() * 1.5).AlignItems(ui.Center).Shrink(0).Role(ui.RoleStatus).Label(name)
if opts.HideText {
row.Tooltip(name)
}
row.Children(func() {
ui.Box(c).Size(size, size).Shrink(0).Draw(func(p2 *ui.Painter, r ui.Rect) { presencePaint(p2, k, p, r) })
if !opts.HideText {
ui.Text(c, name).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
return row
}
func presencePaint(p *ui.Painter, k theme.Tokens, s PresenceStatus, r ui.Rect) {
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
rad := r.W / 2
var disc ui.Path
disc.Circle(cx, cy, rad)
switch s {
case PresenceOnline:
p.FillPath(&disc, k.Success)
case PresenceAway:
p.FillPath(&disc, k.Warning)
var bite ui.Path
bite.Circle(cx-rad*0.45, cy-rad*0.45, rad*0.62)
p.FillPath(&bite, k.Surface)
case PresenceBusy:
p.FillPath(&disc, k.Danger)
p.Fill(ui.Rect{X: cx - rad*0.6, Y: cy - max(1, rad*0.18), W: rad * 1.2, H: max(2, rad*0.36)}, k.Surface, 0)
default:
p.StrokePath(&disc, max(1.5, rad*0.3), k.TextMuted)
}
}
+43
View File
@@ -0,0 +1,43 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestOnlineStatusTextAndShape(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Gap(12).Children(func() {
for _, p := range []PresenceStatus{PresenceOnline, PresenceAway, PresenceBusy, PresenceOffline} {
OnlineStatus(c, p, OnlineStatusOptions{})
}
OnlineStatus(c, PresenceBusy, OnlineStatusOptions{HideText: true}).Key("hidden")
})
}, 500, 80)
for _, s := range []string{"Online", "Away", "Offline"} {
if mediaTextCount(tt, s) != 2 {
t.Fatalf("%s seen %d times: %v", s, mediaTextCount(tt, s), tt.Texts())
}
}
if mediaTextCount(tt, "Busy") != 3 {
t.Fatalf("Busy seen %d times", mediaTextCount(tt, "Busy"))
}
if _, ok := tt.Find("Busy"); !ok {
t.Fatal("hidden text not kept as the name")
}
mediaPanics(t, "bad presence", func(c *ui.Context) { OnlineStatus(c, PresenceStatus(9), OnlineStatusOptions{}) })
}
func TestOnlineStatusSnapshot(t *testing.T) {
snapshot(t, "online_status", 420, 60, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(16).Children(func() {
for _, p := range []PresenceStatus{PresenceOnline, PresenceAway, PresenceBusy, PresenceOffline} {
OnlineStatus(c, p, OnlineStatusOptions{})
}
})
})
}
+75
View File
@@ -0,0 +1,75 @@
package messaging
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chat"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PinnedMessagesResult is the list's element and this frame's events.
type PinnedMessagesResult struct {
Element *ui.Element
// Jump requests navigation; Unpinned requests removing a pin.
Jump, Unpinned string
}
// PinnedMessagesOptions configure a PinnedMessages.
type PinnedMessagesOptions struct {
// Height caps the list; zero means 320.
Height float32
Disabled bool
}
// PinnedMessages virtualizes pins and emits navigation or removal requests.
func PinnedMessages(c *ui.Context, pins []ChatEntry, opts PinnedMessagesOptions) PinnedMessagesResult {
if opts.Height < 0 {
panic("mujica: PinnedMessages Height must not be negative")
}
h := opts.Height
if h == 0 {
h = 320
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r PinnedMessagesResult
col := ui.Column(c).Gap(u * 2).Role(ui.RoleGroup).Label(fmt.Sprintf(core.Msg(c, "pinned_messages.title"), len(pins))).
Disabled(opts.Disabled)
r.Element = col
col.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Icon(c, msgPin).Size(16, 16).TextColor(k.Ornament)
ui.Text(c, fmt.Sprintf(core.Msg(c, "pinned_messages.title"), len(pins))).Font(theme.SerifFont).FontWeight(600)
})
if len(pins) == 0 {
ui.Text(c, core.Msg(c, "pinned_messages.empty")).TextColor(k.TextMuted)
return
}
state := ui.Local(col, "pinned.rows", func() ui.ListState { return ui.ListState{} })
state.Key = func(i int) any { return pins[i].ID }
ui.List(c, state, len(pins), func(i int) {
m := pins[i]
if m.ID == "" || m.Author == "" {
panic("mujica: PinnedMessages entries need ID and Author")
}
ui.Row(c).MinHeight(core.Density(c).ControlHeight()).Gap(u).AlignItems(ui.Center).Children(func() {
if chat.QuoteReply(c, m.Author, m.Text).Grow(1).MaxWidth(10000).Clicked() && !col.IsDisabled() {
r.Jump = m.ID
}
b := core.IconAction(c, msgPin, fmt.Sprintf(core.Msg(c, "pinned_messages.unpin"), m.Author))
if b.Clicked() && !b.IsDisabled() {
r.Unpinned = m.ID
}
})
}).Height(min(h, float32(len(pins))*core.Density(c).ControlHeight())).Gap(u * 2).FocusGroup(ui.Vertical)
})
if r.Jump != "" || r.Unpinned != "" {
slog.Debug("mujica: pinned_messages", "jump", r.Jump, "unpin", r.Unpinned)
}
return r
}
+49
View File
@@ -0,0 +1,49 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestPinnedMessages(t *testing.T) {
pins := []ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}, {ID: "b", Author: "Uika", Text: "Masks are in the hall."}}
var jumps, unpins []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(420).Children(func() {
r := PinnedMessages(c, pins, PinnedMessagesOptions{})
if r.Jump != "" {
jumps = append(jumps, r.Jump)
}
if r.Unpinned != "" {
unpins = append(unpins, r.Unpinned)
}
})
}, 500, 400)
if !tt.HasText("Pinned messages (2)") {
t.Fatalf("texts %v", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if err := tt.Click("Unpin Uika's message"); err != nil {
t.Fatal(err)
}
if len(jumps) != 1 || jumps[0] != "a" || len(unpins) != 1 || unpins[0] != "b" {
t.Fatalf("jumps %v unpins %v", jumps, unpins)
}
pins = nil
tt.Frame()
if !tt.HasText("No pinned messages") {
t.Fatal("no empty state")
}
}
func TestPinnedMessagesSnapshot(t *testing.T) {
pins := []ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}, {ID: "b", Author: "Uika", Text: "Masks are in the hall."}}
snapshot(t, "pinned_messages", 440, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(400).Children(func() { PinnedMessages(c, pins, PinnedMessagesOptions{}) })
})
}
+41
View File
@@ -0,0 +1,41 @@
package messaging
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// QuotedText folds or expands one level of quoted text.
func QuotedText(c *ui.Context, text string, open *bool) core.ChoiceResult {
body := QuoteStrip(text)
n := strings.Count(body, "\n") + 1
k := core.Tokens(c)
u := core.Density(c).Unit()
title := fmt.Sprintf(core.Msg(c, "quoted_text.show"), n)
if n == 1 {
title = core.Msg(c, "quoted_text.showOne")
}
if *open {
title = core.Msg(c, "quoted_text.hide")
}
return data.Collapsible(c, open, data.CollapsibleOptions{Title: title}, func() {
ui.Text(c, body).Selectable().FocusRing(false).TextColor(k.TextMuted).LineHeight(theme.LineHeight).
Padding(0, 0, 0, u*3).BorderWidth(0, 0, 0, 2).BorderColor(k.Border)
})
}
// QuoteStrip removes one quote prefix from each line.
func QuoteStrip(text string) string {
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
for i, l := range lines {
l = strings.TrimPrefix(l, ">")
lines[i] = strings.TrimPrefix(l, " ")
}
return strings.Join(lines, "\n")
}
+50
View File
@@ -0,0 +1,50 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestQuotedTextAndStrip(t *testing.T) {
if got := QuoteStrip("> a\n>b\n> > c"); got != "a\nb\n> c" {
t.Fatalf("strip %q", got)
}
open := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
QuotedText(c, "> first\n> second", &open)
}, 400, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !open || !tt.HasText("first\nsecond") || !tt.HasText("Hide quoted text") {
t.Fatalf("not expanded: %v", tt.Texts())
}
tt.Key(0, ui.KeySpace)
if open {
t.Fatal("Space did not fold")
}
}
func TestQuotedTextSnapshot(t *testing.T) {
a, b := false, true
snapshot(t, "quoted_text", 420, 180, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(8).Children(func() {
QuotedText(c, "> Can you send the score?\n> Thanks", &a)
QuotedText(c, "> Rehearsal moves to nine.\n> Bring the masks.", &b)
})
})
}
func TestQuotedTextSingleLineCopy(t *testing.T) {
open := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
QuotedText(c, "> One line", &open)
}, 400, 200)
if !tt.HasText("Show quoted text (1 line)") {
t.Fatal("singular quote label missing")
}
}
+92
View File
@@ -0,0 +1,92 @@
package messaging
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ReceiptState tracks delivery; its zero value means sent.
type ReceiptState int
const (
ReceiptSent ReceiptState = iota
ReceiptDelivered
ReceiptRead
)
// ReadReceiptOptions configure a ReadReceipt.
type ReadReceiptOptions struct {
// Readers supplies avatars for the read state.
Readers []display.AvatarItem
// Max avatars show before "+N"; zero means 3.
Max int
}
// ReadReceipt displays delivery marks, text and reader avatars.
func ReadReceipt(c *ui.Context, state ReceiptState, opts ReadReceiptOptions) *ui.Element {
if state < ReceiptSent || state > ReceiptRead || opts.Max < 0 {
panic(fmt.Sprintf("mujica: bad ReadReceipt state %d or Max %d", state, opts.Max))
}
limit := opts.Max
if limit == 0 {
limit = 3
}
k := core.Tokens(c)
u := core.Density(c).Unit()
word := core.Msg(c, []string{"read_receipt.sent", "read_receipt.delivered", "read_receipt.read"}[state])
label := word
if state == ReceiptRead && len(opts.Readers) > 0 {
names := make([]string, len(opts.Readers))
for i, r := range opts.Readers {
names[i] = r.Name
}
label = fmt.Sprintf(core.Msg(c, "read_receipt.readBy"), strings.Join(names, ", "))
}
ink := k.TextMuted
if state == ReceiptRead {
ink = k.AccentText
}
row := ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Shrink(0).Role(ui.RoleStatus).Label(label).Tooltip(label)
row.Children(func() {
ui.Box(c).Size(18, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { receiptChecks(p, r, state != ReceiptSent, ink) })
ui.Text(c, word).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(ink).SingleLine()
if state != ReceiptRead || len(opts.Readers) == 0 {
return
}
ui.Row(c).Children(func() {
for i, r := range opts.Readers[:min(limit, len(opts.Readers))] {
cell := ui.Box(c).Key(i).Padding(1).Radius(9).Background(k.Background)
if i > 0 {
cell.Margin(0, 0, 0, -5)
}
cell.Children(func() { display.Avatar(c, r.Name, display.AvatarOptions{Image: r.Image, Size: 16}) })
}
})
if rest := len(opts.Readers) - limit; rest > 0 {
ui.Textf(c, "+%d", rest).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
return row
}
// receiptChecks draws one check, or two overlapping checks.
func receiptChecks(p *ui.Painter, r ui.Rect, double bool, col ui.Color) {
check := func(x float32) {
var path ui.Path
path.MoveTo(internal.Snap(p, x), internal.Snap(p, r.Y+r.H*0.55))
path.LineTo(internal.Snap(p, x+r.H*0.35), internal.Snap(p, r.Y+r.H*0.9))
path.LineTo(internal.Snap(p, x+r.H*0.95), internal.Snap(p, r.Y+r.H*0.15))
p.StrokePath(&path, 1.6, col)
}
check(r.X)
if double {
check(r.X + r.H*0.5)
}
}
+41
View File
@@ -0,0 +1,41 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
)
func TestReadReceiptStates(t *testing.T) {
readers := []display.AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}, {Name: "Umiri"}, {Name: "Nyamu"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Gap(8).AlignItems(ui.Start).Children(func() {
ReadReceipt(c, ReceiptSent, ReadReceiptOptions{})
ReadReceipt(c, ReceiptDelivered, ReadReceiptOptions{})
ReadReceipt(c, ReceiptRead, ReadReceiptOptions{Readers: readers, Max: 2})
})
}, 400, 160)
for _, s := range []string{"Sent", "Delivered", "+2"} {
if !tt.HasText(s) {
t.Fatalf("no %q in %v", s, tt.Texts())
}
}
if _, ok := tt.Find("Read by Uika, Mutsumi, Umiri, Nyamu"); !ok {
t.Fatal("readers not named in full")
}
mediaPanics(t, "bad state", func(c *ui.Context) { ReadReceipt(c, ReceiptState(5), ReadReceiptOptions{}) })
}
func TestReadReceiptSnapshot(t *testing.T) {
snapshot(t, "read_receipt", 360, 130, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(10).AlignItems(ui.Start).Children(func() {
ReadReceipt(c, ReceiptSent, ReadReceiptOptions{})
ReadReceipt(c, ReceiptDelivered, ReadReceiptOptions{})
ReadReceipt(c, ReceiptRead, ReadReceiptOptions{Readers: []display.AvatarItem{{Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"}, {Name: "Nyamu"}}})
})
})
}
+182
View File
@@ -0,0 +1,182 @@
package messaging
import (
"fmt"
"log/slog"
"net/mail"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Contact is a person RecipientInput can suggest.
type Contact struct {
Name, Email string
}
// RecipientInputOptions configure a RecipientInput.
type RecipientInputOptions struct {
// Contacts supplies suggestions matched by name or address.
Contacts []Contact
Placeholder string
// Label names the field, as "To".
Label string
Disabled bool
}
// recipientMax is how many suggestions show.
const recipientMax = 6
type recipientState struct {
query string
matchedOn string
matches []Contact
contacts *Contact
count int
added []string
hi int
dismissed string
}
type recipientKey struct{}
// RecipientInput validates addresses and offers contact suggestions.
func RecipientInput(c *ui.Context, recipients *[]string, opts RecipientInputOptions) input.TagsInputParts {
k := core.Tokens(c)
if opts.Label == "" {
opts.Label = core.Msg(c, "recipient_input.label")
}
wrap := ui.Column(c).Disabled(opts.Disabled)
st := ui.Local(wrap, recipientKey{}, func() *recipientState { return &recipientState{hi: -1} })
s := *st
// Suggestions are found again only when the typed text changes.
var first *Contact
if len(opts.Contacts) > 0 {
first = &opts.Contacts[0]
}
if q := strings.ToLower(strings.TrimSpace(s.query)); q != s.matchedOn || first != s.contacts || len(opts.Contacts) != s.count || !slices.Equal(s.added, *recipients) {
s.matchedOn, s.matches, s.hi = q, recipientFind(opts.Contacts, *recipients, q), -1
s.contacts, s.count, s.added = first, len(opts.Contacts), slices.Clone(*recipients)
}
typed := s.query
var parts input.TagsInputParts
wrap.Children(func() {
parts = input.TagsInput(c, recipients, input.TagsInputOptions{
Placeholder: opts.Placeholder, Label: opts.Label, Disabled: opts.Disabled, Query: &s.query,
Validate: func(tag string) string {
if s.hi >= 0 && tag == strings.TrimSpace(typed) {
return "" // replaced by the highlighted contact below
}
if _, err := mail.ParseAddress(tag); err != nil {
return fmt.Sprintf(core.Msg(c, "recipient_input.invalid"), tag)
}
return ""
},
})
})
parts.Element = wrap
in := parts.Input
live := !wrap.IsDisabled()
showing := live && len(s.matches) > 0 && s.dismissed != s.query && s.query != ""
if showing && in.Focused() {
switch {
case in.Shortcut(0, ui.KeyDown):
s.hi = min(len(s.matches)-1, s.hi+1)
case in.Shortcut(0, ui.KeyUp):
s.hi = max(-1, s.hi-1)
case in.Shortcut(0, ui.KeyEscape):
s.dismissed, s.hi = s.query, -1
}
}
pick := func(ct Contact) {
addr := recipientFormat(ct)
if n := len(*recipients); n > 0 && (*recipients)[n-1] == strings.TrimSpace(typed) {
*recipients = (*recipients)[:n-1] // the raw text Enter added
}
if !slices.Contains(*recipients, addr) {
*recipients = append(slices.Clone(*recipients), addr)
}
s.query, s.hi = "", -1
in.Focus()
slog.Debug("mujica: recipient_input pick", "count", len(*recipients))
}
if live && in.Submitted() && s.hi >= 0 && s.hi < len(s.matches) {
pick(s.matches[s.hi])
parts = input.NewTagsInputParts(wrap, in, true)
}
if showing {
b := wrap.Bounds()
ui.Overlay(c, func() {
ww, wh := c.Size()
w := min(max(240, b.W), max(0, ww-8))
h := min(float32(len(s.matches))*core.Density(c).ControlHeight()+16, max(0, wh-8))
x, y := core.PlaceOverlay(c, b, w, h, 4)
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(4).Role(ui.RoleList).
Label(core.Msg(c, "recipient_input.suggestions"))
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Children(func() {
ui.Scroll(c).Shrink(1).Padding(4).Children(func() {
for i, ct := range s.matches {
row := ui.ButtonBase(c).Key(ct.Email).MinHeight(core.Density(c).ControlHeight()).Justify(ui.Start).Gap(8).Padding(0, 8).
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(recipientFormat(ct)).FocusRing(false)
if i == s.hi || row.Hovered() {
row.Background(k.Selection)
}
if i == s.hi || row.FocusVisible() {
row.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.FocusRing(p, r, theme.ControlRadius, k.Focus) })
}
row.Children(func() {
display.Avatar(c, ct.Name, display.AvatarOptions{Size: 22})
ui.Text(c, ct.Name).SingleLine()
ui.Text(c, ct.Email).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
if row.Clicked() && !row.IsDisabled() {
pick(ct)
parts = input.NewTagsInputParts(parts.Element, parts.Input, true)
}
}
})
})
if !in.Focused() && !panel.FocusWithin() {
panel.Invisible()
}
})
}
return parts
}
func recipientFind(contacts []Contact, added []string, q string) []Contact {
if q == "" {
return nil
}
var out []Contact
for _, ct := range contacts {
if len(out) == recipientMax {
break
}
if (strings.Contains(strings.ToLower(ct.Name), q) || strings.Contains(strings.ToLower(ct.Email), q)) &&
!slices.Contains(added, recipientFormat(ct)) {
out = append(out, ct)
}
}
return out
}
func recipientFormat(ct Contact) string {
if ct.Name == "" {
return ct.Email
}
plain := ct.Name + " <" + ct.Email + ">"
if _, err := mail.ParseAddress(plain); err == nil {
return plain
}
address := mail.Address{Name: ct.Name, Address: ct.Email}
return address.String()
}
+190
View File
@@ -0,0 +1,190 @@
package messaging
import (
"net/mail"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestRecipientInputRefreshesContacts(t *testing.T) {
var recipients []string
contacts := []Contact{{Name: "Alice", Email: "alice@example.com"}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
RecipientInput(c, &recipients, RecipientInputOptions{Contacts: contacts, Label: "To"})
}, 500, 300)
if err := tt.Click("To"); err != nil {
t.Fatal(err)
}
tt.Type("ali")
contacts = []Contact{{Name: "Alina", Email: "alina@example.com"}}
tt.Frame()
if !tt.HasText("Alina") || tt.HasText("Alice") {
t.Fatalf("stale contacts: %v", tt.Texts())
}
}
func TestRecipientInputPopupFitsWindow(t *testing.T) {
var recipients []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Absolute().Left(190).Top(220).Width(110).Children(func() {
RecipientInput(c, &recipients, RecipientInputOptions{Contacts: recipientContacts, Label: "To"})
})
}, 320, 280)
if err := tt.Click("To"); err != nil {
t.Fatal(err)
}
tt.Type("ui")
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
r, ok := tt.Find("Contacts")
if !ok || r.X < 0 || r.Y < 0 || r.X+r.W > 320 || r.Y+r.H > 280 {
t.Fatalf("popup bounds: %+v", r)
}
}
func TestRecipientInputDisabledPreservesAddresses(t *testing.T) {
recipients := []string{"a@example.com"}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(true).Children(func() {
RecipientInput(c, &recipients, RecipientInputOptions{Contacts: recipientContacts, Label: "To"})
})
}, 500, 300)
_ = tt.Click("To")
tt.Type("b@example.com,")
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Remove a@example.com")
if !slices.Equal(recipients, []string{"a@example.com"}) {
t.Fatalf("disabled recipients: %v", recipients)
}
}
func TestRecipientInputValidates(t *testing.T) {
var list []string
tt := recipientTester(&list)
if err := tt.Click("To"); err != nil {
t.Fatal(err)
}
tt.Type("nobody")
tt.Key(0, ui.KeyEnter)
if len(list) != 0 || !tt.HasText(`"nobody" is not an email address`) {
t.Fatalf("invalid address kept: %v %v", list, tt.Texts())
}
tt.Command("selectAll")
tt.Type("guest@example.com,")
if !slices.Equal(list, []string{"guest@example.com"}) {
t.Fatalf("list %v", list)
}
}
func TestRecipientInputSuggests(t *testing.T) {
var list []string
tt := recipientTester(&list)
if err := tt.Click("To"); err != nil {
t.Fatal(err)
}
tt.Type("yah")
if !tt.HasText("Umiri Yahata") || tt.HasText("Sakiko Togawa") {
t.Fatalf("suggestions %v", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !slices.Equal(list, []string{"Umiri Yahata <umiri@example.com>"}) {
t.Fatalf("list %v", list)
}
tt.Type("ui")
if err := tt.Click("Uika Misumi <uika@example.com>"); err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[1] != "Uika Misumi <uika@example.com>" {
t.Fatalf("click pick %v", list)
}
tt.Type("sa")
tt.Key(0, ui.KeyEscape)
if tt.HasText("sakiko@example.com") {
t.Fatal("Escape did not hide suggestions")
}
}
func TestRecipientInputSnapshot(t *testing.T) {
list := []string{"Uika Misumi <uika@example.com>", "guest@example.com"}
snapshot(t, "recipient_input", 460, 120, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(420).Children(func() {
RecipientInput(c, &list, RecipientInputOptions{Contacts: recipientContacts, Label: "To"})
})
})
}
func TestRecipientInputQuotesDisplayNames(t *testing.T) {
var recipients []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
RecipientInput(c, &recipients, RecipientInputOptions{Label: "To", Contacts: []Contact{{Name: "Doe, Jane", Email: "jane@example.com"}}})
}, 500, 300)
if err := tt.Click("To"); err != nil {
t.Fatal(err)
}
tt.Type("jane")
if err := tt.Click(`"Doe, Jane" <jane@example.com>`); err != nil {
t.Fatal(err)
}
if len(recipients) != 1 {
t.Fatalf("recipients=%v texts=%v", recipients, tt.Texts())
}
addr, err := mail.ParseAddress(recipients[0])
if err != nil || addr.Name != "Doe, Jane" || addr.Address != "jane@example.com" {
t.Fatalf("quoted address=%v error=%v", recipients, err)
}
}
func TestRecipientInputTabShowsFocusRing(t *testing.T) {
var recipients []string
var focus ui.Color
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
focus = core.Tokens(c).Focus
ui.Column(c).Padding(20).Children(func() {
RecipientInput(c, &recipients, RecipientInputOptions{Contacts: recipientContacts, Label: "To"})
})
}, 500, 300)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if err := tt.Click("To"); err != nil {
t.Fatal(err)
}
tt.Type("ui")
tt.Key(0, ui.KeyTab)
label := "Uika Misumi <uika@example.com>"
if !tt.Focused(label) {
t.Fatal("Tab did not focus suggestion")
}
r, ok := tt.Find(label)
if !ok {
t.Fatal("suggestion missing")
}
pixels := 0
img := tt.Image()
for y := int(r.Y) - 4; y < int(r.Y+r.H)+4; y++ {
for x := int(r.X) - 4; x < int(r.X+r.W)+4; x++ {
if x >= int(r.X) && x < int(r.X+r.W) && y >= int(r.Y) && y < int(r.Y+r.H) {
continue
}
pixel := img.RGBAAt(x, y)
if pixel.R == focus.R && pixel.G == focus.G && pixel.B == focus.B {
pixels++
}
}
}
if pixels == 0 {
t.Fatal("keyboard suggestion has no focus ring")
}
tt.Key(0, ui.KeyEnter)
if len(recipients) != 1 {
t.Fatal("focused suggestion did not commit")
}
}
+142
View File
@@ -0,0 +1,142 @@
package messaging
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SnoozePickerOptions configure a SnoozePicker.
type SnoozePickerOptions struct {
// Location is the user's time zone; nil means local.
Location *time.Location
// MorningHour defaults to 9; valid values are 0–23.
MorningHour int
// Now defaults to the frame clock.
Now time.Time
Disabled bool
}
// SnoozeChoice pairs a message key with a reminder time.
type SnoozeChoice struct {
Key string
At time.Time
}
// SnoozeChoices returns localized-day shortcuts using morning hours.
func SnoozeChoices(now time.Time, morning int) []SnoozeChoice {
if morning < 0 || morning > 23 {
panic("mujica: SnoozeChoices morning outside 0..23")
}
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
at := func(days int) time.Time {
return time.Date(day.Year(), day.Month(), day.Day()+days, morning, 0, 0, 0, now.Location())
}
var out []SnoozeChoice
if later := now.Add(3 * time.Hour).Truncate(time.Hour); later.Day() == now.Day() {
out = append(out, SnoozeChoice{"snooze_picker.later", later})
}
out = append(out, SnoozeChoice{"snooze_picker.tomorrow", at(1)})
if wd := now.Weekday(); wd != time.Saturday && wd != time.Sunday {
out = append(out, SnoozeChoice{"snooze_picker.weekend", at(int(time.Saturday - wd))})
}
monday := (8 - int(now.Weekday())) % 7
if monday == 0 {
monday = 7
}
return append(out, SnoozeChoice{"snooze_picker.nextWeek", at(monday)})
}
type snoozeState struct {
custom bool
pick time.Time
}
type snoozeKey struct{}
// SnoozePicker offers shortcuts and custom reminder times.
func SnoozePicker(c *ui.Context, until *time.Time, opts SnoozePickerOptions) core.ChoiceResult {
if opts.MorningHour < 0 || opts.MorningHour > 23 {
panic("mujica: SnoozePicker MorningHour outside 0..23")
}
loc := opts.Location
if loc == nil {
loc = time.Local
}
morning := opts.MorningHour
if morning == 0 {
morning = 9
}
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
now := opts.Now
if now.IsZero() {
now = c.Now()
}
now = now.In(loc)
old := *until
col := ui.Column(c).Width(300).Padding(u).FocusGroup(ui.Vertical).Role(ui.RoleList).Label(core.Msg(c, "snooze_picker.label")).
Disabled(opts.Disabled)
st := ui.Local(col, snoozeKey{}, func() *snoozeState { return &snoozeState{} })
s := *st
col.Children(func() {
row := func(name, when string, on bool) bool {
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(u*2).Padding(0, u*3).Radius(theme.ControlRadius).
Role(ui.RoleListItem).Label(name + ", " + when).Selected(on).Background(ui.Transparent).Transition(core.ColorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case on:
b.Background(k.Selection).BorderWidth(0, 0, 0, 3).BorderColor(k.AccentText)
case b.Hovered():
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Text(c, name).Grow(1).SingleLine()
ui.Text(c, when).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
})
return b.Clicked() && !b.IsDisabled()
}
for _, ch := range SnoozeChoices(now, morning) {
ui.Box(c).Key(ch.Key).Children(func() {
if row(core.Msg(c, ch.Key), snoozeWhen(c, now, ch.At), until.Equal(ch.At)) {
*until, s.custom = ch.At, false
}
})
}
ui.Box(c).Height(1).Margin(u, 0).Background(k.Border)
if row(core.Msg(c, "snooze_picker.custom"), "", s.custom) {
s.custom = !s.custom
if s.pick.IsZero() {
s.pick = SnoozeChoices(now, morning)[0].At
}
}
if s.custom {
ui.Column(c).Gap(u * 2).Padding(u * 2).Children(func() {
datetime.DateTimePicker(c, &s.pick, datetime.DateTimePickerOptions{Location: loc, Min: now, Label: core.Msg(c, "snooze_picker.custom"), Disabled: opts.Disabled})
if input.Button(c, core.Msg(c, "snooze_picker.set"), input.ButtonOptions{Disabled: !s.pick.After(now)}).Clicked() {
*until = s.pick
}
})
}
})
changed := !until.Equal(old)
if changed {
slog.Debug("mujica: snooze_picker set", "until", *until)
}
return core.NewChoiceResult(col, changed)
}
func snoozeWhen(c *ui.Context, now, t time.Time) string {
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
return core.MessagingDate(c, t, "snooze_picker.when")
}
+123
View File
@@ -0,0 +1,123 @@
package messaging
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSnoozeChoicesPreserveWallHour(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
for _, now := range []time.Time{
time.Date(2026, 3, 7, 14, 0, 0, 0, loc),
time.Date(2026, 10, 31, 14, 0, 0, 0, loc),
} {
choices := SnoozeChoices(now, 9)
if at := choices[1].At; at.Hour() != 9 || at.Day() != now.AddDate(0, 0, 1).Day() {
t.Fatalf("tomorrow wall time: %v", at)
}
}
}
func TestSnoozeChoices(t *testing.T) {
wed := time.Date(2026, 10, 7, 14, 20, 0, 0, time.UTC)
got := SnoozeChoices(wed, 9)
want := []SnoozeChoice{
{"snooze_picker.later", time.Date(2026, 10, 7, 17, 0, 0, 0, time.UTC)},
{"snooze_picker.tomorrow", time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)},
{"snooze_picker.weekend", time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)},
{"snooze_picker.nextWeek", time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)},
}
if !slices.Equal(got, want) {
t.Fatalf("choices %v", got)
}
late := SnoozeChoices(time.Date(2026, 10, 10, 22, 0, 0, 0, time.UTC), 9)
if len(late) != 2 || late[0].Key != "snooze_picker.tomorrow" {
t.Fatalf("Saturday night %v", late)
}
}
func TestSnoozePickerChoose(t *testing.T) {
var until time.Time
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if SnoozePicker(c, &until, SnoozePickerOptions{Location: time.UTC}).Changed() {
n++
}
}, 500, 600)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
now := time.Now().UTC()
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, time.UTC)
later := now.Add(3 * time.Hour).Truncate(time.Hour)
want := tomorrow
if later.Day() != now.Day() {
want = SnoozeChoices(now, 9)[1].At
}
if !until.Equal(want) || n != 1 {
t.Fatalf("until %v, want %v", until, want)
}
if err := tt.Click("Pick date and time"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Snooze") {
t.Fatal("custom picker not shown")
}
mediaPanics(t, "hour", func(c *ui.Context) { SnoozePicker(c, &until, SnoozePickerOptions{MorningHour: 30}) })
}
func TestSnoozePickerSnapshot(t *testing.T) {
until := time.Time{}
snapshot(t, "snooze_picker", 340, 260, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() {
SnoozePicker(c, &until, SnoozePickerOptions{Location: time.UTC, Now: time.Date(2026, 10, 7, 14, 20, 0, 0, time.UTC)})
})
})
}
func TestSnoozePickerCustomCommitAndDisabled(t *testing.T) {
now := time.Date(2026, 10, 7, 14, 20, 0, 0, time.UTC)
var until time.Time
disabled, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if SnoozePicker(c, &until, SnoozePickerOptions{Now: now, Location: time.UTC, Disabled: disabled}).Changed() {
changes++
}
}, 500, 640)
for _, label := range []string{"Pick date and time", "2026-10-07 17:00 UTC", "Time: Hours"} {
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
tt.Key(0, ui.KeyUp)
if err := tt.Click("Confirm"); err != nil {
t.Fatal(err)
}
if !until.IsZero() || changes != 0 {
t.Fatal("custom draft committed before Snooze")
}
if err := tt.Click("Snooze"); err != nil {
t.Fatal(err)
}
want := time.Date(2026, 10, 7, 18, 0, 0, 0, time.UTC)
if !until.Equal(want) || changes != 1 {
t.Fatalf("until=%v changes=%d", until, changes)
}
disabled = true
tt.Frame()
_ = tt.Click("Tomorrow")
tt.Key(0, ui.KeyEnter)
if !until.Equal(want) || changes != 1 {
t.Fatal("disabled snooze changed")
}
}
+140
View File
@@ -0,0 +1,140 @@
package messaging
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CustomStatus stores content and an optional expiry.
type CustomStatus struct {
Emoji, Text string
Until time.Time
}
// StatusSetterOptions configure a StatusSetter.
type StatusSetterOptions struct {
// Location defines day boundaries; nil uses local time.
Location *time.Location
Disabled bool
}
var statusClear = []struct {
key string
after time.Duration
}{
{"status_setter.never", 0}, {"status_setter.30m", 30 * time.Minute}, {"status_setter.1h", time.Hour},
{"status_setter.4h", 4 * time.Hour}, {"status_setter.today", -1}, {"status_setter.week", -2},
}
type statusSetterState struct {
picking bool
clear int
until time.Time
}
type statusSetterKey struct{}
// StatusSetter edits *st's emoji and text, and when it clears.
func StatusSetter(c *ui.Context, st *CustomStatus, opts StatusSetterOptions) core.ChoiceResult {
loc := opts.Location
if loc == nil {
loc = time.Local
}
k := core.Tokens(c)
u := core.Density(c).Unit()
old := *st
col := ui.Column(c).Gap(u * 2).Role(ui.RoleGroup).Label(core.Msg(c, "status_setter.label")).Disabled(opts.Disabled)
ss := ui.Local(col, statusSetterKey{}, func() *statusSetterState { return &statusSetterState{} })
s := *ss
live := !col.IsDisabled()
if !live {
copy := *st
st = &copy
}
if !st.Until.Equal(s.until) {
s.until = st.Until
s.clear = 0
if !st.Until.IsZero() {
s.clear = -1
}
}
col.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
face := st.Emoji
if face == "" {
face = "🙂"
}
b := ui.ButtonBase(c).Size(core.Density(c).ControlHeight(), core.Density(c).ControlHeight()).Radius(theme.ControlRadius).
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(core.Msg(c, "status_setter.emoji"))
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() { ui.Text(c, face).FontSize(core.FontSize(c, 18)) })
if b.Clicked() && !b.IsDisabled() {
s.picking = !s.picking
}
if b.IsDisabled() {
s.picking = false
}
overlay.Popover(c, b, &s.picking, overlay.PopoverOptions{Label: core.Msg(c, "emoji_picker.label")}, func() {
if e := EmojiPicker(c, EmojiPickerOptions{}); e.Picked != "" {
st.Emoji, s.picking = e.Picked, false
}
})
input.TextInput(c, &st.Text, input.TextInputOptions{Placeholder: core.Msg(c, "status_setter.placeholder"), Label: core.Msg(c, "status_setter.text"),
Clearable: true, Disabled: opts.Disabled}).Element.Grow(1)
})
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "status_setter.clearAfter")).TextColor(k.TextMuted).SingleLine().Shrink(0)
options := make([]input.SelectOption[int], len(statusClear))
for i, o := range statusClear {
options[i] = input.SelectOption[int]{Value: i, Label: core.Msg(c, o.key)}
}
ui.Box(c).Key(live).Children(func() {
if input.Select(c, &s.clear, options, input.SelectOptions{Label: core.Msg(c, "status_setter.clearAfter"), Placeholder: core.MessagingDate(c, st.Until.In(loc), "status_setter.until"), Disabled: !live}).Changed() {
st.Until = statusUntil(c.Now().In(loc), statusClear[s.clear].after)
}
})
if !st.Until.IsZero() {
ui.Text(c, core.MessagingDate(c, st.Until.In(loc), "status_setter.until")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
ui.Box(c).Grow(1)
if input.Button(c, core.Msg(c, "status_setter.clear"), input.ButtonOptions{Variant: input.Ghost, Disabled: *st == CustomStatus{}}).Clicked() {
*st, s.clear = CustomStatus{}, 0
}
})
})
s.until = st.Until
changed := live && *st != old
if changed {
slog.Debug("mujica: status_setter changed", "emoji", st.Emoji, "until", st.Until)
}
return core.NewChoiceResult(col, changed)
}
func statusUntil(now time.Time, d time.Duration) time.Time {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
switch {
case d == 0:
return time.Time{}
case d == -1:
return day.AddDate(0, 0, 1)
case d == -2:
// Weeks end on Sunday night: the next Monday at midnight.
add := (8 - int(day.Weekday())) % 7
if add == 0 {
add = 7
}
return day.AddDate(0, 0, add)
}
return now.Add(d)
}
+107
View File
@@ -0,0 +1,107 @@
package messaging
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestStatusUntil(t *testing.T) {
wed := time.Date(2026, 10, 7, 14, 30, 0, 0, time.UTC)
for d, want := range map[time.Duration]time.Time{
0: {},
time.Hour: wed.Add(time.Hour),
-1: time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC),
-2: time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC),
30 * time.Minute: wed.Add(30 * time.Minute),
} {
if got := statusUntil(wed, d); !got.Equal(want) {
t.Errorf("%v: %v, want %v", d, got, want)
}
}
}
func TestStatusSetterEditAndClear(t *testing.T) {
st := CustomStatus{}
n := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(20).Width(420).Children(func() {
if StatusSetter(c, &st, StatusSetterOptions{Location: time.UTC}).Changed() {
n++
}
})
}, 520, 600)
if err := tt.Click("Choose an emoji"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Activities"); err != nil {
t.Fatal(err)
}
if err := tt.Click("violin"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Status text"); err != nil {
t.Fatal(err)
}
tt.Type("Rehearsing")
if err := tt.Click("Don't clear"); err != nil {
t.Fatal(err)
}
if err := tt.Click("1 hour"); err != nil {
t.Fatal(err)
}
if st.Emoji != "🎻" || st.Text != "Rehearsing" || st.Until.IsZero() || n < 3 {
t.Fatalf("status %+v changes %d", st, n)
}
if err := tt.Click("Clear status"); err != nil {
t.Fatal(err)
}
if st != (CustomStatus{}) {
t.Fatalf("not cleared: %+v", st)
}
}
func TestStatusSetterSnapshot(t *testing.T) {
st := CustomStatus{Emoji: "🎻", Text: "Rehearsing the second act"}
snapshot(t, "status_setter", 460, 140, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(420).Children(func() { StatusSetter(c, &st, StatusSetterOptions{Location: time.UTC}) })
})
}
func TestStatusSetterLoadedExpiryAndDisabledPopup(t *testing.T) {
st := CustomStatus{Until: time.Date(2026, 10, 6, 15, 0, 0, 0, time.UTC)}
disabled := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Disabled(disabled).Children(func() { StatusSetter(c, &st, StatusSetterOptions{Location: time.UTC}) })
}, 600, 400)
if tt.HasText("Don't clear") {
t.Fatal("loaded expiry appears as never")
}
if err := tt.Click("Tue 15:00"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Don't clear"); err != nil {
t.Fatal(err)
}
if !st.Until.IsZero() {
t.Fatal("loaded expiry was not cleared")
}
if err := tt.Click("Don't clear"); err != nil {
t.Fatal(err)
}
disabled = true
tt.Frame()
if tt.HasText("30 minutes") {
t.Fatal("disabled expiry popup remains visible")
}
disabled = false
tt.Frame()
if tt.HasText("30 minutes") {
t.Fatal("expiry popup reopened after enabling")
}
}
+104
View File
@@ -0,0 +1,104 @@
package messaging
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chat"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ThreadPanelOptions configure a ThreadPanel.
type ThreadPanelOptions struct {
// Width is the panel's width; zero means 360.
Width float32
Disabled bool
}
// ThreadPanelResult is the panel's element and this frame's events.
type ThreadPanelResult struct {
Element *ui.Element
// Sent is a reply the user sent; empty is none.
Sent string
}
type threadState struct {
list chat.MessageListState
draft string
}
type threadKey struct{}
// ThreadPanel displays a root message, replies and an editor.
func ThreadPanel(c *ui.Context, open *bool, root ChatEntry, replies []ChatEntry, opts ThreadPanelOptions) ThreadPanelResult {
var r ThreadPanelResult
if opts.Width < 0 {
panic("mujica: ThreadPanel Width must not be negative")
}
w := opts.Width
if w == 0 {
w = 360
}
ww, _ := c.Size()
w = min(w, ww)
k := core.Tokens(c)
u := core.Density(c).Unit()
panel := ui.Column(c).Width(w).FillHeight().Shrink(0).Background(k.Surface).BorderWidth(0, 0, 0, 1).
BorderColor(k.Border).Role(ui.RoleGroup).Label(core.Msg(c, "thread_panel.title")).Disabled(opts.Disabled)
if panel.IsDisabled() {
*open = false
}
if !*open {
panel.Invisible()
return r
}
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.BorderWidth(0, 0, 0, 1).BorderColor(k.Border)
r.Element = panel
st := ui.Local(panel, threadKey{}, func() *threadState { return &threadState{} })
s := *st
panel.Children(func() {
ui.Row(c).Padding(u*3, u*3, u*2, u*4).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "thread_panel.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
if core.IconAction(c, msgClose, core.Msg(c, "thread_panel.close")).Clicked() {
*open = false
}
})
ui.Scroll(c).MaxHeight(160).Shrink(1).Padding(0, u*2).Children(func() { ChatMessage(c, root, ChatMessageOptions{}) })
ui.Row(c).Gap(u*2).Padding(u*2, u*4).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "thread_panel.replies"), len(replies))).FontSize(core.FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).SingleLine()
ui.Box(c).Height(1).Grow(1).Background(k.Border)
})
chat.MessageList(c, &s.list, len(replies), chat.MessageListOptions{
ID: func(i int) string { return replies[i].ID },
Label: func(i int) string { return replies[i].Author + ": " + replies[i].Text },
}, func(i int) {
ChatMessage(c, replies[i], ChatMessageOptions{Continued: i > 0 && ChatContinues(replies[i-1], replies[i])})
})
ui.Row(c).Gap(u*2).Padding(u*3).AlignItems(ui.Center).BorderWidth(1, 0, 0, 0).BorderColor(k.Border).Children(func() {
in := input.TextInput(c, &s.draft, input.TextInputOptions{Placeholder: core.Msg(c, "thread_panel.reply"), Label: core.Msg(c, "thread_panel.reply"), Disabled: opts.Disabled})
in.Element.Grow(1)
send := input.IconButton(c, msgSend, core.Msg(c, "thread_panel.send"), input.IconButtonOptions{Disabled: strings.TrimSpace(s.draft) == ""})
if (in.Submitted() || send.Clicked()) && strings.TrimSpace(s.draft) != "" && !panel.IsDisabled() {
r.Sent, s.draft = strings.TrimSpace(s.draft), ""
s.list.ScrollToEnd()
}
})
})
if !panel.IsDisabled() && panel.Shortcut(0, ui.KeyEscape) {
*open = false
}
switch {
case r.Sent != "":
slog.Debug("mujica: thread_panel send", "len", len(r.Sent))
case !*open:
slog.Debug("mujica: thread_panel close")
}
return r
}
+79
View File
@@ -0,0 +1,79 @@
package messaging
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestThreadPanelReplyAndClose(t *testing.T) {
at := time.Date(2026, 10, 5, 20, 0, 0, 0, time.UTC)
root := ChatEntry{ID: "r", Author: "Sakiko", Time: at, Text: "Set list for Friday?"}
replies := []ChatEntry{{ID: "1", Author: "Uika", Time: at.Add(time.Minute), Text: "Overture first."}}
open := true
var sent []string
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Fill().Children(func() {
ui.Box(c).Grow(1)
if r := ThreadPanel(c, &open, root, replies, ThreadPanelOptions{}); r.Sent != "" {
sent = append(sent, r.Sent)
}
})
}, 600, 600)
if !tt.HasText("Set list for Friday?") || !tt.HasText("1 replies") || !tt.HasText("Overture first.") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Reply in thread"); err != nil {
t.Fatal(err)
}
tt.Type("Then the masquerade.")
tt.Key(0, ui.KeyEnter)
if len(sent) != 1 || sent[0] != "Then the masquerade." {
t.Fatalf("sent %q", sent)
}
tt.Key(0, ui.KeyEscape)
if open {
t.Fatal("Escape did not close")
}
}
func TestThreadPanelSnapshot(t *testing.T) {
at := time.Date(2026, 10, 5, 20, 0, 0, 0, time.UTC)
open := true
root := ChatEntry{ID: "r", Author: "Sakiko", Time: at, Text: "Set list for Friday?"}
replies := []ChatEntry{{ID: "1", Author: "Uika", Time: at.Add(time.Minute), Text: "Overture first."},
{ID: "2", Author: "Uika", Time: at.Add(2 * time.Minute), Text: "Then the masquerade."}}
snapshot(t, "thread_panel", 400, 560, func(c *ui.Context) {
ui.Row(c).Fill().Children(func() { ThreadPanel(c, &open, root, replies, ThreadPanelOptions{}) })
})
}
func TestThreadPanelFitsWindowAndDisabled(t *testing.T) {
open, disabled := true, false
root := ChatEntry{ID: "root", Author: "Author", Text: "Question"}
sent := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Row(c).Fill().Children(func() {
if ThreadPanel(c, &open, root, nil, ThreadPanelOptions{Disabled: disabled}).Sent != "" {
sent++
}
})
}, 280, 400)
r, ok := tt.Find("Thread")
if !ok || r.X < 0 || r.X+r.W > 280 {
t.Fatalf("thread bounds %+v", r)
}
disabled = true
tt.Frame()
_ = tt.Click("Reply in thread")
tt.Type("Blocked")
tt.Key(0, ui.KeyEnter)
if open || sent != 0 {
t.Fatal("disabled thread stayed open or sent")
}
}
+32
View File
@@ -0,0 +1,32 @@
package messaging
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// UnreadDivider marks where unread messages begin; count zero shows "New".
func UnreadDivider(c *ui.Context, count int) *ui.Element {
if count < 0 {
panic("mujica: UnreadDivider count must not be negative")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
text := core.Msg(c, "unread_divider.new")
if count > 0 {
text = fmt.Sprintf(core.Msg(c, "unread_divider.count"), count)
}
row := ui.Row(c).FillWidth().Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Role(ui.RoleStatus).Label(text)
row.Children(func() {
line := func() { ui.Box(c).Height(1).Grow(1).Background(k.Danger) }
line()
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.Danger).SingleLine().
Padding(0, u*2).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Danger)
line()
})
return row
}
+29
View File
@@ -0,0 +1,29 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestUnreadDivider(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Children(func() {
UnreadDivider(c, 12)
UnreadDivider(c, 0)
})
}, 400, 120)
if !tt.HasText("12 new messages") || !tt.HasText("New") {
t.Fatalf("texts %v", tt.Texts())
}
mediaPanics(t, "negative", func(c *ui.Context) { UnreadDivider(c, -1) })
}
func TestUnreadDividerSnapshot(t *testing.T) {
snapshot(t, "unread_divider", 420, 70, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() { UnreadDivider(c, 3) })
})
}
+79
View File
@@ -0,0 +1,79 @@
package messaging
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// UserProfile is the person a UserProfileCard shows.
type UserProfile struct {
// Name is required.
Name string
Image *ui.Bitmap
Presence PresenceStatus
// Status is a custom status line, as "In a rehearsal".
Status string
// Role, as "Admin"; empty shows none.
Role string
// About is a short description.
About string
}
// UserProfileCardOptions configure a UserProfileCard.
type UserProfileCardOptions struct {
// Actions builds buttons at the bottom, as Message or Call.
Actions func()
// Width is the card's width; zero means 300.
Width float32
}
// UserProfileCard displays identity, presence, role and action slots.
func UserProfileCard(c *ui.Context, prof UserProfile, opts UserProfileCardOptions) *ui.Element {
if prof.Name == "" {
panic("mujica: UserProfileCard needs a Name")
}
if opts.Width < 0 {
panic("mujica: UserProfileCard Width must not be negative")
}
w := opts.Width
if w == 0 {
w = 300
}
k := core.Tokens(c)
u := core.Density(c).Unit()
card := ui.Column(c).Width(w).Padding(u*4).Gap(u*3).Radius(theme.CardRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(core.Msg(c, "user_profile_card.label") + ": " + prof.Name)
card.Children(func() {
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
ui.Box(c).Shrink(0).Children(func() {
display.Avatar(c, prof.Name, display.AvatarOptions{Image: prof.Image, Size: 56})
ui.Box(c).Absolute().Right(-1).Bottom(-1).Padding(2).Radius(8).Background(k.Surface).Children(func() {
OnlineStatus(c, prof.Presence, OnlineStatusOptions{HideText: true, Size: 12})
})
})
ui.Column(c).Gap(u).MinWidth(0).Grow(1).Children(func() {
ui.Text(c, prof.Name).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).SingleLine()
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() {
OnlineStatus(c, prof.Presence, OnlineStatusOptions{})
if prof.Role != "" {
display.Badge(c, display.BadgeOptions{Text: prof.Role, Tone: display.BadgeNeutral}, nil)
}
})
})
})
if prof.Status != "" {
ui.Text(c, prof.Status).MaxLines(2)
}
if prof.About != "" {
ui.Text(c, prof.About).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).MaxLines(4)
}
if opts.Actions != nil {
ui.Box(c).Height(1).Background(k.Border)
ui.Row(c).Gap(u * 2).Wrap().Children(opts.Actions)
}
})
return card
}
+46
View File
@@ -0,0 +1,46 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestUserProfileCard(t *testing.T) {
clicked := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
UserProfileCard(c, UserProfile{Name: "Sakiko Togawa", Presence: PresenceBusy, Role: "Keyboard", Status: "In rehearsal"},
UserProfileCardOptions{Actions: func() {
if input.Button(c, "Message", input.ButtonOptions{}).Clicked() {
clicked++
}
}})
}, 400, 300)
for _, s := range []string{"Sakiko Togawa", "Busy", "Keyboard", "In rehearsal"} {
if !tt.HasText(s) {
t.Fatalf("no %q in %v", s, tt.Texts())
}
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if clicked != 1 {
t.Fatal("action slot not reachable by keyboard")
}
mediaPanics(t, "no name", func(c *ui.Context) { UserProfileCard(c, UserProfile{}, UserProfileCardOptions{}) })
}
func TestUserProfileCardSnapshot(t *testing.T) {
snapshot(t, "user_profile_card", 360, 260, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() {
UserProfileCard(c, UserProfile{Name: "Sakiko Togawa", Presence: PresenceOnline, Role: "Keyboard", Status: "Composing the second act",
About: "Founder of the masquerade."}, UserProfileCardOptions{Actions: func() {
input.Button(c, "Message", input.ButtonOptions{})
input.Button(c, "Call", input.ButtonOptions{Variant: input.Secondary})
}})
})
})
}
+110
View File
@@ -0,0 +1,110 @@
package messaging
import (
"math"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/media"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CallParticipant supplies identity, frames and call state.
type CallParticipant struct {
ID, Name string
// Frame is their video; nil shows their avatar.
Frame ui.ImageSource
Image *ui.Bitmap
Muted bool
CameraOff bool
Speaking bool
}
// VideoCallGridOptions configure a VideoCallGrid.
type VideoCallGridOptions struct {
// Columns fixes the column count; zero fits a near-square grid.
Columns int
}
// VideoCallGrid displays participant frames and speaker marks.
func VideoCallGrid(c *ui.Context, people []CallParticipant, opts VideoCallGridOptions) *ui.Element {
if opts.Columns < 0 {
panic("mujica: VideoCallGrid Columns must not be negative")
}
seen := map[string]bool{}
for _, p := range people {
if p.ID == "" || p.Name == "" || seen[p.ID] {
panic("mujica: VideoCallGrid participants need unique IDs and names: " + p.ID)
}
seen[p.ID] = true
}
cols := opts.Columns
if cols == 0 {
cols = max(1, int(math.Ceil(math.Sqrt(float64(len(people))))))
}
k := core.Tokens(c)
u := core.Density(c).Unit()
tracks := make([]ui.Track, cols)
for i := range tracks {
tracks[i] = ui.Fr(1)
}
grid := ui.Box(c).Grid().ColumnTracks(tracks...).Gap(u * 2).FocusGroup(ui.Horizontal | ui.Vertical).
Role(ui.RoleGroup).Label(core.Msg(c, "video_call_grid.label"))
grid.Children(func() {
for _, p := range people {
callTile(c, k, p)
}
})
return grid
}
func callTile(c *ui.Context, k theme.Tokens, p CallParticipant) {
u := core.Density(c).Unit()
var notes []string
if p.Speaking {
notes = append(notes, core.Msg(c, "video_call_grid.speaking"))
}
if p.Muted {
notes = append(notes, core.Msg(c, "video_call_grid.muted"))
}
if p.CameraOff {
notes = append(notes, core.Msg(c, "video_call_grid.cameraOff"))
}
label := p.Name
if len(notes) > 0 {
label += ", " + strings.Join(notes, ", ")
}
tile := ui.Box(c).Key(p.ID).AspectRatio(16.0 / 9).Radius(theme.CardRadius).Clip().Background(media.VideoBlack).Center().
Focusable().Role(ui.RoleImage).Label(label)
internal.Ring(tile, k.Focus, theme.CardRadius)
light := ui.Hex("#F2EBDD")
tile.Children(func() {
if p.Frame != nil && !p.CameraOff {
ui.Image(c, p.Frame).Fit(ui.Cover).Absolute().Left(0).Top(0).Right(0).Bottom(0)
} else {
display.Avatar(c, p.Name, display.AvatarOptions{Image: p.Image, Size: 56})
}
ui.Row(c).Absolute().Left(u*2).Bottom(u*2).Gap(u).AlignItems(ui.Center).Padding(2, u*1.5).
Radius(theme.ControlRadius).Background(ui.RGBA(0, 0, 0, 0.6)).TextColor(light).Children(func() {
if p.Muted {
ui.Icon(c, msgMicOff).Size(13, 13).TextColor(ui.Hex("#F0A1AA"))
}
if p.Speaking {
ui.Icon(c, msgSpeaking).Size(13, 13)
}
ui.Text(c, p.Name).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
})
})
if p.Speaking {
tile.DrawOver(func(pt *ui.Painter, r ui.Rect) {
pt.Stroke(ui.Rect{X: r.X + 1.5, Y: r.Y + 1.5, W: r.W - 3, H: r.H - 3}, k.AccentText, theme.CardRadius, 3)
if tile.FocusVisible() {
internal.FocusRing(pt, r, theme.CardRadius, k.Focus)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package messaging
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestVideoCallGridTilesAndSpeaker(t *testing.T) {
people := []CallParticipant{{ID: "1", Name: "Sakiko", Speaking: true}, {ID: "2", Name: "Uika", Muted: true, Frame: mediaTestBitmap(32, 18)},
{ID: "3", Name: "Mutsumi", CameraOff: true}, {ID: "4", Name: "Umiri"}, {ID: "5", Name: "Nyamu"}}
var rects []ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Width(600).Children(func() { VideoCallGrid(c, people, VideoCallGridOptions{}) })
}, 700, 600)
for _, name := range []string{"Sakiko, speaking", "Uika, muted", "Mutsumi, camera off", "Umiri", "Nyamu"} {
r, ok := tt.Find(name)
if !ok {
t.Fatalf("no tile %q in %v", name, tt.Texts())
}
rects = append(rects, r)
}
if rects[3].Y <= rects[0].Y || rects[1].Y != rects[0].Y || rects[2].Y != rects[0].Y {
t.Fatalf("tiles %v", rects)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if !tt.Focused("Uika, muted") {
t.Fatal("arrows do not move between tiles")
}
mediaPanics(t, "duplicate", func(c *ui.Context) {
VideoCallGrid(c, []CallParticipant{{ID: "a", Name: "A"}, {ID: "a", Name: "B"}}, VideoCallGridOptions{})
})
}
func TestVideoCallGridSnapshot(t *testing.T) {
people := []CallParticipant{{ID: "1", Name: "Sakiko", Speaking: true, Frame: mediaTestBitmap(160, 90)}, {ID: "2", Name: "Uika", Muted: true},
{ID: "3", Name: "Mutsumi", CameraOff: true}, {ID: "4", Name: "Umiri", Frame: mediaTestBitmap(90, 160)}}
snapshot(t, "video_call_grid", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(480).Children(func() { VideoCallGrid(c, people, VideoCallGridOptions{}) })
})
}