Files
MujicaUI/notification_center.go
T
2026-10-05 12:11:45 -04:00

190 lines
6.2 KiB
Go

package mujica
import (
"cmp"
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Notification is one in-app message. The caller owns and persists the list.
type Notification struct {
// ID is required and unique within the list.
ID string
Title string
Body string
// Time shows as given, as "5 min ago".
Time string
Read bool
// Action labels a button; empty shows none.
Action string
}
// NotificationCenterOptions configure a NotificationCenter.
type NotificationCenterOptions struct {
// Title uses the built-in "Notifications" when empty.
Title string
}
// NotificationCenterResult reports the frame's events by notification ID.
type NotificationCenterResult struct {
Element *ui.Element
Action string
// Toggled changed its Read state; Opened was clicked and is now read.
Toggled string
Opened string
Removed string
// MarkedAllRead and ClearedAll report the header buttons.
MarkedAllRead bool
ClearedAll bool
}
// NotificationCenter lists *items and edits them in place: read, unread,
// remove, clear. The list is one Tab stop; arrows move among its buttons
// and Delete removes the focused notification.
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
if items == nil {
panic("mujica: NotificationCenter needs items")
}
seen := map[string]bool{}
unread := 0
for _, n := range *items {
if n.ID == "" || seen[n.ID] {
panic(fmt.Sprintf("mujica: notification ID %q empty or repeated", n.ID))
}
seen[n.ID] = true
if !n.Read {
unread++
}
}
k := Tokens(c)
u := Density(c).Unit()
title := cmp.Or(opts.Title, msg(c, "notification_center.title"))
var res NotificationCenterResult
panel := ui.Column(c).FillWidth().Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Role(ui.RoleGroup).Label(title).Clip()
panel.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
if unread > 0 {
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText)
}
ui.Spacer(c)
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
for i := range *items {
(*items)[i].Read = true
}
res.MarkedAllRead = true
slog.Debug("mujica: notification_center mark all read", "count", unread)
}
if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() {
slog.Debug("mujica: notification_center clear all", "count", len(*items))
*items = (*items)[:0]
res.ClearedAll = true
}
})
// Ornament hairline under the panel title.
ui.Box(c).FillWidth().Height(1).Background(k.Ornament).Role(ui.RoleNone)
if len(*items) == 0 && !res.ClearedAll {
EmptyState(c, EmptyStateOptions{
Title: msg(c, "notification_center.empty"),
Description: msg(c, "notification_center.empty_hint"),
Icon: icons.Must("bell"),
})
return
}
list := ui.Scroll(c).Grow(1).Shrink(1).FillWidth().Role(ui.RoleList).FocusGroup(ui.Vertical | ui.Horizontal)
list.Dividers(theme.BorderWidth, k.Border)
list.Children(func() {
for i := range *items {
notificationRow(c, k, u, &(*items)[i], &res)
}
})
})
if res.Removed != "" {
for i, n := range *items {
if n.ID == res.Removed {
*items = append((*items)[:i], (*items)[i+1:]...)
break
}
}
}
res.Element = panel
return res
}
func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, res *NotificationCenterResult) {
name := n.Title
if !n.Read {
name = msg(c, "notification_center.unread_mark") + ": " + n.Title
}
row := ui.Row(c).Key(n.ID).FillWidth().AlignItems(ui.Start).Gap(u*2).Padding(u*3, u*3, u*3, u*2).
Role(ui.RoleListItem).Label(name)
if row.Hovered() {
row.Background(k.SurfaceHover)
}
row.Children(func() {
// Unread mark: a diamond and bold title, not color alone.
ui.Box(c).Size(12, 20).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
if !n.Read {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText)
}
})
open := ui.ButtonBase(c).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
Radius(theme.ControlRadius).Label(name)
internal.Ring(open, k.Focus, theme.ControlRadius)
open.Children(func() {
ui.Row(c).FillWidth().Gap(u * 2).Children(func() {
t := ui.Text(c, n.Title).TextColor(k.Text).Grow(1).Shrink(1)
if !n.Read {
t.Bold()
}
if n.Time != "" {
ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
}
})
if n.Body != "" {
ui.Text(c, n.Body).TextColor(k.TextMuted).LineHeight(theme.LineHeight)
}
})
if open.Clicked() && !n.Read {
n.Read, res.Opened = true, n.ID
slog.Debug("mujica: notification_center opened", "id", n.ID)
}
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
if n.Action != "" && Button(c, n.Action, ButtonOptions{Variant: Outline}).Clicked() {
res.Action = n.ID
slog.Debug("mujica: notification_center action", "id", n.ID)
}
toggle, icon := "notification_center.mark_read", icons.Must("check")
if n.Read {
toggle, icon = "notification_center.mark_unread", icons.Must("circle")
}
if notificationIconButton(c, k, u, icon, msg(c, toggle)).Clicked() {
n.Read, res.Toggled = !n.Read, n.ID
slog.Debug("mujica: notification_center toggled", "id", n.ID, "read", n.Read)
}
if notificationIconButton(c, k, u, icons.Must("x"), msg(c, "notification_center.clear")).Clicked() {
res.Removed = n.ID
}
})
})
if row.Shortcut(0, ui.KeyDelete) || row.Shortcut(0, ui.KeyBackspace) {
res.Removed = n.ID
}
if res.Removed == n.ID {
slog.Debug("mujica: notification_center removed", "id", n.ID)
}
}
func notificationIconButton(c *ui.Context, k theme.Tokens, u float32, icon *ui.SVG, label string) *ui.Element {
b := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icon, Label: label})
b.TextColor(k.TextMuted).Padding(0, u).Tooltip(label)
return b
}