Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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()) })
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package messaging holds MujicaUI messaging, calls and mail.
|
||||
package messaging
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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, " ", "_"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"/>`)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user