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{}) }) + }) +}