diff --git a/call_controls.go b/call_controls.go
new file mode 100644
index 0000000..4d12d1a
--- /dev/null
+++ b/call_controls.go
@@ -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
+}
diff --git a/call_controls_test.go b/call_controls_test.go
new file mode 100644
index 0000000..4b2fa45
--- /dev/null
+++ b/call_controls_test.go
@@ -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()) })
+ })
+}
diff --git a/channel_list.go b/channel_list.go
new file mode 100644
index 0000000..f929c86
--- /dev/null
+++ b/channel_list.go
@@ -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()
+}
diff --git a/channel_list_test.go b/channel_list_test.go
new file mode 100644
index 0000000..166875a
--- /dev/null
+++ b/channel_list_test.go
@@ -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{}) })
+ })
+}
diff --git a/chat_message.go b/chat_message.go
new file mode 100644
index 0000000..1d24bc8
--- /dev/null
+++ b/chat_message.go
@@ -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
+}
diff --git a/chat_message_test.go b/chat_message_test.go
new file mode 100644
index 0000000..745c5b0
--- /dev/null
+++ b/chat_message_test.go
@@ -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{})
+ })
+ })
+}
diff --git a/docs/components/401-channel-list.md b/docs/components/401-channel-list.md
new file mode 100644
index 0000000..c19fae9
--- /dev/null
+++ b/docs/components/401-channel-list.md
@@ -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 构建。
diff --git a/docs/components/402-chat-message.md b/docs/components/402-chat-message.md
new file mode 100644
index 0000000..6514ec7
--- /dev/null
+++ b/docs/components/402-chat-message.md
@@ -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;回复与回应的业务逻辑在插槽中实现。
diff --git a/docs/components/403-unread-divider.md b/docs/components/403-unread-divider.md
new file mode 100644
index 0000000..acf022b
--- /dev/null
+++ b/docs/components/403-unread-divider.md
@@ -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 显示“新消息”,正值显示完整计数。
+
+## 状态
+
+状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
+
+## 事件
+
+纯展示,计数由调用方更新。
+
+## 键盘操作
+
+展示组件保持自然焦点序列。
+
+## 限制
+
+组件展示调用方提供的计数。
diff --git a/docs/components/404-read-receipt.md b/docs/components/404-read-receipt.md
new file mode 100644
index 0000000..59938ff
--- /dev/null
+++ b/docs/components/404-read-receipt.md
@@ -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 时显示剩余数量。
diff --git a/docs/components/405-member-list.md b/docs/components/405-member-list.md
new file mode 100644
index 0000000..3c5b809
--- /dev/null
+++ b/docs/components/405-member-list.md
@@ -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 或双击打开成员。
+
+## 限制
+
+分组缓存依据切片身份与长度;成员数据变化后替换切片。
diff --git a/docs/components/406-user-profile-card.md b/docs/components/406-user-profile-card.md
new file mode 100644
index 0000000..778b25b
--- /dev/null
+++ b/docs/components/406-user-profile-card.md
@@ -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 激活按钮。
+
+## 限制
+
+业务动作、头像加载与个人资料更新由调用方处理。
diff --git a/docs/components/407-online-status.md b/docs/components/407-online-status.md
new file mode 100644
index 0000000..8e59b96
--- /dev/null
+++ b/docs/components/407-online-status.md
@@ -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。
+
+## 状态
+
+状态由调用方数据控制;浅深主题、密度与文字缩放随窗口设置变化。适用插槽操作沿用控件焦点和禁用状态。
+
+## 事件
+
+纯展示,在线状态由调用方更新。
+
+## 键盘操作
+
+展示组件保持自然焦点序列。
+
+## 限制
+
+在线状态由调用方提供;隐藏文字仍保留状态形状和无障碍名称。
diff --git a/docs/components/408-status-setter.md b/docs/components/408-status-setter.md
new file mode 100644
index 0000000..7e1f96b
--- /dev/null
+++ b/docs/components/408-status-setter.md
@@ -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 作为业务期限返回,调用方负责届时清除状态;组件保持空闲。
diff --git a/docs/components/409-call-controls.md b/docs/components/409-call-controls.md
new file mode 100644
index 0000000..c0ad5dc
--- /dev/null
+++ b/docs/components/409-call-controls.md
@@ -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 挂断。
+
+## 限制
+
+通话、设备和屏幕采集由调用方处理;快捷键作用于当前窗口。
diff --git a/docs/components/410-video-call-grid.md b/docs/components/410-video-call-grid.md
new file mode 100644
index 0000000..2d1b852
--- /dev/null
+++ b/docs/components/410-video-call-grid.md
@@ -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()。
diff --git a/docs/components/411-incoming-call-dialog.md b/docs/components/411-incoming-call-dialog.md
new file mode 100644
index 0000000..26290c3
--- /dev/null
+++ b/docs/components/411-incoming-call-dialog.md
@@ -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 拒绝;关闭后恢复焦点。
+
+## 限制
+
+来电数据和通话连接由调用方处理;点击遮罩保持来电显示;禁用关闭时回调保持静默。
diff --git a/docs/components/412-emoji-picker.md b/docs/components/412-emoji-picker.md
new file mode 100644
index 0000000..a59a711
--- /dev/null
+++ b/docs/components/412-emoji-picker.md
@@ -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 选择。
+
+## 限制
+
+内置常用表情子集;对支持肤色的条目应用修饰符。
diff --git a/docs/components/413-pinned-messages.md b/docs/components/413-pinned-messages.md
new file mode 100644
index 0000000..3d27874
--- /dev/null
+++ b/docs/components/413-pinned-messages.md
@@ -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 激活。
+
+## 限制
+
+调用方维护置顶消息;列表行只构建可见部分。
diff --git a/docs/components/414-thread-panel.md b/docs/components/414-thread-panel.md
new file mode 100644
index 0000000..99abd32
--- /dev/null
+++ b/docs/components/414-thread-panel.md
@@ -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 关闭面板。
+
+## 限制
+
+回复发送和持久化由调用方完成;原消息区限高滚动,回复列表独立滚动。
diff --git a/docs/components/415-mail-list.md b/docs/components/415-mail-list.md
new file mode 100644
index 0000000..758e294
--- /dev/null
+++ b/docs/components/415-mail-list.md
@@ -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 点击选择范围。
+
+## 限制
+
+星标、已读更新、邮件下载与打开由调用方处理;列表只构建可见行。
diff --git a/docs/components/416-mail-reader.md b/docs/components/416-mail-reader.md
new file mode 100644
index 0000000..6f8283b
--- /dev/null
+++ b/docs/components/416-mail-reader.md
@@ -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 展示为文字;链接和附件动作由调用方执行。
diff --git a/docs/components/417-mail-composer.md b/docs/components/417-mail-composer.md
new file mode 100644
index 0000000..e046cf7
--- /dev/null
+++ b/docs/components/417-mail-composer.md
@@ -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 校验;实际发送、附件选择与存储由调用方处理;保存回调在界面线程运行。
diff --git a/docs/components/418-recipient-input.md b/docs/components/418-recipient-input.md
new file mode 100644
index 0000000..5e79205
--- /dev/null
+++ b/docs/components/418-recipient-input.md
@@ -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 项;联系人匹配在查询或联系人、收件人数据变化时计算;业务邮箱可达性由调用方验证。
diff --git a/docs/components/419-quoted-text.md b/docs/components/419-quoted-text.md
new file mode 100644
index 0000000..921d92b
--- /dev/null
+++ b/docs/components/419-quoted-text.md
@@ -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 展开或折叠;展开内容支持选择复制。
+
+## 限制
+
+文本按一层引用规则处理,嵌套引用继续保留标记。
diff --git a/docs/components/420-snooze-picker.md b/docs/components/420-snooze-picker.md
new file mode 100644
index 0000000..5a8ee40
--- /dev/null
+++ b/docs/components/420-snooze-picker.md
@@ -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 的输入、确认和取消操作。
+
+## 限制
+
+组件返回时间,提醒调度与邮件隐藏由调用方处理;本地日期按日历构造以保留夏令时后的早晨时刻。
diff --git a/emoji_data.go b/emoji_data.go
new file mode 100644
index 0000000..a0e7fc4
--- /dev/null
+++ b/emoji_data.go
@@ -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]
+}
diff --git a/emoji_picker.go b/emoji_picker.go
new file mode 100644
index 0000000..a88830c
--- /dev/null
+++ b/emoji_picker.go
@@ -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, " ", "_"))
+}
diff --git a/emoji_picker_test.go b/emoji_picker_test.go
new file mode 100644
index 0000000..6a8155a
--- /dev/null
+++ b/emoji_picker_test.go
@@ -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)
+ }
+}
diff --git a/example_messaging_test.go b/example_messaging_test.go
new file mode 100644
index 0000000..8f93b6a
--- /dev/null
+++ b/example_messaging_test.go
@@ -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)
+}
diff --git a/examples/gallery/pages_messaging.go b/examples/gallery/pages_messaging.go
index fcde9bd..c4c902a 100644
--- a/examples/gallery/pages_messaging.go
+++ b/examples/gallery/pages_messaging.go
@@ -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)`},
+}
diff --git a/examples/gallery/pages_messaging_calls.go b/examples/gallery/pages_messaging_calls.go
new file mode 100644
index 0000000..f5fa3e5
--- /dev/null
+++ b/examples/gallery/pages_messaging_calls.go
@@ -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)
+}
diff --git a/examples/gallery/pages_messaging_chat.go b/examples/gallery/pages_messaging_chat.go
new file mode 100644
index 0000000..586cf95
--- /dev/null
+++ b/examples/gallery/pages_messaging_chat.go
@@ -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")
+ }
+}
diff --git a/examples/gallery/pages_messaging_mail.go b/examples/gallery/pages_messaging_mail.go
new file mode 100644
index 0000000..0e35654
--- /dev/null
+++ b/examples/gallery/pages_messaging_mail.go
@@ -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"
+ }
+ 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))
+}
diff --git a/examples/gallery/pages_messaging_people.go b/examples/gallery/pages_messaging_people.go
new file mode 100644
index 0000000..4f68711
--- /dev/null
+++ b/examples/gallery/pages_messaging_people.go
@@ -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))
+ }
+}
diff --git a/examples/gallery/pages_messaging_test.go b/examples/gallery/pages_messaging_test.go
new file mode 100644
index 0000000..e55faa0
--- /dev/null
+++ b/examples/gallery/pages_messaging_test.go
@@ -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)
+ }
+ }
+ }
+}
diff --git a/incoming_call_dialog.go b/incoming_call_dialog.go
new file mode 100644
index 0000000..93a6958
--- /dev/null
+++ b/incoming_call_dialog.go
@@ -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()
+ }
+}
diff --git a/incoming_call_dialog_test.go b/incoming_call_dialog_test.go
new file mode 100644
index 0000000..1f76ec1
--- /dev/null
+++ b/incoming_call_dialog_test.go
@@ -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)
+ }
+}
diff --git a/mail_composer.go b/mail_composer.go
new file mode 100644
index 0000000..b0863be
--- /dev/null
+++ b/mail_composer.go
@@ -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 = ©
+ }
+ 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)
+}
diff --git a/mail_composer_test.go b/mail_composer_test.go
new file mode 100644
index 0000000..a842e4b
--- /dev/null
+++ b/mail_composer_test.go
@@ -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 "}, 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")
+ }
+}
diff --git a/mail_list.go b/mail_list.go
new file mode 100644
index 0000000..71e6f88
--- /dev/null
+++ b/mail_list.go
@@ -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"))
+}
diff --git a/mail_list_test.go b/mail_list_test.go
new file mode 100644
index 0000000..5d50b59
--- /dev/null
+++ b/mail_list_test.go
@@ -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}) })
+ })
+}
diff --git a/mail_reader.go b/mail_reader.go
new file mode 100644
index 0000000..7fd0968
--- /dev/null
+++ b/mail_reader.go
@@ -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
+ }
+ })
+}
diff --git a/mail_reader_test.go b/mail_reader_test.go
new file mode 100644
index 0000000..03ff774
--- /dev/null
+++ b/mail_reader_test.go
@@ -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"}
+ 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("") {
+ 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) })
+ })
+}
diff --git a/member_list.go b/member_list.go
new file mode 100644
index 0000000..259b02f
--- /dev/null
+++ b/member_list.go
@@ -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)
+}
diff --git a/member_list_test.go b/member_list_test.go
new file mode 100644
index 0000000..f8810cd
--- /dev/null
+++ b/member_list_test.go
@@ -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")
+ }
+}
diff --git a/messages_messaging.go b/messages_messaging.go
new file mode 100644
index 0000000..c479840
--- /dev/null
+++ b/messages_messaging.go
@@ -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)
+}
diff --git a/messaging_calls_test.go b/messaging_calls_test.go
new file mode 100644
index 0000000..b2f205b
--- /dev/null
+++ b/messaging_calls_test.go
@@ -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)
+}
diff --git a/messaging_icons.go b/messaging_icons.go
new file mode 100644
index 0000000..e569df0
--- /dev/null
+++ b/messaging_icons.go
@@ -0,0 +1,15 @@
+package mujica
+
+var (
+ msgBellOff = mediaSVG(``)
+ msgPin = mediaSVG(``)
+ msgPaperclip = mediaSVG(``)
+ msgMicOff = mediaSVG(``)
+ msgVideoOff = mediaSVG(``)
+ msgShare = mediaSVG(``)
+ msgPhone = mediaSVG(``)
+ msgHangUp = mediaSVG(``)
+ msgSpeaking = mediaSVG(``)
+ msgSend = mediaSVG(``)
+ msgClose = mediaSVG(``)
+)
diff --git a/messaging_mail_test.go b/messaging_mail_test.go
new file mode 100644
index 0000000..a04aa60
--- /dev/null
+++ b/messaging_mail_test.go
@@ -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)
+}
diff --git a/messaging_people_test.go b/messaging_people_test.go
new file mode 100644
index 0000000..a7d16b8
--- /dev/null
+++ b/messaging_people_test.go
@@ -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
+}
diff --git a/messaging_regression_test.go b/messaging_regression_test.go
new file mode 100644
index 0000000..ac25aa3
--- /dev/null
+++ b/messaging_regression_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/messaging_small_test.go b/messaging_small_test.go
new file mode 100644
index 0000000..384dae8
--- /dev/null
+++ b/messaging_small_test.go
@@ -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())
+ }
+}
diff --git a/online_status.go b/online_status.go
new file mode 100644
index 0000000..f15f8a7
--- /dev/null
+++ b/online_status.go
@@ -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)
+ }
+}
diff --git a/online_status_test.go b/online_status_test.go
new file mode 100644
index 0000000..f77426d
--- /dev/null
+++ b/online_status_test.go
@@ -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{})
+ }
+ })
+ })
+}
diff --git a/pinned_messages.go b/pinned_messages.go
new file mode 100644
index 0000000..3b167d4
--- /dev/null
+++ b/pinned_messages.go
@@ -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
+}
diff --git a/pinned_messages_test.go b/pinned_messages_test.go
new file mode 100644
index 0000000..c35be4d
--- /dev/null
+++ b/pinned_messages_test.go
@@ -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{}) })
+ })
+}
diff --git a/quoted_text.go b/quoted_text.go
new file mode 100644
index 0000000..0d94fb2
--- /dev/null
+++ b/quoted_text.go
@@ -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")
+}
diff --git a/quoted_text_test.go b/quoted_text_test.go
new file mode 100644
index 0000000..5aebed1
--- /dev/null
+++ b/quoted_text_test.go
@@ -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")
+ }
+}
diff --git a/read_receipt.go b/read_receipt.go
new file mode 100644
index 0000000..97885af
--- /dev/null
+++ b/read_receipt.go
@@ -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)
+ }
+}
diff --git a/read_receipt_test.go b/read_receipt_test.go
new file mode 100644
index 0000000..1039dc8
--- /dev/null
+++ b/read_receipt_test.go
@@ -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"}}})
+ })
+ })
+}
diff --git a/recipient_input.go b/recipient_input.go
new file mode 100644
index 0000000..07dc88e
--- /dev/null
+++ b/recipient_input.go
@@ -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()
+}
diff --git a/recipient_input_test.go b/recipient_input_test.go
new file mode 100644
index 0000000..b86b04b
--- /dev/null
+++ b/recipient_input_test.go
@@ -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 "}) {
+ t.Fatalf("list %v", list)
+ }
+ tt.Type("ui")
+ if err := tt.Click("Uika Misumi "); err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 2 || list[1] != "Uika Misumi " {
+ 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 ", "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" `); 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 "
+ 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")
+ }
+}
diff --git a/snooze_picker.go b/snooze_picker.go
new file mode 100644
index 0000000..49d933c
--- /dev/null
+++ b/snooze_picker.go
@@ -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")
+}
diff --git a/snooze_picker_test.go b/snooze_picker_test.go
new file mode 100644
index 0000000..22a8d0e
--- /dev/null
+++ b/snooze_picker_test.go
@@ -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")
+ }
+}
diff --git a/status_setter.go b/status_setter.go
new file mode 100644
index 0000000..1fb7b16
--- /dev/null
+++ b/status_setter.go
@@ -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 = ©
+ }
+ 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)
+}
diff --git a/status_setter_test.go b/status_setter_test.go
new file mode 100644
index 0000000..9620f2b
--- /dev/null
+++ b/status_setter_test.go
@@ -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")
+ }
+}
diff --git a/tags_input.go b/tags_input.go
index 3f9afc6..b1b2944 100644
--- a/tags_input.go
+++ b/tags_input.go
@@ -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 = ""
}
}
diff --git a/testdata/snapshots/darwin/call_controls-dark.png b/testdata/snapshots/darwin/call_controls-dark.png
new file mode 100644
index 0000000..f672255
Binary files /dev/null and b/testdata/snapshots/darwin/call_controls-dark.png differ
diff --git a/testdata/snapshots/darwin/call_controls-light.png b/testdata/snapshots/darwin/call_controls-light.png
new file mode 100644
index 0000000..bb25ed9
Binary files /dev/null and b/testdata/snapshots/darwin/call_controls-light.png differ
diff --git a/testdata/snapshots/darwin/channel_list-dark.png b/testdata/snapshots/darwin/channel_list-dark.png
new file mode 100644
index 0000000..cabdb23
Binary files /dev/null and b/testdata/snapshots/darwin/channel_list-dark.png differ
diff --git a/testdata/snapshots/darwin/channel_list-light.png b/testdata/snapshots/darwin/channel_list-light.png
new file mode 100644
index 0000000..173404d
Binary files /dev/null and b/testdata/snapshots/darwin/channel_list-light.png differ
diff --git a/testdata/snapshots/darwin/chat_message-dark.png b/testdata/snapshots/darwin/chat_message-dark.png
new file mode 100644
index 0000000..f764071
Binary files /dev/null and b/testdata/snapshots/darwin/chat_message-dark.png differ
diff --git a/testdata/snapshots/darwin/chat_message-light.png b/testdata/snapshots/darwin/chat_message-light.png
new file mode 100644
index 0000000..1a31029
Binary files /dev/null and b/testdata/snapshots/darwin/chat_message-light.png differ
diff --git a/testdata/snapshots/darwin/emoji_picker-dark.png b/testdata/snapshots/darwin/emoji_picker-dark.png
new file mode 100644
index 0000000..8788f18
Binary files /dev/null and b/testdata/snapshots/darwin/emoji_picker-dark.png differ
diff --git a/testdata/snapshots/darwin/emoji_picker-light.png b/testdata/snapshots/darwin/emoji_picker-light.png
new file mode 100644
index 0000000..a4867f6
Binary files /dev/null and b/testdata/snapshots/darwin/emoji_picker-light.png differ
diff --git a/testdata/snapshots/darwin/incoming_call_dialog-dark.png b/testdata/snapshots/darwin/incoming_call_dialog-dark.png
new file mode 100644
index 0000000..310ecba
Binary files /dev/null and b/testdata/snapshots/darwin/incoming_call_dialog-dark.png differ
diff --git a/testdata/snapshots/darwin/incoming_call_dialog-light.png b/testdata/snapshots/darwin/incoming_call_dialog-light.png
new file mode 100644
index 0000000..15a5017
Binary files /dev/null and b/testdata/snapshots/darwin/incoming_call_dialog-light.png differ
diff --git a/testdata/snapshots/darwin/mail_composer-dark.png b/testdata/snapshots/darwin/mail_composer-dark.png
new file mode 100644
index 0000000..55e628d
Binary files /dev/null and b/testdata/snapshots/darwin/mail_composer-dark.png differ
diff --git a/testdata/snapshots/darwin/mail_composer-light.png b/testdata/snapshots/darwin/mail_composer-light.png
new file mode 100644
index 0000000..3f70e3a
Binary files /dev/null and b/testdata/snapshots/darwin/mail_composer-light.png differ
diff --git a/testdata/snapshots/darwin/mail_list-dark.png b/testdata/snapshots/darwin/mail_list-dark.png
new file mode 100644
index 0000000..ade1f58
Binary files /dev/null and b/testdata/snapshots/darwin/mail_list-dark.png differ
diff --git a/testdata/snapshots/darwin/mail_list-light.png b/testdata/snapshots/darwin/mail_list-light.png
new file mode 100644
index 0000000..50851b2
Binary files /dev/null and b/testdata/snapshots/darwin/mail_list-light.png differ
diff --git a/testdata/snapshots/darwin/mail_reader-dark.png b/testdata/snapshots/darwin/mail_reader-dark.png
new file mode 100644
index 0000000..3315209
Binary files /dev/null and b/testdata/snapshots/darwin/mail_reader-dark.png differ
diff --git a/testdata/snapshots/darwin/mail_reader-light.png b/testdata/snapshots/darwin/mail_reader-light.png
new file mode 100644
index 0000000..2d89946
Binary files /dev/null and b/testdata/snapshots/darwin/mail_reader-light.png differ
diff --git a/testdata/snapshots/darwin/member_list-dark.png b/testdata/snapshots/darwin/member_list-dark.png
new file mode 100644
index 0000000..ff378b8
Binary files /dev/null and b/testdata/snapshots/darwin/member_list-dark.png differ
diff --git a/testdata/snapshots/darwin/member_list-light.png b/testdata/snapshots/darwin/member_list-light.png
new file mode 100644
index 0000000..56e8b03
Binary files /dev/null and b/testdata/snapshots/darwin/member_list-light.png differ
diff --git a/testdata/snapshots/darwin/online_status-dark.png b/testdata/snapshots/darwin/online_status-dark.png
new file mode 100644
index 0000000..c3ba64e
Binary files /dev/null and b/testdata/snapshots/darwin/online_status-dark.png differ
diff --git a/testdata/snapshots/darwin/online_status-light.png b/testdata/snapshots/darwin/online_status-light.png
new file mode 100644
index 0000000..8e8555a
Binary files /dev/null and b/testdata/snapshots/darwin/online_status-light.png differ
diff --git a/testdata/snapshots/darwin/pinned_messages-dark.png b/testdata/snapshots/darwin/pinned_messages-dark.png
new file mode 100644
index 0000000..0db5534
Binary files /dev/null and b/testdata/snapshots/darwin/pinned_messages-dark.png differ
diff --git a/testdata/snapshots/darwin/pinned_messages-light.png b/testdata/snapshots/darwin/pinned_messages-light.png
new file mode 100644
index 0000000..424da23
Binary files /dev/null and b/testdata/snapshots/darwin/pinned_messages-light.png differ
diff --git a/testdata/snapshots/darwin/quoted_text-dark.png b/testdata/snapshots/darwin/quoted_text-dark.png
new file mode 100644
index 0000000..a6212e6
Binary files /dev/null and b/testdata/snapshots/darwin/quoted_text-dark.png differ
diff --git a/testdata/snapshots/darwin/quoted_text-light.png b/testdata/snapshots/darwin/quoted_text-light.png
new file mode 100644
index 0000000..c50af2f
Binary files /dev/null and b/testdata/snapshots/darwin/quoted_text-light.png differ
diff --git a/testdata/snapshots/darwin/read_receipt-dark.png b/testdata/snapshots/darwin/read_receipt-dark.png
new file mode 100644
index 0000000..1f77821
Binary files /dev/null and b/testdata/snapshots/darwin/read_receipt-dark.png differ
diff --git a/testdata/snapshots/darwin/read_receipt-light.png b/testdata/snapshots/darwin/read_receipt-light.png
new file mode 100644
index 0000000..75c0c3f
Binary files /dev/null and b/testdata/snapshots/darwin/read_receipt-light.png differ
diff --git a/testdata/snapshots/darwin/recipient_input-dark.png b/testdata/snapshots/darwin/recipient_input-dark.png
new file mode 100644
index 0000000..a68e67a
Binary files /dev/null and b/testdata/snapshots/darwin/recipient_input-dark.png differ
diff --git a/testdata/snapshots/darwin/recipient_input-light.png b/testdata/snapshots/darwin/recipient_input-light.png
new file mode 100644
index 0000000..27f3076
Binary files /dev/null and b/testdata/snapshots/darwin/recipient_input-light.png differ
diff --git a/testdata/snapshots/darwin/snooze_picker-dark.png b/testdata/snapshots/darwin/snooze_picker-dark.png
new file mode 100644
index 0000000..027ca7a
Binary files /dev/null and b/testdata/snapshots/darwin/snooze_picker-dark.png differ
diff --git a/testdata/snapshots/darwin/snooze_picker-light.png b/testdata/snapshots/darwin/snooze_picker-light.png
new file mode 100644
index 0000000..5ef2b08
Binary files /dev/null and b/testdata/snapshots/darwin/snooze_picker-light.png differ
diff --git a/testdata/snapshots/darwin/status_setter-dark.png b/testdata/snapshots/darwin/status_setter-dark.png
new file mode 100644
index 0000000..73dddf5
Binary files /dev/null and b/testdata/snapshots/darwin/status_setter-dark.png differ
diff --git a/testdata/snapshots/darwin/status_setter-light.png b/testdata/snapshots/darwin/status_setter-light.png
new file mode 100644
index 0000000..99e6fc3
Binary files /dev/null and b/testdata/snapshots/darwin/status_setter-light.png differ
diff --git a/testdata/snapshots/darwin/thread_panel-dark.png b/testdata/snapshots/darwin/thread_panel-dark.png
new file mode 100644
index 0000000..fa5a429
Binary files /dev/null and b/testdata/snapshots/darwin/thread_panel-dark.png differ
diff --git a/testdata/snapshots/darwin/thread_panel-light.png b/testdata/snapshots/darwin/thread_panel-light.png
new file mode 100644
index 0000000..98d438d
Binary files /dev/null and b/testdata/snapshots/darwin/thread_panel-light.png differ
diff --git a/testdata/snapshots/darwin/unread_divider-dark.png b/testdata/snapshots/darwin/unread_divider-dark.png
new file mode 100644
index 0000000..f099fe9
Binary files /dev/null and b/testdata/snapshots/darwin/unread_divider-dark.png differ
diff --git a/testdata/snapshots/darwin/unread_divider-light.png b/testdata/snapshots/darwin/unread_divider-light.png
new file mode 100644
index 0000000..543b0f8
Binary files /dev/null and b/testdata/snapshots/darwin/unread_divider-light.png differ
diff --git a/testdata/snapshots/darwin/user_profile_card-dark.png b/testdata/snapshots/darwin/user_profile_card-dark.png
new file mode 100644
index 0000000..123e131
Binary files /dev/null and b/testdata/snapshots/darwin/user_profile_card-dark.png differ
diff --git a/testdata/snapshots/darwin/user_profile_card-light.png b/testdata/snapshots/darwin/user_profile_card-light.png
new file mode 100644
index 0000000..1d9aa03
Binary files /dev/null and b/testdata/snapshots/darwin/user_profile_card-light.png differ
diff --git a/testdata/snapshots/darwin/video_call_grid-dark.png b/testdata/snapshots/darwin/video_call_grid-dark.png
new file mode 100644
index 0000000..e4c418f
Binary files /dev/null and b/testdata/snapshots/darwin/video_call_grid-dark.png differ
diff --git a/testdata/snapshots/darwin/video_call_grid-light.png b/testdata/snapshots/darwin/video_call_grid-light.png
new file mode 100644
index 0000000..bfa23da
Binary files /dev/null and b/testdata/snapshots/darwin/video_call_grid-light.png differ
diff --git a/thread_panel.go b/thread_panel.go
new file mode 100644
index 0000000..2752af3
--- /dev/null
+++ b/thread_panel.go
@@ -0,0 +1,101 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
+)
+
+// ThreadPanelOptions configure a ThreadPanel.
+type ThreadPanelOptions struct {
+ // Width is the panel's width; zero means 360.
+ Width float32
+ Disabled bool
+}
+
+// ThreadPanelResult is the panel's element and this frame's events.
+type ThreadPanelResult struct {
+ Element *ui.Element
+ // Sent is a reply the user sent; empty is none.
+ Sent string
+}
+
+type threadState struct {
+ list MessageListState
+ draft string
+}
+
+type threadKey struct{}
+
+// ThreadPanel displays a root message, replies and an editor.
+func ThreadPanel(c *ui.Context, open *bool, root ChatEntry, replies []ChatEntry, opts ThreadPanelOptions) ThreadPanelResult {
+ var r ThreadPanelResult
+ if opts.Width < 0 {
+ panic("mujica: ThreadPanel Width must not be negative")
+ }
+ w := opts.Width
+ if w == 0 {
+ w = 360
+ }
+ ww, _ := c.Size()
+ w = min(w, ww)
+ k := Tokens(c)
+ u := Density(c).Unit()
+ panel := ui.Column(c).Width(w).FillHeight().Shrink(0).Background(k.Surface).BorderWidth(0, 0, 0, 1).
+ BorderColor(k.Border).Role(ui.RoleGroup).Label(msg(c, "thread_panel.title")).Disabled(opts.Disabled)
+ if panel.IsDisabled() {
+ *open = false
+ }
+ if !*open {
+ panel.Invisible()
+ return r
+ }
+ overlayStyle(c, panel, overlayEnter(c, panel))
+ panel.BorderWidth(0, 0, 0, 1).BorderColor(k.Border)
+ r.Element = panel
+ st := ui.Local(panel, threadKey{}, func() *threadState { return &threadState{} })
+ s := *st
+ panel.Children(func() {
+ ui.Row(c).Padding(u*3, u*3, u*2, u*4).AlignItems(ui.Center).Children(func() {
+ ui.Text(c, msg(c, "thread_panel.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
+ if iconAction(c, msgClose, msg(c, "thread_panel.close")).Clicked() {
+ *open = false
+ }
+ })
+ ui.Scroll(c).MaxHeight(160).Shrink(1).Padding(0, u*2).Children(func() { ChatMessage(c, root, ChatMessageOptions{}) })
+ ui.Row(c).Gap(u*2).Padding(u*2, u*4).AlignItems(ui.Center).Children(func() {
+ ui.Text(c, fmt.Sprintf(msg(c, "thread_panel.replies"), len(replies))).FontSize(FontSize(c, theme.CaptionSize)).
+ TextColor(k.TextMuted).SingleLine()
+ ui.Box(c).Height(1).Grow(1).Background(k.Border)
+ })
+ MessageList(c, &s.list, len(replies), MessageListOptions{
+ ID: func(i int) string { return replies[i].ID },
+ Label: func(i int) string { return replies[i].Author + ": " + replies[i].Text },
+ }, func(i int) {
+ ChatMessage(c, replies[i], ChatMessageOptions{Continued: i > 0 && ChatContinues(replies[i-1], replies[i])})
+ })
+ ui.Row(c).Gap(u*2).Padding(u*3).AlignItems(ui.Center).BorderWidth(1, 0, 0, 0).BorderColor(k.Border).Children(func() {
+ in := TextInput(c, &s.draft, TextInputOptions{Placeholder: msg(c, "thread_panel.reply"), Label: msg(c, "thread_panel.reply"), Disabled: opts.Disabled})
+ in.Element.Grow(1)
+ send := IconButton(c, msgSend, msg(c, "thread_panel.send"), IconButtonOptions{Disabled: strings.TrimSpace(s.draft) == ""})
+ if (in.Submitted() || send.Clicked()) && strings.TrimSpace(s.draft) != "" && !panel.IsDisabled() {
+ r.Sent, s.draft = strings.TrimSpace(s.draft), ""
+ s.list.ScrollToEnd()
+ }
+ })
+ })
+ if !panel.IsDisabled() && panel.Shortcut(0, ui.KeyEscape) {
+ *open = false
+ }
+ switch {
+ case r.Sent != "":
+ slog.Debug("mujica: thread_panel send", "len", len(r.Sent))
+ case !*open:
+ slog.Debug("mujica: thread_panel close")
+ }
+ return r
+}
diff --git a/thread_panel_test.go b/thread_panel_test.go
new file mode 100644
index 0000000..9a2174e
--- /dev/null
+++ b/thread_panel_test.go
@@ -0,0 +1,77 @@
+package mujica
+
+import (
+ "testing"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestThreadPanelReplyAndClose(t *testing.T) {
+ at := time.Date(2026, 10, 5, 20, 0, 0, 0, time.UTC)
+ root := ChatEntry{ID: "r", Author: "Sakiko", Time: at, Text: "Set list for Friday?"}
+ replies := []ChatEntry{{ID: "1", Author: "Uika", Time: at.Add(time.Minute), Text: "Overture first."}}
+ open := true
+ var sent []string
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Fill().Children(func() {
+ ui.Box(c).Grow(1)
+ if r := ThreadPanel(c, &open, root, replies, ThreadPanelOptions{}); r.Sent != "" {
+ sent = append(sent, r.Sent)
+ }
+ })
+ }, 600, 600)
+ if !tt.HasText("Set list for Friday?") || !tt.HasText("1 replies") || !tt.HasText("Overture first.") {
+ t.Fatalf("texts %v", tt.Texts())
+ }
+ if err := tt.Click("Reply in thread"); err != nil {
+ t.Fatal(err)
+ }
+ tt.Type("Then the masquerade.")
+ tt.Key(0, ui.KeyEnter)
+ if len(sent) != 1 || sent[0] != "Then the masquerade." {
+ t.Fatalf("sent %q", sent)
+ }
+ tt.Key(0, ui.KeyEscape)
+ if open {
+ t.Fatal("Escape did not close")
+ }
+}
+
+func TestThreadPanelSnapshot(t *testing.T) {
+ at := time.Date(2026, 10, 5, 20, 0, 0, 0, time.UTC)
+ open := true
+ root := ChatEntry{ID: "r", Author: "Sakiko", Time: at, Text: "Set list for Friday?"}
+ replies := []ChatEntry{{ID: "1", Author: "Uika", Time: at.Add(time.Minute), Text: "Overture first."},
+ {ID: "2", Author: "Uika", Time: at.Add(2 * time.Minute), Text: "Then the masquerade."}}
+ snapshot(t, "thread_panel", 400, 560, func(c *ui.Context) {
+ ui.Row(c).Fill().Children(func() { ThreadPanel(c, &open, root, replies, ThreadPanelOptions{}) })
+ })
+}
+
+func TestThreadPanelFitsWindowAndDisabled(t *testing.T) {
+ open, disabled := true, false
+ root := ChatEntry{ID: "root", Author: "Author", Text: "Question"}
+ sent := 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Fill().Children(func() {
+ if ThreadPanel(c, &open, root, nil, ThreadPanelOptions{Disabled: disabled}).Sent != "" {
+ sent++
+ }
+ })
+ }, 280, 400)
+ r, ok := tt.Find("Thread")
+ if !ok || r.X < 0 || r.X+r.W > 280 {
+ t.Fatalf("thread bounds %+v", r)
+ }
+ disabled = true
+ tt.Frame()
+ _ = tt.Click("Reply in thread")
+ tt.Type("Blocked")
+ tt.Key(0, ui.KeyEnter)
+ if open || sent != 0 {
+ t.Fatal("disabled thread stayed open or sent")
+ }
+}
diff --git a/unread_divider.go b/unread_divider.go
new file mode 100644
index 0000000..6bca759
--- /dev/null
+++ b/unread_divider.go
@@ -0,0 +1,31 @@
+package mujica
+
+import (
+ "fmt"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
+)
+
+// UnreadDivider marks where unread messages begin; count zero shows "New".
+func UnreadDivider(c *ui.Context, count int) *ui.Element {
+ if count < 0 {
+ panic("mujica: UnreadDivider count must not be negative")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ text := msg(c, "unread_divider.new")
+ if count > 0 {
+ text = fmt.Sprintf(msg(c, "unread_divider.count"), count)
+ }
+ row := ui.Row(c).FillWidth().Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Role(ui.RoleStatus).Label(text)
+ row.Children(func() {
+ line := func() { ui.Box(c).Height(1).Grow(1).Background(k.Danger) }
+ line()
+ ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.Danger).SingleLine().
+ Padding(0, u*2).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Danger)
+ line()
+ })
+ return row
+}
diff --git a/unread_divider_test.go b/unread_divider_test.go
new file mode 100644
index 0000000..a18ff7f
--- /dev/null
+++ b/unread_divider_test.go
@@ -0,0 +1,27 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestUnreadDivider(t *testing.T) {
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Children(func() {
+ UnreadDivider(c, 12)
+ UnreadDivider(c, 0)
+ })
+ }, 400, 120)
+ if !tt.HasText("12 new messages") || !tt.HasText("New") {
+ t.Fatalf("texts %v", tt.Texts())
+ }
+ mediaPanics(t, "negative", func(c *ui.Context) { UnreadDivider(c, -1) })
+}
+
+func TestUnreadDividerSnapshot(t *testing.T) {
+ snapshot(t, "unread_divider", 420, 70, func(c *ui.Context) {
+ ui.Column(c).Padding(20).Children(func() { UnreadDivider(c, 3) })
+ })
+}
diff --git a/user_profile_card.go b/user_profile_card.go
new file mode 100644
index 0000000..1457cbe
--- /dev/null
+++ b/user_profile_card.go
@@ -0,0 +1,77 @@
+package mujica
+
+import (
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
+)
+
+// UserProfile is the person a UserProfileCard shows.
+type UserProfile struct {
+ // Name is required.
+ Name string
+ Image *ui.Bitmap
+ Presence Presence
+ // Status is a custom status line, as "In a rehearsal".
+ Status string
+ // Role, as "Admin"; empty shows none.
+ Role string
+ // About is a short description.
+ About string
+}
+
+// UserProfileCardOptions configure a UserProfileCard.
+type UserProfileCardOptions struct {
+ // Actions builds buttons at the bottom, as Message or Call.
+ Actions func()
+ // Width is the card's width; zero means 300.
+ Width float32
+}
+
+// UserProfileCard displays identity, presence, role and action slots.
+func UserProfileCard(c *ui.Context, prof UserProfile, opts UserProfileCardOptions) *ui.Element {
+ if prof.Name == "" {
+ panic("mujica: UserProfileCard needs a Name")
+ }
+ if opts.Width < 0 {
+ panic("mujica: UserProfileCard Width must not be negative")
+ }
+ w := opts.Width
+ if w == 0 {
+ w = 300
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ card := ui.Column(c).Width(w).Padding(u*4).Gap(u*3).Radius(theme.CardRadius).Background(k.Surface).
+ Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(msg(c, "user_profile_card.label") + ": " + prof.Name)
+ card.Children(func() {
+ ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
+ ui.Box(c).Shrink(0).Children(func() {
+ Avatar(c, prof.Name, AvatarOptions{Image: prof.Image, Size: 56})
+ ui.Box(c).Absolute().Right(-1).Bottom(-1).Padding(2).Radius(8).Background(k.Surface).Children(func() {
+ OnlineStatus(c, prof.Presence, OnlineStatusOptions{HideText: true, Size: 12})
+ })
+ })
+ ui.Column(c).Gap(u).MinWidth(0).Grow(1).Children(func() {
+ ui.Text(c, prof.Name).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine()
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() {
+ OnlineStatus(c, prof.Presence, OnlineStatusOptions{})
+ if prof.Role != "" {
+ Badge(c, BadgeOptions{Text: prof.Role, Tone: BadgeNeutral}, nil)
+ }
+ })
+ })
+ })
+ if prof.Status != "" {
+ ui.Text(c, prof.Status).MaxLines(2)
+ }
+ if prof.About != "" {
+ ui.Text(c, prof.About).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).MaxLines(4)
+ }
+ if opts.Actions != nil {
+ ui.Box(c).Height(1).Background(k.Border)
+ ui.Row(c).Gap(u * 2).Wrap().Children(opts.Actions)
+ }
+ })
+ return card
+}
diff --git a/user_profile_card_test.go b/user_profile_card_test.go
new file mode 100644
index 0000000..5741866
--- /dev/null
+++ b/user_profile_card_test.go
@@ -0,0 +1,43 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestUserProfileCard(t *testing.T) {
+ clicked := 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ UserProfileCard(c, UserProfile{Name: "Sakiko Togawa", Presence: PresenceBusy, Role: "Keyboard", Status: "In rehearsal"},
+ UserProfileCardOptions{Actions: func() {
+ if Button(c, "Message", ButtonOptions{}).Clicked() {
+ clicked++
+ }
+ }})
+ }, 400, 300)
+ for _, s := range []string{"Sakiko Togawa", "Busy", "Keyboard", "In rehearsal"} {
+ if !tt.HasText(s) {
+ t.Fatalf("no %q in %v", s, tt.Texts())
+ }
+ }
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ if clicked != 1 {
+ t.Fatal("action slot not reachable by keyboard")
+ }
+ mediaPanics(t, "no name", func(c *ui.Context) { UserProfileCard(c, UserProfile{}, UserProfileCardOptions{}) })
+}
+
+func TestUserProfileCardSnapshot(t *testing.T) {
+ snapshot(t, "user_profile_card", 360, 260, func(c *ui.Context) {
+ ui.Column(c).Padding(20).Children(func() {
+ UserProfileCard(c, UserProfile{Name: "Sakiko Togawa", Presence: PresenceOnline, Role: "Keyboard", Status: "Composing the second act",
+ About: "Founder of the masquerade."}, UserProfileCardOptions{Actions: func() {
+ Button(c, "Message", ButtonOptions{})
+ Button(c, "Call", ButtonOptions{Variant: Secondary})
+ }})
+ })
+ })
+}
diff --git a/video_call_grid.go b/video_call_grid.go
new file mode 100644
index 0000000..4acd2c9
--- /dev/null
+++ b/video_call_grid.go
@@ -0,0 +1,107 @@
+package mujica
+
+import (
+ "math"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// CallParticipant supplies identity, frames and call state.
+type CallParticipant struct {
+ ID, Name string
+ // Frame is their video; nil shows their avatar.
+ Frame ui.ImageSource
+ Image *ui.Bitmap
+ Muted bool
+ CameraOff bool
+ Speaking bool
+}
+
+// VideoCallGridOptions configure a VideoCallGrid.
+type VideoCallGridOptions struct {
+ // Columns fixes the column count; zero fits a near-square grid.
+ Columns int
+}
+
+// VideoCallGrid displays participant frames and speaker marks.
+func VideoCallGrid(c *ui.Context, people []CallParticipant, opts VideoCallGridOptions) *ui.Element {
+ if opts.Columns < 0 {
+ panic("mujica: VideoCallGrid Columns must not be negative")
+ }
+ seen := map[string]bool{}
+ for _, p := range people {
+ if p.ID == "" || p.Name == "" || seen[p.ID] {
+ panic("mujica: VideoCallGrid participants need unique IDs and names: " + p.ID)
+ }
+ seen[p.ID] = true
+ }
+ cols := opts.Columns
+ if cols == 0 {
+ cols = max(1, int(math.Ceil(math.Sqrt(float64(len(people))))))
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ tracks := make([]ui.Track, cols)
+ for i := range tracks {
+ tracks[i] = ui.Fr(1)
+ }
+ grid := ui.Box(c).Grid().ColumnTracks(tracks...).Gap(u * 2).FocusGroup(ui.Horizontal | ui.Vertical).
+ Role(ui.RoleGroup).Label(msg(c, "video_call_grid.label"))
+ grid.Children(func() {
+ for _, p := range people {
+ callTile(c, k, p)
+ }
+ })
+ return grid
+}
+
+func callTile(c *ui.Context, k theme.Tokens, p CallParticipant) {
+ u := Density(c).Unit()
+ var notes []string
+ if p.Speaking {
+ notes = append(notes, msg(c, "video_call_grid.speaking"))
+ }
+ if p.Muted {
+ notes = append(notes, msg(c, "video_call_grid.muted"))
+ }
+ if p.CameraOff {
+ notes = append(notes, msg(c, "video_call_grid.cameraOff"))
+ }
+ label := p.Name
+ if len(notes) > 0 {
+ label += ", " + strings.Join(notes, ", ")
+ }
+ tile := ui.Box(c).Key(p.ID).AspectRatio(16.0 / 9).Radius(theme.CardRadius).Clip().Background(videoBlack).Center().
+ Focusable().Role(ui.RoleImage).Label(label)
+ internal.Ring(tile, k.Focus, theme.CardRadius)
+ light := ui.Hex("#F2EBDD")
+ tile.Children(func() {
+ if p.Frame != nil && !p.CameraOff {
+ ui.Image(c, p.Frame).Fit(ui.Cover).Absolute().Left(0).Top(0).Right(0).Bottom(0)
+ } else {
+ Avatar(c, p.Name, AvatarOptions{Image: p.Image, Size: 56})
+ }
+ ui.Row(c).Absolute().Left(u*2).Bottom(u*2).Gap(u).AlignItems(ui.Center).Padding(2, u*1.5).
+ Radius(theme.ControlRadius).Background(ui.RGBA(0, 0, 0, 0.6)).TextColor(light).Children(func() {
+ if p.Muted {
+ ui.Icon(c, msgMicOff).Size(13, 13).TextColor(ui.Hex("#F0A1AA"))
+ }
+ if p.Speaking {
+ ui.Icon(c, msgSpeaking).Size(13, 13)
+ }
+ ui.Text(c, p.Name).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
+ })
+ })
+ if p.Speaking {
+ tile.DrawOver(func(pt *ui.Painter, r ui.Rect) {
+ pt.Stroke(ui.Rect{X: r.X + 1.5, Y: r.Y + 1.5, W: r.W - 3, H: r.H - 3}, k.AccentText, theme.CardRadius, 3)
+ if tile.FocusVisible() {
+ internal.FocusRing(pt, r, theme.CardRadius, k.Focus)
+ }
+ })
+ }
+}
diff --git a/video_call_grid_test.go b/video_call_grid_test.go
new file mode 100644
index 0000000..0bb961e
--- /dev/null
+++ b/video_call_grid_test.go
@@ -0,0 +1,43 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestVideoCallGridTilesAndSpeaker(t *testing.T) {
+ people := []CallParticipant{{ID: "1", Name: "Sakiko", Speaking: true}, {ID: "2", Name: "Uika", Muted: true, Frame: mediaTestBitmap(32, 18)},
+ {ID: "3", Name: "Mutsumi", CameraOff: true}, {ID: "4", Name: "Umiri"}, {ID: "5", Name: "Nyamu"}}
+ var rects []ui.Rect
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Width(600).Children(func() { VideoCallGrid(c, people, VideoCallGridOptions{}) })
+ }, 700, 600)
+ for _, name := range []string{"Sakiko, speaking", "Uika, muted", "Mutsumi, camera off", "Umiri", "Nyamu"} {
+ r, ok := tt.Find(name)
+ if !ok {
+ t.Fatalf("no tile %q in %v", name, tt.Texts())
+ }
+ rects = append(rects, r)
+ }
+ if rects[3].Y <= rects[0].Y || rects[1].Y != rects[0].Y || rects[2].Y != rects[0].Y {
+ t.Fatalf("tiles %v", rects)
+ }
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyRight)
+ if !tt.Focused("Uika, muted") {
+ t.Fatal("arrows do not move between tiles")
+ }
+ mediaPanics(t, "duplicate", func(c *ui.Context) {
+ VideoCallGrid(c, []CallParticipant{{ID: "a", Name: "A"}, {ID: "a", Name: "B"}}, VideoCallGridOptions{})
+ })
+}
+
+func TestVideoCallGridSnapshot(t *testing.T) {
+ people := []CallParticipant{{ID: "1", Name: "Sakiko", Speaking: true, Frame: mediaTestBitmap(160, 90)}, {ID: "2", Name: "Uika", Muted: true},
+ {ID: "3", Name: "Mutsumi", CameraOff: true}, {ID: "4", Name: "Umiri", Frame: mediaTestBitmap(90, 160)}}
+ snapshot(t, "video_call_grid", 520, 340, func(c *ui.Context) {
+ ui.Column(c).Padding(20).Width(480).Children(func() { VideoCallGrid(c, people, VideoCallGridOptions{}) })
+ })
+}