Merge branch 'worktree-agent-a1dd614394ee20ff2'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:52:46 -04:00
commit 4a8a35114d
116 files changed
+6484 -9

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"log/slog"
"runtime"
"github.com/egoist/mygo/ui"
"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, _ := 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 := Tokens(c)
u := 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(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, msg(c, offKey)
if state {
icon, label = on, msg(c, onKey)
}
b := mediaToggleAction(c, icon, label, state).Size(40, 40).Tooltip(label + " (" + key.String() + ")")
if live && b.Clicked() {
report(name, !state, fn)
}
}
toggle(mediaMic, msgMicOff, "call_controls.mute", "call_controls.unmute", st.Muted, callMute, "mute", opts.OnMute)
toggle(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 := Button(c, msg(c, "call_controls.hangUp"), ButtonOptions{Variant: Danger, Icon: msgHangUp}).
Tooltip(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
}
+61
View File
@@ -0,0 +1,61 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestCallControlsClicksAndShortcuts(t *testing.T) {
st := CallState{Muted: true}
var k callCalls
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
CallControls(c, st, k.opts())
Button(c, "Elsewhere", 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) {
Use(c, 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()) })
})
}
+216
View File
@@ -0,0 +1,216 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 Presence
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) ChoiceResult {
if opts.Height < 0 {
panic("mujica: ChannelList Height must be nonnegative")
}
k := Tokens(c)
d := 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(msg(c, "channel_list.label"))
})
changed := *selected != old
if changed {
slog.Debug("mujica: channel_list select", "id", *selected)
}
return ChoiceResult{Element: col, changed: 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 := Density(c).Unit()
b := ui.ButtonBase(c).MinHeight(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(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(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 := Density(c)
u := d.Unit()
label := ch.Name
if ch.Unread > 0 {
label = fmt.Sprintf(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(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(msg(c, "channel_list.pinned"))
}
if ch.Muted {
ui.Icon(c, msgBellOff).Size(13, 13).TextColor(k.TextMuted).Label(msg(c, "channel_list.muted"))
}
if ch.Unread > 0 {
tone := BadgeAccent
if ch.Muted {
tone = BadgeNeutral
}
Badge(c, BadgeOptions{Count: ch.Unread, Tone: tone}, nil)
}
})
return b.Clicked()
}
+67
View File
@@ -0,0 +1,67 @@
package mujica
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{}) })
})
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"time"
"github.com/egoist/mygo/ui"
"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 := Tokens(c)
u := Density(c).Unit()
role := MessageAssistant
if m.Own {
role = MessageUser
}
bo := MessageBubbleOptions{}
const avatar = 32
if !opts.Continued {
bo.Name = m.Author
bo.Avatar = func() { Avatar(c, m.Author, AvatarOptions{Image: m.Image, Size: avatar}) }
} else {
bo.Avatar = func() { ui.Box(c).Width(avatar).Shrink(0) }
}
e := 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(msg(c, "chat_message.time"))).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
}
if m.Edited {
ui.Text(c, msg(c, "chat_message.edited")).FontSize(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
}
+60
View File
@@ -0,0 +1,60 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, Settings{})
ui.Column(c).Width(500).Children(func() {
ChatMessage(c, ChatEntry{ID: "1", Author: "Uika", Time: at, Text: "Rehearsal at nine."}, ChatMessageOptions{
Reply: func() { QuoteReply(c, "Sakiko", "When do we start?") },
Reactions: func() { Tag(c, "👍 2", 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() { 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{})
})
})
}
+42
View File
@@ -0,0 +1,42 @@
# 401 ChannelList 频道列表
## 用途
展示频道与私信、未读计数、静音和置顶标记,支持折叠分组与频道选择。
## 最小示例
```go
groups := []mujica.ChannelGroup{{ID: "ch", Title: "Channels", Channels: []mujica.Channel{
{ID: "general", Name: "general", Unread: 3},
{ID: "rehearsal", Name: "rehearsal", Pinned: true},
}}}
selected := "general"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ChannelList(c, &selected, groups, mujica.ChannelListOptions{}).Changed() {
_ = selected // show that channel
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`selected *string` 保存频道 ID;`groups []ChannelGroup` 提供分组。分组 ID、频道 ID 必须唯一,名称必填,`Unread` ≥ 0。`Direct`、`Presence`、`Muted`、`Pinned` 控制展示。`Height` 默认 360 DIP;`Disabled` 默认 false;`Revision` 默认 0,原地修改分组或频道数据后递增以刷新缓存。替换分组切片也会刷新。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Changed()` 表示选择 ID 发生变化;分组展开状态在组件内保存。
## 键盘操作
Tab 进入;上下方向键切换行;Enter / Space 选择或折叠;分组标题上的左、右方向键折叠、展开。
## 限制
业务连接、静音设置与置顶持久化由调用方维护。分组排序与校验在数据或 Revision 变化时进行,可见行由 MyGo List 构建。
+41
View File
@@ -0,0 +1,41 @@
# 402 ChatMessage 聊天消息
## 用途
展示连续聊天消息、发送时间、编辑标记,以及调用方提供的回复和回应插槽。
## 最小示例
```go
prev := mujica.ChatEntry{ID: "1", Author: "Uika", Time: time.Now(), Text: "Rehearsal at nine."}
m := mujica.ChatEntry{ID: "2", Author: "Uika", Time: time.Now(), Text: "Bring the masks.", Edited: true}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ChatMessage(c, prev, mujica.ChatMessageOptions{})
mujica.ChatMessage(c, m, mujica.ChatMessageOptions{
Continued: mujica.ChatContinues(prev, m),
Reactions: func() { mujica.Tag(c, "👍 2", mujica.TagOptions{}) },
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`ChatEntry` 包含 `ID`、必填的 `Author`、`Image`、`Time`、`Text`、`Edited`、`Own`。`ChatMessageOptions.Continued` 默认 false;`Reply` 与 `Reactions` 为可选插槽。`ChatContinues(prev, cur)` 要求同一作者、相同 Own、间隔非负且小于五分钟。
## 状态
状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
## 事件
插槽中的按钮、链接和回应事件由调用方处理。
## 键盘操作
可选文字支持选择复制;插槽控件按 Tab 访问。
## 限制
调用方计算 Continued 或使用 ChatContinues;回复与回应的业务逻辑在插槽中实现。
+35
View File
@@ -0,0 +1,35 @@
# 403 UnreadDivider 未读分隔线
## 用途
标记新消息开始位置,并朗读完整未读计数。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UnreadDivider(c, 3)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`count int` 必须 ≥ 0;0 显示“新消息”,正值显示完整计数。
## 状态
状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
## 事件
纯展示,计数由调用方更新。
## 键盘操作
展示组件保持自然焦点序列。
## 限制
组件展示调用方提供的计数。
+36
View File
@@ -0,0 +1,36 @@
# 404 ReadReceipt 已读回执
## 用途
通过勾选标记与文字展示发送进度,已读时显示多人头像。
## 最小示例
```go
readers := []mujica.AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ReadReceipt(c, mujica.ReceiptRead, mujica.ReadReceiptOptions{Readers: readers})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`state ReceiptState` 为 `ReceiptSent`、`ReceiptDelivered`、`ReceiptRead`,零值表示已发送。`Readers []AvatarItem` 提供已读头像;`Max` 默认 3,必须 ≥ 0。
## 状态
状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
## 事件
纯展示,回执进度与读者由调用方更新。
## 键盘操作
展示组件保持自然焦点序列。
## 限制
已读数据由调用方提供;头像超过 Max 时显示剩余数量。
+42
View File
@@ -0,0 +1,42 @@
# 405 MemberList 成员列表
## 用途
将成员分成在线、离线两组,展示头像、状态、角色并提供选择与打开事件。
## 最小示例
```go
var s mujica.MemberListState
members := []mujica.Member{
{ID: "1", Name: "Sakiko", Presence: mujica.PresenceOnline, Role: "Leader"},
{ID: "2", Name: "Umiri", Presence: mujica.PresenceOffline},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MemberList(c, &s, members, mujica.MemberListOptions{}); r.Opened != "" {
_ = r.Opened // show that member's profile
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`MemberListState` 保存 `Selected` 和滚动位置;`members []Member` 的 ID 唯一、姓名必填。`Image`、`Presence`、`Role` 控制展示。`Height` 默认 360 DIP;`Disabled` 默认 false。修改在线分组时替换成员切片,缓存仅在切片变化时重建。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Changed()` 表示选中成员变化;`Opened` 返回 Enter 或双击打开的成员 ID。
## 键盘操作
Tab 聚焦列表;上下方向键选择;Home / End 首尾;Enter 或双击打开成员。
## 限制
分组缓存依据切片身份与长度;成员数据变化后替换切片。
+38
View File
@@ -0,0 +1,38 @@
# 406 UserProfileCard 用户资料卡
## 用途
集中展示用户头像、姓名、在线状态、角色、自定义状态与操作插槽。
## 最小示例
```go
profile := mujica.UserProfile{Name: "Sakiko Togawa", Presence: mujica.PresenceBusy, Role: "Keyboard", Status: "In rehearsal"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UserProfileCard(c, profile, mujica.UserProfileCardOptions{Actions: func() {
mujica.Button(c, "Message", mujica.ButtonOptions{})
}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`UserProfile.Name` 必填;`Image`、`Presence`、`Role`、`Status`、`About` 提供展示内容。`UserProfileCardOptions.Width` 默认 300 DIP,必须 ≥ 0;`Actions func()` 提供可选操作。
## 状态
状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
## 事件
操作由 `Actions` 插槽中的控件返回。
## 键盘操作
Tab 访问操作插槽;Enter / Space 激活按钮。
## 限制
业务动作、头像加载与个人资料更新由调用方处理。
+35
View File
@@ -0,0 +1,35 @@
# 407 OnlineStatus 在线状态
## 用途
用圆形、月牙、横杠圆形和空心圆配合文字表示四种在线状态。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.OnlineStatus(c, mujica.PresenceAway, mujica.OnlineStatusOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`p Presence` 的零值为离线,支持 `PresenceOnline`、`PresenceAway`、`PresenceBusy`、`PresenceOffline`。`HideText` 默认 false;隐藏文字时保留名称和提示。`Size` 默认 10 DIP,必须 ≥ 0。
## 状态
状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
## 事件
纯展示,在线状态由调用方更新。
## 键盘操作
展示组件保持自然焦点序列。
## 限制
在线状态由调用方提供;隐藏文字仍保留状态形状和无障碍名称。
+38
View File
@@ -0,0 +1,38 @@
# 408 StatusSetter 状态设置
## 用途
编辑自定义状态文字和表情,选择清除期限。
## 最小示例
```go
status := mujica.CustomStatus{Emoji: "🎻", Text: "Rehearsing"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StatusSetter(c, &status, mujica.StatusSetterOptions{}).Changed() {
_ = status // publish it; clear it once status.Until passes
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`CustomStatus` 保存 `Emoji`、`Text`、`Until`;零 `Until` 表示持续保留。`Location` 默认 `time.Local`;`Disabled` 默认 false。清除选项为持续保留、30 分钟、1 小时、4 小时、当天结束、本周结束。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Changed()` 表示表情、文字或清除期限改变。调用方载入已有期限时显示实际时间,选择持续保留可清除期限。
## 键盘操作
Tab 访问表情、文字、期限和清除按钮;选择器用方向键移动、Enter 确认、Escape 关闭。
## 限制
Until 作为业务期限返回,调用方负责届时清除状态;组件保持空闲。
+41
View File
@@ -0,0 +1,41 @@
# 409 CallControls 通话控制
## 用途
提供静音、摄像头、屏幕共享和挂断操作,状态由调用方回传。
## 最小示例
```go
st := mujica.CallState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CallControls(c, st, mujica.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)
```
## 参数
`CallState` 包含 `Muted`、`CameraOff`、`Sharing`。`OnMute`、`OnCamera`、`OnShare`、`OnHangUp` 均必填。摄像头回调参数表示是否关闭。`Disabled` 默认 false。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
每次操作调用对应回调一次;调用方回传更新后的 CallState。
## 键盘操作
Tab 进入工具栏;左右方向键移动;Enter / Space 激活。Cmd/Ctrl+D 静音,Cmd/Ctrl+E 摄像头,Cmd/Ctrl+Shift+S 共享,Cmd/Ctrl+Shift+H 挂断。
## 限制
通话、设备和屏幕采集由调用方处理;快捷键作用于当前窗口。
+39
View File
@@ -0,0 +1,39 @@
# 410 VideoCallGrid 视频通话宫格
## 用途
按宫格显示调用方提供的视频帧,标记正在发言和静音的参与者。
## 最小示例
```go
people := []mujica.CallParticipant{
{ID: "1", Name: "Sakiko", Speaking: true}, // Frame: the latest decoded frame
{ID: "2", Name: "Uika", Muted: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoCallGrid(c, people, mujica.VideoCallGridOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`people []CallParticipant` 的 ID 唯一、Name 必填;`Frame ui.ImageSource` 提供视频帧,`Image` 提供头像。`Muted`、`CameraOff`、`Speaking` 控制状态。`Columns` 默认 0,自动选择列数,必须 ≥ 0。
## 状态
状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
## 事件
纯展示,帧和参与者状态由调用方更新。
## 键盘操作
Tab 进入宫格;方向键移动参与者焦点并读取状态。
## 限制
视频解码、采集与帧更新由调用方处理,更新后调用 c.Invalidate()。
@@ -0,0 +1,39 @@
# 411 IncomingCallDialog 来电对话框
## 用途
显示语音或视频来电,提供接听、拒绝与可配置的初始焦点。
## 最小示例
```go
ringing := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.IncomingCallDialog(c, &ringing, mujica.IncomingCall{Caller: "Sakiko", Video: true}, mujica.IncomingCallOptions{
OnAccept: func(video bool) {}, // join, with the camera on when video
OnDecline: func() {},
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`open *bool` 控制显示;`IncomingCall.Caller` 必填,`Image` 为头像,`Video` 默认 false。`OnAccept func(video bool)` 与 `OnDecline func()` 必填。`FocusAccept` 默认 false,初始聚焦拒绝;`Disabled` 默认 false,禁用时关闭。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
接听调用 `OnAccept`,视频接听参数为 true;拒绝和 Escape 调用 `OnDecline`。完成后设置 `*open=false`。
## 键盘操作
默认焦点为拒绝;Tab 循环访问操作;Enter / Space 激活;Escape 拒绝;关闭后恢复焦点。
## 限制
来电数据和通话连接由调用方处理;点击遮罩保持来电显示;禁用关闭时回调保持静默。
+39
View File
@@ -0,0 +1,39 @@
# 412 EmojiPicker 表情选择器
## 用途
按分类、搜索与最近使用选择表情,并给支持肤色的表情添加肤色修饰符。
## 最小示例
```go
var recent []string
tone := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.EmojiPicker(c, mujica.EmojiPickerOptions{Recent: &recent, Tone: &tone}); r.Picked != "" {
_ = r.Picked // insert it
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`EmojiPickerOptions.Recent *[]string` 保存最近选择,选择时去重并保留最新 24 个;`Tone *int` 可选,范围 0–5;`Disabled` 默认 false。返回 `EmojiResult` 的命名字段 `Element` 与 `Picked`。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Picked` 是本帧选择的表情;选择时同时更新 Recent,肤色按钮更新 Tone。
## 键盘操作
Tab 访问搜索、肤色、分类、表情;方向键在组内移动;Enter / Space 选择。
## 限制
内置常用表情子集;对支持肤色的条目应用修饰符。
+39
View File
@@ -0,0 +1,39 @@
# 413 PinnedMessages 置顶消息
## 用途
展示虚拟化置顶消息列表,发出跳转和取消置顶事件。
## 最小示例
```go
pins := []mujica.ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PinnedMessages(c, pins, mujica.PinnedMessagesOptions{})
if r.Unpinned != "" {
pins = pins[:0] // unpin it
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`pins []ChatEntry` 的 ID 与 Author 必填。`Height` 默认 320 DIP,必须 ≥ 0;`Disabled` 默认 false。返回 `PinnedMessagesResult`,包含命名字段 `Element`、`Jump`、`Unpinned`。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Jump` 和 `Unpinned` 返回本帧操作的消息 ID;调用方更新消息定位和置顶数据。
## 键盘操作
Tab 访问跳转与取消置顶;方向键移动;Enter / Space 激活。
## 限制
调用方维护置顶消息;列表行只构建可见部分。
+40
View File
@@ -0,0 +1,40 @@
# 414 ThreadPanel 话题侧栏
## 用途
在侧栏展示原消息与虚拟化回复列表,提供回复输入、发送和关闭。
## 最小示例
```go
open := true
root := mujica.ChatEntry{ID: "r", Author: "Sakiko", Text: "Set list for Friday?"}
var replies []mujica.ChatEntry
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.ThreadPanel(c, &open, root, replies, mujica.ThreadPanelOptions{}); r.Sent != "" {
replies = append(replies, mujica.ChatEntry{ID: r.Sent, Author: "Me", Text: r.Sent, Own: true})
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`open *bool` 控制显示;`root ChatEntry` 提供原消息,`replies []ChatEntry` 提供回复。`Width` 默认 360 DIP并受窗口宽度约束;`Disabled` 默认 false,禁用时关闭面板。回复 ID 必须稳定唯一。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Sent` 返回本帧发送的非空回复,发送后清空输入;关闭时设置 `*open=false`。
## 键盘操作
Tab 访问关闭、回复和发送;Enter 发送回复;Escape 关闭面板。
## 限制
回复发送和持久化由调用方完成;原消息区限高滚动,回复列表独立滚动。
+39
View File
@@ -0,0 +1,39 @@
# 415 MailList 邮件列表
## 用途
展示虚拟化邮件列表,包含未读粗体、星标、附件计数、多选与打开事件。
## 最小示例
```go
var s mujica.ListState[string]
mails := []mujica.MailItem{{ID: "1", From: "Sakiko", Subject: "Second act", Unread: true, Attachments: 1}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MailList(c, &s, mails, mujica.MailListOptions{}); r.Starred != "" {
mails[0].Starred = !mails[0].Starred
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`ListState[string]` 保存滚动与选择;`MailItem` 包含必填 ID、`From`、`Subject`、`Snippet`、`Time`、`Unread`、`Starred`、`Attachments`。`Height` 默认 420 DIP,必须 ≥ 0;`Disabled` 默认 false。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Changed()` 表示选择变化;`Opened` 返回打开的邮件 ID;`Starred` 返回请求切换星标的 ID。调用方更新数据。
## 键盘操作
上下方向键、Home / End 移动选择;Enter 或双击打开;S 请求星标;Cmd/Ctrl+A 全选;Cmd/Ctrl 点击切换单项,Shift 点击选择范围。
## 限制
星标、已读更新、邮件下载与打开由调用方处理;列表只构建可见行。
+39
View File
@@ -0,0 +1,39 @@
# 416 MailReader 邮件阅读器
## 用途
展示邮件头、纯文本或安全 Markdown 子集正文,以及附件操作。
## 最小示例
```go
m := mujica.Mail{From: "Sakiko", Subject: "Second act", Body: "The score is attached.\n> Can you send it?",
Attachments: []mujica.MailAttachment{{Name: "score.pdf", Size: 2048}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MailReader(c, m); r.Link != "" {
c.OpenURL(r.Link)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`Mail.From`、`Subject` 必填;`To`、`Cc`、`Date`、`Body` 为邮件内容。`Format` 默认 `MailPlain`,也支持 `MailMarkdown`。`Attachments []MailAttachment` 提供名称、字节数和 MIME 类型。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Link` 返回安全链接;`Opened`、`Downloaded` 返回附件索引,无事件时为 -1。
## 键盘操作
Tab 访问链接、引用开关和附件;Enter / Space 激活;正文支持选择复制。
## 限制
MarkdownView 处理安全 Markdown 子集,HTML 展示为文字;链接和附件动作由调用方执行。
+43
View File
@@ -0,0 +1,43 @@
# 417 MailComposer 邮件编辑器
## 用途
编辑收件人、抄送、密送、主题、正文和附件,将草稿交给调用方保存。
## 最小示例
```go
draft := mujica.MailDraft{To: []string{"uika@example.com"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MailComposer(c, &draft, mujica.MailComposerOptions{
OnSaveDraft: func(d mujica.MailDraft) {}, // store d
})
if r.Sent {
draft = mujica.MailDraft{}
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`MailDraft` 保存 `To`、`Cc`、`Bcc`、`Subject`、`Body`、`Attachments`。`Contacts` 提供联系人。`OnSaveDraft func(MailDraft)` 必填,收到独立的切片副本;`OnAttach func()` 可选;`Disabled` 默认 false。
## 状态
载入含抄送、密送地址的草稿时自动显示对应字段;点击地址字段标签会聚焦编辑器。
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
`Sent` 表示发送请求,`Discarded` 表示舍弃请求。编辑后静置两秒调用 OnSaveDraft;手动保存立即回调。
## 键盘操作
Tab 访问字段与操作;Cmd/Ctrl+S 保存草稿;组件内 Cmd/Ctrl+Enter 发送;正文 Enter 换行。
## 限制
邮箱语法由 net/mail.ParseAddress 校验;实际发送、附件选择与存储由调用方处理;保存回调在界面线程运行。
+37
View File
@@ -0,0 +1,37 @@
# 418 RecipientInput 收件人输入
## 用途
通过标签输入收件人,校验邮箱格式并提供联系人建议。
## 最小示例
```go
var to []string
contacts := []mujica.Contact{{Name: "Uika Misumi", Email: "uika@example.com"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RecipientInput(c, &to, mujica.RecipientInputOptions{Contacts: contacts, Label: "To"})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`recipients *[]string` 保存邮箱标签。`Contacts []Contact` 提供 Name、Email;联系人数据变化时替换切片。`Label` 默认“收件人”;`Placeholder` 默认空;`Disabled` 默认 false。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
返回 `TagsInputParts`;`Changed()` 表示标签变化,`Input` 可用于焦点控制。
## 键盘操作
Enter 或分隔符提交标签;空输入 Backspace 删除末项;上下方向键移动联系人建议,Enter 选择,Escape 收起建议;Tab 可聚焦建议并显示焦点环。
## 限制
联系人姓名中的逗号和引号按邮件地址语法转义。建议最多显示 6 项;联系人匹配在查询或联系人、收件人数据变化时计算;业务邮箱可达性由调用方验证。
+36
View File
@@ -0,0 +1,36 @@
# 419 QuotedText 引用文字
## 用途
折叠或展开引用文字,并去掉一层引用前缀。
## 最小示例
```go
open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.QuotedText(c, "> Can you send the score?", &open)
}
ui.Render(view, 400, 300, 1)
```
## 参数
`text string` 提供引用文字,`open *bool` 控制展开。`QuoteStrip` 去掉每行一个 `>` 及其后一个空格。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
返回 `ChoiceResult`;`Changed()` 表示折叠状态变化。
## 键盘操作
Tab 聚焦开关;Enter / Space 展开或折叠;展开内容支持选择复制。
## 限制
文本按一层引用规则处理,嵌套引用继续保留标记。
+38
View File
@@ -0,0 +1,38 @@
# 420 SnoozePicker 稍后提醒
## 用途
选择快捷提醒时间,或通过日期时间输入设定自定义提醒。
## 最小示例
```go
var until time.Time
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SnoozePicker(c, &until, mujica.SnoozePickerOptions{}).Changed() {
_ = until // hide the mail until then
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`until *time.Time` 保存提醒时间。`Location` 默认 `time.Local`;`MorningHour` 默认 9,允许 0–23,其中 0 使用默认值;`Now` 默认使用当前帧时间,可传固定时间;`Disabled` 默认 false。`SnoozeChoices` 的 morning 参数直接接受 0–23。
## 状态
默认、聚焦和悬停状态沿用主题、密度与文字缩放;适用操作在自身或祖先禁用时停止处理。
## 事件
返回 `ChoiceResult`;`Changed()` 表示提醒时间改变。
## 键盘操作
Tab 进入;上下方向键选择快捷项;Enter / Space 激活;自定义时间沿用 DateTimePicker 的输入、确认和取消操作。
## 限制
组件返回时间,提醒调度与邮件隐藏由调用方处理;本地日期按日历构造以保留夏令时后的早晨时刻。
+71
View File
@@ -0,0 +1,71 @@
package mujica
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]
}
+197
View File
@@ -0,0 +1,197 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 := Tokens(c)
u := 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(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() {
TextInput(c, &st.query, TextInputOptions{Placeholder: msg(c, "emoji_picker.search"), Label: 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, msg(c, "emoji_picker.none")).TextColor(Tokens(c).TextMuted).Padding(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(msg(c, t.key)).
Selected(i == *tab).Background(ui.Transparent).Tooltip(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(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(msg(c, "emoji_picker.tone")).Children(func() {
ui.Text(c, msg(c, "emoji_picker.tone")).FontSize(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(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(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(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(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 msg(c, "emoji_picker.name_"+strings.ReplaceAll(e.name, " ", "_"))
}
+142
View File
@@ -0,0 +1,142 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestEmojiPickerPickSearchToneRecent(t *testing.T) {
var recent []string
tone := 0
var picked []string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, Settings{Locale: 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) {
Use(c, Settings{})
EmojiPicker(c, EmojiPickerOptions{Recent: &recent})
}, 400, 400)
if got := mediaTextCount(tt, "🌹"); got != 3 {
t.Fatalf("initial history rose occurrences=%d, want 3", got)
}
}
+242
View File
@@ -0,0 +1,242 @@
package mujica_test
import (
mujica "mujicaui"
"time"
"github.com/egoist/mygo/ui"
)
func ExampleChannelList() {
groups := []mujica.ChannelGroup{{ID: "ch", Title: "Channels", Channels: []mujica.Channel{
{ID: "general", Name: "general", Unread: 3},
{ID: "rehearsal", Name: "rehearsal", Pinned: true},
}}}
selected := "general"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ChannelList(c, &selected, groups, mujica.ChannelListOptions{}).Changed() {
_ = selected // show that channel
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleChatMessage() {
prev := mujica.ChatEntry{ID: "1", Author: "Uika", Time: time.Now(), Text: "Rehearsal at nine."}
m := mujica.ChatEntry{ID: "2", Author: "Uika", Time: time.Now(), Text: "Bring the masks.", Edited: true}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ChatMessage(c, prev, mujica.ChatMessageOptions{})
mujica.ChatMessage(c, m, mujica.ChatMessageOptions{
Continued: mujica.ChatContinues(prev, m),
Reactions: func() { mujica.Tag(c, "👍 2", mujica.TagOptions{}) },
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleUnreadDivider() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UnreadDivider(c, 3)
}
ui.Render(view, 400, 300, 1)
}
func ExampleReadReceipt() {
readers := []mujica.AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ReadReceipt(c, mujica.ReceiptRead, mujica.ReadReceiptOptions{Readers: readers})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMemberList() {
var s mujica.MemberListState
members := []mujica.Member{
{ID: "1", Name: "Sakiko", Presence: mujica.PresenceOnline, Role: "Leader"},
{ID: "2", Name: "Umiri", Presence: mujica.PresenceOffline},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MemberList(c, &s, members, mujica.MemberListOptions{}); r.Opened != "" {
_ = r.Opened // show that member's profile
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleUserProfileCard() {
profile := mujica.UserProfile{Name: "Sakiko Togawa", Presence: mujica.PresenceBusy, Role: "Keyboard", Status: "In rehearsal"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UserProfileCard(c, profile, mujica.UserProfileCardOptions{Actions: func() {
mujica.Button(c, "Message", mujica.ButtonOptions{})
}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOnlineStatus() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.OnlineStatus(c, mujica.PresenceAway, mujica.OnlineStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStatusSetter() {
status := mujica.CustomStatus{Emoji: "🎻", Text: "Rehearsing"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StatusSetter(c, &status, mujica.StatusSetterOptions{}).Changed() {
_ = status // publish it; clear it once status.Until passes
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCallControls() {
st := mujica.CallState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CallControls(c, st, mujica.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 := []mujica.CallParticipant{
{ID: "1", Name: "Sakiko", Speaking: true}, // Frame: the latest decoded frame
{ID: "2", Name: "Uika", Muted: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoCallGrid(c, people, mujica.VideoCallGridOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleIncomingCallDialog() {
ringing := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.IncomingCallDialog(c, &ringing, mujica.IncomingCall{Caller: "Sakiko", Video: true}, mujica.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) {
mujica.Use(c, mujica.Settings{})
if r := mujica.EmojiPicker(c, mujica.EmojiPickerOptions{Recent: &recent, Tone: &tone}); r.Picked != "" {
_ = r.Picked // insert it
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePinnedMessages() {
pins := []mujica.ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PinnedMessages(c, pins, mujica.PinnedMessagesOptions{})
if r.Unpinned != "" {
pins = pins[:0] // unpin it
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleThreadPanel() {
open := true
root := mujica.ChatEntry{ID: "r", Author: "Sakiko", Text: "Set list for Friday?"}
var replies []mujica.ChatEntry
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.ThreadPanel(c, &open, root, replies, mujica.ThreadPanelOptions{}); r.Sent != "" {
replies = append(replies, mujica.ChatEntry{ID: r.Sent, Author: "Me", Text: r.Sent, Own: true})
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMailList() {
var s mujica.ListState[string]
mails := []mujica.MailItem{{ID: "1", From: "Sakiko", Subject: "Second act", Unread: true, Attachments: 1}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MailList(c, &s, mails, mujica.MailListOptions{}); r.Starred != "" {
mails[0].Starred = !mails[0].Starred
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMailReader() {
m := mujica.Mail{From: "Sakiko", Subject: "Second act", Body: "The score is attached.\n> Can you send it?",
Attachments: []mujica.MailAttachment{{Name: "score.pdf", Size: 2048}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MailReader(c, m); r.Link != "" {
c.OpenURL(r.Link)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMailComposer() {
draft := mujica.MailDraft{To: []string{"uika@example.com"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MailComposer(c, &draft, mujica.MailComposerOptions{
OnSaveDraft: func(d mujica.MailDraft) {}, // store d
})
if r.Sent {
draft = mujica.MailDraft{}
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleRecipientInput() {
var to []string
contacts := []mujica.Contact{{Name: "Uika Misumi", Email: "uika@example.com"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RecipientInput(c, &to, mujica.RecipientInputOptions{Contacts: contacts, Label: "To"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleQuotedText() {
open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.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) {
mujica.Use(c, mujica.Settings{})
if mujica.SnoozePicker(c, &until, mujica.SnoozePickerOptions{}).Changed() {
_ = until // hide the mail until then
}
}
ui.Render(view, 400, 300, 1)
}
+198 -1
View File
@@ -1,3 +1,200 @@
package main
var messagingPages = []page{}
const messagingCategory = "Messaging & Mail"
var messagingPages = []page{
{ID: "401", Name: "ChannelList", ZhName: "频道列表", Category: messagingCategory, Demo: channelListDemo,
Keys: "Tab 进入;上下方向键切换行;Enter / Space 选择或折叠;分组标题上的左、右方向键折叠、展开。", Code: `groups := []mujica.ChannelGroup{{ID: "ch", Title: "Channels", Channels: []mujica.Channel{
{ID: "general", Name: "general", Unread: 3},
{ID: "rehearsal", Name: "rehearsal", Pinned: true},
}}}
selected := "general"
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ChannelList(c, &selected, groups, mujica.ChannelListOptions{}).Changed() {
_ = selected // show that channel
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "402", Name: "ChatMessage", ZhName: "聊天消息", Category: messagingCategory, Demo: chatMessageDemo,
Keys: "可选文字支持选择复制;插槽控件按 Tab 访问。", Code: `prev := mujica.ChatEntry{ID: "1", Author: "Uika", Time: time.Now(), Text: "Rehearsal at nine."}
m := mujica.ChatEntry{ID: "2", Author: "Uika", Time: time.Now(), Text: "Bring the masks.", Edited: true}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ChatMessage(c, prev, mujica.ChatMessageOptions{})
mujica.ChatMessage(c, m, mujica.ChatMessageOptions{
Continued: mujica.ChatContinues(prev, m),
Reactions: func() { mujica.Tag(c, "👍 2", mujica.TagOptions{}) },
})
}
ui.Render(view, 400, 300, 1)`},
{ID: "403", Name: "UnreadDivider", ZhName: "未读分隔线", Category: messagingCategory, Demo: unreadDividerDemo,
Keys: "展示组件保持自然焦点序列。", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UnreadDivider(c, 3)
}
ui.Render(view, 400, 300, 1)`},
{ID: "404", Name: "ReadReceipt", ZhName: "已读回执", Category: messagingCategory, Demo: readReceiptDemo,
Keys: "展示组件保持自然焦点序列。", Code: `readers := []mujica.AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ReadReceipt(c, mujica.ReceiptRead, mujica.ReadReceiptOptions{Readers: readers})
}
ui.Render(view, 400, 300, 1)`},
{ID: "405", Name: "MemberList", ZhName: "成员列表", Category: messagingCategory, Demo: memberListDemo,
Keys: "Tab 聚焦列表;上下方向键选择;Home / End 首尾;Enter 或双击打开成员。", Code: `var s mujica.MemberListState
members := []mujica.Member{
{ID: "1", Name: "Sakiko", Presence: mujica.PresenceOnline, Role: "Leader"},
{ID: "2", Name: "Umiri", Presence: mujica.PresenceOffline},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MemberList(c, &s, members, mujica.MemberListOptions{}); r.Opened != "" {
_ = r.Opened // show that member's profile
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "406", Name: "UserProfileCard", ZhName: "用户资料卡", Category: messagingCategory, Demo: userProfileCardDemo,
Keys: "Tab 访问操作插槽;Enter / Space 激活按钮。", Code: `profile := mujica.UserProfile{Name: "Sakiko Togawa", Presence: mujica.PresenceBusy, Role: "Keyboard", Status: "In rehearsal"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UserProfileCard(c, profile, mujica.UserProfileCardOptions{Actions: func() {
mujica.Button(c, "Message", mujica.ButtonOptions{})
}})
}
ui.Render(view, 400, 300, 1)`},
{ID: "407", Name: "OnlineStatus", ZhName: "在线状态", Category: messagingCategory, Demo: onlineStatusDemo,
Keys: "展示组件保持自然焦点序列。", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.OnlineStatus(c, mujica.PresenceAway, mujica.OnlineStatusOptions{})
}
ui.Render(view, 400, 300, 1)`},
{ID: "408", Name: "StatusSetter", ZhName: "状态设置", Category: messagingCategory, Demo: statusSetterDemo,
Keys: "Tab 访问表情、文字、期限和清除按钮;选择器用方向键移动、Enter 确认、Escape 关闭。", Code: `status := mujica.CustomStatus{Emoji: "🎻", Text: "Rehearsing"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StatusSetter(c, &status, mujica.StatusSetterOptions{}).Changed() {
_ = status // publish it; clear it once status.Until passes
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "409", Name: "CallControls", ZhName: "通话控制", Category: messagingCategory, Demo: callControlsDemo,
Keys: "Tab 进入工具栏;左右方向键移动;Enter / Space 激活。Cmd/Ctrl+D 静音,Cmd/Ctrl+E 摄像头,Cmd/Ctrl+Shift+S 共享,Cmd/Ctrl+Shift+H 挂断。", Code: `st := mujica.CallState{}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CallControls(c, st, mujica.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)`},
{ID: "410", Name: "VideoCallGrid", ZhName: "视频通话宫格", Category: messagingCategory, Demo: videoCallGridDemo,
Keys: "Tab 进入宫格;方向键移动参与者焦点并读取状态。", Code: `people := []mujica.CallParticipant{
{ID: "1", Name: "Sakiko", Speaking: true}, // Frame: the latest decoded frame
{ID: "2", Name: "Uika", Muted: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.VideoCallGrid(c, people, mujica.VideoCallGridOptions{})
}
ui.Render(view, 400, 300, 1)`},
{ID: "411", Name: "IncomingCallDialog", ZhName: "来电对话框", Category: messagingCategory, Demo: incomingCallDialogDemo,
Keys: "默认焦点为拒绝;Tab 循环访问操作;Enter / Space 激活;Escape 拒绝;关闭后恢复焦点。", Code: `ringing := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.IncomingCallDialog(c, &ringing, mujica.IncomingCall{Caller: "Sakiko", Video: true}, mujica.IncomingCallOptions{
OnAccept: func(video bool) {}, // join, with the camera on when video
OnDecline: func() {},
})
}
ui.Render(view, 400, 300, 1)`},
{ID: "412", Name: "EmojiPicker", ZhName: "表情选择器", Category: messagingCategory, Demo: emojiPickerDemo,
Keys: "Tab 访问搜索、肤色、分类、表情;方向键在组内移动;Enter / Space 选择。", Code: `var recent []string
tone := 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.EmojiPicker(c, mujica.EmojiPickerOptions{Recent: &recent, Tone: &tone}); r.Picked != "" {
_ = r.Picked // insert it
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "413", Name: "PinnedMessages", ZhName: "置顶消息", Category: messagingCategory, Demo: pinnedMessagesDemo,
Keys: "Tab 访问跳转与取消置顶;方向键移动;Enter / Space 激活。", Code: `pins := []mujica.ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PinnedMessages(c, pins, mujica.PinnedMessagesOptions{})
if r.Unpinned != "" {
pins = pins[:0] // unpin it
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "414", Name: "ThreadPanel", ZhName: "话题侧栏", Category: messagingCategory, Demo: threadPanelDemo,
Keys: "Tab 访问关闭、回复和发送;Enter 发送回复;Escape 关闭面板。", Code: `open := true
root := mujica.ChatEntry{ID: "r", Author: "Sakiko", Text: "Set list for Friday?"}
var replies []mujica.ChatEntry
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.ThreadPanel(c, &open, root, replies, mujica.ThreadPanelOptions{}); r.Sent != "" {
replies = append(replies, mujica.ChatEntry{ID: r.Sent, Author: "Me", Text: r.Sent, Own: true})
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "415", Name: "MailList", ZhName: "邮件列表", Category: messagingCategory, Demo: mailListDemo,
Keys: "上下方向键、Home / End 移动选择;Enter 或双击打开;S 请求星标;Cmd/Ctrl+A 全选;Cmd/Ctrl 点击切换单项,Shift 点击选择范围。", Code: `var s mujica.ListState[string]
mails := []mujica.MailItem{{ID: "1", From: "Sakiko", Subject: "Second act", Unread: true, Attachments: 1}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MailList(c, &s, mails, mujica.MailListOptions{}); r.Starred != "" {
mails[0].Starred = !mails[0].Starred
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "416", Name: "MailReader", ZhName: "邮件阅读器", Category: messagingCategory, Demo: mailReaderDemo,
Keys: "Tab 访问链接、引用开关和附件;Enter / Space 激活;正文支持选择复制。", Code: `m := mujica.Mail{From: "Sakiko", Subject: "Second act", Body: "The score is attached.\n> Can you send it?",
Attachments: []mujica.MailAttachment{{Name: "score.pdf", Size: 2048}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MailReader(c, m); r.Link != "" {
c.OpenURL(r.Link)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "417", Name: "MailComposer", ZhName: "邮件编辑器", Category: messagingCategory, Demo: mailComposerDemo,
Keys: "Tab 访问字段与操作;Cmd/Ctrl+S 保存草稿;组件内 Cmd/Ctrl+Enter 发送;正文 Enter 换行。", Code: `draft := mujica.MailDraft{To: []string{"uika@example.com"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MailComposer(c, &draft, mujica.MailComposerOptions{
OnSaveDraft: func(d mujica.MailDraft) {}, // store d
})
if r.Sent {
draft = mujica.MailDraft{}
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "418", Name: "RecipientInput", ZhName: "收件人输入", Category: messagingCategory, Demo: recipientInputDemo,
Keys: "Enter 或分隔符提交标签;空输入 Backspace 删除末项;上下方向键移动联系人建议,Enter 选择,Escape 收起建议。", Code: `var to []string
contacts := []mujica.Contact{{Name: "Uika Misumi", Email: "uika@example.com"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RecipientInput(c, &to, mujica.RecipientInputOptions{Contacts: contacts, Label: "To"})
}
ui.Render(view, 400, 300, 1)`},
{ID: "419", Name: "QuotedText", ZhName: "引用文字", Category: messagingCategory, Demo: quotedTextDemo,
Keys: "Tab 聚焦开关;Enter / Space 展开或折叠;展开内容支持选择复制。", Code: `open := false
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.QuotedText(c, "> Can you send the score?", &open)
}
ui.Render(view, 400, 300, 1)`},
{ID: "420", Name: "SnoozePicker", ZhName: "稍后提醒", Category: messagingCategory, Demo: snoozePickerDemo,
Keys: "Tab 进入;上下方向键选择快捷项;Enter / Space 激活;自定义时间沿用 DateTimePicker 的输入、确认和取消操作。", Code: `var until time.Time
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SnoozePicker(c, &until, mujica.SnoozePickerOptions{}).Changed() {
_ = until // hide the mail until then
}
}
ui.Render(view, 400, 300, 1)`},
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func callControlsDemo(c *ui.Context, log func(string)) {
type state struct {
call mujica.CallState
disabled bool
ended bool
}
s := ui.Local(c.Root(), "409", func() state { return state{} })
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Reconnect", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.ended = false
log("call joined")
}
mujica.CallControls(c, s.call, mujica.CallControlsOptions{
Disabled: s.disabled || s.ended,
OnMute: func(on bool) { s.call.Muted = on; log(fmt.Sprintf("mute %v", on)) },
OnCamera: func(off bool) { s.call.CameraOff = off; log(fmt.Sprintf("camera off %v", off)) },
OnShare: func(on bool) { s.call.Sharing = on; log(fmt.Sprintf("sharing %v", on)) },
OnHangUp: func() { s.ended = true; log("call left") },
})
ui.Text(c, fmt.Sprintf("Muted: %v; camera off: %v; sharing: %v; ended: %v", s.call.Muted, s.call.CameraOff, s.call.Sharing, s.ended))
}
func videoCallGridDemo(c *ui.Context, log func(string)) {
type state struct {
speaking, muted, cameraOff, frame bool
columns float64
}
s := ui.Local(c.Root(), "410", func() state { return state{speaking: true, frame: true, columns: 2} })
mediaCheck(c, log, &s.speaking, "Speaking")
mediaCheck(c, log, &s.muted, "Muted")
mediaCheck(c, log, &s.cameraOff, "Camera off")
mediaCheck(c, log, &s.frame, "Caller-provided frame")
if mujica.NumberInput(c, &s.columns, mujica.NumberInputOptions{Min: 0, Max: 3, Step: 1, Label: "Columns (0 = auto)"}).Changed() {
log(fmt.Sprintf("columns %.0f", s.columns))
}
people := []mujica.CallParticipant{
{ID: "1", Name: "Sakiko", Speaking: s.speaking, Muted: s.muted, CameraOff: s.cameraOff},
{ID: "2", Name: "Uika", Muted: true}, {ID: "3", Name: "Mutsumi", CameraOff: true}, {ID: "4", Name: "Umiri"},
}
if s.frame {
people[0].Frame = mediaBitmaps[0]
people[3].Frame = mediaBitmaps[1]
}
mujica.VideoCallGrid(c, people, mujica.VideoCallGridOptions{Columns: int(s.columns)})
}
func incomingCallDialogDemo(c *ui.Context, log func(string)) {
type state struct {
open, video, focusAccept, disabled bool
result string
}
s := ui.Local(c.Root(), "411", func() state { return state{video: true} })
mediaCheck(c, log, &s.video, "Video call")
mediaCheck(c, log, &s.focusAccept, "Focus Accept")
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Incoming call", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
s.open = true
log("incoming call")
}
mujica.IncomingCallDialog(c, &s.open, mujica.IncomingCall{Caller: "Sakiko Togawa", Video: s.video}, mujica.IncomingCallOptions{
Disabled: s.disabled, FocusAccept: s.focusAccept,
OnAccept: func(video bool) { s.result = fmt.Sprintf("Accepted, video: %v", video); log(s.result) },
OnDecline: func() { s.result = "Declined"; log(s.result) },
})
ui.Text(c, s.result)
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"fmt"
"slices"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func chatMessageDemo(c *ui.Context, log func(string)) {
type state struct {
continued, edited, own, reply, reactions bool
reacted bool
}
s := ui.Local(c.Root(), "402", func() state { return state{continued: true, edited: true, reply: true, reactions: true} })
mediaCheck(c, log, &s.continued, "Merge consecutive messages")
mediaCheck(c, log, &s.edited, "Edited")
mediaCheck(c, log, &s.own, "Own message")
mediaCheck(c, log, &s.reply, "Reply slot")
mediaCheck(c, log, &s.reactions, "Reaction slot")
at := time.Date(2026, 10, 5, 21, 7, 0, 0, time.Local)
mujica.ChatMessage(c, mujica.ChatEntry{Author: "Uika", Time: at, Text: "Rehearsal at nine.", Own: s.own}, mujica.ChatMessageOptions{})
opts := mujica.ChatMessageOptions{Continued: s.continued}
if s.reply {
opts.Reply = func() {
if mujica.QuoteReply(c, "Sakiko", "When do we start?").Clicked() {
log("jump to quoted message")
}
}
}
if s.reactions {
opts.Reactions = func() {
if mujica.Toggle(c, &s.reacted, "Like", mujica.ToggleOptions{}).Changed() {
log(fmt.Sprintf("reaction %v", s.reacted))
}
}
}
mujica.ChatMessage(c, mujica.ChatEntry{Author: "Uika", Time: at.Add(time.Minute), Text: "Bring the masks.", Own: s.own, Edited: s.edited}, opts)
}
func unreadDividerDemo(c *ui.Context, log func(string)) {
count := ui.Local(c.Root(), "403", func() float64 { return 3 })
if mujica.NumberInput(c, count, mujica.NumberInputOptions{Min: 0, Max: 999, Step: 1, Label: "Unread count"}).Changed() {
log(fmt.Sprintf("unread %.0f", *count))
}
mujica.UnreadDivider(c, int(*count))
inputSection(c, "Zero count", func() { mujica.UnreadDivider(c, 0) })
}
func readReceiptDemo(c *ui.Context, log func(string)) {
type state struct {
receipt mujica.ReceiptState
readers bool
max float64
}
s := ui.Local(c.Root(), "404", func() state { return state{receipt: mujica.ReceiptRead, readers: true, max: 3} })
items := []mujica.SelectOption[mujica.ReceiptState]{{Value: mujica.ReceiptSent, Label: "Sent"}, {Value: mujica.ReceiptDelivered, Label: "Delivered"}, {Value: mujica.ReceiptRead, Label: "Read"}}
if mujica.Select(c, &s.receipt, items, mujica.SelectOptions{Label: "Receipt"}).Changed() {
log(fmt.Sprintf("receipt %d", s.receipt))
}
mediaCheck(c, log, &s.readers, "Reader avatars")
if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 1, Max: 4, Step: 1, Label: "Max avatars"}).Changed() {
log(fmt.Sprintf("max %.0f", s.max))
}
opts := mujica.ReadReceiptOptions{Max: int(s.max)}
if s.readers {
opts.Readers = []mujica.AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}, {Name: "Umiri"}, {Name: "Nyamu"}}
}
mujica.ReadReceipt(c, s.receipt, opts)
}
func emojiPickerDemo(c *ui.Context, log func(string)) {
type state struct {
recent []string
tone int
picked string
disabled bool
}
s := ui.Local(c.Root(), "412", func() state { return state{} })
mediaCheck(c, log, &s.disabled, "Disabled")
if r := mujica.EmojiPicker(c, mujica.EmojiPickerOptions{Recent: &s.recent, Tone: &s.tone, Disabled: s.disabled}); r.Picked != "" {
s.picked = r.Picked
log("emoji " + r.Picked)
}
ui.Text(c, "Picked: "+s.picked)
}
func pinnedMessagesDemo(c *ui.Context, log func(string)) {
type state struct {
pins []mujica.ChatEntry
disabled bool
jump string
}
newPins := func() []mujica.ChatEntry {
return []mujica.ChatEntry{{ID: "a", Author: "Sakiko", Text: "Rehearsal moves to nine."}, {ID: "b", Author: "Uika", Text: "Masks are in the hall."}}
}
s := ui.Local(c.Root(), "413", func() state { return state{pins: newPins()} })
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Restore pins", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.pins = newPins()
log("pins restored")
}
r := mujica.PinnedMessages(c, s.pins, mujica.PinnedMessagesOptions{Disabled: s.disabled, Height: 180})
if r.Jump != "" {
s.jump = r.Jump
log("jump " + r.Jump)
}
if r.Unpinned != "" {
s.pins = slices.DeleteFunc(slices.Clone(s.pins), func(m mujica.ChatEntry) bool { return m.ID == r.Unpinned })
log("unpin " + r.Unpinned)
}
ui.Text(c, "Jump target: "+s.jump)
}
func threadPanelDemo(c *ui.Context, log func(string)) {
type state struct {
open, disabled bool
replies []mujica.ChatEntry
}
s := ui.Local(c.Root(), "414", func() state {
return state{open: true, replies: []mujica.ChatEntry{{ID: "1", Author: "Uika", Text: "Overture first."}}}
})
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Open thread", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
s.open = true
log("thread opened")
}
before := s.open
ui.Row(c).Height(420).Children(func() {
r := mujica.ThreadPanel(c, &s.open, mujica.ChatEntry{ID: "root", Author: "Sakiko", Text: "Set list for Friday?"}, s.replies, mujica.ThreadPanelOptions{Disabled: s.disabled})
if r.Sent != "" {
s.replies = append(s.replies, mujica.ChatEntry{ID: fmt.Sprint(len(s.replies) + 1), Author: "Me", Text: r.Sent, Own: true})
log("reply sent")
}
})
if before && !s.open {
log("thread closed")
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func mailListDemo(c *ui.Context, log func(string)) {
type state struct {
list mujica.ListState[string]
mails []mujica.MailItem
disabled, empty bool
opened string
}
s := ui.Local(c.Root(), "415", func() state {
return state{mails: []mujica.MailItem{
{ID: "1", From: "Sakiko", Subject: "Second act", Snippet: "The score is attached.", Unread: true, Attachments: 2},
{ID: "2", From: "Uika", Subject: "Costumes", Snippet: "Fittings on Thursday.", Starred: true},
{ID: "3", From: "Umiri", Subject: "Venue", Snippet: "The hall is booked."},
}}
})
mediaCheck(c, log, &s.disabled, "Disabled")
mediaCheck(c, log, &s.empty, "Empty")
if mujica.Button(c, "Load 100,000 mails", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.mails = make([]mujica.MailItem, 100000)
for i := range s.mails {
s.mails[i] = mujica.MailItem{ID: fmt.Sprint(i), From: "Sender", Subject: fmt.Sprintf("Mail %d", i), Unread: i%2 == 0}
}
log("loaded 100,000 mails")
}
mails := s.mails
if s.empty {
mails = nil
}
r := mujica.MailList(c, &s.list, mails, mujica.MailListOptions{Disabled: s.disabled, Height: 240})
if r.Changed() {
log(fmt.Sprintf("selected %d mails", s.list.Selection.Len()))
}
if r.Opened != "" {
s.opened = r.Opened
for i := range s.mails {
if s.mails[i].ID == r.Opened {
s.mails[i].Unread = false
break
}
}
log("open " + r.Opened)
}
if r.Starred != "" {
for i := range s.mails {
if s.mails[i].ID == r.Starred {
s.mails[i].Starred = !s.mails[i].Starred
break
}
}
log("star " + r.Starred)
}
ui.Text(c, "Opened: "+s.opened)
}
func mailReaderDemo(c *ui.Context, log func(string)) {
type state struct{ markdown, attachments, disabled bool }
s := ui.Local(c.Root(), "416", func() state { return state{attachments: true} })
mediaCheck(c, log, &s.markdown, "Safe Markdown")
mediaCheck(c, log, &s.attachments, "Attachments")
mediaCheck(c, log, &s.disabled, "Disabled actions")
m := mujica.Mail{From: "Sakiko Togawa", To: []string{"Uika Misumi"}, Cc: []string{"Umiri Yahata"}, Subject: "Second act", Date: time.Date(2026, 10, 5, 21, 7, 0, 0, time.Local), Body: "The score is attached.\n> Can you send it tonight?"}
if s.markdown {
m.Format = mujica.MailMarkdown
m.Body = "**Rehearsal** at nine. [Venue](https://example.com)\n\n<script>alert(1)</script>"
}
if s.attachments {
m.Attachments = []mujica.MailAttachment{{Name: "score.pdf", Size: 2048}}
}
ui.Column(c).Disabled(s.disabled).Children(func() {
r := mujica.MailReader(c, m)
if r.Link != "" {
log("link " + r.Link)
}
if r.Opened >= 0 {
log(fmt.Sprintf("open attachment %d", r.Opened))
}
if r.Downloaded >= 0 {
log(fmt.Sprintf("download attachment %d", r.Downloaded))
}
})
}
func mailComposerDemo(c *ui.Context, log func(string)) {
type state struct {
draft mujica.MailDraft
disabled bool
saved string
}
s := ui.Local(c.Root(), "417", func() state { return state{draft: mujica.MailDraft{To: []string{"uika@example.com"}}} })
mediaCheck(c, log, &s.disabled, "Disabled")
r := mujica.MailComposer(c, &s.draft, mujica.MailComposerOptions{
Disabled: s.disabled, Contacts: messagingContacts,
OnSaveDraft: func(d mujica.MailDraft) { s.saved = d.Subject; log("draft saved") },
OnAttach: func() {
s.draft.Attachments = append(s.draft.Attachments, mujica.MailAttachment{Name: fmt.Sprintf("score-%d.pdf", len(s.draft.Attachments)+1), Size: 2048})
log("attachment added")
},
})
if r.Sent {
log("mail sent")
s.draft = mujica.MailDraft{}
}
if r.Discarded {
log("draft discarded")
s.draft = mujica.MailDraft{}
}
ui.Text(c, "Saved subject: "+s.saved)
}
var messagingContacts = []mujica.Contact{{Name: "Uika Misumi", Email: "uika@example.com"}, {Name: "Umiri Yahata", Email: "umiri@example.com"}, {Name: "Sakiko Togawa", Email: "sakiko@example.com"}}
func recipientInputDemo(c *ui.Context, log func(string)) {
type state struct {
to []string
disabled, contacts bool
}
s := ui.Local(c.Root(), "418", func() state { return state{contacts: true} })
mediaCheck(c, log, &s.disabled, "Disabled")
mediaCheck(c, log, &s.contacts, "Contact suggestions")
contacts := messagingContacts
if !s.contacts {
contacts = nil
}
if mujica.RecipientInput(c, &s.to, mujica.RecipientInputOptions{Contacts: contacts, Label: "Recipients", Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("recipients %d", len(s.to)))
}
if mujica.Button(c, "Clear recipients", mujica.ButtonOptions{Variant: mujica.Secondary, Disabled: s.disabled}).Clicked() {
s.to = nil
log("recipients cleared")
}
ui.Text(c, "Type a name or email; Enter or comma commits valid addresses.")
}
func quotedTextDemo(c *ui.Context, log func(string)) {
type state struct {
open, disabled bool
text string
}
s := ui.Local(c.Root(), "419", func() state { return state{text: "> Can you send the score?\n> Thanks."} })
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.TextArea(c, &s.text, mujica.TextAreaOptions{Label: "Quoted source", MinHeight: 80}).Changed() {
log("quote source edited")
}
ui.Column(c).Disabled(s.disabled).Children(func() {
if mujica.QuotedText(c, s.text, &s.open).Changed() {
log(fmt.Sprintf("quote expanded %v", s.open))
}
})
}
func snoozePickerDemo(c *ui.Context, log func(string)) {
type state struct {
until time.Time
disabled bool
morning float64
}
s := ui.Local(c.Root(), "420", func() state { return state{morning: 9} })
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.NumberInput(c, &s.morning, mujica.NumberInputOptions{Min: 1, Max: 23, Step: 1, Label: "Morning hour"}).Changed() {
log(fmt.Sprintf("morning %.0f", s.morning))
}
if mujica.SnoozePicker(c, &s.until, mujica.SnoozePickerOptions{Disabled: s.disabled, MorningHour: int(s.morning)}).Changed() {
log("snooze " + s.until.Format(time.RFC3339))
}
if mujica.Button(c, "Clear snooze", mujica.ButtonOptions{Variant: mujica.Secondary, Disabled: s.disabled}).Clicked() {
s.until = time.Time{}
log("snooze cleared")
}
ui.Text(c, fmt.Sprint("Until: ", s.until))
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"fmt"
"slices"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func channelListDemo(c *ui.Context, log func(string)) {
type state struct {
selected string
disabled bool
revision int
groups []mujica.ChannelGroup
}
s := ui.Local(c.Root(), "401", func() state {
return state{groups: []mujica.ChannelGroup{
{ID: "channels", Title: "Channels", Channels: []mujica.Channel{
{ID: "general", Name: "general", Unread: 3}, {ID: "rehearsal", Name: "rehearsal", Pinned: true},
{ID: "random", Name: "random", Unread: 120, Muted: true},
}},
{ID: "direct", Title: "Direct messages", Channels: []mujica.Channel{{ID: "uika", Name: "Uika", Direct: true, Presence: mujica.PresenceOnline}}},
}}
})
mediaCheck(c, log, &s.disabled, "Disabled")
before := s.groups[0].Channels[0]
mediaCheck(c, log, &s.groups[0].Channels[0].Muted, "Mute general")
mediaCheck(c, log, &s.groups[0].Channels[0].Pinned, "Pin general")
if before != s.groups[0].Channels[0] {
s.revision++
}
if mujica.ChannelList(c, &s.selected, s.groups, mujica.ChannelListOptions{Disabled: s.disabled, Revision: s.revision}).Changed() {
log("channel " + s.selected)
}
ui.Text(c, "Selected: "+s.selected)
}
func memberListDemo(c *ui.Context, log func(string)) {
type state struct {
list mujica.MemberListState
disabled, empty bool
members []mujica.Member
}
s := ui.Local(c.Root(), "405", func() state {
return state{members: []mujica.Member{
{ID: "1", Name: "Sakiko", Presence: mujica.PresenceOnline, Role: "Leader"},
{ID: "2", Name: "Uika", Presence: mujica.PresenceBusy, Role: "Vocal"},
{ID: "3", Name: "Mutsumi", Presence: mujica.PresenceAway}, {ID: "4", Name: "Umiri", Presence: mujica.PresenceOffline},
}}
})
mediaCheck(c, log, &s.disabled, "Disabled")
mediaCheck(c, log, &s.empty, "Empty")
if mujica.Button(c, "Toggle Uika presence", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.members = slices.Clone(s.members)
if s.members[1].Presence == mujica.PresenceOffline {
s.members[1].Presence = mujica.PresenceOnline
} else {
s.members[1].Presence = mujica.PresenceOffline
}
log("Uika presence changed")
}
members := s.members
if s.empty {
members = nil
}
r := mujica.MemberList(c, &s.list, members, mujica.MemberListOptions{Disabled: s.disabled, Height: 240})
if r.Changed() {
log("member " + s.list.Selected)
}
if r.Opened != "" {
log("profile " + r.Opened)
}
}
func userProfileCardDemo(c *ui.Context, log func(string)) {
type state struct {
presence mujica.Presence
actions, disabled bool
}
s := ui.Local(c.Root(), "406", func() state { return state{actions: true, presence: mujica.PresenceOnline} })
mediaCheck(c, log, &s.actions, "Actions")
mediaCheck(c, log, &s.disabled, "Disable actions")
messagingPresence(c, log, &s.presence)
opts := mujica.UserProfileCardOptions{}
if s.actions {
opts.Actions = func() {
for _, action := range []string{"Message", "Call"} {
if mujica.Button(c, action, mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
log(action)
}
}
}
}
mujica.UserProfileCard(c, mujica.UserProfile{Name: "Sakiko Togawa", Presence: s.presence, Role: "Keyboard", Status: "In rehearsal", About: "Founder of the masquerade."}, opts)
}
func onlineStatusDemo(c *ui.Context, log func(string)) {
type state struct {
presence mujica.Presence
hidden bool
}
s := ui.Local(c.Root(), "407", func() state { return state{presence: mujica.PresenceOnline} })
mediaCheck(c, log, &s.hidden, "Hide text")
messagingPresence(c, log, &s.presence)
mujica.OnlineStatus(c, s.presence, mujica.OnlineStatusOptions{HideText: s.hidden})
inputSection(c, "States", func() {
for p := mujica.PresenceOffline; p <= mujica.PresenceBusy; p++ {
mujica.OnlineStatus(c, p, mujica.OnlineStatusOptions{})
}
})
}
func statusSetterDemo(c *ui.Context, log func(string)) {
type state struct {
status mujica.CustomStatus
disabled bool
}
s := ui.Local(c.Root(), "408", func() state { return state{} })
mediaCheck(c, log, &s.disabled, "Disabled")
if mujica.StatusSetter(c, &s.status, mujica.StatusSetterOptions{Disabled: s.disabled}).Changed() {
log("status changed")
}
ui.Text(c, fmt.Sprintf("Text: %s; expires: %v", s.status.Text, s.status.Until))
}
func messagingPresence(c *ui.Context, log func(string), value *mujica.Presence) {
items := []mujica.SelectOption[mujica.Presence]{
{Value: mujica.PresenceOnline, Label: "Online"}, {Value: mujica.PresenceAway, Label: "Away"},
{Value: mujica.PresenceBusy, Label: "Busy"}, {Value: mujica.PresenceOffline, Label: "Offline"},
}
if mujica.Select(c, value, items, mujica.SelectOptions{Label: "Presence"}).Changed() {
log(fmt.Sprintf("presence %d", *value))
}
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"fmt"
"image/png"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func messagingDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) {
t.Helper()
i := slices.IndexFunc(messagingPages, func(p page) bool { return p.ID == id })
if i < 0 {
t.Fatalf("missing page %s", id)
}
events := &[]string{}
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
ui.Scroll(c).Fill().Padding(24).Gap(12).Children(func() {
messagingPages[i].Demo(c, func(s string) { *events = append(*events, s) })
})
}, 900, 800)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
return tt, events
}
func TestMessagingDemosInteractAndLog(t *testing.T) {
cases := []struct{ id, click, event string }{
{"401", "random, 120 unread", "channel random"},
{"402", "Like", "reaction true"},
{"403", "Unread count", "unread 7"},
{"404", "Read", "receipt 0"},
{"405", "Sakiko", "member 1"},
{"406", "Message", "Message"},
{"407", "Presence", "presence 3"},
{"408", "Status text", "status changed"},
{"409", "Mute", "mute true"},
{"410", "Speaking", "Speaking = false"},
{"411", "Incoming call", "Declined"},
{"412", "grinning face", "emoji "},
{"413", "Unpin Uika's message", "unpin b"},
{"414", "Reply in thread", "reply sent"},
{"415", "Star Second act", "star 1"},
{"416", "Download score.pdf", "download attachment 0"},
{"417", "Attach", "attachment added"},
{"418", "Recipients", "recipients 1"},
{"419", "Show quoted text (2 lines)", "quote expanded true"},
{"420", "Tomorrow", "snooze "},
}
if len(messagingPages) != len(cases) {
t.Fatalf("pages=%d, cases=%d", len(messagingPages), len(cases))
}
for _, tc := range cases {
for _, mode := range []mujica.Mode{mujica.Light, mujica.Dark} {
t.Run(tc.id+mode.String(), func(t *testing.T) {
tt, events := messagingDemoTester(t, tc.id, mode)
if err := tt.Click(tc.click); err != nil {
t.Fatal(err)
}
switch tc.id {
case "403":
tt.Command("selectAll")
tt.Type("7")
tt.Key(0, ui.KeyEnter)
case "404":
if err := tt.Click("Sent"); err != nil {
t.Fatal(err)
}
case "407":
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
case "408":
tt.Type("Rehearsing")
case "411":
tt.Key(0, ui.KeyEnter)
case "414":
tt.Type("Second act")
tt.Key(0, ui.KeyEnter)
case "418":
tt.Type("guest@example.com")
tt.Key(0, ui.KeyEnter)
}
if !slices.ContainsFunc(*events, func(s string) bool { return strings.HasPrefix(s, tc.event) }) {
t.Fatalf("want %q, events %v", tc.event, *events)
}
})
}
}
}
func TestMessagingDemoCaptures(t *testing.T) {
dir := os.Getenv("MESSAGING_DEMO_DIR")
if dir == "" {
t.Skip("set MESSAGING_DEMO_DIR to export demos")
}
for _, p := range messagingPages {
if p.Category != "Messaging & Mail" || p.Code == "" || p.Keys == "" {
t.Fatalf("incomplete page %s", p.ID)
}
for _, mode := range []mujica.Mode{mujica.Light, mujica.Dark} {
tt, _ := messagingDemoTester(t, p.ID, mode)
f, err := os.Create(filepath.Join(dir, fmt.Sprintf("%s-%s.png", p.ID, mode)))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, tt.Image()); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"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 := overlaySite(c)
if opts.Disabled || site.IsDisabled() {
*open = false
}
if !*open {
focus.closed(c, site)
return
}
overlayLayer(c, open)
k := Tokens(c)
u := Density(c).Unit()
title := fmt.Sprintf(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 := overlayEnter(c, panel)
back.Background(overlayScrim(k).Alpha(p)).Padding(16)
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() {
Avatar(c, call.Caller, AvatarOptions{Image: call.Image, Size: 72})
ui.Text(c, call.Caller).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).TextAlign(ui.Center)
kind := msg(c, "incoming_call_dialog.voice")
if call.Video {
kind = 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 := Button(c, msg(c, "incoming_call_dialog.decline"), ButtonOptions{Variant: Danger, Icon: msgHangUp, Label: msg(c, "incoming_call_dialog.decline")})
accept := Button(c, msg(c, "incoming_call_dialog.accept"), ButtonOptions{Icon: msgPhone, Label: msg(c, "incoming_call_dialog.accept")})
if opts.FocusAccept {
accept.AutoFocus()
} else {
decline.AutoFocus()
}
declined, accepted = decline.Clicked(), accept.Clicked()
if call.Video && Button(c, msg(c, "incoming_call_dialog.acceptVideo"), ButtonOptions{Variant: Secondary, Icon: mediaCamera, Label: 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()
}
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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)
}
}
+213
View File
@@ -0,0 +1,213 @@
package mujica
import (
"fmt"
"log/slog"
"net/mail"
"slices"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := Tokens(c)
u := Density(c).Unit()
var r MailComposerResult
col := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(msg(c, "mail_composer.label")).Disabled(opts.Disabled)
r.Element = col
st := ui.Local(col, composerKey{}, func() *composerState { return &composerState{} })
s := *st
live := !col.IsDisabled()
if !live {
copy := composerCopy(*d)
d = &copy
}
if !s.started {
s.started, s.last = true, composerCopy(*d)
s.cc, s.bcc = len(d.Cc) > 0, len(d.Bcc) > 0
}
s.cc = s.cc || len(d.Cc) > 0
s.bcc = s.bcc || len(d.Bcc) > 0
col.Children(func() {
field := func(key string, list *[]string, extra func()) {
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
label := ui.Text(c, msg(c, key)).Width(56).TextColor(k.TextMuted).Margin(u*2, 0, 0, 0).SingleLine()
parts := RecipientInput(c, list, RecipientInputOptions{Contacts: opts.Contacts, Label: 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 && Button(c, msg(c, "mail_composer.cc"), ButtonOptions{Variant: Ghost}).Clicked() {
s.cc = true
}
if !s.bcc && Button(c, msg(c, "mail_composer.bcc"), ButtonOptions{Variant: 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)
}
TextInput(c, &d.Subject, TextInputOptions{Placeholder: msg(c, "mail_composer.subject"), Label: msg(c, "mail_composer.subject"), Disabled: opts.Disabled})
if TextArea(c, &d.Body, TextAreaOptions{Label: 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 Button(c, msg(c, "mail_composer.send"), ButtonOptions{Icon: msgSend, Disabled: !ready}).Clicked() {
r.Sent = true
}
if opts.OnAttach != nil && Button(c, msg(c, "mail_composer.attach"), ButtonOptions{Variant: Secondary, Icon: msgPaperclip}).Clicked() {
slog.Debug("mujica: mail_composer attach")
opts.OnAttach()
}
ui.Box(c).Grow(1)
composerStatus(c, k, s)
if Button(c, msg(c, "mail_composer.discard"), ButtonOptions{Variant: 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 = msg(c, "mail_composer.unsaved")
case !s.saved.IsZero():
text = fmt.Sprintf(msg(c, "mail_composer.saved"), s.saved.Format("15:04"))
}
if text != "" {
ui.Text(c, text).FontSize(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(Density(c).Unit() * 2).Wrap().Children(func() {
for i, a := range d.Attachments {
var v TagView
ui.Box(c).Key(fmt.Sprint(i, a.Name)).Children(func() {
v = Tag(c, a.Name, 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)
}
+187
View File
@@ -0,0 +1,187 @@
package mujica
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, Settings{})
if MailComposer(c, &draft, MailComposerOptions{OnSaveDraft: func(MailDraft) { saves++ }}).Sent {
sent++
}
TextInput(c, new(string), 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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")
}
}
+139
View File
@@ -0,0 +1,139 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 *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 := Tokens(c)
u := Density(c).Unit()
small := FontSize(c, theme.CaptionSize)
var r MailListResult
box := ui.Column(c).Height(h).Disabled(opts.Disabled)
r.Element = box
box.Children(func() {
v := List(c, s, len(mails), ListOptions[string]{
Key: func(i int) string { return mails[i].ID },
Label: func(i int) string { return mailLabel(c, mails[i]) },
Multiple: true,
Empty: 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 := mediaToggleAction(c, icons.Must("star"), fmt.Sprintf(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(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 = msg(c, "mail_list.starred") + ", " + s
}
if m.Unread {
s = 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(msg(c, "mail_list.date"))
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func TestMailListSelectStarOpen(t *testing.T) {
var s ListState[string]
mails := mailTestItems(6)
var opened, starred []string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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 ListState[string]
mails := mailTestItems(100000)
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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 ListState[string]
starred := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(20).Width(440).Children(func() { MailList(c, *s, mails, MailListOptions{Height: 220}) })
})
}
+135
View File
@@ -0,0 +1,135 @@
package mujica
import (
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"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 := Tokens(c)
u := 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 := FontSize(c, theme.CaptionSize)
col.Children(func() {
ui.Text(c, m.Subject).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).FontWeight(600)
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
Avatar(c, m.From, 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, msg(c, line[0])+" "+line[1]).FontSize(small).TextColor(k.TextMuted).Selectable().FocusRing(false)
}
}
})
if !m.Date.IsZero() {
ui.Text(c, 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 := 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, 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 := FileMessage(c, 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(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
}
})
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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) })
})
}
+192
View File
@@ -0,0 +1,192 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Member supplies a unique ID, name, presence and role.
type Member struct {
ID, Name string
Image *ui.Bitmap
Presence Presence
// 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 := Tokens(c)
d := 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, msg(c, "member_list.empty")).TextColor(k.TextMuted)
return
}
restore := 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(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() {
Avatar(c, m.Name, 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 != "" {
Badge(c, BadgeOptions{Text: m.Role, Tone: 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(msg(c, "member_list.online"), s.cache.online)
}
return fmt.Sprintf(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)
}
+99
View File
@@ -0,0 +1,99 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestMemberListGroupsAndChoose(t *testing.T) {
var s MemberListState
members := memberTestList(8)
opened := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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")
}
}
+255
View File
@@ -0,0 +1,255 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"strconv"
"strings"
"time"
)
func init() {
addMessages(map[string][2]string{
"channel_list.label": {"Channels", "频道"},
"channel_list.unread": {"%s, %d unread", "%s,%d 条未读"},
"channel_list.pinned": {"Pinned", "已置顶"},
"channel_list.muted": {"Muted", "已静音"},
"chat_message.time": {"15:04", "15:04"},
"chat_message.edited": {"(edited)", "(已编辑)"},
"unread_divider.new": {"New", "新消息"},
"unread_divider.count": {"%d new messages", "%d 条新消息"},
"read_receipt.sent": {"Sent", "已发送"},
"read_receipt.delivered": {"Delivered", "已送达"},
"read_receipt.read": {"Read", "已读"},
"read_receipt.readBy": {"Read by %s", "%s 已读"},
"member_list.empty": {"No members", "没有成员"},
"user_profile_card.label": {"User profile", "用户资料"},
"recipient_input.label": {"Recipients", "收件人"},
"member_list.online": {"Online — %d", "在线 — %d"},
"member_list.offline": {"Offline — %d", "离线 — %d"},
"online_status.online": {"Online", "在线"},
"online_status.away": {"Away", "离开"},
"online_status.busy": {"Busy", "忙碌"},
"online_status.offline": {"Offline", "离线"},
"status_setter.label": {"Status", "状态"},
"status_setter.emoji": {"Choose an emoji", "选择表情"},
"status_setter.text": {"Status text", "状态文字"},
"status_setter.placeholder": {"What is your status?", "你现在的状态是?"},
"status_setter.clearAfter": {"Clear after", "清除时间"},
"status_setter.never": {"Don't clear", "不清除"},
"status_setter.30m": {"30 minutes", "30 分钟"},
"status_setter.1h": {"1 hour", "1 小时"},
"status_setter.4h": {"4 hours", "4 小时"},
"status_setter.today": {"Today", "今天"},
"status_setter.week": {"This week", "本周"},
"status_setter.clear": {"Clear status", "清除状态"},
"call_controls.label": {"Call controls", "通话控制"},
"call_controls.mute": {"Mute", "静音"},
"call_controls.unmute": {"Unmute", "取消静音"},
"call_controls.cameraOff": {"Turn camera off", "关闭摄像头"},
"call_controls.cameraOn": {"Turn camera on", "打开摄像头"},
"call_controls.share": {"Share screen", "共享屏幕"},
"call_controls.stopShare": {"Stop sharing", "停止共享"},
"call_controls.hangUp": {"Leave", "挂断"},
"video_call_grid.label": {"Participants", "参与者"},
"video_call_grid.speaking": {"speaking", "正在发言"},
"video_call_grid.muted": {"muted", "已静音"},
"video_call_grid.cameraOff": {"camera off", "摄像头已关闭"},
"incoming_call_dialog.title": {"%s is calling", "%s 来电"},
"incoming_call_dialog.voice": {"Incoming voice call", "语音来电"},
"incoming_call_dialog.video": {"Incoming video call", "视频来电"},
"incoming_call_dialog.accept": {"Accept", "接听"},
"incoming_call_dialog.acceptVideo": {"With video", "视频接听"},
"incoming_call_dialog.decline": {"Decline", "拒绝"},
"emoji_picker.label": {"Emoji", "表情"},
"emoji_picker.search": {"Search emoji", "搜索表情"},
"emoji_picker.none": {"No emoji found", "没有找到表情"},
"emoji_picker.tone": {"Skin tone", "肤色"},
"emoji_picker.toneN": {"Skin tone %d", "肤色 %d"},
"emoji_picker.recent": {"Recent", "最近使用"},
"emoji_picker.smileys": {"Smileys", "表情"},
"emoji_picker.people": {"People", "人物"},
"emoji_picker.nature": {"Nature", "自然"},
"emoji_picker.food": {"Food and drink", "食物"},
"emoji_picker.activities": {"Activities", "活动"},
"emoji_picker.travel": {"Places", "地点"},
"emoji_picker.objects": {"Objects", "物品"},
"emoji_picker.symbols": {"Symbols", "符号"},
"emoji_picker.name_grinning_face": {"grinning face", "笑脸"},
"emoji_picker.name_smiling_face_with_open_mouth": {"smiling face with open mouth", "张嘴笑脸"},
"emoji_picker.name_face_with_tears_of_joy": {"face with tears of joy", "笑哭"},
"emoji_picker.name_slightly_smiling_face": {"slightly smiling face", "微笑"},
"emoji_picker.name_winking_face": {"winking face", "眨眼"},
"emoji_picker.name_smiling_face_with_smiling_eyes": {"smiling face with smiling eyes", "眉眼带笑"},
"emoji_picker.name_smiling_face_with_heart_eyes": {"smiling face with heart eyes", "爱心眼睛"},
"emoji_picker.name_face_blowing_a_kiss": {"face blowing a kiss", "飞吻"},
"emoji_picker.name_thinking_face": {"thinking face", "思考"},
"emoji_picker.name_neutral_face": {"neutral face", "面无表情"},
"emoji_picker.name_face_with_rolling_eyes": {"face with rolling eyes", "翻白眼"},
"emoji_picker.name_sleeping_face": {"sleeping face", "睡觉"},
"emoji_picker.name_smiling_face_with_sunglasses": {"smiling face with sunglasses", "墨镜笑脸"},
"emoji_picker.name_star_struck": {"star struck", "星星眼"},
"emoji_picker.name_crying_face": {"crying face", "哭泣"},
"emoji_picker.name_pouting_face": {"pouting face", "生气"},
"emoji_picker.name_face_screaming_in_fear": {"face screaming in fear", "惊恐"},
"emoji_picker.name_partying_face": {"partying face", "庆祝"},
"emoji_picker.name_waving_hand": {"waving hand", "挥手"},
"emoji_picker.name_thumbs_up": {"thumbs up", "赞"},
"emoji_picker.name_thumbs_down": {"thumbs down", "踩"},
"emoji_picker.name_clapping_hands": {"clapping hands", "鼓掌"},
"emoji_picker.name_raising_hands": {"raising hands", "举双手"},
"emoji_picker.name_folded_hands": {"folded hands", "合十"},
"emoji_picker.name_ok_hand": {"ok hand", "好手势"},
"emoji_picker.name_victory_hand": {"victory hand", "胜利手势"},
"emoji_picker.name_handshake": {"handshake", "握手"},
"emoji_picker.name_flexed_biceps": {"flexed biceps", "肌肉"},
"emoji_picker.name_writing_hand": {"writing hand", "写字"},
"emoji_picker.name_eyes": {"eyes", "眼睛"},
"emoji_picker.name_person": {"person", "人物"},
"emoji_picker.name_crown": {"crown", "王冠"},
"emoji_picker.name_performing_arts_masks": {"performing arts masks", "戏剧面具"},
"emoji_picker.name_woman_dancing": {"woman dancing", "跳舞"},
"emoji_picker.name_rose": {"rose", "玫瑰"},
"emoji_picker.name_wilted_flower": {"wilted flower", "枯萎的花"},
"emoji_picker.name_cherry_blossom": {"cherry blossom", "樱花"},
"emoji_picker.name_crescent_moon": {"crescent moon", "新月"},
"emoji_picker.name_star": {"star", "星星"},
"emoji_picker.name_fire": {"fire", "火"},
"emoji_picker.name_cloud_with_rain": {"cloud with rain", "雨云"},
"emoji_picker.name_snowflake": {"snowflake", "雪花"},
"emoji_picker.name_black_cat": {"black cat", "黑猫"},
"emoji_picker.name_bat": {"bat", "蝙蝠"},
"emoji_picker.name_black_bird": {"black bird", "黑鸟"},
"emoji_picker.name_dove": {"dove", "鸽子"},
"emoji_picker.name_fallen_leaf": {"fallen leaf", "落叶"},
"emoji_picker.name_evergreen_tree": {"evergreen tree", "常青树"},
"emoji_picker.name_water_wave": {"water wave", "海浪"},
"emoji_picker.name_rainbow": {"rainbow", "彩虹"},
"emoji_picker.name_wine_glass": {"wine glass", "红酒杯"},
"emoji_picker.name_clinking_glasses": {"clinking glasses", "碰杯"},
"emoji_picker.name_hot_beverage": {"hot beverage", "热饮"},
"emoji_picker.name_teacup": {"teacup", "茶杯"},
"emoji_picker.name_shortcake": {"shortcake", "蛋糕"},
"emoji_picker.name_chocolate_bar": {"chocolate bar", "巧克力"},
"emoji_picker.name_strawberry": {"strawberry", "草莓"},
"emoji_picker.name_grapes": {"grapes", "葡萄"},
"emoji_picker.name_red_apple": {"red apple", "红苹果"},
"emoji_picker.name_cheese_wedge": {"cheese wedge", "奶酪"},
"emoji_picker.name_baguette": {"baguette", "法棍"},
"emoji_picker.name_bread": {"bread", "面包"},
"emoji_picker.name_violin": {"violin", "小提琴"},
"emoji_picker.name_musical_keyboard": {"musical keyboard", "键盘"},
"emoji_picker.name_guitar": {"guitar", "吉他"},
"emoji_picker.name_drum": {"drum", "鼓"},
"emoji_picker.name_microphone": {"microphone", "麦克风"},
"emoji_picker.name_musical_note": {"musical note", "音符"},
"emoji_picker.name_musical_notes": {"musical notes", "多个音符"},
"emoji_picker.name_party_popper": {"party popper", "礼花"},
"emoji_picker.name_trophy": {"trophy", "奖杯"},
"emoji_picker.name_artist_palette": {"artist palette", "调色盘"},
"emoji_picker.name_chess_pawn": {"chess pawn", "棋子"},
"emoji_picker.name_clapper_board": {"clapper board", "场记板"},
"emoji_picker.name_castle": {"castle", "城堡"},
"emoji_picker.name_church": {"church", "教堂"},
"emoji_picker.name_tower": {"tower", "塔"},
"emoji_picker.name_night_with_stars": {"night with stars", "星夜"},
"emoji_picker.name_locomotive": {"locomotive", "火车"},
"emoji_picker.name_airplane": {"airplane", "飞机"},
"emoji_picker.name_automobile": {"automobile", "汽车"},
"emoji_picker.name_sailboat": {"sailboat", "帆船"},
"emoji_picker.name_world_map": {"world map", "世界地图"},
"emoji_picker.name_snow_capped_mountain": {"snow capped mountain", "雪山"},
"emoji_picker.name_sunrise": {"sunrise", "日出"},
"emoji_picker.name_ferris_wheel": {"ferris wheel", "摩天轮"},
"emoji_picker.name_candle": {"candle", "蜡烛"},
"emoji_picker.name_old_key": {"old key", "旧钥匙"},
"emoji_picker.name_scroll": {"scroll", "卷轴"},
"emoji_picker.name_envelope": {"envelope", "信封"},
"emoji_picker.name_paperclip": {"paperclip", "回形针"},
"emoji_picker.name_pushpin": {"pushpin", "图钉"},
"emoji_picker.name_calendar": {"calendar", "日历"},
"emoji_picker.name_light_bulb": {"light bulb", "灯泡"},
"emoji_picker.name_bell": {"bell", "铃铛"},
"emoji_picker.name_locked": {"locked", "锁"},
"emoji_picker.name_gem_stone": {"gem stone", "宝石"},
"emoji_picker.name_hourglass": {"hourglass", "沙漏"},
"emoji_picker.name_red_heart": {"red heart", "红心"},
"emoji_picker.name_black_heart": {"black heart", "黑心"},
"emoji_picker.name_broken_heart": {"broken heart", "心碎"},
"emoji_picker.name_sparkles": {"sparkles", "闪光"},
"emoji_picker.name_check_mark_button": {"check mark button", "勾选"},
"emoji_picker.name_cross_mark": {"cross mark", "叉号"},
"emoji_picker.name_exclamation_mark": {"exclamation mark", "感叹号"},
"emoji_picker.name_question_mark": {"question mark", "问号"},
"emoji_picker.name_hundred_points": {"hundred points", "满分"},
"emoji_picker.name_diamond_suit": {"diamond suit", "方块"},
"emoji_picker.name_fleur_de_lis": {"fleur de lis", "鸢尾花纹"},
"emoji_picker.name_red_triangle": {"red triangle", "红三角"},
"pinned_messages.title": {"Pinned messages (%d)", "置顶消息(%d)"},
"pinned_messages.empty": {"No pinned messages", "没有置顶消息"},
"pinned_messages.unpin": {"Unpin %s's message", "取消置顶 %s 的消息"},
"thread_panel.title": {"Thread", "话题"},
"thread_panel.close": {"Close thread", "关闭话题"},
"thread_panel.replies": {"%d replies", "%d 条回复"},
"thread_panel.reply": {"Reply in thread", "在话题中回复"},
"thread_panel.send": {"Send", "发送"},
"mail_list.empty": {"No mail", "没有邮件"},
"mail_list.star": {"Star %s", "星标 %s"},
"mail_list.starred": {"Starred", "已加星标"},
"mail_list.unread": {"Unread", "未读"},
"mail_list.attachments": {"%d attachments", "%d 个附件"},
"mail_list.date": {"Jan 2", "1月2日"},
"mail_reader.to": {"To:", "收件人:"},
"mail_reader.cc": {"Cc:", "抄送:"},
"mail_reader.date": {"Mon, Jan 2 2006 15:04", "2006年1月2日 {weekday} 15:04"},
"mail_reader.attachments": {"Attachments", "附件"},
"mail_composer.label": {"New message", "新邮件"},
"mail_composer.to": {"To", "收件人"},
"mail_composer.cc": {"Cc", "抄送"},
"mail_composer.bcc": {"Bcc", "密送"},
"mail_composer.subject": {"Subject", "主题"},
"mail_composer.body": {"Message", "正文"},
"mail_composer.send": {"Send", "发送"},
"mail_composer.attach": {"Attach", "添加附件"},
"mail_composer.discard": {"Discard", "舍弃"},
"mail_composer.unsaved": {"Unsaved changes", "有未保存的更改"},
"mail_composer.saved": {"Draft saved %s", "草稿已于 %s 保存"},
"recipient_input.invalid": {"%q is not an email address", "“%s”不是有效的邮箱地址"},
"recipient_input.suggestions": {"Contacts", "联系人"},
"quoted_text.showOne": {"Show quoted text (1 line)", "显示引用内容(1 行)"},
"quoted_text.show": {"Show quoted text (%d lines)", "显示引用内容(%d 行)"},
"quoted_text.hide": {"Hide quoted text", "隐藏引用内容"},
"snooze_picker.label": {"Snooze until", "稍后提醒"},
"snooze_picker.later": {"Later today", "今天稍晚"},
"snooze_picker.tomorrow": {"Tomorrow", "明天"},
"snooze_picker.weekend": {"This weekend", "本周末"},
"snooze_picker.nextWeek": {"Next week", "下周"},
"snooze_picker.custom": {"Pick date and time", "选择日期和时间"},
"snooze_picker.set": {"Snooze", "设定提醒"},
"snooze_picker.when": {"Mon 15:04", "{weekday} 15:04"},
"status_setter.until": {"Mon 15:04", "{weekday} 15:04"},
})
}
func messagingDate(c *ui.Context, date time.Time, key string) string {
weekday := msg(c, "calendar.weekday_long_"+strconv.Itoa(int(date.Weekday())))
return strings.ReplaceAll(date.Format(msg(c, key)), "{weekday}", weekday)
}
+25
View File
@@ -0,0 +1,25 @@
package mujica
import (
"github.com/egoist/mygo/ui"
)
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) {
Use(c, Settings{})
if Button(c, "Ring", ButtonOptions{}).Clicked() {
*open = true
}
IncomingCallDialog(c, open, call, opts)
}, 600, 500)
}
+15
View File
@@ -0,0 +1,15 @@
package mujica
var (
msgBellOff = 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 = mediaSVG(`<path d="M12 17v5M9 3h6l-1 6 3 3v2H7v-2l3-3z"/>`)
msgPaperclip = 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 = 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 = 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 = mediaSVG(`<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v7"/>`)
msgPhone = 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 = 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 = mediaSVG(`<path d="M4 10v4M8 7v10M12 4v16M16 7v10M20 10v4"/>`)
msgSend = mediaSVG(`<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>`)
msgClose = mediaSVG(`<path d="M18 6 6 18M6 6l12 12"/>`)
)
+30
View File
@@ -0,0 +1,30 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(400).Children(func() {
RecipientInput(c, list, RecipientInputOptions{Contacts: recipientContacts, Label: "To"})
})
}, 520, 400)
}
+36
View File
@@ -0,0 +1,36 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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: Presence(i % 4)}
}
out[0].Role = "Owner"
return out
}
+47
View File
@@ -0,0 +1,47 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
"time"
)
// 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) {
Use(c, Settings{Locale: 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())
}
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// Presence is a person's availability. The zero value is offline.
type Presence int
const (
PresenceOffline Presence = iota
PresenceOnline
PresenceAway
PresenceBusy
)
func (p Presence) 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 Presence %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 Presence, opts OnlineStatusOptions) *ui.Element {
name := 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 := Tokens(c)
row := ui.Row(c).Gap(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(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
return row
}
func presencePaint(p *ui.Painter, k theme.Tokens, s Presence, 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)
}
}
+41
View File
@@ -0,0 +1,41 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestOnlineStatusTextAndShape(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Gap(12).Children(func() {
for _, p := range []Presence{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, Presence(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 []Presence{PresenceOnline, PresenceAway, PresenceBusy, PresenceOffline} {
OnlineStatus(c, p, OnlineStatusOptions{})
}
})
})
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"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 := Tokens(c)
u := Density(c).Unit()
var r PinnedMessagesResult
col := ui.Column(c).Gap(u * 2).Role(ui.RoleGroup).Label(fmt.Sprintf(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(msg(c, "pinned_messages.title"), len(pins))).Font(theme.SerifFont).FontWeight(600)
})
if len(pins) == 0 {
ui.Text(c, 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(Density(c).ControlHeight()).Gap(u).AlignItems(ui.Center).Children(func() {
if QuoteReply(c, m.Author, m.Text).Grow(1).MaxWidth(10000).Clicked() && !col.IsDisabled() {
r.Jump = m.ID
}
b := iconAction(c, msgPin, fmt.Sprintf(msg(c, "pinned_messages.unpin"), m.Author))
if b.Clicked() && !b.IsDisabled() {
r.Unpinned = m.ID
}
})
}).Height(min(h, float32(len(pins))*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
}
+47
View File
@@ -0,0 +1,47 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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{}) })
})
}
+39
View File
@@ -0,0 +1,39 @@
package mujica
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// QuotedText folds or expands one level of quoted text.
func QuotedText(c *ui.Context, text string, open *bool) ChoiceResult {
body := QuoteStrip(text)
n := strings.Count(body, "\n") + 1
k := Tokens(c)
u := Density(c).Unit()
title := fmt.Sprintf(msg(c, "quoted_text.show"), n)
if n == 1 {
title = msg(c, "quoted_text.showOne")
}
if *open {
title = msg(c, "quoted_text.hide")
}
return Collapsible(c, open, 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")
}
+48
View File
@@ -0,0 +1,48 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, Settings{})
QuotedText(c, "> One line", &open)
}, 400, 200)
if !tt.HasText("Show quoted text (1 line)") {
t.Fatal("singular quote label missing")
}
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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 []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 := Tokens(c)
u := Density(c).Unit()
word := 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(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(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() { Avatar(c, r.Name, AvatarOptions{Image: r.Image, Size: 16}) })
}
})
if rest := len(opts.Readers) - limit; rest > 0 {
ui.Textf(c, "+%d", rest).FontSize(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)
}
}
+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestReadReceiptStates(t *testing.T) {
readers := []AvatarItem{{Name: "Uika"}, {Name: "Mutsumi"}, {Name: "Umiri"}, {Name: "Nyamu"}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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: []AvatarItem{{Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"}, {Name: "Nyamu"}}})
})
})
}
+179
View File
@@ -0,0 +1,179 @@
package mujica
import (
"fmt"
"log/slog"
"net/mail"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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) TagsInputParts {
k := Tokens(c)
if opts.Label == "" {
opts.Label = 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 TagsInputParts
wrap.Children(func() {
parts = TagsInput(c, recipients, 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(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 = TagsInputParts{Element: wrap, Input: in, changed: 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))*Density(c).ControlHeight()+16, max(0, wh-8))
x, y := overlayPlace(c, b, w, h, 4)
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(4).Role(ui.RoleList).
Label(msg(c, "recipient_input.suggestions"))
overlayStyle(c, panel, 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(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() {
Avatar(c, ct.Name, AvatarOptions{Size: 22})
ui.Text(c, ct.Name).SingleLine()
ui.Text(c, ct.Email).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
if row.Clicked() && !row.IsDisabled() {
pick(ct)
parts.changed = 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()
}
+188
View File
@@ -0,0 +1,188 @@
package mujica
import (
"net/mail"
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestRecipientInputRefreshesContacts(t *testing.T) {
var recipients []string
contacts := []Contact{{Name: "Alice", Email: "alice@example.com"}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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) {
Use(c, Settings{})
focus = 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")
}
}
+139
View File
@@ -0,0 +1,139 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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) 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 := Tokens(c)
d := 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(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(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(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(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(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() {
DateTimePicker(c, &s.pick, DateTimePickerOptions{Location: loc, Min: now, Label: msg(c, "snooze_picker.custom"), Disabled: opts.Disabled})
if Button(c, msg(c, "snooze_picker.set"), 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 ChoiceResult{Element: col, changed: 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 messagingDate(c, t, "snooze_picker.when")
}
+121
View File
@@ -0,0 +1,121 @@
package mujica
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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")
}
}
+137
View File
@@ -0,0 +1,137 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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) ChoiceResult {
loc := opts.Location
if loc == nil {
loc = time.Local
}
k := Tokens(c)
u := Density(c).Unit()
old := *st
col := ui.Column(c).Gap(u * 2).Role(ui.RoleGroup).Label(msg(c, "status_setter.label")).Disabled(opts.Disabled)
ss := ui.Local(col, statusSetterKey{}, func() *statusSetterState { return &statusSetterState{} })
s := *ss
live := !col.IsDisabled()
if !live {
copy := *st
st = &copy
}
if !st.Until.Equal(s.until) {
s.until = st.Until
s.clear = 0
if !st.Until.IsZero() {
s.clear = -1
}
}
col.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
face := st.Emoji
if face == "" {
face = "🙂"
}
b := ui.ButtonBase(c).Size(Density(c).ControlHeight(), Density(c).ControlHeight()).Radius(theme.ControlRadius).
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(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(FontSize(c, 18)) })
if b.Clicked() && !b.IsDisabled() {
s.picking = !s.picking
}
if b.IsDisabled() {
s.picking = false
}
Popover(c, b, &s.picking, PopoverOptions{Label: msg(c, "emoji_picker.label")}, func() {
if e := EmojiPicker(c, EmojiPickerOptions{}); e.Picked != "" {
st.Emoji, s.picking = e.Picked, false
}
})
TextInput(c, &st.Text, TextInputOptions{Placeholder: msg(c, "status_setter.placeholder"), Label: 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, msg(c, "status_setter.clearAfter")).TextColor(k.TextMuted).SingleLine().Shrink(0)
options := make([]SelectOption[int], len(statusClear))
for i, o := range statusClear {
options[i] = SelectOption[int]{Value: i, Label: msg(c, o.key)}
}
ui.Box(c).Key(live).Children(func() {
if Select(c, &s.clear, options, SelectOptions{Label: msg(c, "status_setter.clearAfter"), Placeholder: 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, messagingDate(c, st.Until.In(loc), "status_setter.until")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
ui.Box(c).Grow(1)
if Button(c, msg(c, "status_setter.clear"), ButtonOptions{Variant: 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 ChoiceResult{Element: col, changed: 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)
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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")
}
}
+15 -8
View File
@@ -27,6 +27,8 @@ type TagsInputOptions struct {
Disabled bool
// Invalid draws the border in the danger color.
Invalid bool
// Query optionally holds the current editor text.
Query *string
}
// TagsInputParts are the parts of a TagsInput.
@@ -42,6 +44,7 @@ func (p TagsInputParts) Changed() bool { return p.changed }
type tagsInputState struct {
query string
q *string
err string
backspace bool
}
@@ -61,6 +64,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
outer := ui.Column(c).Gap(d.Unit())
parts.Element = outer
st := ui.Local(outer, "tags", func() tagsInputState { return tagsInputState{} })
st.q = &st.query
if opts.Query != nil {
st.q = opts.Query
}
add := func(s string) bool {
s = strings.TrimSpace(s)
switch {
@@ -92,7 +99,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
disabled := opts.Disabled || outer.IsDisabled()
if st.backspace {
st.backspace = false
if !disabled && len(*tags) > 0 && st.query == "" {
if !disabled && len(*tags) > 0 && *st.q == "" {
remove(len(*tags) - 1)
}
}
@@ -120,13 +127,13 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
// The input keeps its identity whatever chips come before it.
holder := ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24)
holder.Children(func() {
in := inputGroupEditor(c, holder, &st.query, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder)
in := inputGroupEditor(c, holder, st.q, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder)
parts.Input = in
if opts.Label != "" {
in.Label(opts.Label)
}
in.HandleInput(func(ev ui.InputEvent) bool {
if !disabled && ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" {
if !disabled && ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && *st.q == "" {
st.backspace = true
return true
}
@@ -168,8 +175,8 @@ func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(s
if in.Changed() {
st.err = ""
}
if strings.ContainsAny(st.query, seps) {
pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) })
if strings.ContainsAny(*st.q, seps) {
pieces := strings.FieldsFunc(*st.q+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) })
last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00")
var kept []string
for _, p := range pieces[:len(pieces)-1] {
@@ -177,9 +184,9 @@ func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(s
kept = append(kept, strings.TrimSpace(p))
}
}
st.query = strings.Join(append(kept, last), string([]rune(seps)[0]))
*st.q = strings.Join(append(kept, last), string([]rune(seps)[0]))
}
if in.Submitted() && add(st.query) {
st.query = ""
if in.Submitted() && add(*st.q) {
*st.q = ""
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more