Merge branch 'main' into worktree-agent-ab9ba373bc47c2352

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:39:12 -04:00
commit eb78b0a4a1
1005 files changed
+62360 -18

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AgendaViewOptions configure an AgendaView.
type AgendaViewOptions struct {
// Location is the zone days are cut in; required.
Location *time.Location
// Version must change when events change in place.
Version int
// From is the first day listed; zero means today.
From Date
// Days is how many days are listed; zero means 30.
Days int
// Today is marked; zero uses the system clock.
Today Date
Hour12 bool
Disabled bool
Label string
}
// agendaRow is a day heading (ev < 0) or event.
type agendaRow struct {
day Date
ev int
}
// AgendaView lists events by day, building only visible rows.
func AgendaView(c *ui.Context, events []CalendarEvent, opts AgendaViewOptions) CalendarViewResult {
loc := eventLocation("AgendaView", opts.Location)
if opts.Days < 0 {
panic("mujica: AgendaView Days is negative")
}
days := opts.Days
if days == 0 {
days = 30
}
eventsCheck("AgendaView", events)
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
from := opts.From
if from.IsZero() {
from = today
}
datetimeCheckDate("AgendaView From", from)
label := opts.Label
if label == "" {
label = msg(c, "agenda_view.label")
}
r := CalendarViewResult{}
root := ui.Column(c).Grow(1).MinHeight(0).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
rows := eventsDerived(root, "agenda-rows", events, opts.Version, eventDayStart(from, loc), eventDayStart(from.AddDays(days), loc),
func(hits []int) []agendaRow { return agendaRows(events, hits, from, days, loc) })
state := ui.Local(root, "agenda-list", func() *ui.ListState { return &ui.ListState{} })
st := *state
st.Key = func(i int) any { return rows[i] }
st.Header = func(i int) bool { return rows[i].ev < 0 }
st.Label = func(i int) string {
if rows[i].ev < 0 {
return datetimeDayLabel(c, rows[i].day)
}
return events[rows[i].ev].Title
}
root.Children(func() {
list := ui.List(c, st, len(rows), func(i int) {
row := rows[i]
if row.ev < 0 {
h := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit() * 2).PaddingX(d.Unit() * 3).Background(k.Background).Role(ui.RoleColumnHeader)
h.Children(func() {
t := ui.Text(c, datetimeDayLabel(c, row.day)).Font(theme.SerifFont).TextColor(k.Text)
if row.day == today {
t.Bold().TextColor(k.AccentText)
ui.Text(c, msg(c, "agenda_view.today")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
}
})
h.DrawOver(func(p *ui.Painter, rr ui.Rect) { internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+rr.H-1, k.Ornament) })
return
}
if agendaItem(c, events[row.ev], loc, opts.Hour12) && !root.IsDisabled() {
r.opened = events[row.ev].ID
}
}).Grow(1).MinHeight(d.ControlHeight() * 4).FocusGroup(ui.Vertical)
list.Children(func() {
if len(rows) == 0 {
ui.Text(c, fmt.Sprintf(msg(c, "agenda_view.empty"), days)).TextColor(k.TextMuted).Padding(d.Unit() * 3)
}
})
})
return r
}
// agendaRows flattens the span into headed days.
func agendaRows(events []CalendarEvent, hits []int, from Date, days int, loc *time.Location) []agendaRow {
buckets := make([][]int, days)
for _, h := range hits {
ev := events[h]
first := max(0, monthDays(ev.Start.In(loc), from)*-1)
last := min(days-1, -monthDays(ev.End.Add(-time.Nanosecond).In(loc), from))
for i := first; i <= last; i++ {
buckets[i] = append(buckets[i], h)
}
}
var out []agendaRow
for i, b := range buckets {
if len(b) > 0 {
out = append(out, agendaRow{from.AddDays(i), -1})
for _, h := range b {
out = append(out, agendaRow{from.AddDays(i), h})
}
}
}
return out
}
// agendaItem is one event row; it reports a click.
func agendaItem(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) bool {
k := Tokens(c)
d := Density(c)
when := eventTimeText(c, ev, loc, hour12)
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(d.Unit() * 3).PaddingX(d.Unit() * 3).Label(ev.Title + ", " + when)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
col := eventColor(k, ev.Color)
b.Children(func() {
ui.Text(c, when).Width(d.ControlHeight() * 3.5).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), col)
})
ui.Column(c).Grow(1).Shrink(1).Children(func() {
ui.Text(c, ev.Title).TextColor(k.Text).SingleLine().Ellipsis("…")
if ev.Location != "" {
ui.Text(c, ev.Location).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…")
}
})
})
return b.Clicked()
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func agendaTester(events []CalendarEvent, opts AgendaViewOptions, opened *[]string) *ui.Tester {
opts.Location, opts.Today = calTokyo, calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Height(500).Children(func() {
if id, ok := AgendaView(c, events, opts).Opened(); ok {
*opened = append(*opened, id)
}
})
}, 600, 520)
}
func TestAgendaViewGroupsByDay(t *testing.T) {
evs := []CalendarEvent{calEvent("b", 7, 14, 0, 60), calEvent("a", 7, 9, 0, 30), calEvent("c", 9, 10, 0, 60), calEvent("old", 1, 9, 0, 60)}
evs[0].Location = "Rehearsal hall"
var opened []string
tt := agendaTester(evs, AgendaViewOptions{}, &opened)
texts := tt.Texts()
idx := func(s string) int {
for i, x := range texts {
if x == s {
return i
}
}
return -1
}
d7, a, b, d9, c := idx("Wednesday, October 7, 2026"), idx("Event a, 09:00 – 09:30"), idx("Event b, 14:00 – 15:00"), idx("Friday, October 9, 2026"), idx("Event c, 10:00 – 11:00")
if d7 < 0 || !(d7 < a && a < b && b < d9 && d9 < c) || tt.HasText("Event old") || !tt.HasText("Rehearsal hall") {
t.Fatalf("order %d %d %d %d %d in %v", d7, a, b, d9, c, texts)
}
_ = tt.Click("Event b, 14:00 – 15:00")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(opened) != 2 || opened[0] != "b" || opened[1] != "c" {
t.Fatalf("opened %v", opened)
}
}
func TestAgendaViewMultiDayEmptyAndDisabled(t *testing.T) {
trip := calEvent("t", 6, 20, 0, 26*60)
var opened []string
tt := agendaTester([]CalendarEvent{trip}, AgendaViewOptions{Days: 3}, &opened)
if !tt.HasText("Tuesday, October 6, 2026") || !tt.HasText("Wednesday, October 7, 2026") {
t.Fatalf("a multi-day event lists on each day: %v", tt.Texts())
}
tt = agendaTester(nil, AgendaViewOptions{Days: 7}, &opened)
if !tt.HasText("No events in the next 7 days.") {
t.Fatal("no empty state")
}
tt = agendaTester([]CalendarEvent{calEvent("a", 6, 9, 0, 30)}, AgendaViewOptions{Disabled: true}, &opened)
_ = tt.Click("Event a, 09:00 – 09:30")
if len(opened) != 0 {
t.Fatal("disabled agenda opened")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
agendaTester(nil, AgendaViewOptions{Days: -1}, &opened)
}
func TestAgendaViewLargeData(t *testing.T) {
var evs []CalendarEvent
base := time.Date(2026, time.October, 5, 0, 0, 0, 0, calTokyo)
for i := range 100000 {
s := base.Add(time.Duration(i%(365*24)) * time.Hour)
evs = append(evs, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(30 * time.Minute)})
}
var opened []string
tt := agendaTester(evs, AgendaViewOptions{Days: 365}, &opened)
if n := len(tt.Texts()); n > 200 {
t.Fatalf("built %d labels", n)
}
}
func TestAgendaViewSnapshot(t *testing.T) {
evs := []CalendarEvent{calEvent("a", 5, 9, 0, 30), calEvent("b", 5, 14, 0, 60), calEvent("c", 7, 10, 0, 60), calEvent("d", 8, 18, 0, 120)}
evs[1].Location, evs[1].Color, evs[3].Color = "Rehearsal hall", EventInfo, EventSuccess
snapshot(t, "agenda_view", 520, 420, func(c *ui.Context) {
ui.Column(c).Padding(16).Height(420).Children(func() {
AgendaView(c, evs, AgendaViewOptions{Location: calTokyo, Today: calendarToday})
})
})
}
+127
View File
@@ -0,0 +1,127 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// FileChange is a proposed edit; empty Old adds a file.
type FileChange struct {
Path string
Old, New string
}
// FileDecision is a review state; zero is FilePending.
type FileDecision int
const (
FilePending FileDecision = iota
FileAccepted
FileRejected
)
// agentDiffCache holds a change's line diff until its text changes.
type agentDiffCache struct {
old, new string
lines []internal.DiffLine
add, del int
firstEdit int
done bool
}
func (dc *agentDiffCache) update(ch FileChange) {
if dc.done && dc.old == ch.Old && dc.new == ch.New {
return
}
dc.old, dc.new, dc.done = ch.Old, ch.New, true
dc.lines = internal.LineDiff(internal.SplitLines(ch.Old), internal.SplitLines(ch.New))
dc.add, dc.del = internal.DiffStats(dc.lines)
dc.firstEdit = 0
for i, l := range dc.lines {
if l.Op != internal.DiffEqual {
dc.firstEdit = i
break
}
}
}
// agentDiffOf returns the cached diff of ch kept on holder.
func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache {
dc := ui.Local(holder, "mujica.agent.diff", func() *agentDiffCache { return &agentDiffCache{} })
(*dc).update(ch)
return *dc
}
// agentDiffStats shows signed added and removed counts.
func agentDiffStats(c *ui.Context, add, del int) {
k := Tokens(c)
size := FontSize(c, theme.CaptionSize+1)
ui.Row(c).Gap(Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(msg(c, "file_change_card.stats"), add, del)).Children(func() {
ui.Text(c, fmt.Sprintf("+%d", add)).TextColor(k.Success).FontSize(size).Font(theme.MonoFont)
ui.Text(c, fmt.Sprintf("−%d", del)).TextColor(k.Danger).FontSize(size).Font(theme.MonoFont)
})
}
// agentDiffRow draws one numbered, signed, tinted diff line.
func agentDiffRow(c *ui.Context, l internal.DiffLine) {
k := Tokens(c)
u := Density(c).Unit()
sign, bg, fg := " ", ui.Transparent, k.TextMuted
switch l.Op {
case internal.DiffInsert:
sign, bg, fg = "+", k.Success.Alpha(0.14), k.Success
case internal.DiffDelete:
sign, bg, fg = "−", k.Danger.Alpha(0.14), k.Danger
}
num := func(n int) string {
if n == 0 {
return ""
}
return fmt.Sprint(n)
}
ui.Row(c).Gap(u*2).Background(bg).Padding(0, u).Children(func() {
ui.Text(c, num(l.Old)).Width(36).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
ui.Text(c, num(l.New)).Width(36).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
ui.Text(c, sign).TextColor(fg).Width(10).Shrink(0)
text := l.Text
if text == "" {
text = " "
}
ui.Text(c, text).Grow(1).Shrink(1)
})
}
// agentDiffView shows lines[from:to] in a capped well, virtualized when long.
func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) *ui.Element {
w := agentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
return w.Padding(Density(c).Unit(), 0)
}
// agentDecisionMark shows a decision by icon and word.
func agentDecisionMark(c *ui.Context, d FileDecision) {
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
agentCaption(c, agentDecisionIcon(c, d))
})
}
// agentDecisionIcon draws a named decision icon, returning its word.
func agentDecisionIcon(c *ui.Context, d FileDecision) string {
k := Tokens(c)
tone, icon, word := k.TextMuted, "circle", msg(c, "file_change_card.pending")
switch d {
case FileAccepted:
tone, icon, word = k.Success, "circle-check", msg(c, "file_change_card.accepted")
case FileRejected:
tone, icon, word = k.Danger, "circle-x", msg(c, "file_change_card.rejected")
case FilePending:
default:
panic(fmt.Sprintf("mujica: unknown FileDecision %d", int(d)))
}
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone).Shrink(0).Label(word)
return word
}
+293
View File
@@ -0,0 +1,293 @@
package mujica
import (
"bytes"
"encoding/json"
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AgentState is an agent or step state; zero is pending.
type AgentState int
const (
AgentPending AgentState = iota
AgentPlanning
AgentRunning
AgentWaiting
AgentDone
AgentFailed
AgentSkipped
)
func (s AgentState) String() string {
switch s {
case AgentPending:
return "pending"
case AgentPlanning:
return "planning"
case AgentRunning:
return "running"
case AgentWaiting:
return "waiting"
case AgentDone:
return "done"
case AgentFailed:
return "failed"
case AgentSkipped:
return "skipped"
}
panic(fmt.Sprintf("mujica: unknown AgentState %d", int(s)))
}
// agentTone returns the color and icon marking s.
func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) {
switch s {
case AgentPlanning:
return k.Info, icons.Must("list")
case AgentRunning:
return k.AccentText, icons.Must("loader-circle")
case AgentWaiting:
return k.Warning, icons.Must("clock")
case AgentDone:
return k.Success, icons.Must("circle-check")
case AgentFailed:
return k.Danger, icons.Must("circle-x")
case AgentSkipped:
return k.TextMuted, icons.Must("ban")
}
return k.TextMuted, icons.Must("circle")
}
// agentMark draws s; running spins unless still.
func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element {
tone, icon := agentTone(Tokens(c), s)
if s == AgentRunning && !still {
return Spinner(c, SpinnerOptions{Size: size, Color: tone, Label: msg(c, "agent_status.running")})
}
return ui.Icon(c, icon).Size(size, size).TextColor(tone).Shrink(0)
}
// agentWord is the built-in word for s.
func agentWord(c *ui.Context, s AgentState) string { return msg(c, "agent_status."+s.String()) }
// agentDuration formats d like "320 ms" or "4.2 s".
func agentDuration(d time.Duration) string {
if d < 0 {
panic(fmt.Sprintf("mujica: negative duration %v", d))
}
switch {
case d < time.Second:
return fmt.Sprintf("%d ms", d.Milliseconds())
case d < time.Minute:
return fmt.Sprintf("%.1f s", d.Seconds())
case d < time.Hour:
return fmt.Sprintf("%d m %02d s", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%d h %02d m", int(d.Hours()), int(d.Minutes())%60)
}
// agentCard is the bordered surface every agent card sits on.
func agentCard(c *ui.Context) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border)
}
// agentCaption is small muted text.
func agentCaption(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
}
// agentWell styles a sunken monospace well.
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Font(theme.MonoFont).
FontSize(FontSize(c, theme.CaptionSize+1))
}
// agentWellRows builds n rows in a well.
func agentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
return agentWell(c, agentRows(c, n, row), maxHeight)
}
// agentRows builds n scrolling rows, virtualized when many.
func agentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
if n > agentVirtualRows {
return ui.List(c, nil, n, row)
}
return ui.Scroll(c).Children(func() {
for i := range n {
row(i)
}
})
}
// agentVirtualRows is the row count above which wells virtualize.
const agentVirtualRows = 200
// agentCodeCache keeps lines and comment states per text.
type agentCodeCache struct {
language, text string
lines []string
open []bool
}
func (cc *agentCodeCache) update(language, text string) {
if cc.lines != nil && cc.language == language && cc.text == text {
return
}
cc.language, cc.text = language, text
cc.lines = internal.SplitLines(text)
cc.open = make([]bool, len(cc.lines))
block := false
for i, line := range cc.lines {
cc.open[i] = block
_, block = internal.Highlight(language, line, block)
}
}
// agentPrettyJSON indents valid JSON; anything else comes back unchanged.
func agentPrettyJSON(s string) string {
var b bytes.Buffer
if json.Indent(&b, []byte(s), "", " ") != nil {
return s
}
return b.String()
}
// agentTokenColor colors a highlight token from the theme.
func agentTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
switch kind {
case internal.TokKeyword:
return k.AccentText
case internal.TokString:
return k.Success
case internal.TokNumber, internal.TokType:
return k.Info
case internal.TokComment, internal.TokPunct:
return k.TextMuted
}
return k.Text
}
// agentCodeLine builds one highlighted line of language.
func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
k := Tokens(c)
toks, _ := internal.Highlight(language, line, inBlock)
spans := make([]ui.Span, 0, len(toks))
for _, t := range toks {
spans = append(spans, ui.Span{Text: t.Text, Color: agentTokenColor(k, t.Kind)})
}
if len(spans) == 0 {
spans = append(spans, ui.Span{Text: " "})
}
ui.RichText(c, spans...)
}
// agentCode shows highlighted text in a capped well.
func agentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
holder := ui.Column(c).Shrink(1)
cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} })
(*cc).update(language, text)
lines, open := (*cc).lines, (*cc).open
row := func(i int) { agentCodeLine(c, language, lines[i], open[i]) }
holder.Children(func() {
agentWellRows(c, len(lines), row, maxHeight)
})
return holder
}
// agentAnsiColor maps a terminal color onto the theme.
func agentAnsiColor(k theme.Tokens, a internal.AnsiColor, fallback ui.Color) ui.Color {
switch a.Kind {
case internal.AnsiRGB:
return ui.RGB(a.R, a.G, a.B)
case internal.AnsiIndexed:
switch a.Index % 8 {
case 0:
return k.TextMuted
case 1:
return k.Danger
case 2:
return k.Success
case 3:
return k.Warning
case 4, 6:
return k.Info
case 5:
return k.AccentText
case 7:
return k.Text
}
}
return fallback
}
// agentAnsiSpans turns parsed terminal spans into rich text spans.
func agentAnsiSpans(k theme.Tokens, in []internal.AnsiSpan) []ui.Span {
out := make([]ui.Span, 0, len(in)+1)
for _, s := range in {
fg := agentAnsiColor(k, s.Style.FG, k.Text)
bg := agentAnsiColor(k, s.Style.BG, ui.Transparent)
if s.Style.Inverse {
fg, bg = agentAnsiColor(k, s.Style.BG, k.Background), fg
}
if s.Style.Dim {
fg = fg.Alpha(0.7)
}
sp := ui.Span{Text: s.Text, Color: fg, Background: bg, Italic: s.Style.Italic,
Underline: s.Style.Underline, Strikethrough: s.Style.Strike}
if s.Style.Bold {
sp.Weight = 700
}
out = append(out, sp)
}
if len(out) == 0 {
out = append(out, ui.Span{Text: " "})
}
return out
}
// agentBar is a thin progress bar named label.
func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
if frac < 0 || frac > 1 || frac != frac {
panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac))
}
k := Tokens(c)
return ui.Box(c).FillWidth().Height(4).Shrink(0).Role(ui.RoleProgress).Label(label).
Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, k.Border, 2)
if frac > 0 {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(frac), H: r.H}, k.AccentText, 2)
}
})
}
// agentToggle is a small text button reporting a click.
func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
k := Tokens(c)
u := Density(c).Unit()
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u).
Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
internal.Ring(b, k.Focus, theme.ControlRadius)
if !disabled && b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
if icon != "" {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
}
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
})
return !b.IsDisabled() && b.Clicked()
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// PlanItem is a plan task: pending, running, done or skipped.
type PlanItem struct {
ID string
Text string
State AgentState
}
// AgentPlanOptions configure an AgentPlan.
type AgentPlanOptions struct {
// Title heads the checklist; empty uses "Plan".
Title string
Disabled bool
Still bool
}
type agentPlanState struct{ draft string }
// AgentPlan is a live checklist with skip, restore and add.
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult {
if items == nil {
panic("mujica: AgentPlan needs items")
}
seen := map[string]bool{}
done, counted := 0, 0
for i, it := range *items {
if it.ID == "" || seen[it.ID] {
panic(fmt.Sprintf("mujica: AgentPlan item %d needs a unique ID", i))
}
switch it.State {
case AgentPending, AgentRunning, AgentDone, AgentSkipped:
default:
panic(fmt.Sprintf("mujica: AgentPlan item %q has state %v", it.ID, it.State))
}
seen[it.ID] = true
if it.State != AgentSkipped {
counted++
}
done += agentB2I(it.State == AgentDone)
}
u := Density(c).Unit()
title := opts.Title
if title == "" {
title = msg(c, "agent_plan.title")
}
var r ChoiceResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(title)
st := ui.Local(r.Element, "mujica.plan", func() agentPlanState { return agentPlanState{} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
agentCaption(c, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
})
frac := 0.0
if counted > 0 {
frac = float64(done) / float64(counted)
}
agentBar(c, frac, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
list := *items
for i := range list {
ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() {
if agentPlanRow(c, &list[i], locked, opts.Still) {
r.changed = true
}
})
}
if !locked && agentPlanAdd(c, &st.draft) {
*items = append(*items, PlanItem{ID: agentPlanID(seen), Text: strings.TrimSpace(st.draft)})
st.draft = ""
r.changed = true
}
})
if r.changed {
slog.Debug("mujica: agent_plan change", "items", len(*items))
}
return r
}
// agentPlanRow builds one item, reporting a user change.
func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
k := Tokens(c)
checked := it.State == AgentDone
box := Checkbox(c, &checked, "", CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
changed := !locked && box.Changed()
if changed {
it.State = AgentPending
if checked {
it.State = AgentDone
}
}
t := ui.Text(c, it.Text).Grow(1).Shrink(1)
switch it.State {
case AgentSkipped:
t.Strikethrough().TextColor(k.TextMuted)
case AgentRunning:
agentMark(c, AgentRunning, 14, still)
agentCaption(c, msg(c, "agent_plan.doing"))
}
if it.State == AgentDone || locked {
return changed
}
label, icon, next := msg(c, "agent_plan.skip"), "ban", AgentSkipped
if it.State == AgentSkipped {
label, icon, next = msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
}
if agentToggle(c, label, icon, false) {
it.State = next
changed = true
}
return changed
}
// agentPlanAdd builds the new-item field and reports a non-empty submit.
func agentPlanAdd(c *ui.Context, draft *string) bool {
u := Density(c).Unit()
add := false
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
in := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "agent_plan.placeholder"), Label: msg(c, "agent_plan.placeholder")})
in.Element.Grow(1)
blank := strings.TrimSpace(*draft) == ""
add = Button(c, msg(c, "agent_plan.add"), ButtonOptions{Variant: Secondary, Disabled: blank}).Clicked()
add = (add || in.Submitted()) && !blank
})
return add
}
// agentPlanID returns an ID no item uses yet.
func agentPlanID(seen map[string]bool) string {
for n := len(seen) + 1; ; n++ {
if id := fmt.Sprintf("item-%d", n); !seen[id] {
return id
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func agentPlanSample() []PlanItem {
return []PlanItem{
{ID: "a", Text: "Read the guest list", State: AgentDone},
{ID: "b", Text: "Assign the seats", State: AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
}
func planTester(items *[]PlanItem, opts AgentPlanOptions, changes *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if AgentPlan(c, items, opts).Changed() {
*changes++
}
}, 560, 500)
}
func TestAgentPlanProgressCheckSkipAdd(t *testing.T) {
items := agentPlanSample()
changes := 0
tt := planTester(&items, AgentPlanOptions{}, &changes)
if !tt.HasText("1 / 3 done") || !tt.HasText("In progress") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Order the flowers") // the box is named by the item
if items[2].State != AgentDone || changes != 1 || !tt.HasText("2 / 3 done") {
t.Fatalf("check: %+v changes %d", items[2], changes)
}
skips, _ := tt.Find("Skip")
tt.ClickAt(skips.X+4, skips.Y+4) // the running item's Skip
if items[1].State != AgentSkipped || !tt.HasText("2 / 2 done") {
t.Fatalf("skip: %+v texts %q", items[1], tt.Texts())
}
_ = tt.Click("Restore")
if items[1].State != AgentPending {
t.Fatalf("restore: %+v", items[1])
}
_ = tt.Click("Add a step")
tt.Type(" ")
tt.Key(0, ui.KeyEnter)
if len(items) != 3 {
t.Fatal("blank item added")
}
tt.Type("Light the candles")
tt.Key(0, ui.KeyEnter)
if len(items) != 4 || items[3].Text != "Light the candles" || items[3].ID == "" || !tt.HasText("Light the candles") {
t.Fatalf("add: %+v", items)
}
}
func TestAgentPlanLiveUpdateAndDisabled(t *testing.T) {
items := agentPlanSample()
changes := 0
tt := planTester(&items, AgentPlanOptions{Disabled: true, Title: "Ball"}, &changes)
_ = tt.Click("Order the flowers")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if changes != 0 || items[2].State != AgentPending || tt.HasText("Add a step") || tt.HasText("Skip") {
t.Fatalf("disabled plan changed: %+v", items)
}
items[2].State = AgentDone // the agent checks it off
tt.Frame()
if !tt.HasText("2 / 3 done") || !tt.HasText("Ball") {
t.Fatalf("live update: %q", tt.Texts())
}
}
func TestAgentPlanRejectsBadState(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
items := []PlanItem{{ID: "a", Text: "x", State: AgentFailed}}
n := 0
planTester(&items, AgentPlanOptions{}, &n)
}
func TestAgentPlanSnapshot(t *testing.T) {
snapshot(t, "agent_plan", 520, 330, func(c *ui.Context) {
items := agentPlanSample()
items = append(items, PlanItem{ID: "d", Text: "Hire the quartet", State: AgentSkipped})
ui.Column(c).Padding(16).Children(func() { AgentPlan(c, &items, AgentPlanOptions{Still: true}) })
})
}
+36
View File
@@ -0,0 +1,36 @@
package mujica
import (
"github.com/egoist/mygo/ui"
)
// AgentProgressOptions configure an AgentProgress.
type AgentProgressOptions struct {
// Label names the work; empty uses "Progress".
Label string
// Action is what the agent does now.
Action string
// Indeterminate shows work of unknown length; the value is ignored.
Indeterminate bool
Still bool
}
// AgentProgress shows progress 0 to 1 and the current action.
func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element {
label := opts.Label
if label == "" {
label = msg(c, "agent_progress.label")
}
u := Density(c).Unit()
col := ui.Column(c).FillWidth().Shrink(0).Gap(u * 1.5)
col.Children(func() {
Progress(c, value, ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
if opts.Action != "" {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(opts.Action).Children(func() {
agentMark(c, AgentRunning, 14, opts.Still)
agentCaption(c, opts.Action)
})
}
})
return col
}
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestAgentProgressValueAndAction(t *testing.T) {
value, action := 0.25, "Reading ledger.md"
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AgentProgress(c, value, AgentProgressOptions{Action: action, Still: true})
}, 400, 200)
if !tt.HasText("25%") || !tt.HasText("Reading ledger.md") || !tt.HasText("Progress") {
t.Fatalf("texts %q", tt.Texts())
}
value, action = 0.8, "Writing summary.md"
tt.Frame()
if !tt.HasText("80%") || !tt.HasText("Writing summary.md") || tt.HasText("Reading ledger.md") {
t.Fatalf("update: %q", tt.Texts())
}
}
func TestAgentProgressIndeterminate(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AgentProgress(c, 0, AgentProgressOptions{Label: "Research", Indeterminate: true, Still: true})
}, 400, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if tt.HasText("0%") || !tt.HasText("Research") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestAgentProgressRejectsOutOfRange(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AgentProgress(c, 1.5, AgentProgressOptions{})
}, 200, 100)
}
func TestAgentProgressSnapshot(t *testing.T) {
snapshot(t, "agent_progress", 420, 110, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AgentProgress(c, 0.62, AgentProgressOptions{Label: "Preparing the ball", Action: "Writing the seating chart"})
})
})
}
+34
View File
@@ -0,0 +1,34 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// AgentStatusOptions configure an AgentStatus.
type AgentStatusOptions struct {
// Label replaces the built-in word, as "Writing the report".
Label string
// Still stops the running animation.
Still bool
}
// AgentStatus shows a state by icon shape and word.
func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element {
label := opts.Label
if label == "" {
label = agentWord(c, state) // panics on an unknown state
}
k := Tokens(c)
u := Density(c).Unit()
tone, _ := agentTone(k, state)
pill := ui.Row(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*1.5).Padding(u, u*2.5).
Radius(theme.CardRadius).Border(theme.BorderWidth, tone).Background(k.Surface).
Role(ui.RoleStatus).Label(label)
pill.Children(func() {
agentMark(c, state, 14, opts.Still)
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
})
return pill
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestAgentStatusWords(t *testing.T) {
states := []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
for _, s := range states {
AgentStatus(c, s, AgentStatusOptions{Still: true})
}
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: "Writing the report", Still: true})
}, 400, 400)
for _, w := range []string{"Planning", "Running", "Waiting", "Done", "Failed", "Writing the report"} {
if !tt.HasText(w) {
t.Fatalf("missing %q in %q", w, tt.Texts())
}
}
}
func TestAgentStatusAnimationStopsWhenStill(t *testing.T) {
still := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AgentStatus(c, AgentRunning, AgentStatusOptions{Still: still})
}, 300, 100)
tt.Frame()
moving := string(tt.Image().Pix)
time.Sleep(80 * time.Millisecond)
tt.Frame()
if moving == string(tt.Image().Pix) {
t.Fatal("running status did not animate")
}
still = true
tt.Frame()
a := string(tt.Image().Pix)
time.Sleep(80 * time.Millisecond)
tt.Frame()
if a != string(tt.Image().Pix) {
t.Fatal("still status kept moving")
}
}
func TestAgentStatusRejectsUnknownState(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AgentStatus(c, AgentState(42), AgentStatusOptions{})
}, 200, 100)
}
func TestAgentStatusSnapshot(t *testing.T) {
snapshot(t, "agent_status", 480, 120, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(8).Wrap().Children(func() {
for _, s := range []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed} {
AgentStatus(c, s, AgentStatusOptions{})
}
})
})
}
+140
View File
@@ -0,0 +1,140 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AgentStep is one step of an agent run.
type AgentStep struct {
ID string
Title string
Detail string
State AgentState
Duration time.Duration
}
// AgentStepListOptions configure an AgentStepList.
type AgentStepListOptions struct {
Disabled bool
Still bool
}
// AgentStepListResult reports this frame's events.
type AgentStepListResult struct {
Element *ui.Element
// Retry is the failed step to retry, else "".
Retry string
// Toggled is the step whose detail toggled, else "".
Toggled string
}
// AgentStepList is a step timeline with details and retries.
func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) AgentStepListResult {
current := -1
seen := make(map[string]bool, len(steps))
for i, s := range steps {
if s.ID == "" || s.Title == "" || seen[s.ID] {
panic(fmt.Sprintf("mujica: AgentStepList step %d needs a unique ID and a Title", i))
}
seen[s.ID] = true
if current < 0 && (s.State == AgentRunning || s.State == AgentWaiting || s.State == AgentPlanning) {
current = i
}
}
var r AgentStepListResult
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
open := ui.Local(r.Element, "mujica.steps.open", func() map[string]bool { return map[string]bool{} })
r.Element.Children(func() {
for i, s := range steps {
ui.Column(c).Key(s.ID).Children(func() {
if agentStepRow(c, s, i, len(steps), i == current, (*open)[s.ID], opts.Still, &r) {
(*open)[s.ID] = !(*open)[s.ID]
r.Toggled = s.ID
}
})
}
})
if r.Element.IsDisabled() {
r.Retry, r.Toggled = "", ""
}
for id := range *open {
if !seen[id] {
delete(*open, id)
}
}
if r.Retry != "" || r.Toggled != "" {
slog.Debug("mujica: agent_step_list event", "retry", r.Retry, "toggled", r.Toggled)
}
return r
}
// agentStepRow builds one step, reporting a header click.
func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still bool, r *AgentStepListResult) bool {
k := Tokens(c)
d := Density(c)
u := d.Unit()
clicked := false
top := (d.ControlHeight() - 18) / 2 // centers the mark on the step header
ui.Row(c).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
// The rail joins the marks; it is drawn, not focusable.
ui.Column(c).Width(20).Shrink(0).AlignItems(ui.Center).Draw(func(p *ui.Painter, rc ui.Rect) {
x := internal.Snap(p, rc.X+rc.W/2)
if i > 0 {
p.Line(x, rc.Y, x, rc.Y+top-3, 1, k.ControlBorder)
}
if i < n-1 {
p.Line(x, rc.Y+top+21, x, rc.Y+rc.H, 1, k.ControlBorder)
}
}).Children(func() {
ui.Box(c).Height(top)
agentMark(c, s.State, 18, still)
})
ui.Column(c).Grow(1).Shrink(1).Gap(u).Padding(0, 0, u*2, 0).Children(func() {
head := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2).
Radius(theme.ControlRadius).Label(s.Title + ", " + agentWord(c, s.State))
internal.Ring(head, k.Focus, theme.ControlRadius)
if current {
head.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
} else if head.Hovered() {
head.Background(k.SurfaceHover)
}
head.Children(func() {
ui.Text(c, s.Title).FontWeight(600).Grow(1).Shrink(1)
if current {
agentCaption(c, msg(c, "agent_step_list.current"))
}
agentCaption(c, agentWord(c, s.State))
if s.State == AgentDone || s.State == AgentFailed {
agentCaption(c, agentDuration(s.Duration))
}
if s.Detail != "" {
chev := "chevron-right"
if open {
chev = "chevron-down"
}
ui.Icon(c, icons.Must(chev)).Size(14, 14).TextColor(k.TextMuted)
}
})
clicked = s.Detail != "" && !head.IsDisabled() && head.Clicked()
if open && s.Detail != "" {
ui.Text(c, s.Detail).TextColor(k.TextMuted).Selectable().Padding(0, u*2)
}
if s.State == AgentFailed {
ui.Row(c).Children(func() {
if agentToggle(c, msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
r.Retry = s.ID
}
})
}
})
})
return clicked
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var agentStepsSample = []AgentStep{
{ID: "plan", Title: "Read the guest list", State: AgentDone, Duration: 800 * time.Millisecond, Detail: "42 guests, 3 late."},
{ID: "seat", Title: "Assign the seats", State: AgentFailed, Duration: 2 * time.Second, Detail: "Table 4 is over capacity."},
{ID: "send", Title: "Send the invitations", State: AgentRunning},
{ID: "log", Title: "Record the ledger", State: AgentPending},
}
func stepTester(steps []AgentStep, opts AgentStepListOptions, got *[]AgentStepListResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := AgentStepList(c, steps, opts)
if r.Retry != "" || r.Toggled != "" {
*got = append(*got, r)
}
}, 600, 500)
}
func TestAgentStepListCurrentDetailRetry(t *testing.T) {
var got []AgentStepListResult
tt := stepTester(agentStepsSample, AgentStepListOptions{Still: true}, &got)
if !tt.HasText("Current") || !tt.HasText("Running") || !tt.HasText("800 ms") {
t.Fatalf("texts %q", tt.Texts())
}
if tt.HasText("Table 4 is over capacity.") {
t.Fatal("detail shown while closed")
}
_ = tt.Click("Assign the seats")
if len(got) != 1 || got[0].Toggled != "seat" || !tt.HasText("Table 4 is over capacity.") {
t.Fatalf("toggle: %+v", got)
}
_ = tt.Click("Retry")
if len(got) != 2 || got[1].Retry != "seat" {
t.Fatalf("retry: %+v", got)
}
}
func TestAgentStepListKeyboard(t *testing.T) {
var got []AgentStepListResult
tt := stepTester(agentStepsSample, AgentStepListOptions{Still: true}, &got)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0].Toggled != "plan" || !tt.HasText("42 guests, 3 late.") {
t.Fatalf("Enter on the first step: %+v", got)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown) // past Assign the seats onto its Retry
tt.Key(0, ui.KeyEnter)
if len(got) != 2 || got[1].Retry != "seat" {
t.Fatalf("arrows to Retry: %+v", got)
}
}
func TestAgentStepListDisabled(t *testing.T) {
var got []AgentStepListResult
tt := stepTester(agentStepsSample, AgentStepListOptions{Disabled: true, Still: true}, &got)
_ = tt.Click("Assign the seats")
_ = tt.Click("Retry")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 0 {
t.Fatalf("disabled list reported %+v", got)
}
}
func TestAgentStepListRejectsDuplicateIDs(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
stepTester([]AgentStep{{ID: "a", Title: "x"}, {ID: "a", Title: "y"}}, AgentStepListOptions{}, nil)
}
func TestAgentStepListSnapshot(t *testing.T) {
snapshot(t, "agent_step_list", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { AgentStepList(c, agentStepsSample, AgentStepListOptions{Still: true}) })
})
}
+67
View File
@@ -0,0 +1,67 @@
package mujica
import (
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AnsiTextOptions configure an AnsiText.
type AnsiTextOptions struct {
// Wrap breaks long lines at the width.
Wrap bool
}
// AnsiText renders terminal output with its SGR styles.
func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) *ui.Element {
k := Tokens(c)
col := ui.Column(c).Font(theme.MonoFont).TextColor(k.Text).Shrink(0)
col.Children(func() {
var st internal.AnsiStyle
for _, line := range strings.Split(text, "\n") {
var spans []internal.AnsiSpan
spans, st = internal.ParseAnsi(line, st)
out := make([]ui.Span, 0, len(spans))
for _, s := range spans {
out = append(out, ansiTextSpan(k, s))
}
if len(out) == 0 {
out = append(out, ui.Span{Text: " "})
}
t := ui.RichText(c, out...)
if !opts.Wrap {
t.NoWrap()
}
}
})
return col
}
// ansiTextSpan maps one parsed span onto theme-readable colors.
func ansiTextSpan(k theme.Tokens, s internal.AnsiSpan) ui.Span {
fg, hasFG := internal.AnsiResolve(k.Dark, s.Style.FG)
if !hasFG {
fg = k.Text
}
bg, hasBG := internal.AnsiResolve(k.Dark, s.Style.BG)
if s.Style.Inverse {
if !hasBG {
bg = k.Surface
}
fg, bg, hasBG = bg, fg, true
}
if s.Style.Dim {
fg = fg.Mix(k.Surface, 0.4)
}
out := ui.Span{Text: s.Text, Color: fg, Italic: s.Style.Italic, Underline: s.Style.Underline, Strikethrough: s.Style.Strike}
if s.Style.Bold {
out.Weight = 700
}
if hasBG {
out.Background = bg
}
return out
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
func TestAnsiTextStripsSGRKeepsUnknown(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AnsiText(c, "\x1b[1;31merror\x1b[0m: bad\n\x1b[2Kcleared", AnsiTextOptions{})
}, 400, 100)
if !tt.HasText("error: bad") {
t.Fatalf("styled line: %q", tt.Texts())
}
if !tt.HasText("\x1b[2Kcleared") {
t.Fatalf("unknown escape not kept: %q", tt.Texts())
}
}
func TestAnsiTextSpanStyles(t *testing.T) {
k := theme.Light()
parse := func(s string) ui.Span {
spans, _ := internal.ParseAnsi(s, internal.AnsiStyle{})
return ansiTextSpan(k, spans[0])
}
red, _ := internal.AnsiResolve(false, internal.AnsiColor{Kind: internal.AnsiIndexed, Index: 1})
if s := parse("\x1b[1;3;4;31mx"); s.Color != red || s.Weight != 700 || !s.Italic || !s.Underline {
t.Errorf("bold italic underline red: %+v", s)
}
if s := parse("\x1b[38;5;196mx"); s.Color != ui.RGB(255, 0, 0) {
t.Errorf("256 color: %+v", s.Color)
}
if s := parse("\x1b[38;2;10;20;30;48;2;1;2;3mx"); s.Color != ui.RGB(10, 20, 30) || s.Background != ui.RGB(1, 2, 3) {
t.Errorf("true color: %+v", s)
}
if s := parse("\x1b[7mx"); s.Color != k.Surface || s.Background != k.Text {
t.Errorf("inverse: %+v", s)
}
if s := parse("\x1b[9mx"); !s.Strikethrough || s.Color != k.Text {
t.Errorf("strike: %+v", s)
}
}
func TestAnsiTextSnapshot(t *testing.T) {
snapshot(t, "ansi_text", 520, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AnsiText(c, "\x1b[1mBuilding\x1b[0m mujica \x1b[2m(v1.1)\x1b[0m\n"+
"\x1b[32m✓\x1b[0m 300 passed \x1b[31m✗\x1b[0m 2 failed \x1b[33m!\x1b[0m 1 skipped\n"+
"\x1b[30m0\x1b[31m1\x1b[32m2\x1b[33m3\x1b[34m4\x1b[35m5\x1b[36m6\x1b[37m7\x1b[90m8\x1b[91m9\x1b[92mA\x1b[93mB\x1b[94mC\x1b[95mD\x1b[96mE\x1b[97mF\x1b[0m\n"+
"\x1b[38;5;208m256-color\x1b[0m \x1b[38;2;125;32;52mtrue color\x1b[0m \x1b[4munderline\x1b[0m \x1b[3mitalic\x1b[0m \x1b[7m inverse \x1b[0m\n"+
"\x1b[41;97m FAIL \x1b[0m kept: \x1b[2K", AnsiTextOptions{})
})
})
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"math"
"time"
"github.com/egoist/mygo/ui"
)
// AreaChartOptions configure an AreaChart.
type AreaChartOptions struct {
Frame ChartFrame
// X places point i at ascending X[i]; nil means i.
X []float64
// Times makes a time axis; set X or Times.
Times []time.Time
// Stacked piles visible series on each other.
Stacked bool
// Baseline is where areas start; zero by default.
Baseline float64
Format func(v float64) string
}
// AreaChart fills series to the baseline, or stacked.
func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult {
n := chartCheckSeries(series)
ch := newChart(c, opts.Frame)
ax := chartMemo(ch, "x", [3]string{chartID(opts.X), chartID(opts.Times), chartID(series)}, func() chartX { return newChartX(n, opts.X, opts.Times) })
deps := struct {
id string
stacked bool
base float64
}{chartSeriesID(ch, series), opts.Stacked, opts.Baseline}
bands := chartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.hidden, opts.Stacked, opts.Baseline) })
lo, hi, ok := chartYRange(ch, series, false, bands[0])
if !ok {
n = 0
}
lo, hi = min(lo, opts.Baseline), max(hi, opts.Baseline)
return chartLines(ch, chartLineData{series: series, tops: bands[0], bases: bands[1], ax: ax, lo: lo, hi: hi, n: n, format: opts.Format})
}
// areaBands returns each series' top and bottom edges.
func areaBands(series []ChartSeries, hidden func(string) bool, stacked bool, base float64) [2][][]float64 {
var tops, bases [][]float64
var run []float64
if len(series) > 0 {
run = make([]float64, len(series[0].Values))
for i := range run {
run[i] = base
}
}
for _, s := range series {
top, bot := make([]float64, len(s.Values)), make([]float64, len(s.Values))
for i, v := range s.Values {
bot[i], top[i] = base, v
if stacked {
bot[i], top[i] = run[i], run[i]+v
}
if stacked && !math.IsNaN(v) && !hidden(s.Name) {
run[i] += v
}
}
tops, bases = append(tops, top), append(bases, bot)
}
return [2][][]float64{tops, bases}
}
func chartBool(b bool) float64 {
if b {
return 1
}
return 0
}
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestAreaBands(t *testing.T) {
series := []ChartSeries{{Name: "a", Values: []float64{1, math.NaN(), 3}}, {Name: "b", Values: []float64{2, 2, 2}}}
none := func(string) bool { return false }
st := areaBands(series, none, true, 10)
if st[0][0][0] != 11 || st[1][1][0] != 11 || st[0][1][0] != 13 || !math.IsNaN(st[0][0][1]) || st[0][1][1] != 12 {
t.Fatalf("stacked %v", st)
}
hideA := func(s string) bool { return s == "a" }
st = areaBands(series, hideA, true, 0)
if st[0][1][2] != 2 || st[1][1][2] != 0 {
t.Fatalf("hidden series still stacked: %v", st)
}
flat := areaBands(series, none, false, -1)
if flat[0][0][2] != 3 || flat[1][0][2] != -1 || flat[1][1][2] != -1 {
t.Fatalf("baseline %v", flat)
}
}
func TestAreaChartStackedReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return AreaChart(c, lineSeries(), AreaChartOptions{Frame: ChartFrame{Label: "Nave"}, Stacked: true, Baseline: 1})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Nave")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "0 · Pews 3 · Candles 5" {
t.Fatalf("readout shows the series values, not the stack: %q", got)
}
b := h.res.Element.Bounds()
tt.Move(b.X+52+(b.W-64)*0.5, b.Y+b.H/2)
if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" || h.res.Point != 2 {
t.Fatalf("hover %q", got)
}
}
func TestAreaChartSnapshot(t *testing.T) {
snapshot(t, "area_chart", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AreaChart(c, []ChartSeries{
{Name: "Matins", Values: []float64{3, 4, 6, 5, 7, 8}},
{Name: "Vespers", Values: []float64{2, 3, 2, 4, 3, 5}},
}, AreaChartOptions{Frame: ChartFrame{Label: "Attendance"}, Stacked: true})
})
})
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ArtifactCardOptions configure an ArtifactCard. Title is required.
type ArtifactCardOptions struct {
Kind ArtifactKind
Title string
// Detail is a short fact, such as "Go, 120 lines".
Detail string
// Streaming shows the artifact is still being written.
Streaming bool
Disabled bool
}
var artifactKinds = [3]struct{ icon, name string }{
{"file", "artifact_card.code"}, {"file-text", "artifact_card.document"}, {"layout-grid", "artifact_card.web"},
}
// ArtifactCard stands for an artifact; activating asks to open it.
func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) ChoiceResult {
if opts.Title == "" {
panic("mujica: ArtifactCard needs a Title")
}
if opts.Kind < ArtifactCode || opts.Kind > ArtifactWeb {
panic("mujica: ArtifactCard Kind out of range")
}
k := Tokens(c)
d := Density(c)
kind := artifactKinds[opts.Kind]
var b *ui.Element
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() {
b = ui.ButtonBase(c).MinWidth(260).MaxWidth(420).MinHeight(56).Padding(d.Unit()*2, d.Unit()*3).Gap(d.Unit()*3).
Justify(ui.Start).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
Label(msg(c, kind.name) + ": " + opts.Title)
})
internal.Ring(b, k.Focus, theme.CardRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Transition(colorFade)
b.Children(func() {
ui.Box(c).Size(36, 36).Shrink(0).Center().Radius(theme.ControlRadius).Background(k.Selection).Children(func() {
ui.Icon(c, icons.Must(kind.icon)).Size(18, 18).TextColor(k.AccentText)
})
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
ui.Text(c, opts.Title).SingleLine().Bold().TextColor(k.Text)
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
detail := msg(c, kind.name)
if opts.Detail != "" {
detail += " · " + opts.Detail
}
ui.Text(c, detail).SingleLine().FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
if opts.Streaming {
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "artifact_card.writing")})
}
})
})
ui.Text(c, msg(c, "artifact_card.open")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).Shrink(0)
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.AccentText)
})
open := b.Clicked() && !b.IsDisabled()
if open {
slog.Debug("mujica: artifact_card open", "kind", opts.Kind)
}
return ChoiceResult{Element: b, changed: open}
}
+63
View File
@@ -0,0 +1,63 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestArtifactCardOpens(t *testing.T) {
opens := 0
tt := chatTester(480, 160, func(c *ui.Context) {
if ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactDocument, Title: "Decree", Detail: "2 pages"}).Changed() {
opens++
}
})
if !tt.HasText("Document · 2 pages") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Decree")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if opens != 2 {
t.Fatalf("opens %d", opens)
}
}
func TestArtifactCardStreamingDisabledAndChecks(t *testing.T) {
opens := 0
tt := chatTester(480, 160, func(c *ui.Context) {
if ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactWeb, Title: "Menu", Streaming: true, Disabled: true}).Changed() {
opens++
}
})
_ = tt.Click("Menu")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if opens != 0 || !tt.HasText("Web page") {
t.Fatalf("opens %d texts %q", opens, tt.Texts())
}
for _, opts := range []ArtifactCardOptions{{}, {Title: "x", Kind: 7}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", opts)
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ArtifactCard(c, opts)
}, 100, 100, 1)
}()
}
}
func TestArtifactCardSnapshot(t *testing.T) {
snapshot(t, "artifact_card", 460, 210, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(8).Children(func() {
ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactCode, Title: "seating.go", Detail: "Go · 120 lines"})
ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactDocument, Title: "Royal decree", Streaming: true})
ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactWeb, Title: "Ball invitation", Disabled: true})
})
})
}
+148
View File
@@ -0,0 +1,148 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ArtifactKind is what an artifact holds; zero is code.
type ArtifactKind int
const (
ArtifactCode ArtifactKind = iota
ArtifactDocument
ArtifactWeb
)
// Artifact is something an agent made; the caller owns it.
type Artifact struct {
Title string
Kind ArtifactKind
// Language highlights code, as "go" or "json".
Language string
Content string
}
// ArtifactPanelOptions configure an ArtifactPanel.
type ArtifactPanelOptions struct {
// Preview renders documents and web pages; the caller supplies it.
Preview func()
// Height of the body; 320 DIPs when zero.
Height float32
Disabled bool
}
// ArtifactPanelResult reports this frame's events.
type ArtifactPanelResult struct {
Element *ui.Element
// Copied reports the content went to the clipboard.
Copied bool
// Exported asks the caller to export the artifact.
Exported bool
}
type artifactPanelState struct {
source bool
copiedAt time.Time
}
// ArtifactPanel shows an artifact, with copy and export.
func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) ArtifactPanelResult {
if art.Title == "" {
panic("mujica: ArtifactPanel needs a Title")
}
if art.Kind < ArtifactCode || art.Kind > ArtifactWeb {
panic(fmt.Sprintf("mujica: unknown ArtifactKind %d", int(art.Kind)))
}
if art.Kind != ArtifactCode && opts.Preview == nil {
panic("mujica: ArtifactPanel needs a Preview for documents and web pages")
}
if opts.Height < 0 {
panic("mujica: ArtifactPanel Height is negative")
}
h := opts.Height
if h == 0 {
h = 320
}
k := Tokens(c)
u := Density(c).Unit()
var r ArtifactPanelResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(art.Title)
st := ui.Local(r.Element, "mujica.artifact", func() artifactPanelState { return artifactPanelState{source: art.Kind == ArtifactCode} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
if opts.Preview != nil {
artifactViewSwitch(c, &st.source, locked)
}
copied := !st.copiedAt.IsZero() && c.Now().Sub(st.copiedAt) < 2*time.Second
label, icon := msg(c, "artifact_panel.copy"), "copy"
if copied {
label, icon = msg(c, "artifact_panel.copied"), "check"
c.After(2*time.Second - c.Now().Sub(st.copiedAt))
}
if agentToggle(c, label, icon, locked) {
c.WriteClipboard(art.Content)
st.copiedAt, r.Copied = c.Now(), true
}
if agentToggle(c, msg(c, "artifact_panel.export"), "upload", locked) {
r.Exported = true
}
})
ui.Column(c).Height(h).Shrink(0).Children(func() {
if st.source || opts.Preview == nil {
agentCode(c, art.Language, art.Content, h).Grow(1)
return
}
ui.Scroll(c).Grow(1).Padding(u*3).Radius(theme.ControlRadius).Background(k.Background).
Border(theme.BorderWidth, k.Border).Children(opts.Preview)
})
})
if r.Copied || r.Exported {
slog.Debug("mujica: artifact_panel event", "title", art.Title, "copied", r.Copied, "exported", r.Exported)
}
return r
}
// artifactViewSwitch is the Preview / Source segmented switch.
func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
k := Tokens(c)
u := Density(c).Unit()
ui.Row(c).Shrink(0).FocusGroup(ui.Horizontal).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.ControlRadius).
Role(ui.RoleRadioGroup).Children(func() {
for _, src := range []bool{false, true} {
label, icon := msg(c, "artifact_panel.preview"), "eye"
if src {
label, icon = msg(c, "artifact_panel.source"), "file-text"
}
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Role(ui.RoleRadio).Label(label).
Disabled(locked)
internal.Ring(b, k.Focus, theme.ControlRadius)
on := *source == src
if on {
b.Background(k.Selection)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
if on {
ui.Icon(c, icons.Must("check")).Size(12, 12).TextColor(k.AccentText)
}
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1))
})
if !b.IsDisabled() && b.Clicked() && !on {
*source = src
slog.Debug("mujica: artifact_panel view", "source", src)
}
}
})
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var artifactCodeSample = Artifact{Title: "seating.go", Language: "go", Content: "package court\n\n// Seats maps guests to tables.\nfunc Seats() map[string]int {\n\treturn map[string]int{\"Sakiko\": 1}\n}\n"}
func artifactTester(art Artifact, opts ArtifactPanelOptions, got *[]ArtifactPanelResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := ArtifactPanel(c, art, opts)
if r.Copied || r.Exported {
*got = append(*got, r)
}
}, 600, 460)
}
func TestArtifactPanelCodeCopyExport(t *testing.T) {
var got []ArtifactPanelResult
tt := artifactTester(artifactCodeSample, ArtifactPanelOptions{}, &got)
if !tt.HasText("// Seats maps guests to tables.") || tt.HasText("Preview") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Copy")
if len(got) != 1 || !got[0].Copied || tt.Clipboard() != artifactCodeSample.Content || !tt.HasText("Copied") {
t.Fatalf("copy: %+v clipboard %q", got, tt.Clipboard())
}
_ = tt.Click("Export")
if len(got) != 2 || !got[1].Exported {
t.Fatalf("export: %+v", got)
}
}
func TestArtifactPanelPreviewSourceSwitch(t *testing.T) {
doc := Artifact{Title: "Invitation", Kind: ArtifactDocument, Content: "# You are invited\n"}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ArtifactPanel(c, doc, ArtifactPanelOptions{Preview: func() { ui.Text(c, "Rendered invitation") }})
}, 600, 460)
if !tt.HasText("Rendered invitation") || tt.HasText("# You are invited") {
t.Fatalf("preview first: %q", tt.Texts())
}
_ = tt.Click("Source")
if !tt.HasText("# You are invited") || tt.HasText("Rendered invitation") {
t.Fatalf("source: %q", tt.Texts())
}
tt.Key(0, ui.KeyLeft) // the switch is a focus group
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Rendered invitation") {
t.Fatal("keyboard back to preview failed")
}
}
func TestArtifactPanelDisabledAndRules(t *testing.T) {
var got []ArtifactPanelResult
tt := artifactTester(artifactCodeSample, ArtifactPanelOptions{Disabled: true}, &got)
_ = tt.Click("Copy")
_ = tt.Click("Export")
if len(got) != 0 || tt.Clipboard() != "" {
t.Fatalf("disabled panel acted: %+v", got)
}
defer func() {
if recover() == nil {
t.Fatal("web artifact without Preview did not panic")
}
}()
artifactTester(Artifact{Title: "x", Kind: ArtifactWeb}, ArtifactPanelOptions{}, &got)
}
func TestArtifactPanelSnapshot(t *testing.T) {
snapshot(t, "artifact_panel", 560, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ArtifactPanel(c, artifactCodeSample, ArtifactPanelOptions{Height: 170, Preview: func() {}})
})
})
}
+89
View File
@@ -0,0 +1,89 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// ArtifactVersion is one saved version of an artifact.
type ArtifactVersion struct {
Label string
Note string
}
// ArtifactVersionSwitcherOptions configure an ArtifactVersionSwitcher.
type ArtifactVersionSwitcherOptions struct {
Disabled bool
}
// ArtifactVersionResult reports this frame's events.
type ArtifactVersionResult struct {
Element *ui.Element
// Changed reports *current moved.
Changed bool
// Compare asks to diff current against the previous version.
Compare bool
}
// ArtifactVersionSwitcher steps between artifact versions.
func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVersion, opts ArtifactVersionSwitcherOptions) ArtifactVersionResult {
if current == nil || len(versions) == 0 || *current < 0 || *current >= len(versions) {
panic("mujica: ArtifactVersionSwitcher needs versions and a current index inside them")
}
for i, v := range versions {
if v.Label == "" {
panic(fmt.Sprintf("mujica: ArtifactVersionSwitcher version %d needs a Label", i))
}
}
u := Density(c).Unit()
n := len(versions)
var r ArtifactVersionResult
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled).
Label(msg(c, "artifact_version_switcher.label"))
locked := r.Element.IsDisabled()
move := func(d int) {
if to := *current + d; !locked && to >= 0 && to < n {
*current, r.Changed = to, true
}
}
r.Element.Children(func() {
if iconAction(c, icons.Must("chevron-left"), msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
move(-1)
}
v := versions[*current]
ui.Column(c).Shrink(1).Role(ui.RoleStatus).Label(v.Label).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, v.Label).FontWeight(600).SingleLine()
agentCaption(c, fmt.Sprintf(msg(c, "artifact_version_switcher.count"), *current+1, n))
})
if v.Note != "" {
agentCaption(c, v.Note).SingleLine()
}
})
if iconAction(c, icons.Must("chevron-right"), msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
move(1)
}
if *current > 0 {
label := fmt.Sprintf(msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
if agentToggle(c, label, "arrow-up-down", locked) {
r.Compare = true
}
}
})
if !locked {
if r.Element.Shortcut(0, ui.KeyLeft) {
move(-1)
}
if r.Element.Shortcut(0, ui.KeyRight) {
move(1)
}
}
if r.Changed || r.Compare {
slog.Debug("mujica: artifact_version_switcher event", "current", *current, "compare", r.Compare)
}
return r
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var artifactVersionsSample = []ArtifactVersion{
{Label: "v1", Note: "First draft"},
{Label: "v2", Note: "Added the quartet"},
{Label: "v3", Note: "Moved the wine table"},
}
func versionTester(cur *int, opts ArtifactVersionSwitcherOptions, got *[]ArtifactVersionResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := ArtifactVersionSwitcher(c, cur, artifactVersionsSample, opts)
if r.Changed || r.Compare {
*got = append(*got, r)
}
}, 600, 120)
}
func TestArtifactVersionSwitcherStepsAndCompare(t *testing.T) {
cur := 2
var got []ArtifactVersionResult
tt := versionTester(&cur, ArtifactVersionSwitcherOptions{}, &got)
if !tt.HasText("3 of 3") || !tt.HasText("Moved the wine table") || !tt.HasText("Compare with v2") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Compare with v2")
if len(got) != 1 || !got[0].Compare || cur != 2 {
t.Fatalf("compare: %+v", got)
}
_ = tt.Click("Older version")
_ = tt.Click("Older version")
if cur != 0 || tt.HasText("Compare with v1") || !tt.HasText("First draft") {
t.Fatalf("older: cur %d texts %q", cur, tt.Texts())
}
_ = tt.Click("Older version") // disabled at the first version
if cur != 0 || len(got) != 3 {
t.Fatalf("past the first: cur %d events %d", cur, len(got))
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if cur != 1 {
t.Fatalf("Right: cur %d", cur)
}
}
func TestArtifactVersionSwitcherDisabledAndRange(t *testing.T) {
cur := 1
var got []ArtifactVersionResult
tt := versionTester(&cur, ArtifactVersionSwitcherOptions{Disabled: true}, &got)
_ = tt.Click("Older version")
_ = tt.Click("Compare with v1")
if cur != 1 || len(got) != 0 {
t.Fatalf("disabled moved: cur %d %+v", cur, got)
}
defer func() {
if recover() == nil {
t.Fatal("out-of-range current did not panic")
}
}()
cur = 5
versionTester(&cur, ArtifactVersionSwitcherOptions{}, &got)
}
func TestArtifactVersionSwitcherSnapshot(t *testing.T) {
snapshot(t, "artifact_version_switcher", 560, 90, func(c *ui.Context) {
cur := 1
ui.Column(c).Padding(16).Children(func() {
ArtifactVersionSwitcher(c, &cur, artifactVersionsSample, ArtifactVersionSwitcherOptions{})
})
})
}
+119
View File
@@ -0,0 +1,119 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// AttendeeStatus is an attendee's reply.
type AttendeeStatus int
const (
AttendeePending AttendeeStatus = iota
AttendeeAccepted
AttendeeTentative
AttendeeDeclined
)
// Attendee is one person invited to an event.
type Attendee struct {
Name string
Email string
Status AttendeeStatus
Organizer bool
Optional bool
}
// AttendeeListOptions configure an AttendeeList.
type AttendeeListOptions struct {
// Removable shows a remove button on everyone but the organizer.
Removable bool
Disabled bool
Label string
}
// AttendeeListResult is the list and the attendee the user removed.
type AttendeeListResult struct {
Element *ui.Element
removed int
}
// Removed returns the index of the attendee the user removed.
func (r AttendeeListResult) Removed() (int, bool) { return r.removed, r.removed >= 0 }
// AttendeeList shows invitees, their replies and a summary.
func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult {
k := Tokens(c)
d := Density(c)
var counts [4]int
for _, a := range attendees {
if a.Status < AttendeePending || a.Status > AttendeeDeclined {
panic(fmt.Sprintf("mujica: AttendeeList status %d out of range", a.Status))
}
counts[a.Status]++
}
label := opts.Label
if label == "" {
label = msg(c, "attendee_list.label")
}
r := AttendeeListResult{removed: -1}
r.Element = ui.Column(c).Gap(d.Unit()).Role(ui.RoleList).Label(label).Disabled(opts.Disabled)
r.Element.Children(func() {
summary := fmt.Sprintf(msg(c, "attendee_list.summary"), counts[AttendeeAccepted], counts[AttendeeTentative],
counts[AttendeeDeclined], counts[AttendeePending])
ui.Text(c, summary).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
if len(attendees) == 0 {
ui.Text(c, msg(c, "attendee_list.empty")).TextColor(k.TextMuted)
}
for i, a := range attendees {
ui.Row(c).Key(i).MinHeight(d.ControlHeight()).Gap(d.Unit() * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
Avatar(c, a.Name, AvatarOptions{Size: 28})
ui.Column(c).Grow(1).Shrink(1).Children(func() {
name := a.Name
if a.Organizer {
name += " · " + msg(c, "attendee_list.organizer")
}
if a.Optional {
name += " · " + msg(c, "attendee_list.optional")
}
ui.Text(c, name).TextColor(k.Text).SingleLine().Ellipsis("…")
if a.Email != "" {
ui.Text(c, a.Email).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Ellipsis("…")
}
})
icon, fg := attendeeStatusLook(k, a.Status)
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Shrink(0).TextColor(fg).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(16, 16)
ui.Text(c, msg(c, attendeeStatusKeys[a.Status])).FontSize(FontSize(c, theme.CaptionSize))
})
if opts.Removable && !a.Organizer {
b := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "attendee_list.remove"), a.Name))
if b.Clicked() && !b.IsDisabled() {
slog.Debug("mujica: attendee_list remove", "index", i)
r.removed = i
}
}
})
}
})
return r
}
var attendeeStatusKeys = [...]string{"attendee_list.pending", "attendee_list.accepted", "attendee_list.tentative", "attendee_list.declined"}
func attendeeStatusLook(k theme.Tokens, s AttendeeStatus) (icon string, fg ui.Color) {
switch s {
case AttendeeAccepted:
return "circle-check", k.Success
case AttendeeTentative:
return "circle-alert", k.Warning
case AttendeeDeclined:
return "circle-x", k.Danger
}
return "clock", k.TextMuted
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var calAttendees = []Attendee{
{Name: "Sakiko Togawa", Email: "sakiko@court.example", Status: AttendeeAccepted, Organizer: true},
{Name: "Mutsumi Wakaba", Status: AttendeeTentative},
{Name: "Uika Misumi", Email: "uika@court.example", Status: AttendeeDeclined, Optional: true},
{Name: "Nyamu Yuutenji", Status: AttendeePending},
}
func attendeeTester(list *[]Attendee, opts AttendeeListOptions) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if i, ok := AttendeeList(c, *list, opts).Removed(); ok {
*list = append((*list)[:i:i], (*list)[i+1:]...)
}
}, 480, 400)
}
func TestAttendeeListStatusesAndSummary(t *testing.T) {
list := append([]Attendee(nil), calAttendees...)
tt := attendeeTester(&list, AttendeeListOptions{})
for _, s := range []string{"1 accepted · 1 maybe · 1 declined · 1 awaiting", "Sakiko Togawa · Organizer",
"Uika Misumi · Optional", "Accepted", "Maybe", "Declined", "Awaiting reply", "uika@court.example"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %v", s, tt.Texts())
}
}
if tt.HasText("Remove Mutsumi Wakaba") {
t.Fatal("remove buttons without Removable")
}
}
func TestAttendeeListRemoveKeyboardAndOrganizer(t *testing.T) {
list := append([]Attendee(nil), calAttendees...)
tt := attendeeTester(&list, AttendeeListOptions{Removable: true})
if tt.HasText("Remove Sakiko Togawa") {
t.Fatal("the organizer cannot be removed")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(list) != 3 || list[0].Name != "Sakiko Togawa" || list[1].Name != "Uika Misumi" {
t.Fatalf("keyboard remove left %v", list)
}
_ = tt.Click("Remove Uika Misumi")
if len(list) != 2 || !tt.HasText("1 accepted · 0 maybe · 0 declined · 1 awaiting") {
t.Fatalf("click remove left %v", list)
}
}
func TestAttendeeListDisabledEmptyInvalid(t *testing.T) {
list := append([]Attendee(nil), calAttendees...)
tt := attendeeTester(&list, AttendeeListOptions{Removable: true, Disabled: true})
_ = tt.Click("Remove Mutsumi Wakaba")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(list) != 4 {
t.Fatal("disabled list removed someone")
}
empty := []Attendee{}
if tt := attendeeTester(&empty, AttendeeListOptions{}); !tt.HasText("No attendees yet.") {
t.Fatal("no empty state")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic on bad status")
}
}()
bad := []Attendee{{Name: "X", Status: 7}}
attendeeTester(&bad, AttendeeListOptions{})
}
func TestAttendeeListSnapshot(t *testing.T) {
snapshot(t, "attendee_list", 420, 260, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { AttendeeList(c, calAttendees, AttendeeListOptions{Removable: true}) })
})
}
+112
View File
@@ -0,0 +1,112 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AudioMessageOptions describe a voice message the caller plays.
type AudioMessageOptions struct {
// Duration is the length; required.
Duration time.Duration
// Position is how far playback has gone, 0 to Duration.
Position time.Duration
Playing bool
// Waveform holds loudness samples from 0 to 1; empty draws a flat line.
Waveform []float32
}
// AudioMessageResult is an AudioMessage's element and the user's requests this frame.
type AudioMessageResult struct {
Element *ui.Element
toggled bool
seek time.Duration
seeked bool
}
// Toggled reports a press of play or pause.
func (r AudioMessageResult) Toggled() bool { return r.toggled }
// Seek returns the position the user moved to by click, drag or keys.
func (r AudioMessageResult) Seek() (time.Duration, bool) { return r.seek, r.seeked }
// audioBarWidth and audioBarGap space the waveform's bars.
const audioBarWidth, audioBarGap float32 = 2, 2
// AudioMessage shows a voice bar: play button, waveform with progress, and times.
func AudioMessage(c *ui.Context, opts AudioMessageOptions) AudioMessageResult {
if opts.Duration <= 0 || opts.Position < 0 || opts.Position > opts.Duration {
panic("mujica: AudioMessage needs 0 <= Position <= Duration and Duration > 0")
}
for _, v := range opts.Waveform {
if !(v >= 0 && v <= 1) {
panic("mujica: AudioMessage Waveform samples must be from 0 to 1")
}
}
k := Tokens(c)
u := Density(c).Unit()
var r AudioMessageResult
r.Element = ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u*3, u, u).Width(300).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
icon, name := "play", msg(c, "audio_message.play")
if opts.Playing {
icon, name = "pause", msg(c, "audio_message.pause")
}
if iconAction(c, icons.Must(icon), name).Clicked() && !disabled {
r.toggled = true
slog.Debug("mujica: audio_message toggle", "playing", !opts.Playing)
}
pos := opts.Position.Seconds()
s := ui.SliderBase(c, &pos, 0, opts.Duration.Seconds()).Grow(1).Height(theme.MinHitSize).
Label(msg(c, "audio_message.seek")).Description(audioClock(opts.Position) + " / " + audioClock(opts.Duration))
internal.Ring(s, k.Focus, theme.ControlRadius)
if s.Changed() && !disabled {
r.seek, r.seeked = time.Duration(pos*float64(time.Second)), true
slog.Debug("mujica: audio_message seek", "at", r.seek)
}
frac := float32(opts.Position.Seconds() / opts.Duration.Seconds())
s.Draw(func(p *ui.Painter, b ui.Rect) { audioWave(p, b, opts.Waveform, frac, k) })
ui.Text(c, audioClock(opts.Position)+" / "+audioClock(opts.Duration)).FontFeatures("tnum").
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Shrink(0)
})
return r
}
// audioWave draws bars, played ones in AccentText, with a position line so progress is not color alone.
func audioWave(p *ui.Painter, b ui.Rect, wave []float32, frac float32, k theme.Tokens) {
n := int((b.W + audioBarGap) / (audioBarWidth + audioBarGap))
if n <= 0 {
return
}
cy := b.Y + b.H/2
for i := range n {
v := float32(0.12)
if len(wave) > 0 {
v = max(v, wave[i*len(wave)/n])
}
h := max(2, v*(b.H-8))
x := b.X + float32(i)*(audioBarWidth+audioBarGap)
col := k.TextMuted.Alpha(0.55)
if (x-b.X)/b.W < frac {
col = k.AccentText
}
p.Fill(ui.Rect{X: x, Y: cy - h/2, W: audioBarWidth, H: h}, col, 1)
}
at := internal.Snap(p, b.X+b.W*frac)
p.Fill(ui.Rect{X: at - 1, Y: b.Y + 2, W: 2, H: b.H - 4}, k.Text, 1)
}
// audioClock formats d as m:ss.
func audioClock(d time.Duration) string {
s := int(d.Round(time.Second).Seconds())
return fmt.Sprintf("%d:%02d", s/60, s%60)
}
+92
View File
@@ -0,0 +1,92 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var audioMessageWave = []float32{0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8, 0.6, 0.2, 0.5}
func TestAudioMessagePlaySeekAndKeys(t *testing.T) {
playing, pos := false, 10*time.Second
var seeks []time.Duration
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := AudioMessage(c, AudioMessageOptions{Duration: time.Minute, Position: pos, Playing: playing, Waveform: audioMessageWave})
if r.Toggled() {
playing = !playing
}
if d, ok := r.Seek(); ok {
pos = d
seeks = append(seeks, d)
}
}, 400, 200)
if !tt.HasText("0:10 / 1:00") {
t.Fatalf("times %q", tt.Texts())
}
_ = tt.Click("Play")
if !playing {
t.Fatal("play not reported")
}
if _, ok := tt.Find("Pause"); !ok {
t.Fatal("button did not turn to pause")
}
r, _ := tt.Find("Playback position")
tt.ClickAt(r.X+r.W*0.75, r.Y+r.H/2)
if len(seeks) == 0 || pos < 40*time.Second || pos > 50*time.Second {
t.Fatalf("click at 3/4 seeked to %v", pos)
}
tt.Key(0, ui.KeyHome)
if pos != 0 {
t.Fatalf("Home gave %v", pos)
}
tt.Key(0, ui.KeyEnd)
if pos != time.Minute {
t.Fatalf("End gave %v", pos)
}
}
func TestAudioMessageDisabled(t *testing.T) {
events := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(true).Children(func() {
r := AudioMessage(c, AudioMessageOptions{Duration: time.Minute})
if _, ok := r.Seek(); ok || r.Toggled() {
events++
}
})
}, 400, 200)
_ = tt.Click("Play")
r, _ := tt.Find("Playback position")
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
if events != 0 {
t.Fatalf("disabled audio reported %d events", events)
}
}
func TestAudioMessageBadOptions(t *testing.T) {
for _, o := range []AudioMessageOptions{{}, {Duration: time.Second, Position: 2 * time.Second}, {Duration: time.Second, Waveform: []float32{2}}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", o)
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
AudioMessage(c, o)
}, 100, 100)
}()
}
}
func TestAudioMessageSnapshot(t *testing.T) {
snapshot(t, "audio_message", 340, 80, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AudioMessage(c, AudioMessageOptions{Duration: 47 * time.Second, Position: 18 * time.Second, Playing: true, Waveform: audioMessageWave})
})
})
}
+84
View File
@@ -0,0 +1,84 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// AudioState is what the caller's playback reports.
type AudioState struct {
Title string
Position time.Duration
Duration time.Duration
Playing bool
}
// AudioPlayerOptions are the callbacks a player shell reports to.
type AudioPlayerOptions struct {
OnPlay, OnPause func()
OnSeek func(time.Duration)
Disabled bool
}
// AudioPlayer is a play, pause and seek shell.
func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element {
if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil {
panic("mujica: AudioPlayer needs OnPlay, OnPause and OnSeek")
}
k := Tokens(c)
u := Density(c).Unit()
row := ui.Row(c).Gap(u*3).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Padding(u*2, u*3).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).
Disabled(opts.Disabled)
if st.Title != "" {
row.Label(st.Title)
}
row.Children(func() {
mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause)
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
if st.Title != "" {
ui.Text(c, st.Title).SingleLine()
}
if st.Duration > 0 {
pos := min(max(0, st.Position), st.Duration)
if VideoScrubber(c, &pos, VideoScrubberOptions{Duration: st.Duration, Label: st.Title}).Changed() {
opts.OnSeek(pos)
}
}
})
ui.Text(c, mediaClock(st.Position)+" / "+mediaClock(st.Duration)).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
})
// Space plays or pauses wherever the focus is inside.
if !row.IsDisabled() && row.Shortcut(0, ui.KeySpace) {
mediaToggle(st.Playing, opts.OnPlay, opts.OnPause)
}
return row
}
// mediaPlayToggle is the play/pause button; it calls the matching callback.
func mediaPlayToggle(c *ui.Context, playing bool, onPlay, onPause func()) *ui.Element {
icon, label := mediaPlay, msg(c, "media_controls.play")
if playing {
icon, label = mediaPause, msg(c, "media_controls.pause")
}
b := IconButton(c, icon, label, IconButtonOptions{})
if b.Clicked() && !b.IsDisabled() {
mediaToggle(playing, onPlay, onPause)
}
return b
}
func mediaToggle(playing bool, onPlay, onPause func()) {
if playing {
slog.Debug("mujica: media pause")
onPause()
return
}
slog.Debug("mujica: media play")
onPlay()
}
+89
View File
@@ -0,0 +1,89 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type mediaCalls struct {
play, pause int
seeks []time.Duration
}
func (m *mediaCalls) opts() AudioPlayerOptions {
return AudioPlayerOptions{OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, OnSeek: func(d time.Duration) { m.seeks = append(m.seeks, d) }}
}
func audioTester(st *AudioState, m *mediaCalls, disabled bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := m.opts()
o.Disabled = disabled
ui.Column(c).Padding(20).Children(func() { AudioPlayer(c, *st, o) })
}, 600, 200)
}
func TestAudioPlayerPlayPause(t *testing.T) {
st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute}
var m mediaCalls
tt := audioTester(&st, &m, false)
if err := tt.Click("Play"); err != nil {
t.Fatal(err)
}
st.Playing = true
tt.Frame()
if err := tt.Click("Pause"); err != nil {
t.Fatal(err)
}
if m.play != 1 || m.pause != 1 {
t.Fatalf("play %d pause %d", m.play, m.pause)
}
if !tt.HasText("0:30 / 3:00") {
t.Fatalf("texts %v", tt.Texts())
}
}
func TestAudioPlayerKeyboard(t *testing.T) {
st := AudioState{Title: "Ave Mujica", Position: 30 * time.Second, Duration: 3 * time.Minute}
var m mediaCalls
tt := audioTester(&st, &m, false)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if len(m.seeks) != 1 || m.seeks[0] != 35*time.Second {
t.Fatalf("seeks %v", m.seeks)
}
tt.Key(0, ui.KeySpace)
if m.play != 1 {
t.Fatalf("Space on the timeline: play %d", m.play)
}
}
func TestAudioPlayerDisabled(t *testing.T) {
st := AudioState{Title: "Ave Mujica", Duration: time.Minute}
var m mediaCalls
tt := audioTester(&st, &m, true)
_ = tt.Click("Play")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyRight)
if m.play != 0 || len(m.seeks) != 0 {
t.Fatalf("disabled player reported %+v", m)
}
}
func TestAudioPlayerNeedsCallbacks(t *testing.T) {
mediaPanics(t, "no callbacks", func(c *ui.Context) { AudioPlayer(c, AudioState{}, AudioPlayerOptions{}) })
}
func TestAudioPlayerSnapshot(t *testing.T) {
var m mediaCalls
snapshot(t, "audio_player", 560, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).Children(func() {
AudioPlayer(c, AudioState{Title: "Ave Mujica — Overture", Position: 72 * time.Second, Duration: 4 * time.Minute, Playing: true}, m.opts())
AudioPlayer(c, AudioState{Title: "Imprisoned XII", Duration: 5 * time.Minute}, m.opts())
})
})
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AudioSpectrumOptions configure an AudioSpectrum.
type AudioSpectrumOptions struct {
// Height is the chart's height; zero means 120.
Height float32
// BandLabel names band i in the readout; nil numbers them.
BandLabel func(i int) string
// Status and Error show loading and failure at one size.
Status DataStatus
Error string
// Label names the chart for assistive technology.
Label string
}
type spectrumKey struct{}
// AudioSpectrum draws caller bands as bars; it never animates itself.
func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element {
for _, b := range bands {
if b < 0 || b > 1 || math.IsNaN(float64(b)) {
panic("mujica: AudioSpectrum band outside 0..1")
}
}
h := opts.Height
if h < 0 {
panic("mujica: AudioSpectrum Height must not be negative")
}
if h == 0 {
h = 120
}
k := Tokens(c)
label := opts.Label
if label == "" {
label = msg(c, "audio_spectrum.label")
}
e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Focusable().Label(label)
internal.Ring(e, k.Focus, theme.ControlRadius)
if mediaChartState(c, e, opts.Status, opts.Error, len(bands) == 0, "audio_spectrum.empty") {
return e
}
cur := ui.Local(e, spectrumKey{}, func() int { return -1 })
*cur = min(*cur, len(bands)-1)
name := func(i int) string {
if opts.BandLabel != nil {
return opts.BandLabel(i)
}
return fmt.Sprintf(msg(c, "audio_spectrum.band"), i+1)
}
if moved, ok := chartCursor(e, cur, len(bands)); ok && moved {
c.Announce(spectrumReadout(name(*cur), bands[*cur]))
}
readout := ""
if *cur >= 0 {
readout = spectrumReadout(name(*cur), bands[*cur])
e.Description(readout)
}
sel, size := *cur, FontSize(c, theme.CaptionSize)
e.Draw(func(p *ui.Painter, r ui.Rect) {
pad := float32(8)
n := float32(len(bands))
w := (r.W - 2*pad) / n
base := r.Y + r.H - pad
for i, b := range bands {
x := internal.Snap(p, r.X+pad+float32(i)*w)
bh := internal.Snap(p, (r.H-2*pad-16)*b)
col := k.AccentText
if i == sel {
col = k.Text
}
p.Fill(ui.Rect{X: x + 1, Y: base - bh, W: max(1, w-2), H: bh}, col, 0)
}
p.Fill(ui.Rect{X: r.X + pad, Y: base, W: r.W - 2*pad, H: 1}, k.Border, 0)
if sel >= 0 {
x := r.X + pad + float32(sel)*w
p.StrokeDashed(ui.Rect{X: x, Y: r.Y + pad, W: w, H: base - r.Y - pad}, k.TextMuted, 0, 1)
p.Text(r.X+pad, r.Y+4, readout, size, k.Text)
}
})
return e
}
func spectrumReadout(name string, v float32) string {
return fmt.Sprintf("%s: %d%%", name, int(math.Round(float64(v*100))))
}
// chartCursor moves *cur by arrows, Home and End.
func chartCursor(e *ui.Element, cur *int, n int) (moved, ok bool) {
if e.IsDisabled() || n == 0 {
return false, false
}
old := *cur
switch {
case e.Shortcut(0, ui.KeyRight):
*cur = min(n-1, *cur+1)
case e.Shortcut(0, ui.KeyLeft):
*cur = max(0, *cur-1)
case e.Shortcut(0, ui.KeyHome):
*cur = 0
case e.Shortcut(0, ui.KeyEnd):
*cur = n - 1
default:
return false, false
}
return *cur != old, true
}
// mediaChartState fills e with a loading, error or empty state.
func mediaChartState(c *ui.Context, e *ui.Element, status DataStatus, errText string, empty bool, emptyKey string) bool {
k := Tokens(c)
switch {
case status == DataLoading:
e.Center().Children(func() { Spinner(c, SpinnerOptions{ShowLabel: true}) })
case status == DataFailed:
if errText == "" {
panic("mujica: a failed media chart needs Error")
}
e.Center().Description(errText).Children(func() {
ui.Row(c).Gap(6).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).TextColor(k.Danger)
ui.Text(c, errText).TextColor(k.Danger).MaxLines(2)
})
})
case status != DataReady:
panic(fmt.Sprintf("mujica: media chart Status %d is not Ready, Loading or Failed", status))
case empty:
e.Center().Description(msg(c, emptyKey)).Children(func() {
ui.Text(c, msg(c, emptyKey)).TextColor(k.TextMuted)
})
default:
return false
}
return true
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestAudioSpectrumKeyboardReadout(t *testing.T) {
bands := []float32{0.2, 0.9, 0.5}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(300).Children(func() {
AudioSpectrum(c, bands, AudioSpectrumOptions{BandLabel: func(i int) string { return []string{"63 Hz", "1 kHz", "8 kHz"}[i] }})
})
}, 400, 200)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnd)
if got := tt.Announcements(); !slices.Equal(got, []string{"63 Hz: 20%", "1 kHz: 90%", "8 kHz: 50%"}) {
t.Fatalf("announced %v", got)
}
}
func TestAudioSpectrumNeverAnimates(t *testing.T) {
builds := 0
bands := []float32{0.3, 0.6}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
builds++
AudioSpectrum(c, bands, AudioSpectrumOptions{})
}, 300, 200)
start := builds
tt.Frame()
if builds-start != 1 {
t.Fatalf("one frame built %d times: the spectrum asked for more", builds-start)
}
}
func TestAudioSpectrumStates(t *testing.T) {
var got []ui.Rect
for _, o := range []AudioSpectrumOptions{{}, {Status: DataLoading}, {Status: DataFailed, Error: "Device lost"}} {
var r ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(300).Children(func() { r = AudioSpectrum(c, nil, o).Bounds() })
}, 400, 200)
tt.Frame()
got = append(got, r)
if o.Status == DataFailed && !tt.HasText("Device lost") {
t.Fatal("error not shown")
}
if o.Status == DataReady && !tt.HasText("No signal") {
t.Fatal("empty state not shown")
}
}
if got[1] != got[0] || got[2] != got[0] {
t.Fatalf("state sizes %v", got)
}
mediaPanics(t, "band over 1", func(c *ui.Context) { AudioSpectrum(c, []float32{2}, AudioSpectrumOptions{}) })
mediaPanics(t, "failed without error", func(c *ui.Context) { AudioSpectrum(c, nil, AudioSpectrumOptions{Status: DataFailed}) })
}
func TestAudioSpectrumSnapshot(t *testing.T) {
bands := make([]float32, 32)
for i := range bands {
bands[i] = float32(0.15 + 0.8*float64((i*37)%29)/29)
}
snapshot(t, "audio_spectrum", 420, 160, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(380).Children(func() { AudioSpectrum(c, bands, AudioSpectrumOptions{}) })
})
}
+187
View File
@@ -0,0 +1,187 @@
package mujica
import (
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AudioWaveformOptions configure an AudioWaveform.
type AudioWaveformOptions struct {
// Duration is the audio's length; required when data is ready.
Duration time.Duration
// Position draws the played part; OnSeek reports clicks and arrows.
Position time.Duration
OnSeek func(time.Duration)
// Selection, when set, is chosen by dragging or Shift+arrows.
Selection *MediaRange
// Height is the chart's height; zero means 96.
Height float32
Status DataStatus
Error string
Label string
}
// waveformPeaks caches column peaks until data or width change.
type waveformPeaks struct {
first *float32
n int
cols int
peaks []float32
}
type waveformKey struct{}
type waveformPress struct {
on, dragged bool
x float32
}
// waveformBuckets counts peak rebuilds, for tests.
var waveformBuckets int
// AudioWaveform draws samples with played part and selectable range.
func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult {
h := opts.Height
if h < 0 || opts.Position < 0 {
panic("mujica: AudioWaveform Height and Position must not be negative")
}
if h == 0 {
h = 96
}
k := Tokens(c)
label := opts.Label
if label == "" {
label = msg(c, "audio_waveform.label")
}
e := ui.Box(c).Height(h).MinWidth(120).Radius(theme.ControlRadius).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Focusable().Label(label).Cursor(ui.CursorText)
internal.Ring(e, k.Focus, theme.ControlRadius)
v := ChoiceResult{Element: e}
if mediaChartState(c, e, opts.Status, opts.Error, len(samples) == 0, "audio_waveform.empty") {
return v
}
if opts.Duration <= 0 || opts.OnSeek == nil || opts.Position > opts.Duration {
panic("mujica: AudioWaveform needs Duration > 0, OnSeek and Position <= Duration")
}
if s := opts.Selection; s != nil && (s.Start < 0 || s.End < s.Start || s.End > opts.Duration) {
panic("mujica: AudioWaveform Selection outside 0..Duration")
}
r := e.Bounds()
cols := int(r.W)
cache := ui.Local(e, waveformKey{}, func() waveformPeaks { return waveformPeaks{} })
if cols <= 0 {
// Peaks need the width: lay out first, then build again.
c.AnimationFrame()
} else if cache.first != &samples[0] || cache.n != len(samples) || cache.cols != cols {
*cache = waveformPeaks{&samples[0], len(samples), cols, waveformBucket(samples, cols)}
}
if !e.IsDisabled() {
v.changed = waveformInput(c, e, r, opts)
}
e.Description(mediaClock(opts.Position) + " / " + mediaClock(opts.Duration))
peaks, pos, sel := cache.peaks, float32(opts.Position)/float32(opts.Duration), opts.Selection
dur := float32(opts.Duration)
e.Draw(func(p *ui.Painter, r ui.Rect) {
mid := r.Y + r.H/2
amp := r.H/2 - 6
if sel != nil && sel.End > sel.Start {
x0, x1 := r.X+r.W*float32(sel.Start)/dur, r.X+r.W*float32(sel.End)/dur
p.Fill(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.Selection, 0)
p.Fill(ui.Rect{X: x0, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0)
p.Fill(ui.Rect{X: x1 - 1, Y: r.Y, W: 1, H: r.H}, k.AccentText, 0)
}
played := r.X + r.W*pos
for i, pk := range peaks {
x := r.X + r.W*float32(i)/float32(len(peaks))
col := k.TextMuted
if x < played {
col = k.AccentText
}
ph := max(1, amp*pk)
p.Fill(ui.Rect{X: x, Y: mid - ph, W: max(1, r.W/float32(len(peaks))-0.5), H: 2 * ph}, col, 0)
}
px := internal.Snap(p, played)
p.Fill(ui.Rect{X: px - 1, Y: r.Y, W: 2, H: r.H}, k.Text, 0)
p.FillPath(internal.Diamond(px, internal.Snap(p, r.Y+5), 8), k.Text)
})
return v
}
// waveformBucket returns the largest magnitude in each of cols spans.
func waveformBucket(samples []float32, cols int) []float32 {
waveformBuckets++
peaks := make([]float32, cols)
for i, s := range samples {
col := i * cols / len(samples)
peaks[col] = max(peaks[col], float32(math.Abs(float64(s))))
}
return peaks
}
// waveformInput seeks and selects; true means the selection changed.
func waveformInput(c *ui.Context, e *ui.Element, r ui.Rect, opts AudioWaveformOptions) bool {
at := func(x float32) time.Duration {
f := max(0, min(1, (x-r.X)/max(1, r.W)))
return time.Duration(float64(f) * float64(opts.Duration)).Round(time.Millisecond)
}
seek := func(t time.Duration) {
t = max(0, min(opts.Duration, t))
slog.Debug("mujica: audio_waveform seek", "pos", t)
opts.OnSeek(t)
}
sel := opts.Selection
old := MediaRange{}
if sel != nil {
old = *sel
}
px, _, _ := e.PointerPosition()
px += r.X
st := ui.Local(e, waveformPress{}, func() waveformPress { return waveformPress{} })
_, _, dragging := e.Dragged()
switch {
case e.Pressed() && !st.on:
*st = waveformPress{on: true, x: px}
case e.Pressed() && sel != nil && (dragging || st.dragged) && math.Abs(float64(px-st.x)) > 3:
st.dragged = true
a, b := at(st.x), at(px)
*sel = MediaRange{min(a, b), max(a, b)}
case !e.Pressed() && st.on:
if !st.dragged {
seek(at(st.x))
}
*st = waveformPress{}
}
const step = 5 * time.Second
switch {
case e.Shortcut(0, ui.KeyLeft):
seek(opts.Position - step)
case e.Shortcut(0, ui.KeyRight):
seek(opts.Position + step)
case sel != nil && e.Shortcut(ui.Shift, ui.KeyRight):
*sel = waveformExtend(*sel, opts.Position, step, opts.Duration)
case sel != nil && e.Shortcut(ui.Shift, ui.KeyLeft):
*sel = waveformExtend(*sel, opts.Position, -step, opts.Duration)
case sel != nil && e.Shortcut(0, ui.KeyEscape):
*sel = MediaRange{}
}
changed := sel != nil && *sel != old
if changed {
slog.Debug("mujica: audio_waveform select", "start", sel.Start, "end", sel.End)
}
return changed
}
// waveformExtend starts a selection at pos or moves its end.
func waveformExtend(s MediaRange, pos, d, dur time.Duration) MediaRange {
if s.End <= s.Start {
s = MediaRange{pos, pos}
}
s.End = max(s.Start, min(dur, s.End+d))
return s
}
+131
View File
@@ -0,0 +1,131 @@
package mujica
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func waveSamples(n int) []float32 {
s := make([]float32, n)
for i := range s {
s[i] = float32(math.Sin(float64(i)/40) * (0.3 + 0.6*math.Abs(math.Sin(float64(i)/3000))))
}
return s
}
func waveTester(samples []float32, opts *AudioWaveformOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(400).Children(func() {
if AudioWaveform(c, samples, *opts).Changed() {
*n++
}
})
}, 500, 200)
}
func TestAudioWaveformSeekAndSelect(t *testing.T) {
var seeks []time.Duration
sel := MediaRange{}
opts := AudioWaveformOptions{Duration: 100 * time.Second, Position: 10 * time.Second, Selection: &sel,
OnSeek: func(d time.Duration) { seeks = append(seeks, d) }}
n := 0
tt := waveTester(waveSamples(5000), &opts, &n)
r, ok := tt.Find("Waveform")
if !ok {
t.Fatal("not named")
}
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
if len(seeks) != 1 || seeks[0] < 48*time.Second || seeks[0] > 52*time.Second {
t.Fatalf("click seeks %v", seeks)
}
tt.Press(r.X+r.W*0.2, r.Y+r.H/2)
tt.Move(r.X+r.W*0.3, r.Y+r.H/2)
tt.Move(r.X+r.W*0.4, r.Y+r.H/2)
tt.Release(r.X+r.W*0.4, r.Y+r.H/2)
if sel.Start < 18*time.Second || sel.Start > 22*time.Second || sel.End < 38*time.Second || sel.End > 42*time.Second || len(seeks) != 1 {
t.Fatalf("drag selection %+v, seeks %v", sel, seeks)
}
tt.Key(0, ui.KeyEscape)
tt.Key(ui.Shift, ui.KeyRight)
if sel != (MediaRange{10 * time.Second, 15 * time.Second}) || n < 3 {
t.Fatalf("Shift+Right selection %+v, changes %d", sel, n)
}
tt.Key(0, ui.KeyRight)
if seeks[len(seeks)-1] != 15*time.Second {
t.Fatalf("Right seek %v", seeks)
}
}
func TestAudioWaveformComputesPeaksOnce(t *testing.T) {
samples := waveSamples(100000)
opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(time.Duration) {}}
n := 0
tt := waveTester(samples, &opts, &n)
before := waveformBuckets
for range 5 {
opts.Position += time.Second
tt.Frame()
}
if got := waveformBuckets - before; got != 0 {
t.Fatalf("peaks rebuilt %d times without new data", got)
}
samples = waveSamples(100000)
tt = waveTester(samples, &opts, &n)
tt.Frame()
if waveformBuckets-before == 0 {
t.Fatal("new data did not rebuild peaks")
}
}
func TestAudioWaveformStatesKeepSize(t *testing.T) {
var got []ui.Rect
seek := func(time.Duration) {}
for _, o := range []AudioWaveformOptions{{Duration: time.Second, OnSeek: seek}, {Status: DataLoading}, {Status: DataFailed, Error: "Decode failed"}} {
var r ui.Rect
data := waveSamples(100)
if o.Status != DataReady {
data = nil
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(300).Children(func() { r = AudioWaveform(c, data, o).Element.Bounds() })
}, 400, 200)
tt.Frame()
got = append(got, r)
}
if got[1] != got[0] || got[2] != got[0] {
t.Fatalf("state sizes %v", got)
}
}
func TestAudioWaveformDisabled(t *testing.T) {
var seeks []time.Duration
opts := AudioWaveformOptions{Duration: time.Minute, OnSeek: func(d time.Duration) { seeks = append(seeks, d) }}
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(true).Width(300).Children(func() { AudioWaveform(c, waveSamples(500), opts) })
}, 400, 200)
r, _ := tt.Find("Waveform")
tt.ClickAt(r.X+10, r.Y+10)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if len(seeks) != 0 || n != 0 {
t.Fatalf("disabled waveform seeked %v", seeks)
}
}
func TestAudioWaveformSnapshot(t *testing.T) {
samples := waveSamples(20000)
sel := MediaRange{50 * time.Second, 70 * time.Second}
snapshot(t, "audio_waveform", 460, 150, func(c *ui.Context) {
ui.Column(c).Padding(20).Width(420).Children(func() {
AudioWaveform(c, samples, AudioWaveformOptions{Duration: 2 * time.Minute, Position: 40 * time.Second,
Selection: &sel, OnSeek: func(time.Duration) {}})
})
})
}
+180
View File
@@ -0,0 +1,180 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// AvailabilityPerson is another person's free slots, [day][slot].
type AvailabilityPerson struct {
Name string
Free [][]bool
}
// AvailabilityPickerOptions configure an AvailabilityPicker.
type AvailabilityPickerOptions struct {
// Days are the grid's columns; required.
Days []Date
// From and To are hours; zero means 9 to 18.
From, To int
// SlotMinutes must divide an hour; zero means 30.
SlotMinutes int
// Others are counted as free per slot.
Others []AvailabilityPerson
Hour12 bool
Disabled bool
Label string
}
// availabilityDrag is a paint stroke: the value being painted.
type availabilityDrag struct {
active bool
paint bool
}
// AvailabilityPicker paints free slots and counts others' overlap.
func AvailabilityPicker(c *ui.Context, mine *[][]bool, opts AvailabilityPickerOptions) ChoiceResult {
from, to := opts.From, opts.To
if from == 0 && to == 0 {
from, to = 9, 18
}
slot := timeGridSlot("AvailabilityPicker", opts.SlotMinutes)
if len(opts.Days) == 0 || from < 0 || to > 24 || to <= from || slot > 60 {
panic(fmt.Sprintf("mujica: AvailabilityPicker needs days and hours 0 <= %d < %d <= 24", from, to))
}
slots := (to - from) * 60 / slot
availabilityCheck("value", *mine, len(opts.Days), slots)
for _, p := range opts.Others {
availabilityCheck(p.Name, p.Free, len(opts.Days), slots)
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "availability_picker.label")
}
gutter := d.ControlHeight() * 1.8
rowH := float32(theme.MinHitSize)
headH := d.ControlHeight()
changed := false
set := func(day, s int, v bool) {
if (*mine)[day][s] != v {
(*mine)[day][s], changed = v, true
}
}
grid := ui.Column(c).Focusable().Role(ui.RoleTable).Label(label).Disabled(opts.Disabled)
internal.Ring(grid, k.Focus, theme.ControlRadius)
drag := ui.Local(grid, "availability-drag", func() availabilityDrag { return availabilityDrag{} })
cursor := ui.Local(grid, "availability-cursor", func() [2]int { return [2]int{} })
if !grid.IsDisabled() {
b := grid.Bounds()
px, py, _ := grid.PointerPosition()
cw := (b.W - gutter) / float32(len(opts.Days))
day, s := int((px-gutter)/max(cw, 1)), int((py-headH)/rowH)
inside := px >= gutter && py >= headH && day < len(opts.Days) && s < slots
if _, _, pressing := grid.Dragged(); pressing && inside {
if !drag.active {
*drag = availabilityDrag{true, !(*mine)[day][s]}
*cursor = [2]int{day, s}
}
set(day, s, drag.paint)
} else if !pressing {
drag.active = false
}
for key, dd := range map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, -1}, ui.KeyDown: {0, 1}} {
if grid.Shortcut(0, key) {
*cursor = [2]int{max(0, min(len(opts.Days)-1, cursor[0]+dd[0])), max(0, min(slots-1, cursor[1]+dd[1]))}
}
}
if grid.Shortcut(0, ui.KeySpace) || grid.Shortcut(0, ui.KeyEnter) {
set(cursor[0], cursor[1], !(*mine)[cursor[0]][cursor[1]])
}
}
focused := grid.FocusVisible() || grid.Focused() && !drag.active
grid.Children(func() {
ui.Row(c).Height(headH).Children(func() {
ui.Box(c).Width(gutter).Shrink(0)
for _, day := range opts.Days {
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))+" "+strconv.Itoa(day.Day)).Grow(1).Basis(0).
TextAlign(ui.Center).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleColumnHeader)
}
})
for s := range slots {
m := from*60 + s*slot
ui.Row(c).Height(rowH).Role(ui.RoleRow).Children(func() {
hour := ""
if m%60 == 0 {
hour = timeText(c, TimeOfDay{m / 60, 0, 0}, timeConfig{hour12: opts.Hour12})
}
ui.Text(c, hour).Width(gutter).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
for di, day := range opts.Days {
free := 0
for _, p := range opts.Others {
if p.Free[di][s] {
free++
}
}
availabilityCell(c, day, m, (*mine)[di][s], free, len(opts.Others), focused && *cursor == [2]int{di, s}, opts.Hour12)
}
})
}
})
if changed {
slog.Debug("mujica: availability_picker change")
}
return ChoiceResult{Element: grid, changed: changed}
}
func availabilityCheck(name string, v [][]bool, days, slots int) {
if len(v) != days {
panic(fmt.Sprintf("mujica: AvailabilityPicker %s has %d days, want %d", name, len(v), days))
}
for _, row := range v {
if len(row) != slots {
panic(fmt.Sprintf("mujica: AvailabilityPicker %s has %d slots, want %d", name, len(row), slots))
}
}
}
// availabilityCell shows one slot's choice and free count.
func availabilityCell(c *ui.Context, day Date, m int, mine bool, free, others int, cursor, hour12 bool) {
k := Tokens(c)
text := datetimeDayLabel(c, day) + " " + timeText(c, TimeOfDay{m / 60, m % 60, 0}, timeConfig{hour12: hour12})
if mine {
text += ", " + msg(c, "availability_picker.mine")
}
if others > 0 {
text += ", " + fmt.Sprintf(msg(c, "availability_picker.others"), free, others)
}
everyone := mine && others > 0 && free == others
cell := ui.Box(c).Grow(1).Basis(0).MinWidth(0).FillHeight().Center().Role(ui.RoleCell).Label(text).BorderWidth(0, 0, theme.BorderWidth, theme.BorderWidth).BorderColor(k.Border)
if mine {
cell.Background(k.Selection)
}
cell.Draw(func(p *ui.Painter, r ui.Rect) {
if others > 0 && free > 0 {
h := (r.H - 4) * float32(free) / float32(others)
p.Fill(ui.Rect{X: r.X + 2, Y: r.Y + r.H - 2 - h, W: 3, H: h}, k.Info, 1)
}
if mine {
internal.Mark(p, ui.Rect{X: r.X + r.W - 16, Y: r.Y + 2, W: 12, H: 12}, "check", k.AccentText)
}
if cursor {
datetimeSelectedMark(p, r, k.Focus)
}
})
cell.Children(func() {
if others > 0 && free > 0 {
t := ui.Text(c, strconv.Itoa(free)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum")
if everyone {
t.Bold().TextColor(k.AccentText)
}
}
})
}
+108
View File
@@ -0,0 +1,108 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func availabilityGrid(days, slots int) [][]bool {
v := make([][]bool, days)
for i := range v {
v[i] = make([]bool, slots)
}
return v
}
var availabilityDays = []Date{oct(12), oct(13), oct(14)}
func availabilityTester(v *[][]bool, opts *AvailabilityPickerOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := *opts
o.Days, o.From, o.To = availabilityDays, 9, 12
ui.Column(c).Padding(16).Children(func() {
if AvailabilityPicker(c, v, o).Changed() {
*n++
}
})
}, 560, 360)
}
func TestAvailabilityPickerPaintDrag(t *testing.T) {
v, n := availabilityGrid(3, 6), 0
tt := availabilityTester(&v, &AvailabilityPickerOptions{}, &n)
a, _ := tt.Find("Monday, October 12, 2026 09:00")
b, _ := tt.Find("Monday, October 12, 2026 10:30")
calDrag(tt, a.X+a.W/2, a.Y+a.H/2, b.X+b.W/2, b.Y+b.H/2)
for s := range 6 {
if v[0][s] != (s <= 3) {
t.Fatalf("painted %v", v[0])
}
}
if n == 0 || !tt.HasText("Monday, October 12, 2026 10:00, you are free") {
t.Fatal("no change or label")
}
// Starting on a free slot erases.
calDrag(tt, a.X+a.W/2, a.Y+a.H/2, a.X+a.W/2, a.Y+a.H*1.5)
if v[0][0] || v[0][1] || !v[0][2] {
t.Fatalf("erase stroke %v", v[0])
}
}
func TestAvailabilityPickerOthersAndKeyboard(t *testing.T) {
v, n := availabilityGrid(3, 6), 0
free := availabilityGrid(3, 6)
free[1][0] = true
other := availabilityGrid(3, 6)
other[1][0], other[1][1] = true, true
opts := &AvailabilityPickerOptions{Others: []AvailabilityPerson{{Name: "Mutsumi", Free: free}, {Name: "Uika", Free: other}}}
tt := availabilityTester(&v, opts, &n)
if !tt.HasText("Tuesday, October 13, 2026 09:00, 2 of 2 others free") || !tt.HasText("Tuesday, October 13, 2026 09:30, 1 of 2 others free") {
t.Fatalf("overlap counts: %v", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !v[1][0] || !v[1][1] || n != 2 {
t.Fatalf("keyboard toggles %v", v[1])
}
}
func TestAvailabilityPickerDisabledAndInvalid(t *testing.T) {
v, n := availabilityGrid(3, 6), 0
tt := availabilityTester(&v, &AvailabilityPickerOptions{Disabled: true}, &n)
a, _ := tt.Find("Monday, October 12, 2026 09:00")
calDrag(tt, a.X+a.W/2, a.Y+a.H/2, a.X+a.W/2, a.Y+a.H*2.5)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if v[0][0] || n != 0 {
t.Fatal("disabled picker painted")
}
for _, bad := range []*AvailabilityPickerOptions{{SlotMinutes: 7}, {Others: []AvailabilityPerson{{Name: "X", Free: availabilityGrid(2, 6)}}}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: expected panic", bad)
}
}()
w := availabilityGrid(3, 6)
availabilityTester(&w, bad, &n)
}()
}
}
func TestAvailabilityPickerSnapshot(t *testing.T) {
v := availabilityGrid(3, 6)
v[0][1], v[0][2], v[1][2] = true, true, true
free := availabilityGrid(3, 6)
free[0][2], free[1][2], free[2][0] = true, true, true
snapshot(t, "availability_picker", 520, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AvailabilityPicker(c, &v, AvailabilityPickerOptions{Days: availabilityDays, From: 9, To: 12,
Others: []AvailabilityPerson{{Name: "Mutsumi", Free: free}}})
})
})
}
+185
View File
@@ -0,0 +1,185 @@
package mujica
import (
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BarChartOptions configure a BarChart.
type BarChartOptions struct {
Frame ChartFrame
// Horizontal lays categories down the side and bars across.
Horizontal bool
// Stacked piles series into one bar per category.
Stacked bool
// Labels writes each bar's value at its end.
Labels bool
Format func(v float64) string
}
type barMark struct{ cat, series int }
// BarChart draws grouped or stacked bars; NaN draws none.
func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts BarChartOptions) ChartResult {
if n := chartCheckSeries(series); n != len(categories) && len(series) > 0 {
panic("mujica: BarChart series length differs from categories")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
keys := make([]chartKey, len(series))
var vis []int
for i, s := range series {
keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
if !ch.hidden(s.Name) {
vis = append(vis, i)
}
}
// Bars stacked or not; lo and hi always include zero.
type layout struct {
marks []barMark
from []float64
lo, hi float64
}
lay := chartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
var l layout
for ci := range categories {
pos, neg := 0.0, 0.0
for _, si := range vis {
v := series[si].Values[ci]
if math.IsNaN(v) {
continue
}
from := 0.0
if opts.Stacked && v >= 0 {
from, pos = pos, pos+v
} else if opts.Stacked {
from, neg = neg, neg+v
}
l.marks, l.from = append(l.marks, barMark{ci, si}), append(l.from, from)
l.lo, l.hi = min(l.lo, from, from+v), max(l.hi, from, from+v)
}
}
return l
})
var lo, hi, step float64
var ticks []float64
if len(lay.marks) > 0 {
lo, hi = lay.lo, lay.hi
if opts.Labels {
// Headroom keeps value labels inside the plot.
pad := (hi - lo) * 0.15
lo, hi = lo-pad*chartBool(lo < 0), hi+pad*chartBool(hi > 0)
}
lo, hi, step = internal.Nice(lo, hi, 4)
ticks = internal.Ticks(lo, hi, step)
}
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
if opts.Horizontal {
plot := chartInset(r, 96, 26)
return plot, internal.Band{N: len(categories), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.3},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
plot := chartInset(r, 52, 26)
return plot, internal.Band{N: len(categories), R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
// rect is bar i's box in plot space.
rect := func(band internal.Band, vs internal.Linear, i int) ui.Rect {
m := lay.marks[i]
at, w := band.Start(m.cat), band.Width()
if !opts.Stacked && len(vis) > 0 {
slot := 0
for j, si := range vis {
if si == m.series {
slot = j
}
}
w /= float32(len(vis))
at += w * float32(slot)
}
a, b := vs.Map(lay.from[i]), vs.Map(lay.from[i]+series[m.series].Values[m.cat])
a, b = min(a, b), max(a, b)
if opts.Horizontal {
return ui.Rect{X: a, Y: at, W: max(b-a, 1), H: w}
}
return ui.Rect{X: at, Y: a, W: w, H: max(b-a, 1)}
}
value := func(i int) float64 { m := lay.marks[i]; return series[m.series].Values[m.cat] }
spec := chartSpec{n: len(lay.marks), keys: keys, toggle: len(series) > 1}
spec.readout = func(i int) string {
m := lay.marks[i]
return categories[m.cat] + " · " + series[m.series].Name + " " + chartValue(c, value(i), opts.Format)
}
spec.pick = func(r ui.Rect, x, y float32) int {
_, band, vs := geom(r)
for i := range lay.marks {
b := rect(band, vs, i)
// Thin bars still take a 6 DIP target.
if (ui.Rect{X: b.X - 3, Y: b.Y - 3, W: b.W + 6, H: b.H + 6}).Contains(x, y) {
return i
}
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band, vs := geom(r)
label := func(v float64) string { return internal.FormatTick(v, step) }
var pos []float32
for i := range categories {
pos = append(pos, band.Center(i))
}
zero := vs.Map(0)
if opts.Horizontal {
for _, t := range ticks {
x := internal.Snap(p, vs.Map(t))
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.Border)
chartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
}
for i, y := range pos {
chartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
}
p.Line(internal.Snap(p, zero), plot.Y, internal.Snap(p, zero), plot.Y+plot.H, 1, k.ControlBorder)
} else {
internal.AxisY(p, plot, vs, ticks, label, size, k)
internal.AxisX(p, plot, pos, categories, size, k)
internal.Rule(p, plot.X, plot.X+plot.W, zero, k.ControlBorder)
}
for i, m := range lay.marks {
b := rect(band, vs, i)
col := keys[m.series].Color
p.Fill(b, col, 1)
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 2, 1.5)
}
// The series shape marks the bar besides its color.
if min(b.W, b.H) >= 10 && max(b.W, b.H) >= 14 {
internal.Marker(p, m.series, b.X+b.W/2, b.Y+b.H/2, min(b.W, b.H, 14)*0.6, k.Surface)
}
if opts.Labels && !opts.Stacked {
barLabel(p, chartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
}
}
}
res, _ := ch.build(spec)
return res
}
// barLabel writes a value just past the bar's far end.
func barLabel(p *ui.Painter, s string, b ui.Rect, neg, horizontal bool, size float32, col ui.Color) {
switch {
case horizontal && neg:
chartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
case horizontal:
chartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
case neg:
chartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
default:
chartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
}
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var barCats = []string{"Nave", "Choir", "Crypt"}
func barSeries() []ChartSeries {
return []ChartSeries{
{Name: "Gold", Values: []float64{4, -2, 3}},
{Name: "Silver", Values: []float64{2, 5, math.NaN()}},
}
}
func TestBarChartKeyboardPerBar(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Tithes"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Tithes")
want := []string{"Nave · Gold 4", "Nave · Silver 2", "Choir · Gold -2", "Choir · Silver 5", "Crypt · Gold 3"}
for i, w := range want {
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != w {
t.Fatalf("bar %d readout %q, want %q", i, got, w)
}
}
tt.Key(0, ui.KeyRight)
if h.res.Point != 4 {
t.Fatalf("stepped past the missing bar: %d", h.res.Point)
}
}
func TestBarChartHoverStackedHorizontal(t *testing.T) {
for _, o := range []BarChartOptions{{Stacked: true}, {Horizontal: true, Labels: true}, {Horizontal: true, Stacked: true}} {
o.Frame = ChartFrame{Label: "Tithes"}
h := &chartHarness{build: func(c *ui.Context) ChartResult { return BarChart(c, barCats, barSeries(), o) }}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Tithes")
found := map[string]bool{}
b := h.res.Element.Bounds()
for y := b.Y + 30; y < b.Y+b.H-40; y += 4 {
for x := b.X + 60; x < b.X+b.W-16; x += 8 {
tt.Move(x, y)
if h.res.Point >= 0 {
found[chartReadout(tt)] = true
}
}
}
if len(found) != 5 || !found["Choir · Gold -2"] {
t.Fatalf("%+v: hover found %v", o, found)
}
}
}
func TestBarChartBadAndEmpty(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for mismatched categories")
}
}()
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BarChart(c, barCats, nil, BarChartOptions{Frame: ChartFrame{Label: "Tithes"}})
}}
tt := ui.NewTester(h.view, 520, 420)
if !tt.HasText("No data") {
t.Fatalf("empty: %q", tt.Texts())
}
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BarChart(c, barCats[:2], barSeries(), BarChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestBarChartSnapshot(t *testing.T) {
snapshot(t, "bar_chart", 640, 320, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(296).Children(func() {
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Grouped", Height: 220}, Labels: true})
})
ui.Column(c).Width(296).Children(func() {
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Stacked", Height: 220}, Horizontal: true, Stacked: true})
})
})
})
}
+60
View File
@@ -0,0 +1,60 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// BidAskBarOptions configure a BidAskBar.
type BidAskBarOptions struct {
// Decimals of the volumes shown; zero shows whole numbers.
Decimals int
// Label names the bar; empty uses the built-in name.
Label string
}
// BidAskBar compares buy and sell volume in a split bar.
func BidAskBar(c *ui.Context, bid, ask float64, opts BidAskBarOptions) *ui.Element {
if bid < 0 || ask < 0 || math.IsNaN(bid+ask) {
panic("mujica: BidAskBar volumes must be zero or more")
}
if opts.Decimals < 0 {
panic("mujica: BidAskBar with negative Decimals")
}
k := Tokens(c)
u := Density(c).Unit()
total := bid + ask
share := 0.5
if total > 0 {
share = bid / total
}
label := opts.Label
if label == "" {
label = msg(c, "bid_ask_bar.label")
}
buy := fmt.Sprintf(msg(c, "bid_ask_bar.buy"), statisticFormat(share*100, 1), statisticFormat(bid, opts.Decimals))
sell := fmt.Sprintf(msg(c, "bid_ask_bar.sell"), statisticFormat(ask, opts.Decimals), statisticFormat((1-share)*100, 1))
up, down := trendColor(c, 1), trendColor(c, -1)
upText, downText := up, down
if total == 0 {
up, down, upText, downText = k.Border, k.Border, k.TextMuted, k.TextMuted
}
e := ui.Column(c).Gap(u).MinWidth(160).Role(ui.RoleMeter).Label(label).Description(buy + ", " + sell)
e.Children(func() {
ui.Row(c).Justify(ui.SpaceBetween).Gap(u * 2).Children(func() {
ui.Text(c, buy).TextColor(upText).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine()
ui.Text(c, sell).TextColor(downText).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine()
})
ui.Box(c).Height(8).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) {
split := r.X + r.W*float32(share)
const gap = 2
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: max(0, split-r.X-gap/2), H: r.H}, up, theme.ControlRadius)
p.Fill(ui.Rect{X: split + gap/2, Y: r.Y, W: max(0, r.X+r.W-split-gap/2), H: r.H}, down, theme.ControlRadius)
})
})
return e
}
+34
View File
@@ -0,0 +1,34 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBidAskBarShares(t *testing.T) {
bid, ask := 620.0, 380.0
tt := tradingTester(GreenUp, 400, 100, func(c *ui.Context) {
ui.Box(c).Width(300).Children(func() { BidAskBar(c, bid, ask, BidAskBarOptions{}) })
})
if !tt.HasText("Buy 62.0% · 620") || !tt.HasText("380 · Sell 38.0%") {
t.Fatalf("texts %q", tt.Texts())
}
bid, ask = 0, 0
tt.Frame()
if !tt.HasText("Buy 50.0% · 0") {
t.Fatalf("empty: %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("negative volume accepted")
}
}()
tradingTester(GreenUp, 100, 100, func(c *ui.Context) { BidAskBar(c, -1, 2, BidAskBarOptions{}) })
}
func TestBidAskBarSnapshot(t *testing.T) {
snapshot(t, "bid_ask_bar", 340, 80, func(c *ui.Context) {
ui.Column(c).Padding(20).Children(func() { BidAskBar(c, 6234, 3820, BidAskBarOptions{}) })
})
}
+107
View File
@@ -0,0 +1,107 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BlameLine is a source line and its last commit.
type BlameLine struct {
Hash string
Author string
When time.Time
Summary string
Text string
}
// BlameViewState keeps scroll place and highlight state.
type BlameViewState struct {
list ui.ListState
blocks []bool
src *BlameLine
n int
lang string
}
// BlameViewOptions configure a BlameView.
type BlameViewOptions struct {
// Language picks syntax colors, as "go"; empty is plain.
Language string
// TimeFormat lays out commit dates; empty means "2006-01-02".
TimeFormat string
}
// BlameView shows code with its commits; give it a height.
func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) *ui.Element {
var first *BlameLine
if len(lines) > 0 {
first = &lines[0]
}
if first != s.src || len(lines) != s.n || opts.Language != s.lang {
s.blocks = blameViewBlocks(lines, opts.Language)
s.src, s.n, s.lang = first, len(lines), opts.Language
slog.Debug("mujica: blame view index", "lines", len(lines))
}
layout := opts.TimeFormat
if layout == "" {
layout = "2006-01-02"
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
digits := float32(len(strconv.Itoa(len(lines))))
s.list.Label = func(i int) string { return lines[i].Text }
e := ui.List(c, &s.list, len(lines), func(i int) {
ln := lines[i]
if ln.Hash == "" {
panic("mujica: BlameLine needs a Hash")
}
head := i == 0 || lines[i-1].Hash != ln.Hash
row := ui.Row(c).AlignItems(ui.Stretch).MinHeight(theme.MinHitSize)
if head && i > 0 {
row.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W, H: 1}, k.Border, 0) })
}
row.Children(func() {
gut := ui.Row(c).Width(240).Shrink(0).AlignItems(ui.Center).Gap(u*2).Padding(0, u*3).Background(k.Surface).
Label(fmt.Sprintf(msg(c, "blame_view.line"), i+1, ln.Author, gitShortHash(ln.Hash)))
gut.Tooltip(fmt.Sprintf("%s\n%s · %s\n%s", ln.Hash, ln.Author, ln.When.Format(layout), ln.Summary))
if gut.Hovered() {
gut.Background(k.SurfaceHover)
}
gut.Transition(colorFade)
gut.Children(func() {
if !head {
return
}
small := FontSize(c, theme.CaptionSize)
ui.Text(c, gitShortHash(ln.Hash)).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0)
ui.Text(c, ln.Author).FontSize(small).SingleLine().Grow(1).MinWidth(0)
ui.Text(c, ln.When.Format(layout)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).Shrink(0)
})
ui.Text(c, strconv.Itoa(i+1)).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").
TextColor(k.TextMuted).Width(digits*8+u*4).TextAlign(ui.End).Padding(u, u*2, 0, 0).Shrink(0)
spans, _ := internal.CodeSpans(k, opts.Language, ln.Text, s.blocks[i])
ui.RichText(c, spans...).Font(theme.MonoFont).NoWrap().Padding(u, u*2).AlignSelf(ui.Center)
})
})
e.Background(k.Background).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
return e
}
// blameViewBlocks records where block comments are open.
func blameViewBlocks(lines []BlameLine, language string) []bool {
blocks := make([]bool, len(lines))
open := false
for i, ln := range lines {
blocks[i] = open
_, open = internal.Highlight(language, ln.Text, open)
}
return blocks
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func blameSample() []BlameLine {
a := BlameLine{Hash: gitTestHash(1), Author: "Sakiko", When: gitTestTime, Summary: "Add the graph"}
b := BlameLine{Hash: gitTestHash(2), Author: "Uika", When: gitTestTime, Summary: "Comment the lanes"}
lines := []string{"package mujica", "", "/* lanes", " per row */", "func lanes() int {", "\treturn 2", "}"}
out := make([]BlameLine, len(lines))
for i, s := range lines {
ln := a
if i >= 2 && i <= 3 {
ln = b
}
ln.Text = s
out[i] = ln
}
return out
}
func TestBlameViewGroupsCommits(t *testing.T) {
var s BlameViewState
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BlameView(c, &s, blameSample(), BlameViewOptions{Language: "go"}).Height(300)
}, 700, 340)
count := 0
for _, txt := range tt.Texts() {
if txt == "Sakiko" || txt == "Uika" {
count++
}
}
// Three runs: lines 1-2, 3-4 and 5-7.
if count != 3 {
t.Fatalf("author shown %d times, want 3: %q", count, tt.Texts())
}
if _, ok := tt.Find(fmt.Sprintf("Line 4, Uika, %s", gitShortHash(gitTestHash(2)))); !ok {
t.Fatal("line 4 gutter not named")
}
if !s.blocks[3] || s.blocks[4] {
t.Fatalf("block comment state %v", s.blocks)
}
}
func TestBlameViewHoverDetails(t *testing.T) {
var s BlameViewState
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BlameView(c, &s, blameSample(), BlameViewOptions{}).Height(300)
}, 700, 340)
r, _ := tt.Find("Uika")
tt.Move(r.X+2, r.Y+2)
time.Sleep(650 * time.Millisecond)
tt.Move(r.X+3, r.Y+2)
tt.Frame()
if tt.HasText(gitTestHash(2) + "\nUika · 2026-10-05\nComment the lanes") {
return
}
t.Fatalf("no hover details: %q", tt.Texts())
}
func TestBlameViewVirtualized(t *testing.T) {
lines := make([]BlameLine, 100000)
for i := range lines {
lines[i] = BlameLine{Hash: gitTestHash(i / 3), Author: "Sakiko", When: gitTestTime, Text: fmt.Sprintf("line %d", i)}
}
var s BlameViewState
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BlameView(c, &s, lines, BlameViewOptions{}).Height(400)
}, 700, 440)
if n := len(tt.Texts()); n > 400 {
t.Fatalf("%d texts for 100,000 lines", n)
}
blocks := &s.blocks[0]
tt.Frame()
if &s.blocks[0] != blocks {
t.Fatal("highlight state recomputed without a data change")
}
}
func TestBlameViewSnapshot(t *testing.T) {
lines := blameSample()
snapshot(t, "blame_view", 640, 300, func(c *ui.Context) {
s := ui.Local(c.Root(), "blame", func() *BlameViewState { return &BlameViewState{} })
ui.Column(c).Padding(16).Children(func() {
BlameView(c, *s, lines, BlameViewOptions{Language: "go"}).Height(260)
})
})
}
+160
View File
@@ -0,0 +1,160 @@
package mujica
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BoxGroup is one named sample of a BoxPlot.
type BoxGroup struct {
Name string
Values []float64
}
// BoxPlotOptions configure a BoxPlot.
type BoxPlotOptions struct {
Frame ChartFrame
Format func(v float64) string
}
// boxStats are quartiles, whisker ends and outliers.
type boxStats struct {
q1, median, q3, lo, hi float64
outliers []float64
n int
}
// boxSummary summarizes finite values; n is 0 without any.
func boxSummary(values []float64) boxStats {
var v []float64
for _, x := range values {
if !math.IsNaN(x) && !math.IsInf(x, 0) {
v = append(v, x)
}
}
if len(v) == 0 {
return boxStats{}
}
slices.Sort(v)
q := func(p float64) float64 {
at := p * float64(len(v)-1)
i := int(at)
if i+1 >= len(v) {
return v[i]
}
return v[i] + (at-float64(i))*(v[i+1]-v[i])
}
s := boxStats{q1: q(0.25), median: q(0.5), q3: q(0.75), n: len(v)}
fence := 1.5 * (s.q3 - s.q1)
s.lo, s.hi = s.q1, s.q3
for _, x := range v {
if x < s.q1-fence || x > s.q3+fence {
s.outliers = append(s.outliers, x)
continue
}
s.lo, s.hi = min(s.lo, x), max(s.hi, x)
}
return s
}
// BoxPlot compares groups by quartile box, whiskers and outlier points.
func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult {
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
ids := ""
for _, g := range groups {
if g.Name == "" {
panic("mujica: BoxPlot groups need names")
}
ids += chartID(g.Values) + ";"
}
stats := chartMemo(ch, "stats", ids, func() []boxStats {
out := make([]boxStats, len(groups))
for i, g := range groups {
out[i] = boxSummary(g.Values)
}
return out
})
lo, hi, n := math.Inf(1), math.Inf(-1), 0
for _, s := range stats {
if s.n > 0 {
n++
lo, hi = min(lo, s.lo, slices.Min(append([]float64{s.lo}, s.outliers...))), max(hi, s.hi, slices.Max(append([]float64{s.hi}, s.outliers...)))
}
}
var step float64
if n > 0 {
lo, hi, step = internal.Nice(lo, hi, 4)
} else {
groups = nil
}
val := func(v float64) string { return chartValue(c, v, opts.Format) }
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
plot := chartInset(r, 52, 26)
return plot, internal.Band{N: len(groups), R0: plot.X, R1: plot.X + plot.W, Pad: 0.45},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
spec := chartSpec{n: len(groups)}
spec.readout = func(i int) string {
s := stats[i]
if s.n == 0 {
return groups[i].Name + " · " + msg(c, "chart.missing")
}
return fmt.Sprintf(msg(c, "box_plot.readout"), groups[i].Name, val(s.median), val(s.q1), val(s.q3), val(s.lo), val(s.hi), len(s.outliers))
}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, band, _ := geom(r)
if !plot.Contains(x, y) {
return -1
}
return min(len(groups)-1, int((x-plot.X)/band.Step()))
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band, ys := geom(r)
internal.AxisY(p, plot, ys, internal.Ticks(lo, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k)
var xs []float32
var names []string
for i, g := range groups {
xs, names = append(xs, band.Center(i)), append(names, g.Name)
}
internal.AxisX(p, plot, xs, names, size, k)
for i, s := range stats {
if s.n == 0 {
continue
}
col := pal[i%len(pal)]
x, w := band.Start(i), band.Width()
cx := internal.Snap(p, x+w/2)
whisker := func(a, b float64) {
var path ui.Path
path.MoveTo(cx, ys.Map(a)).LineTo(cx, ys.Map(b))
p.StrokePath(&path, 1.5, k.TextMuted)
p.Line(cx-w/4, ys.Map(b), cx+w/4, ys.Map(b), 1.5, k.TextMuted)
}
whisker(s.q3, s.hi)
whisker(s.q1, s.lo)
box := ui.Rect{X: x, Y: ys.Map(s.q3), W: w, H: max(ys.Map(s.q1)-ys.Map(s.q3), 1)}
p.Fill(box, col.Alpha(0.22), 2)
p.Stroke(box, col, 2, 1.5)
p.Line(x, ys.Map(s.median), x+w, ys.Map(s.median), 2.5, k.Text)
for _, o := range s.outliers {
var dot ui.Path
dot.Circle(cx, internal.Snap(p, ys.Map(o)), 3.5)
p.StrokePath(&dot, 1.5, col)
}
if i == active {
p.Stroke(ui.Rect{X: x - 6, Y: plot.Y, W: w + 12, H: plot.H}, k.Text, 3, 1.5)
}
}
}
res, _ := ch.build(spec)
return res
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"fmt"
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var boxGroups = []BoxGroup{
{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 40}},
{Name: "Nave", Values: []float64{4, 5, 5, 6, 7, math.NaN()}},
}
func TestBoxSummary(t *testing.T) {
s := boxSummary(boxGroups[0].Values)
if s.q1 != 3.25 || s.median != 5.5 || s.q3 != 7.75 || s.lo != 1 || s.hi != 9 || fmt.Sprint(s.outliers) != "[40]" || s.n != 10 {
t.Fatalf("summary %+v", s)
}
if s := boxSummary([]float64{math.NaN()}); s.n != 0 {
t.Fatal("NaN counted")
}
}
func TestBoxPlotReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BoxPlot(c, boxGroups, BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Hymns")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Choir · median 5.5 · quartiles 3.25–7.75 · whiskers 1–9 · 1 outliers" {
t.Fatalf("readout %q", got)
}
b := h.res.Element.Bounds()
tt.Key(0, ui.KeyEscape)
tt.Move(b.X+52+(b.W-64)*0.75, b.Y+100)
if got := chartReadout(tt); got != "Nave · median 5 · quartiles 5–6 · whiskers 4–7 · 0 outliers" {
t.Fatalf("hover %q", got)
}
}
func TestBoxPlotEmpty(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BoxPlot(c, []BoxGroup{{Name: "a"}}, BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
}}
if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestBoxPlotSnapshot(t *testing.T) {
snapshot(t, "box_plot", 520, 320, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
BoxPlot(c, append(boxGroups, BoxGroup{Name: "Crypt", Values: []float64{12, 14, 15, 18, 22, 25}}), BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
})
})
}
+120
View File
@@ -0,0 +1,120 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Branch is a local or remote branch.
type Branch struct {
// Name is unique; remote names carry the remote, as origin/main.
Name string
Remote bool
// Current marks the checked-out branch.
Current bool
// Upstream is the tracked remote branch, if any.
Upstream string
Ahead, Behind int
}
// GitAction is a row action of a version-control list.
type GitAction struct {
ID string
Label string
// Icon names an icon from icons/.
Icon string
}
// BranchListOptions configure a BranchList.
type BranchListOptions struct {
// Actions show on every row; Action returns their IDs.
Actions []GitAction
Status DataStatus
Error string
}
// BranchList shows branches with upstream and ahead/behind counts.
func BranchList(c *ui.Context, s *ListState[string], branches []Branch, opts BranchListOptions) GitListResult {
gitActionsCheck(opts.Actions)
var r GitListResult
v := List(c, s, len(branches), ListOptions[string]{
Key: func(i int) string { return branchKey(branches[i]) },
Label: func(i int) string { return branches[i].Name },
Status: opts.Status, Error: opts.Error, Empty: msg(c, "branch_list.empty"),
}, func(i int) {
b := branches[i]
branchListRow(c, b)
gitRowActions(c, opts.Actions, b.Name, &r)
})
r.Element, r.changed, r.submitted = v.Element, v.Changed(), v.Submitted()
return r
}
func branchKey(b Branch) string {
if b.Name == "" {
panic("mujica: Branch needs a Name")
}
if b.Ahead < 0 || b.Behind < 0 {
panic("mujica: Branch Ahead and Behind must not be negative")
}
return b.Name
}
func branchListRow(c *ui.Context, b Branch) {
k := Tokens(c)
u := Density(c).Unit()
small := FontSize(c, theme.CaptionSize)
mark := ui.Box(c).Size(16, 16).Shrink(0).Center().Role(ui.RoleNone)
if b.Current {
mark.TextColor(k.AccentText).Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14) })
}
ui.Icon(c, icons.Must("git-branch")).Size(14, 14).Shrink(0).TextColor(k.TextMuted)
name := ui.Text(c, b.Name).SingleLine().MinWidth(0).Shrink(1)
if b.Current {
name.FontWeight(600)
ui.Text(c, msg(c, "branch_list.current")).FontSize(small).TextColor(k.AccentText).Shrink(0)
}
ui.Spacer(c)
if b.Upstream != "" {
ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).TextColor(k.TextMuted).Children(func() {
ui.Text(c, b.Upstream).FontSize(small).SingleLine().MaxWidth(160)
if b.Ahead > 0 {
ui.Text(c, fmt.Sprintf("↑%d", b.Ahead)).FontSize(small).FontFeatures("tnum").Label(fmt.Sprintf(msg(c, "branch_list.ahead"), b.Ahead))
}
if b.Behind > 0 {
ui.Text(c, fmt.Sprintf("↓%d", b.Behind)).FontSize(small).FontFeatures("tnum").Label(fmt.Sprintf(msg(c, "branch_list.behind"), b.Behind))
}
})
}
}
// gitActionsCheck panics on incomplete or duplicate actions.
func gitActionsCheck(actions []GitAction) {
seen := map[string]bool{}
for _, a := range actions {
if a.ID == "" || a.Label == "" || a.Icon == "" {
panic("mujica: GitAction needs an ID, Label and Icon")
}
if seen[a.ID] {
panic("mujica: duplicate GitAction " + a.ID)
}
seen[a.ID] = true
icons.Must(a.Icon)
}
}
// gitRowActions shows row actions and records the one used.
func gitRowActions(c *ui.Context, actions []GitAction, item string, r *GitListResult) {
for _, a := range actions {
b := iconAction(c, icons.Must(a.Icon), a.Label).Tooltip(a.Label).Shrink(0)
if b.Clicked() && !b.IsDisabled() {
r.action, r.item = a.ID, item
slog.Debug("mujica: git list action", "action", a.ID, "item", item)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func branchSample() []Branch {
return []Branch{
{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2, Behind: 1},
{Name: "feature/graph", Upstream: "origin/feature/graph"},
{Name: "fix/blame"},
{Name: "origin/main", Remote: true},
{Name: "origin/release", Remote: true},
}
}
func TestBranchListRowsAndActions(t *testing.T) {
var s ListState[string]
var acted [][2]string
actions := []GitAction{{ID: "checkout", Label: "Check out", Icon: "git-branch"}, {ID: "delete", Label: "Delete", Icon: "trash-2"}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := BranchList(c, &s, branchSample(), BranchListOptions{Actions: actions})
r.Element.Height(300)
if a, item, ok := r.Action(); ok {
acted = append(acted, [2]string{a, item})
}
}, 700, 340)
if !tt.HasText("current") || !tt.HasText("origin/feature/graph") {
t.Fatalf("current, ahead, behind: %q", tt.Texts())
}
for _, name := range []string{"2 ahead of upstream", "1 behind upstream"} {
if _, ok := tt.Find(name); !ok {
t.Fatalf("%q not shown", name)
}
}
r, _ := tt.Find("fix/blame")
if err := tt.Click("fix/blame"); err != nil {
t.Fatal(err)
}
if key, _ := s.Selected(); key != "fix/blame" {
t.Fatalf("selected %q", key)
}
// The third row's Delete button sits at the row's right end.
tt.ClickAt(700-60, r.Y+r.H/2)
if len(acted) != 1 || acted[0] != [2]string{"delete", "fix/blame"} {
t.Fatalf("actions %q", acted)
}
}
func TestBranchListInvalid(t *testing.T) {
for name, tc := range map[string]struct {
b []Branch
a []GitAction
}{
"name": {[]Branch{{}}, nil},
"ahead": {[]Branch{{Name: "x", Ahead: -1}}, nil},
"action": {nil, []GitAction{{ID: "x"}}},
"dup": {nil, []GitAction{{ID: "x", Label: "X", Icon: "x"}, {ID: "x", Label: "Y", Icon: "x"}}},
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
var s ListState[string]
BranchList(c, &s, tc.b, BranchListOptions{Actions: tc.a}).Element.Height(100)
}, 300, 120, 1)
})
}
}
func TestBranchListSnapshot(t *testing.T) {
snapshot(t, "branch_list", 600, 260, func(c *ui.Context) {
s := ui.Local(c.Root(), "list", func() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(16).Children(func() {
BranchList(c, *s, branchSample(), BranchListOptions{Actions: []GitAction{{ID: "checkout", Label: "Check out", Icon: "git-branch"}}}).Element.Height(220)
})
})
}
+52
View File
@@ -0,0 +1,52 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BranchNavigator switches among count branches of a message, showing "< 2 / 3 >"; Left and Right step.
func BranchNavigator(c *ui.Context, index *int, count int) ChoiceResult {
if count < 1 || *index < 0 || *index >= count {
panic("mujica: BranchNavigator needs 0 <= index < count")
}
k := Tokens(c)
label := fmt.Sprintf(msg(c, "branch_navigator.label"), *index+1, count)
var r ChoiceResult
r.Element = ui.Row(c).Focusable().AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleGroup).Label(label)
internal.Ring(r.Element, k.Focus, theme.ControlRadius)
disabled := r.Element.IsDisabled()
step := func(d int) {
if i := *index + d; i >= 0 && i < count && !disabled {
*index, r.changed = i, true
c.Announce(fmt.Sprintf(msg(c, "branch_navigator.label"), i+1, count))
slog.Debug("mujica: branch_navigator change", "index", i)
}
}
switch {
case r.Element.Shortcut(0, ui.KeyLeft):
step(-1)
case r.Element.Shortcut(0, ui.KeyRight):
step(1)
case r.Element.Shortcut(0, ui.KeyHome):
step(-*index)
case r.Element.Shortcut(0, ui.KeyEnd):
step(count - 1 - *index)
}
r.Element.Children(func() {
if numberInputButton(c, "chevron-left", msg(c, "branch_navigator.previous"), *index > 0) {
step(-1)
}
ui.Text(c, fmt.Sprintf("%d / %d", *index+1, count)).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).SingleLine()
if numberInputButton(c, "chevron-right", msg(c, "branch_navigator.next"), *index < count-1) {
step(1)
}
})
return r
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBranchNavigatorButtonsAndKeys(t *testing.T) {
i, changes, off := 1, 0, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(off).Children(func() {
if BranchNavigator(c, &i, 3).Changed() {
changes++
}
})
}, 300, 100)
if !tt.HasText("2 / 3") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Next branch")
_ = tt.Click("Next branch")
if i != 2 || changes != 1 {
t.Fatalf("index %d changes %d at the end", i, changes)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyLeft)
if i != 0 || changes != 3 {
t.Fatalf("keys: index %d changes %d", i, changes)
}
tt.Key(0, ui.KeyEnd)
if i != 2 {
t.Fatalf("End gave %d", i)
}
off = true
tt.Key(0, ui.KeyLeft)
_ = tt.Click("Previous branch")
if i != 2 {
t.Fatalf("disabled navigator moved to %d", i)
}
}
func TestBranchNavigatorBounds(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an index past count")
}
}()
i := 3
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BranchNavigator(c, &i, 3)
}, 100, 100)
}
func TestBranchNavigatorSnapshot(t *testing.T) {
i := 1
snapshot(t, "branch_navigator", 200, 60, func(c *ui.Context) {
ui.Column(c).Padding(12).Children(func() { BranchNavigator(c, &i, 3) })
})
}
+211
View File
@@ -0,0 +1,211 @@
package mujica
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// BranchSelectorOptions configure a BranchSelector.
type BranchSelectorOptions struct {
// AllowCreate offers to create a branch named by the search.
AllowCreate bool
Disabled bool
}
// BranchSelectorResult is a built BranchSelector and its events.
type BranchSelectorResult struct {
Element *ui.Element
changed bool
create string
}
// Changed reports a new branch in *selected this frame.
func (r BranchSelectorResult) Changed() bool { return r.changed }
// Created returns a branch name to create this frame.
func (r BranchSelectorResult) Created() (string, bool) { return r.create, r.create != "" }
type branchSelectorState struct {
open, wasOpen bool
query string
hl int
reveal bool
// found caches the filter of src for query fq.
found []Branch
fq string
src *Branch
n int
}
// BranchSelector chooses a branch from a searchable popup.
func BranchSelector(c *ui.Context, selected *string, branches []Branch, opts BranchSelectorOptions) BranchSelectorResult {
for _, b := range branches {
branchKey(b)
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
tr := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u*2).Radius(theme.ControlRadius).
Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Disabled(opts.Disabled).
Label(msg(c, "branch_selector.label") + ": " + *selected)
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() {
tr.Background(k.SurfaceHover)
}
tr.Transition(colorFade)
st := ui.Local(tr, "state", func() *branchSelectorState { return &branchSelectorState{} })
s := *st
res := BranchSelectorResult{Element: tr}
if tr.Clicked() && !tr.IsDisabled() {
s.open = !s.open
}
if tr.IsDisabled() {
s.open = false
}
if s.open && !s.wasOpen {
s.query, s.hl = "", 0
slog.Debug("mujica: branch selector open")
}
s.wasOpen = s.open
tr.Children(func() {
ui.Icon(c, icons.Must("git-branch")).Size(16, 16).TextColor(k.TextMuted)
ui.Text(c, *selected).SingleLine().MaxWidth(220)
ui.Icon(c, icons.Must("chevrons-up-down")).Size(14, 14).TextColor(k.TextMuted)
})
b := tr.Bounds()
ui.PopoverBase(c, tr, &s.open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayAnchored(c, panel, b))
panel.Margin(6, 0, 0, 0).Padding(u * 2).Gap(u * 2).Width(300).MaxHeight(popoverRoom(c, b)).AlignItems(ui.Stretch)
var first *Branch
if len(branches) > 0 {
first = &branches[0]
}
if s.found == nil || s.fq != s.query || s.src != first || s.n != len(branches) {
s.found, s.fq, s.src, s.n = branchSelectorFilter(branches, s.query), s.query, first, len(branches)
}
found := s.found
create := opts.AllowCreate && strings.TrimSpace(s.query) != "" && !branchSelectorHas(branches, strings.TrimSpace(s.query))
n := len(found)
if create {
n++
}
in := TextInput(c, &s.query, TextInputOptions{Placeholder: msg(c, "branch_selector.search"), Label: msg(c, "branch_selector.search"),
Leading: func() { ui.Icon(c, icons.Must("search")).Size(14, 14).TextColor(k.TextMuted) }})
in.Input.AutoFocus()
if in.Changed() {
s.hl, s.reveal = 0, true
}
switch {
case n == 0:
case in.Input.Shortcut(0, ui.KeyDown):
s.hl, s.reveal = (s.hl+1)%n, true
case in.Input.Shortcut(0, ui.KeyUp):
s.hl, s.reveal = (s.hl-1+n)%n, true
}
s.hl = max(0, min(s.hl, n-1))
pick := -1
if in.Submitted() && n > 0 {
pick = s.hl
}
ui.Scroll(c).Shrink(1).Gap(2).Role(ui.RoleList).Children(func() {
if n == 0 {
ui.Text(c, msg(c, "branch_selector.empty")).TextColor(k.TextMuted).Padding(u * 3)
}
group := -1
for i, br := range found {
if g := branchSelectorGroup(br); g != group {
group = g
ui.Text(c, msg(c, []string{"branch_selector.local", "branch_selector.remote"}[g])).Font(theme.SerifFont).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u*2, u*2, u, u*2)
}
if branchSelectorRow(c, s, i, br.Name, br.Name == *selected, "git-branch") {
pick = i
}
}
if create && branchSelectorRow(c, s, len(found), msg(c, "branch_selector.create")+" “"+strings.TrimSpace(s.query)+"”", false, "plus") {
pick = len(found)
}
})
switch {
case pick < 0:
case pick == len(found):
res.create = strings.TrimSpace(s.query)
s.open = false
slog.Debug("mujica: branch selector create", "name", res.create)
default:
s.open = false
if found[pick].Name != *selected {
*selected, res.changed = found[pick].Name, true
slog.Debug("mujica: branch selector choose", "branch", *selected)
}
}
})
return res
}
// branchSelectorRow builds row i and reports a click on it.
func branchSelectorRow(c *ui.Context, s *branchSelectorState, i int, label string, current bool, icon string) bool {
k := Tokens(c)
d := Density(c)
u := d.Unit()
row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2).Radius(theme.ControlRadius).
Role(ui.RoleListItem).Label(label).Cursor(ui.CursorPointer)
if i == s.hl {
row.Background(k.Selection)
if s.reveal {
row.ScrollIntoView()
s.reveal = false
}
} else if row.Hovered() {
row.Background(k.SurfaceHover)
}
row.Transition(colorFade)
row.Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(k.TextMuted)
t := ui.Text(c, label).SingleLine().Grow(1).MinWidth(0)
if current {
t.FontWeight(600)
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
})
return row.Clicked()
}
func branchSelectorGroup(b Branch) int {
if b.Remote {
return 1
}
return 0
}
// branchSelectorFilter keeps branches whose name holds the query, locals first.
func branchSelectorFilter(branches []Branch, query string) []Branch {
q := strings.ToLower(strings.TrimSpace(query))
var local, remote []Branch
for _, b := range branches {
if !strings.Contains(strings.ToLower(b.Name), q) {
continue
}
if b.Remote {
remote = append(remote, b)
} else {
local = append(local, b)
}
}
return append(append(make([]Branch, 0, len(local)+len(remote)), local...), remote...)
}
func branchSelectorHas(branches []Branch, name string) bool {
for _, b := range branches {
if b.Name == name {
return true
}
}
return false
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBranchSelectorSearchAndChoose(t *testing.T) {
sel, changes := "main", 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if BranchSelector(c, &sel, branchSample(), BranchSelectorOptions{}).Changed() {
changes++
}
}, 600, 500)
if err := tt.Click("Branch: main"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Local") || !tt.HasText("Remote") || !tt.HasText("origin/release") {
t.Fatalf("groups: %q", tt.Texts())
}
tt.Type("rel")
if tt.HasText("fix/blame") || !tt.HasText("origin/release") {
t.Fatalf("filter: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if sel != "origin/release" || changes != 1 || tt.HasText("Remote") {
t.Fatalf("Enter chose %q changes %d", sel, changes)
}
_ = tt.Click("Branch: origin/release")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if sel != "fix/blame" {
t.Fatalf("arrows chose %q", sel)
}
_ = tt.Click("Branch: fix/blame")
tt.Key(0, ui.KeyEscape)
if tt.HasText("Local") {
t.Fatal("Escape did not close")
}
}
func TestBranchSelectorCreateAndDisabled(t *testing.T) {
sel, created, disabled := "main", "", false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := BranchSelector(c, &sel, branchSample(), BranchSelectorOptions{AllowCreate: true, Disabled: disabled})
if name, ok := r.Created(); ok {
created = name
}
}, 600, 500)
_ = tt.Click("Branch: main")
tt.Type("feature/new")
if !tt.HasText("Create branch “feature/new”") {
t.Fatalf("create row: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if created != "feature/new" || sel != "main" {
t.Fatalf("created %q sel %q", created, sel)
}
_ = tt.Click("Branch: main")
disabled = true
tt.Frame()
if tt.HasText("Local") {
t.Fatal("popup stayed open on a disabled selector")
}
_ = tt.Click("Branch: main")
if tt.HasText("Local") {
t.Fatal("disabled selector opened")
}
}
func TestBranchSelectorSnapshot(t *testing.T) {
snapshotAfter(t, "branch_selector", 420, 420, func(c *ui.Context) {
sel := ui.Local(c.Root(), "sel", func() string { return "main" })
ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() {
BranchSelector(c, sel, branchSample(), BranchSelectorOptions{AllowCreate: true})
})
}, func(tt *ui.Tester) {
_ = tt.Click("Branch: main")
tt.Type("ma")
})
}
+126
View File
@@ -0,0 +1,126 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Breakpoint stops at File:Line when Condition holds.
type Breakpoint struct {
ID string
File string
Line int
Enabled bool
Condition string
}
// BreakpointListOptions configure a BreakpointList.
type BreakpointListOptions struct {
Disabled bool
}
// BreakpointListResult fields hold breakpoint IDs.
type BreakpointListResult struct {
Element *ui.Element
Toggled, Edited, Opened string
}
type breakpointEdit struct{ id, draft string }
// BreakpointList lists breakpoints with toggles and conditions.
func BreakpointList(c *ui.Context, bps *[]Breakpoint, opts BreakpointListOptions) BreakpointListResult {
if bps == nil {
panic("mujica: BreakpointList needs breakpoints")
}
seen := map[string]bool{}
for _, b := range *bps {
if b.ID == "" || seen[b.ID] {
panic("mujica: Breakpoint IDs must be set and unique")
}
seen[b.ID] = true
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
var res BreakpointListResult
res.Element = codePanel(c).Disabled(opts.Disabled).Label(msg(c, "breakpoint_list.label"))
edit := ui.Local(res.Element, "breakpoint_list.edit", func() breakpointEdit { return breakpointEdit{} })
disabled := res.Element.IsDisabled()
if disabled {
edit.id = ""
}
res.Element.Children(func() {
if len(*bps) == 0 {
codeCaption(c, msg(c, "breakpoint_list.empty")).Padding(u * 4)
return
}
ScrollArea(c, nil, ScrollAreaOptions{}, func() {
for i := range *bps {
b := &(*bps)[i]
ui.Column(c).Key(b.ID).Padding(u, u*2).Gap(u).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Row(c).MinHeight(d.ControlHeight()).Gap(u * 2).AlignItems(ui.Center).Children(func() {
on := b.Enabled
Checkbox(c, &on, "", CheckboxOptions{Disabled: opts.Disabled}).Label(fmt.Sprintf(msg(c, "breakpoint_list.enable"), path.Base(b.File), b.Line))
if on != b.Enabled && !disabled {
b.Enabled, res.Toggled = on, b.ID
slog.Debug("mujica: breakpoint_list toggle", "id", b.ID, "enabled", on)
}
if breakpointLocation(c, *b).Clicked() && !disabled {
res.Opened = b.ID
slog.Debug("mujica: breakpoint_list open", "id", b.ID)
}
if iconAction(c, icons.Must("funnel"), fmt.Sprintf(msg(c, "breakpoint_list.edit"), path.Base(b.File), b.Line)).Clicked() && !disabled {
*edit = breakpointEdit{b.ID, b.Condition}
}
})
switch {
case edit.id == b.ID:
in := TextInput(c, &edit.draft, TextInputOptions{Placeholder: msg(c, "breakpoint_list.placeholder"), Label: msg(c, "breakpoint_list.condition")})
in.Input.AutoFocus()
if in.Input.Shortcut(0, ui.KeyEscape) {
edit.id = ""
} else if in.Submitted() && !disabled {
b.Condition, res.Edited, edit.id = edit.draft, b.ID, ""
slog.Debug("mujica: breakpoint_list condition", "id", b.ID)
}
case b.Condition != "":
codeMono(c, fmt.Sprintf(msg(c, "breakpoint_list.when"), b.Condition)).TextColor(k.Warning).Padding(0, 0, 0, 28)
}
})
}
}).Grow(1)
})
return res
}
// breakpointLocation is a button opening the location.
func breakpointLocation(c *ui.Context, b Breakpoint) *ui.Element {
k := Tokens(c)
d := Density(c)
e := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(theme.MinHitSize).Justify(ui.Start).Gap(d.Unit()*2).Padding(0, d.Unit()).
Radius(theme.ControlRadius).Label(fmt.Sprintf("%s:%d", b.File, b.Line))
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Hovered() {
e.Background(k.SurfaceHover)
}
fg := k.Text
if !b.Enabled {
fg = k.TextMuted
}
return e.Children(func() {
// A filled dot is enabled, a ring disabled.
dot := ui.Box(c).Size(10, 10).Radius(5).Shrink(0).Role(ui.RoleNone).Border(theme.BorderWidth, k.Danger)
if b.Enabled {
dot.Background(k.Danger)
}
ui.Text(c, path.Base(b.File)).TextColor(fg).SingleLine()
codeCaption(c, fmt.Sprintf("%s:%d", path.Dir(b.File), b.Line)).MinWidth(0)
})
}
+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type breakpointProbe struct {
bps []Breakpoint
disabled bool
toggled, edited, opened []string
}
func (p *breakpointProbe) view(c *ui.Context) {
Use(c, Settings{})
r := BreakpointList(c, &p.bps, BreakpointListOptions{Disabled: p.disabled})
r.Element.Height(300)
for _, e := range []struct {
id string
out *[]string
}{{r.Toggled, &p.toggled}, {r.Edited, &p.edited}, {r.Opened, &p.opened}} {
if e.id != "" {
*e.out = append(*e.out, e.id)
}
}
}
func newBreakpointProbe() *breakpointProbe {
return &breakpointProbe{bps: []Breakpoint{
{ID: "a", File: "court/seal.go", Line: 12, Enabled: true},
{ID: "b", File: "court/ledger.go", Line: 40, Enabled: false, Condition: "n > 3"},
}}
}
func TestBreakpointListToggleAndOpen(t *testing.T) {
p := newBreakpointProbe()
tt := ui.NewTester(p.view, 520, 320)
if err := tt.Click("Enable ledger.go:40"); err != nil {
t.Fatal(err)
}
if !p.bps[1].Enabled || len(p.toggled) != 1 || p.toggled[0] != "b" {
t.Fatalf("toggle: %+v %v", p.bps[1], p.toggled)
}
if err := tt.Click("court/seal.go:12"); err != nil {
t.Fatal(err)
}
if len(p.opened) != 1 || p.opened[0] != "a" {
t.Fatalf("opened %v", p.opened)
}
if !tt.HasText("when n > 3") {
t.Fatalf("condition not shown: %q", tt.Texts())
}
}
func TestBreakpointListEditCondition(t *testing.T) {
p := newBreakpointProbe()
tt := ui.NewTester(p.view, 520, 320)
if err := tt.Click("Edit condition of seal.go:12"); err != nil {
t.Fatal(err)
}
if !tt.Focused("Condition") {
t.Fatal("editor did not take the focus")
}
tt.Type("i == 7")
tt.Key(0, ui.KeyEnter)
if p.bps[0].Condition != "i == 7" || len(p.edited) != 1 {
t.Fatalf("condition %q edited %v", p.bps[0].Condition, p.edited)
}
_ = tt.Click("Edit condition of ledger.go:40")
tt.Type("x")
tt.Key(0, ui.KeyEscape)
if p.bps[1].Condition != "n > 3" {
t.Fatalf("Escape kept %q", p.bps[1].Condition)
}
}
func TestBreakpointListKeyboardToggle(t *testing.T) {
p := newBreakpointProbe()
tt := ui.NewTester(p.view, 520, 320)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if p.bps[0].Enabled {
t.Fatal("Space did not disable the first breakpoint")
}
}
func TestBreakpointListDisabledAndDuplicate(t *testing.T) {
p := newBreakpointProbe()
p.disabled = true
tt := ui.NewTester(p.view, 520, 320)
_ = tt.Click("Enable ledger.go:40")
_ = tt.Click("court/seal.go:12")
if p.bps[1].Enabled || len(p.opened) != 0 {
t.Fatal("disabled list changed")
}
defer func() {
if recover() == nil {
t.Fatal("duplicate IDs did not panic")
}
}()
p = newBreakpointProbe()
p.bps[1].ID = "a"
ui.NewTester(p.view, 520, 320)
}
func TestBreakpointListSnapshot(t *testing.T) {
snapshot(t, "breakpoint_list", 460, 170, func(c *ui.Context) {
p := ui.Local(c.Root(), "snap", newBreakpointProbe)
ui.Column(c).Padding(12).Children(func() {
BreakpointList(c, &(*p).bps, BreakpointListOptions{}).Element.Height(146)
})
})
}
+149
View File
@@ -0,0 +1,149 @@
package mujica
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BulletChartOptions configure a BulletChart; Frame.Height 0 means 72 DIPs.
type BulletChartOptions struct {
Frame ChartFrame
// Target, when set, draws a cross line at the goal.
Target *float64
// Bands are the ascending upper ends of the qualitative ranges.
Bands []float64
// Max ends the scale; 0 means automatic.
Max float64
Vertical bool
Format func(v float64) string
}
// BulletChart shows a value against a target and ranges.
func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartResult {
if math.IsNaN(value) || value < 0 || opts.Max < 0 || !slices.IsSorted(opts.Bands) || len(opts.Bands) > 0 && opts.Bands[0] <= 0 {
panic("mujica: BulletChart needs a value ≥ 0, Max ≥ 0 and positive ascending Bands")
}
if opts.Frame.Height == 0 {
opts.Frame.Height = 72
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
size := FontSize(c, theme.CaptionSize)
top := opts.Max
if top == 0 {
top = value
if opts.Target != nil {
top = max(top, *opts.Target)
}
if len(opts.Bands) > 0 {
top = max(top, opts.Bands[len(opts.Bands)-1])
}
_, top, _ = internal.Nice(0, max(top, 1e-9), 4)
}
_, _, step := internal.Nice(0, top, 4)
val := func(v float64) string { return chartValue(c, v, opts.Format) }
marks := []string{msg(c, "bullet_chart.actual") + " " + val(value)}
if opts.Target != nil {
marks = append(marks, msg(c, "bullet_chart.target")+" "+val(*opts.Target))
}
for i, b := range opts.Bands {
lo := 0.0
if i > 0 {
lo = opts.Bands[i-1]
}
marks = append(marks, fmt.Sprintf(msg(c, "bullet_chart.band"), i+1, val(lo), val(b)))
}
// geom returns the plot and its value scale.
geom := func(r ui.Rect) (ui.Rect, internal.Linear) {
if opts.Vertical {
plot := ui.Rect{X: r.X + r.W/2 - 14, Y: r.Y + 8, W: 28, H: r.H - 16}
return plot, internal.Linear{D0: 0, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
plot := ui.Rect{X: r.X + 8, Y: r.Y + 6, W: r.W - 24, H: max(r.H-30, 12)}
return plot, internal.Linear{D0: 0, D1: top, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
span := func(plot ui.Rect, s internal.Linear, a, b, inset float32) ui.Rect {
if opts.Vertical {
return ui.Rect{X: plot.X + inset, Y: s.Map(float64(b)), W: plot.W - 2*inset, H: s.Map(float64(a)) - s.Map(float64(b))}
}
return ui.Rect{X: s.Map(float64(a)), Y: plot.Y + inset, W: s.Map(float64(b)) - s.Map(float64(a)), H: plot.H - 2*inset}
}
spec := chartSpec{n: len(marks)}
spec.readout = func(i int) string { return marks[i] }
spec.pick = func(r ui.Rect, x, y float32) int {
plot, s := geom(r)
if !plot.Contains(x, y) {
return -1
}
at := x
if opts.Vertical {
at = y
}
v := s.Invert(at)
if opts.Target != nil && math.Abs(float64(s.Map(*opts.Target)-at)) < 4 {
return 1
}
if span(plot, s, 0, float32(min(value, top)), bulletInset(plot, opts.Vertical)).Contains(x, y) {
return 0
}
for i, b := range opts.Bands {
if v <= b {
return len(marks) - len(opts.Bands) + i
}
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, s := geom(r)
inset := bulletInset(plot, opts.Vertical)
prev := 0.0
for i, b := range opts.Bands {
shade := k.SurfaceHover.Mix(k.Border, float32(len(opts.Bands)-1-i)/float32(max(len(opts.Bands)-1, 1)))
p.Fill(span(plot, s, float32(prev), float32(min(b, top)), 0), shade, 0)
if active == len(marks)-len(opts.Bands)+i {
p.Stroke(span(plot, s, float32(prev), float32(min(b, top)), 0), k.Text, 0, 1.5)
}
prev = b
}
bar := span(plot, s, 0, float32(min(value, top)), inset)
p.Fill(bar, internal.Palette(k)[0], 0)
if active == 0 {
p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 1, 1.5)
}
if opts.Target != nil {
t := internal.Snap(p, s.Map(min(*opts.Target, top)))
w := float32(2.5)
if active == 1 {
w = 4
}
if opts.Vertical {
p.Line(plot.X+2, t, plot.X+plot.W-2, t, w, k.Text)
} else {
p.Line(t, plot.Y+2, t, plot.Y+plot.H-2, w, k.Text)
}
}
for _, tk := range internal.Ticks(0, top, step) {
if opts.Vertical {
chartText(p, internal.FormatTick(tk, step), plot.X+plot.W+6, s.Map(tk), size, k.TextMuted, 0, 0.5)
} else {
chartText(p, internal.FormatTick(tk, step), s.Map(tk), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
}
}
}
res, _ := ch.build(spec)
return res
}
// bulletInset is a third of the band's thickness.
func bulletInset(plot ui.Rect, vertical bool) float32 {
if vertical {
return plot.W / 3
}
return plot.H / 3
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBulletChartReadout(t *testing.T) {
target := 250.0
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BulletChart(c, 270, BulletChartOptions{Frame: ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Alms")
want := []string{"Actual 270", "Target 250", "Range 1: 0–150", "Range 2: 150–225", "Range 3: 225–300"}
for i, w := range want {
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != w {
t.Fatalf("mark %d %q, want %q", i, got, w)
}
}
tt.Key(0, ui.KeyEscape)
b := h.res.Element.Bounds()
plotW := b.W - 24
tt.Move(b.X+8+plotW*0.1, b.Y+6+21) // band 1, inside the value bar
if got := chartReadout(tt); got != "Actual 270" {
t.Fatalf("hover bar %q", got)
}
tt.Move(b.X+8+plotW*0.1, b.Y+8) // band 1, above the bar
if got := chartReadout(tt); got != "Range 1: 0–150" {
t.Fatalf("hover band %q", got)
}
}
func TestBulletChartVerticalAndBad(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Alms", Height: 200}, Vertical: true, Max: 100})
}}
tt := ui.NewTester(h.view, 520, 420)
b := h.res.Element.Bounds()
tt.Move(b.X+b.W/2, b.Y+8+184*0.8) // low on the scale: inside the bar
if got := chartReadout(tt); got != "Actual 40" {
t.Fatalf("vertical hover %q", got)
}
defer func() {
if recover() == nil {
t.Fatal("no panic for unsorted bands")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BulletChart(c, 1, BulletChartOptions{Frame: ChartFrame{Label: "x"}, Bands: []float64{5, 2}})
}, 300, 300)
}
func TestBulletChartSnapshot(t *testing.T) {
target := 250.0
snapshot(t, "bullet_chart", 520, 300, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(360).Children(func() {
BulletChart(c, 270, BulletChartOptions{Frame: ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
})
ui.Column(c).Width(100).Children(func() {
BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Oil", Height: 200}, Vertical: true, Target: &[]float64{70}[0], Bands: []float64{50, 80, 100}})
})
})
})
}
+50
View File
@@ -0,0 +1,50 @@
package mujica
import (
"time"
"github.com/egoist/mygo/ui"
)
// CalendarDayViewOptions configure a CalendarDayView.
type CalendarDayViewOptions struct {
// Location is the zone the day is cut in; required.
Location *time.Location
// Version must change when events change in place.
Version int
// SlotMinutes is the grid step; zero means 30.
SlotMinutes int
// Today is marked; zero uses the system clock.
Today Date
// Now places the current-time line; zero uses the system clock.
Now time.Time
Hour12 bool
Disabled bool
Label string
}
// CalendarDayView shows one day's time grid.
func CalendarDayView(c *ui.Context, day *Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult {
datetimeCheckDate("CalendarDayView day", *day)
if day.IsZero() {
panic("mujica: CalendarDayView needs a day")
}
cfg := timeGridConfig{name: "calendar_day_view", loc: eventLocation("CalendarDayView", opts.Location), version: opts.Version,
slot: timeGridSlot("CalendarDayView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12}
eventsCheck("CalendarDayView", events)
title := datetimeDayLabel(c, *day)
label := opts.Label
if label == "" {
label = title
}
r := CalendarViewResult{}
root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
root.Children(func() {
calendarViewHeader(c, title, msg(c, "calendar_day_view.prev"), msg(c, "calendar_day_view.next"), func(dir int) {
*day = day.AddDays(dir)
})
timeGrid(c, root, []Date{*day}, events, cfg, &r)
})
return r
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func dayViewTester(day *Date, events []CalendarEvent, opts CalendarDayViewOptions, changes *[]CalendarChange) *ui.Tester {
opts.Location, opts.Today = calTokyo, calendarToday
opts.Now = time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ch, ok := CalendarDayView(c, day, events, opts).Changed(); ok {
*changes = append(*changes, ch)
}
}, 600, 900)
}
func TestCalendarDayViewOverlapSideBySide(t *testing.T) {
day := oct(14)
evs := []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 9, 30, 60), calEvent("c", 14, 10, 0, 60), calEvent("d", 14, 12, 0, 30)}
var ch []CalendarChange
tt := dayViewTester(&day, evs, CalendarDayViewOptions{}, &ch)
a, _ := tt.Find("Event a, 09:00 – 11:00")
b, _ := tt.Find("Event b, 09:30 – 10:30")
c, _ := tt.Find("Event c, 10:00 – 11:00")
d, _ := tt.Find("Event d, 12:00 – 12:30")
if !(a.X < b.X && b.X < c.X) || a.W > d.W/2 || d.X != a.X {
t.Fatalf("three overlapping events need three columns, the lone one the full width: %v %v %v %v", a, b, c, d)
}
}
func TestCalendarDayViewNavigationSlotAndDisabled(t *testing.T) {
day := oct(14)
var ch []CalendarChange
tt := dayViewTester(&day, nil, CalendarDayViewOptions{SlotMinutes: 15}, &ch)
if !tt.HasText("Wednesday, October 14, 2026") {
t.Fatalf("title %v", tt.Texts()[:4])
}
_ = tt.Click("Schedule, Wednesday, October 14, 2026")
tt.Key(0, ui.KeyEnter)
if len(ch) != 1 || ch[0].End.Sub(ch[0].Start) != 15*time.Minute {
t.Fatalf("15-minute slot create %+v", ch)
}
_ = tt.Click("Next day")
if day != oct(15) {
t.Fatalf("day %v", day)
}
d := oct(14)
ch = nil
tt = dayViewTester(&d, nil, CalendarDayViewOptions{Disabled: true}, &ch)
_ = tt.Click("Schedule, Wednesday, October 14, 2026")
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Previous day")
if len(ch) != 0 || d != oct(14) {
t.Fatal("disabled day view reacted")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic without a day")
}
}()
zero := Date{}
dayViewTester(&zero, nil, CalendarDayViewOptions{}, &ch)
}
func TestCalendarDayViewSnapshot(t *testing.T) {
day := oct(14)
evs := []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 9, 30, 60), calEvent("c", 14, 10, 0, 60), calEvent("d", 14, 12, 0, 30)}
evs[1].Color, evs[2].Color = EventInfo, EventSuccess
snapshot(t, "calendar_day_view", 560, 860, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CalendarDayView(c, &day, evs, CalendarDayViewOptions{Location: calTokyo, Today: calendarToday,
Now: time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)})
})
})
}
+238
View File
@@ -0,0 +1,238 @@
package mujica
import (
"fmt"
"sort"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// EventColor picks an event's color from the theme's event palette.
type EventColor int
const (
EventAccent EventColor = iota
EventInfo
EventSuccess
EventWarning
EventDanger
EventNeutral
)
// CalendarEvent is one entry of the views; End is exclusive.
type CalendarEvent struct {
ID string
Title string
Start time.Time
End time.Time
AllDay bool
Color EventColor
Location string
// Attendees, Reminders and Repeat are edited by EventEditor.
Attendees []Attendee
Reminders []time.Duration
Repeat Recurrence
}
// CalendarChangeKind says what a view gesture did.
type CalendarChangeKind int
const (
CalendarMove CalendarChangeKind = iota
CalendarResize
CalendarCreate
)
// CalendarChange is a move, resize or creation to apply.
type CalendarChange struct {
Kind CalendarChangeKind
ID string
Start, End time.Time
AllDay bool
}
// CalendarViewResult is a view and what the user asked.
type CalendarViewResult struct {
Element *ui.Element
opened string
change *CalendarChange
day Date
}
// Opened returns the event the user activated.
func (r CalendarViewResult) Opened() (string, bool) { return r.opened, r.opened != "" }
// Changed returns the move, resize or creation the user made.
func (r CalendarViewResult) Changed() (CalendarChange, bool) {
if r.change == nil {
return CalendarChange{}, false
}
return *r.change, true
}
// DayChosen returns the day the user activated.
func (r CalendarViewResult) DayChosen() (Date, bool) { return r.day, !r.day.IsZero() }
// eventPalette maps EventColor to tokens at 3:1 on Surface.
func eventPalette(k theme.Tokens) [6]ui.Color {
return [6]ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Danger, k.TextMuted}
}
func eventColor(k theme.Tokens, c EventColor) ui.Color {
if c < EventAccent || c > EventNeutral {
panic(fmt.Sprintf("mujica: EventColor %d out of range", c))
}
return eventPalette(k)[c]
}
// eventsCheck panics on events no view can place.
func eventsCheck(name string, events []CalendarEvent) {
for _, e := range events {
if e.ID == "" {
panic("mujica: " + name + " event without ID")
}
if !e.End.After(e.Start) {
panic(fmt.Sprintf("mujica: %s event %q ends before it starts", name, e.ID))
}
}
}
// eventsCache holds data derived from events overlapping a span.
type eventsCache[T any] struct {
first *CalendarEvent
n int
version int
from, to time.Time
value T
valid bool
}
// eventsDerived rebuilds build(hits) only when events or span change.
func eventsDerived[T any](e *ui.Element, key string, events []CalendarEvent, version int, from, to time.Time, build func(hits []int) T) T {
cache := ui.Local(e, key, func() *eventsCache[T] { return &eventsCache[T]{} })
var first *CalendarEvent
if len(events) > 0 {
first = &events[0]
}
if x := *cache; x.valid && x.first == first && x.n == len(events) && x.version == version && x.from.Equal(from) && x.to.Equal(to) {
return x.value
}
hits := []int{}
for i, ev := range events {
if ev.Start.Before(to) && ev.End.After(from) {
hits = append(hits, i)
}
}
sort.SliceStable(hits, func(a, b int) bool {
x, y := events[hits[a]], events[hits[b]]
if !x.Start.Equal(y.Start) {
return x.Start.Before(y.Start)
}
return x.End.After(y.End)
})
v := build(hits)
**cache = eventsCache[T]{first, len(events), version, from, to, v, true}
return v
}
// eventSpan is a placed interval, in any unit.
type eventSpan struct{ from, to float64 }
// eventColumns gives overlapping spans side-by-side columns and cluster widths.
func eventColumns(spans []eventSpan) (col, cols []int) {
col, cols = make([]int, len(spans)), make([]int, len(spans))
order := make([]int, len(spans))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool { return spans[order[a]].from < spans[order[b]].from })
var ends []float64
var cluster []int
clusterEnd := -1.0
flush := func() {
for _, i := range cluster {
cols[i] = len(ends)
}
cluster, ends = cluster[:0], ends[:0]
}
for _, i := range order {
s := spans[i]
if len(cluster) > 0 && s.from >= clusterEnd {
flush()
}
placed := false
for c, end := range ends {
if end <= s.from {
ends[c], col[i], placed = s.to, c, true
break
}
}
if !placed {
col[i] = len(ends)
ends = append(ends, s.to)
}
cluster = append(cluster, i)
clusterEnd = max(clusterEnd, s.to)
}
flush()
return col, cols
}
// eventDayStart is d's first instant in loc.
func eventDayStart(d Date, loc *time.Location) time.Time {
return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, loc)
}
// eventTimeText formats an event's wall-clock span in loc.
func eventTimeText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) string {
if ev.AllDay {
return msg(c, "event_chip.all_day")
}
cfg := timeConfig{hour12: hour12}
s, e := ev.Start.In(loc), ev.End.In(loc)
text := timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, cfg) + " – " + timeText(c, TimeOfDay{e.Hour(), e.Minute(), 0}, cfg)
return text
}
// eventShift moves ev by days, keeping wall time.
func eventShift(c *ui.Context, ev CalendarEvent, days int, loc *time.Location) (CalendarChange, string) {
s, e := ev.Start.In(loc), ev.End.In(loc)
ch := CalendarChange{Kind: CalendarMove, ID: ev.ID, AllDay: ev.AllDay}
if ev.AllDay {
ch.Start, ch.End = eventDayStart(DateOf(s).AddDays(days), loc), eventDayStart(DateOf(e).AddDays(days), loc)
return ch, ""
}
day := DateOf(s).AddDays(days)
wall := TimeOfDay{s.Hour(), s.Minute(), s.Second()}
at, n := dateTimeResolve(day, wall, loc)
if p := eventWallProblem(c, day, wall, loc, n); p != "" {
return ch, p
}
ch.Start, ch.End = at, at.Add(ev.End.Sub(ev.Start))
return ch, ""
}
// eventWallProblem reports a wall time DST skips or repeats.
func eventWallProblem(c *ui.Context, day Date, wall TimeOfDay, loc *time.Location, n int) string {
key := ""
switch {
case n == 0:
key = "date_time_picker.gap"
case n > 1:
key = "date_time_picker.ambiguous"
default:
return ""
}
return fmt.Sprintf(msg(c, key), day.String()+" "+fmt.Sprintf("%02d:%02d", wall.Hour, wall.Minute), loc.String())
}
// eventLocation panics without a caller zone; views never assume UTC.
func eventLocation(name string, loc *time.Location) *time.Location {
if loc == nil {
panic("mujica: " + name + " needs a Location")
}
return loc
}
+139
View File
@@ -0,0 +1,139 @@
package mujica
import (
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CalendarHeatmapOptions configure a CalendarHeatmap.
type CalendarHeatmapOptions struct {
Frame ChartFrame
// WeekStart picks each column's first weekday; zero follows the locale.
WeekStart WeekStart
Format func(v float64) string
}
// CalendarHeatmap colors each day of *year, a column per week.
func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts CalendarHeatmapOptions) ChartResult {
if *year < 1 || *year > 9999 {
panic("mujica: CalendarHeatmap year out of range")
}
k := Tokens(c)
u := Density(c).Unit()
size := FontSize(c, theme.CaptionSize)
first := Date{Year: *year, Month: time.January, Day: 1}
days := first.AddMonths(12).civil().Sub(first.civil()).Hours() / 24
n := int(days)
lead := (int(first.Weekday()) - int(calendarFirstWeekday(c, opts.WeekStart)) + 7) % 7
weeks := (lead + n + 6) / 7
top := 0.0
for i := range n {
if v, ok := values[first.AddDays(i)]; ok {
top = max(top, v)
}
}
// level is a day's color step, 0 to 4.
level := func(i int) int {
v, ok := values[first.AddDays(i)]
if !ok || v <= 0 || top <= 0 {
return 0
}
return 1 + min(3, int(v/top*4))
}
var res ChartResult
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
wrap.Children(func() {
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
prev := iconAction(c, icons.Must("chevron-left"), msg(c, "calendar_heatmap.prev")).Disabled(*year <= 1)
ui.Text(c, strconv.Itoa(*year)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).Width(64).TextAlign(ui.Center)
next := iconAction(c, icons.Must("chevron-right"), msg(c, "calendar_heatmap.next")).Disabled(*year >= 9999)
if prev.Clicked() && !prev.IsDisabled() {
*year--
res.value = true
}
if next.Clicked() && !next.IsDisabled() {
*year++
res.value = true
}
})
ch := newChart(c, opts.Frame)
geom := func(r ui.Rect) (x, y, step float32) {
step = min((r.W-36)/float32(weeks), (r.H-48)/7)
return r.X + 32, r.Y + 18, step
}
cell := func(r ui.Rect, i int) ui.Rect {
x, y, step := geom(r)
at := lead + i
return ui.Rect{X: x + float32(at/7)*step, Y: y + float32(at%7)*step, W: step - 2, H: step - 2}
}
spec := chartSpec{n: n}
spec.readout = func(i int) string {
d := first.AddDays(i)
v, ok := values[d]
s := d.String() + " · "
if !ok {
return s + msg(c, "chart.missing")
}
return s + chartValue(c, v, opts.Format)
}
spec.move = func(i int, key ui.Key) int {
step := map[ui.Key]int{ui.KeyLeft: -7, ui.KeyRight: 7, ui.KeyUp: -1, ui.KeyDown: 1}[key]
return max(0, min(n-1, i+step))
}
spec.pick = func(r ui.Rect, x, y float32) int {
x0, y0, step := geom(r)
col, row := int((x-x0)/step), int((y-y0)/step)
i := col*7 + row - lead
if x < x0 || y < y0 || row > 6 || i < 0 || i >= n {
return -1
}
return i
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
x0, y0, step := geom(r)
for i := range n {
b := cell(r, i)
if lv := level(i); lv == 0 {
p.Fill(b, k.SurfaceHover, 2)
} else {
p.Fill(b, internal.Ramp(k, float64(lv)/4), 2)
}
if d := first.AddDays(i); d.Day == 1 {
chartText(p, d.Format("Jan"), b.X, y0-4, size, k.TextMuted, 0, 1)
}
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 3, 2)
}
}
for row := 0; row < 7; row += 2 {
wd := time.Weekday((int(calendarFirstWeekday(c, opts.WeekStart)) + row) % 7)
chartText(p, wd.String()[:3], x0-4, y0+float32(row)*step+step/2, size, k.TextMuted, 1, 0.5)
}
ly := y0 + 7*step + 8
w, _ := chartText(p, msg(c, "calendar_heatmap.less"), x0, ly, size, k.TextMuted, 0, 0)
for lv := range 5 {
sw := ui.Rect{X: x0 + w + 6 + float32(lv)*(step+2), Y: ly + 2, W: step - 2, H: step - 2}
if lv == 0 {
p.Fill(sw, k.SurfaceHover, 2)
} else {
p.Fill(sw, internal.Ramp(k, float64(lv)/4), 2)
}
}
chartText(p, msg(c, "calendar_heatmap.more"), x0+w+12+5*(step+2), ly, size, k.TextMuted, 0, 0)
}
res2, _ := ch.build(spec)
res.Point, res.changed, res.retry = res2.Point, res2.changed, res2.retry
})
if res.value {
slog.Debug("mujica: calendar_heatmap year", "year", *year)
}
res.Element = wrap
return res
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func calendarValues() map[Date]float64 {
m := map[Date]float64{}
d := Date{Year: 2026, Month: time.January, Day: 1}
for i := range 365 {
if i%3 == 0 {
m[d.AddDays(i)] = float64(i % 11)
}
}
return m
}
func TestCalendarHeatmapKeysAndYear(t *testing.T) {
year := 2026
values := 0
start := WeekStartMonday
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: start})
if r.ValueChanged() {
values++
}
return r
}}
tt := ui.NewTester(h.view, 560, 420)
tabTo(t, tt, "Prayers")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "2026-01-01 · 0" {
t.Fatalf("first day %q", got)
}
tt.Key(0, ui.KeyRight) // a week later
tt.Key(0, ui.KeyDown) // a day later
if got := chartReadout(tt); got != "2026-01-09 · no value" {
t.Fatalf("week and day %q", got)
}
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "2026-12-31 · no value" {
t.Fatalf("End %q", got)
}
if err := tt.Click("Next year"); err != nil {
t.Fatal(err)
}
if year != 2027 || values != 1 || !tt.HasText("2027") {
t.Fatalf("year %d events %d", year, values)
}
}
func TestCalendarHeatmapWeekStartHover(t *testing.T) {
year := 2026 // 1 January 2026 is a Thursday
for _, tc := range []struct {
start WeekStart
row int
}{{WeekStartSunday, 4}, {WeekStartMonday, 3}} {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: tc.start})
}}
tt := ui.NewTester(h.view, 560, 420)
b := h.res.Element.Bounds()
plotY := b.Y + b.H - 20 - 8 - 240 // above the readout line and gap
step := min((b.W-36)/53, float32(240-48)/7)
tt.Move(b.X+32+step/2, plotY+18+float32(tc.row)*step+step/2)
if got := chartReadout(tt); got != "2026-01-01 · 0" {
t.Fatalf("start %d: row %d reads %q", tc.start, tc.row, got)
}
}
}
func TestCalendarHeatmapBadYear(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
year := 0
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CalendarHeatmap(c, &year, nil, CalendarHeatmapOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestCalendarHeatmapSnapshot(t *testing.T) {
snapshot(t, "calendar_heatmap", 600, 260, func(c *ui.Context) {
year := 2026
ui.Column(c).Padding(16).Children(func() {
CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers", Height: 150}, WeekStart: WeekStartMonday})
})
})
}
+248
View File
@@ -0,0 +1,248 @@
package mujica
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CalendarMonthViewOptions configure a CalendarMonthView.
type CalendarMonthViewOptions struct {
// Location is the zone days are cut in; required.
Location *time.Location
// Version must change when events change in place.
Version int
WeekStart WeekStart
// MaxPerDay is rows shown before "+N"; zero means 3.
MaxPerDay int
// Today is marked; zero uses the system clock.
Today Date
Hour12 bool
Disabled bool
Label string
}
// monthDrag is the event id a month view chip drags.
type monthDrag string
// monthDay is one cell's rows of side-by-side events.
type monthDay struct {
rows [][]int
n int
}
// CalendarMonthView shows a month of events; drags reschedule them.
func CalendarMonthView(c *ui.Context, month *YearMonth, events []CalendarEvent, opts CalendarMonthViewOptions) CalendarViewResult {
loc := eventLocation("CalendarMonthView", opts.Location)
if month.IsZero() || month.Month < time.January || month.Month > time.December {
panic(fmt.Sprintf("mujica: CalendarMonthView month %v is not valid", *month))
}
if opts.MaxPerDay < 0 {
panic("mujica: CalendarMonthView MaxPerDay is negative")
}
maxRows := opts.MaxPerDay
if maxRows == 0 {
maxRows = 3
}
eventsCheck("CalendarMonthView", events)
k := Tokens(c)
d := Density(c)
today := datetimeToday(c, opts.Today)
first := calendarFirstWeekday(c, opts.WeekStart)
start := Date{month.Year, month.Month, 1}
start = start.AddDays(-((int(start.Weekday()) - int(first) + 7) % 7))
label := opts.Label
if label == "" {
label = datetimeMonthTitle(c, month.Year, month.Month)
}
r := CalendarViewResult{}
root := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
problem := ui.Local(root, "month-view-problem", func() string { return "" })
more := ui.Local(root, "month-view-more", func() Date { return Date{} })
days := eventsDerived(root, "month-view-days", events, opts.Version, eventDayStart(start, loc), eventDayStart(start.AddDays(42), loc),
func(hits []int) []monthDay { return monthLayout(events, hits, start, loc) })
disabled := func() bool { return root.IsDisabled() }
apply := func(ch CalendarChange, p string) {
*problem = p
if p == "" && !disabled() {
slog.Debug("mujica: calendar_month_view move", "id", ch.ID, "start", ch.Start)
r.change = &ch
}
}
root.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(d.Unit()).Children(func() {
if iconAction(c, icons.Must("chevron-left"), msg(c, "calendar.prev_month")).Clicked() && !disabled() {
*month = YearMonth{start.AddDays(7).AddMonths(-1).Year, start.AddDays(7).AddMonths(-1).Month}
}
ui.Text(c, datetimeMonthTitle(c, month.Year, month.Month)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).
TextColor(k.Text).Grow(1).TextAlign(ui.Center)
if iconAction(c, icons.Must("chevron-right"), msg(c, "calendar.next_month")).Clicked() && !disabled() {
*month = YearMonth{start.AddDays(7).AddMonths(1).Year, start.AddDays(7).AddMonths(1).Month}
}
})
ui.Row(c).Children(func() {
for i := range 7 {
wd := (int(first) + i) % 7
ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Grow(1).Basis(0).TextAlign(ui.Center).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Label(msg(c, "calendar.weekday_long_"+strconv.Itoa(wd)))
}
})
ui.Column(c).Border(theme.BorderWidth, k.Border).Dividers(theme.BorderWidth, k.Border).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() {
for w := range 6 {
ui.Row(c).Dividers(theme.BorderWidth, k.Border).Children(func() {
for i := range 7 {
at := w*7 + i
day := start.AddDays(at)
monthCell(c, day, *month, today, days[at], events, maxRows, loc, opts, &r, more, apply)
}
})
}
})
datetimeError(c, *problem)
})
return r
}
// monthLayout buckets events per day, concurrent ones side by side.
func monthLayout(events []CalendarEvent, hits []int, start Date, loc *time.Location) []monthDay {
out := make([]monthDay, 42)
for i := range out {
from, to := eventDayStart(start.AddDays(i), loc), eventDayStart(start.AddDays(i+1), loc)
var idx []int
var spans []eventSpan
for _, h := range hits {
ev := events[h]
if ev.Start.Before(to) && ev.End.After(from) {
idx = append(idx, h)
spans = append(spans, eventSpan{float64(max(ev.Start.Unix(), from.Unix())), float64(min(ev.End.Unix(), to.Unix()))})
}
}
col, _ := eventColumns(spans)
for j, h := range idx {
if col[j] == 0 || len(out[i].rows) == 0 {
out[i].rows = append(out[i].rows, []int{h})
} else {
last := len(out[i].rows) - 1
out[i].rows[last] = append(out[i].rows[last], h)
}
}
out[i].n = len(idx)
}
return out
}
// monthCell draws one day: number, chips and "+N".
func monthCell(c *ui.Context, day Date, month YearMonth, today Date, lay monthDay, events []CalendarEvent, maxRows int,
loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, more *Date, apply func(CalendarChange, string)) {
k := Tokens(c)
d := Density(c)
cell := ui.Column(c).Grow(1).Basis(0).MinWidth(0).MinHeight(d.ControlHeight() * 3.5).Padding(d.Unit()).Gap(d.Unit()).Clip()
if id, ok := ui.Drop[monthDrag](cell); ok && !cell.IsDisabled() {
for _, ev := range events {
if ev.ID == string(id) {
apply(eventShift(c, ev, monthDays(ev.Start.In(loc), day), loc))
}
}
}
if _, over := ui.DragOver[monthDrag](cell); over {
cell.Background(k.Selection)
cell.DrawOver(func(p *ui.Painter, rr ui.Rect) { datetimeSelectedMark(p, rr, k.Focus) })
}
cell.Children(func() {
fg := k.Text
if day.Month != month.Month {
fg = k.TextMuted
}
num := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.MinHitSize / 2).Label(datetimeDayLabel(c, day))
internal.Ring(num, k.Focus, theme.MinHitSize/2)
if day == today {
num.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
} else if num.Hovered() {
num.Background(k.SurfaceHover)
}
num.Children(func() {
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(fg).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
if day == today {
t.Bold()
}
})
if num.Clicked() && !num.IsDisabled() {
r.day = day
}
shown := 0
for i, row := range lay.rows {
if i >= maxRows {
break
}
ui.Row(c).Gap(d.Unit() / 2).Children(func() {
for _, h := range row {
monthChip(c, events[h], loc, opts, r, apply)
shown++
}
})
}
if hidden := lay.n - shown; hidden > 0 {
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Justify(ui.Start).PaddingX(d.Unit()).Radius(theme.ControlRadius).
Label(calendarCount(c, "calendar_month_view.more_label", hidden))
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "calendar_month_view.more"), hidden)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
})
if b.Clicked() && !b.IsDisabled() {
*more = day
}
open := *more == day
ui.PopoverBase(c, b, &open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayEnter(c, panel))
_, wh := c.Size()
panel.Padding(d.Unit() * 2).Gap(d.Unit()).MinWidth(d.ControlHeight() * 6).MaxHeight(wh / 2)
ui.Text(c, datetimeDayLabel(c, day)).Font(theme.SerifFont).TextColor(k.Text)
ui.Scroll(c).Shrink(1).Children(func() {
ui.Column(c).Gap(d.Unit()).Children(func() {
for _, row := range lay.rows {
for _, h := range row {
monthChip(c, events[h], loc, opts, r, apply)
}
}
})
})
})
if !open && *more == day {
*more = Date{}
}
}
})
}
// monthChip is a draggable chip; Alt+arrows move it.
func monthChip(c *ui.Context, ev CalendarEvent, loc *time.Location, opts CalendarMonthViewOptions, r *CalendarViewResult, apply func(CalendarChange, string)) {
chip := EventChip(c, ev, EventChipOptions{Location: loc, Hour12: opts.Hour12, Inline: true}).Grow(1).Basis(0).MinWidth(theme.MinHitSize)
if chip.IsDisabled() {
return
}
chip.Drag(monthDrag(ev.ID))
if chip.Clicked() {
r.opened = ev.ID
}
for key, days := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1, ui.KeyUp: -7, ui.KeyDown: 7} {
if chip.Shortcut(ui.Alt, key) {
apply(eventShift(c, ev, days, loc))
}
}
}
// monthDays counts calendar days from t's day to day.
func monthDays(t time.Time, day Date) int {
return int(day.civil().Sub(DateOf(t).civil()).Hours() / 24)
}
+171
View File
@@ -0,0 +1,171 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type monthProbe struct {
month YearMonth
events []CalendarEvent
opts CalendarMonthViewOptions
opened []string
changes []CalendarChange
days []Date
disabled bool
}
func (p *monthProbe) tester() *ui.Tester {
if p.month.IsZero() {
p.month = YearMonth{2026, time.October}
}
p.opts.Location, p.opts.Today = calTokyo, calendarToday
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := p.opts
o.Disabled = p.disabled
r := CalendarMonthView(c, &p.month, p.events, o)
if id, ok := r.Opened(); ok {
p.opened = append(p.opened, id)
}
if ch, ok := r.Changed(); ok {
p.changes = append(p.changes, ch)
}
if d, ok := r.DayChosen(); ok {
p.days = append(p.days, d)
}
}, 1000, 900)
}
func TestCalendarMonthViewOverflowAndPopover(t *testing.T) {
p := &monthProbe{}
for i := range 5 {
p.events = append(p.events, calEvent(fmt.Sprint(i), 14, 8+2*i, 0, 60))
}
tt := p.tester()
if !tt.HasText("+2") || tt.HasText("Event 3") {
t.Fatalf("3 rows then +2 expected: %v", tt.Texts())
}
_ = tt.Click("2 more events")
if !tt.HasText("Event 4") {
t.Fatal("+N should list the hidden events")
}
_ = tt.Click("Event 4, 16:00 – 17:00")
if len(p.opened) != 1 || p.opened[0] != "4" {
t.Fatalf("opened %v", p.opened)
}
}
func TestCalendarMonthViewOverlapSideBySide(t *testing.T) {
p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 120), calEvent("b", 14, 10, 0, 60), calEvent("c", 14, 15, 0, 60)}}
tt := p.tester()
a, _ := tt.Find("Event a, 09:00 – 11:00")
b, _ := tt.Find("Event b, 10:00 – 11:00")
cc, _ := tt.Find("Event c, 15:00 – 16:00")
if a.Y != b.Y || b.X <= a.X || cc.Y <= a.Y {
t.Fatalf("overlapping events should share a row: %v %v %v", a, b, cc)
}
}
func TestCalendarMonthViewDragAndKeys(t *testing.T) {
p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 30, 60)}}
tt := p.tester()
chip, _ := tt.Find("Event a, 09:30 – 10:30")
target, _ := tt.Find(datetimeTestLabel(oct(16)))
tt.Press(chip.X+10, chip.Y+5)
for i := 1; i <= 4; i++ {
f := float32(i) / 4
tt.Move(chip.X+10+(target.X+20-chip.X-10)*f, chip.Y+5+(target.Y+40-chip.Y-5)*f)
}
tt.Release(target.X+20, target.Y+40)
want := time.Date(2026, time.October, 16, 9, 30, 0, 0, calTokyo)
if len(p.changes) != 1 || !p.changes[0].Start.Equal(want) || p.changes[0].End.Sub(p.changes[0].Start) != time.Hour {
t.Fatalf("drop gave %+v", p.changes)
}
_ = tt.Click("Event a, 09:30 – 10:30")
tt.Key(ui.Alt, ui.KeyDown)
if len(p.changes) != 2 || !p.changes[1].Start.Equal(time.Date(2026, time.October, 21, 9, 30, 0, 0, calTokyo)) {
t.Fatalf("Alt+Down gave %+v", p.changes)
}
}
func TestCalendarMonthViewDSTGapReported(t *testing.T) {
ny, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
ev := CalendarEvent{ID: "g", Title: "Early", Start: time.Date(2026, time.March, 7, 2, 30, 0, 0, ny)}
ev.End = ev.Start.Add(time.Hour)
p := &monthProbe{month: YearMonth{2026, time.March}, events: []CalendarEvent{ev}}
p.opts.Location = ny
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ch, ok := CalendarMonthView(c, &p.month, p.events, CalendarMonthViewOptions{Location: ny, Today: Date{2026, time.March, 1}}).Changed(); ok {
p.changes = append(p.changes, ch)
}
}, 1000, 900)
_ = tt.Click("Early, 02:30 – 03:30")
tt.Key(ui.Alt, ui.KeyRight)
if len(p.changes) != 0 || !tt.HasText("2026-03-08 02:30 does not exist in America/New_York") {
t.Fatalf("gap not reported: %v", tt.Texts())
}
}
func TestCalendarMonthViewNavigationDayAndDisabled(t *testing.T) {
p := &monthProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60)}}
tt := p.tester()
_ = tt.Click(datetimeTestLabel(oct(20)))
_ = tt.Click("Next month")
if len(p.days) != 1 || p.days[0] != oct(20) || p.month != (YearMonth{2026, time.November}) {
t.Fatalf("day %v month %v", p.days, p.month)
}
p.month, p.disabled = YearMonth{2026, time.October}, true
tt.Frame()
_ = tt.Click("Next month")
_ = tt.Click("Event a, 09:00 – 10:00")
tt.Key(ui.Alt, ui.KeyRight)
if p.month != (YearMonth{2026, time.October}) || len(p.opened) != 0 || len(p.changes) != 0 {
t.Fatal("disabled view reacted")
}
}
func TestCalendarMonthViewLargeDataAndInvalid(t *testing.T) {
p := &monthProbe{}
base := time.Date(2000, 1, 1, 9, 0, 0, 0, calTokyo)
for i := range 100000 {
s := base.Add(time.Duration(i) * 3 * time.Hour)
p.events = append(p.events, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(time.Hour)})
}
tt := p.tester()
if n := len(tt.Texts()); n > 800 {
t.Fatalf("built %d labels for one month", n)
}
for _, bad := range []func(){
func() { q := &monthProbe{events: []CalendarEvent{{ID: "x", Start: base, End: base}}}; q.tester() },
func() { q := &monthProbe{opts: CalendarMonthViewOptions{MaxPerDay: -1}}; q.tester() },
} {
func() {
defer func() {
if recover() == nil {
t.Error("expected panic")
}
}()
bad()
}()
}
}
func TestCalendarMonthViewSnapshot(t *testing.T) {
m := YearMonth{2026, time.October}
evs := []CalendarEvent{calEvent("1", 6, 9, 0, 60), calEvent("2", 14, 9, 0, 120), calEvent("3", 14, 10, 0, 30),
calEvent("4", 14, 13, 0, 30), calEvent("5", 14, 15, 0, 30), calEvent("6", 14, 17, 0, 30), calEvent("7", 22, 18, 0, 60)}
evs[1].Color, evs[2].Color, evs[6].Color = EventInfo, EventSuccess, EventWarning
snapshot(t, "calendar_month_view", 900, 860, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CalendarMonthView(c, &m, evs, CalendarMonthViewOptions{Location: calTokyo, Today: calendarToday})
})
})
}
+369
View File
@@ -0,0 +1,369 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// timeGridConfig is what the week and day views share.
type timeGridConfig struct {
name string
loc *time.Location
version int
slot int
today Date
now time.Time
hour12 bool
}
// timeGesture is a drag in progress on the time grid.
type timeGesture struct {
active bool
kind CalendarChangeKind
id string
day int
anchor int
dx, dy float32
cur int
}
// timeGridDay is one column's timed and all-day events.
type timeGridDay struct {
timed []timePlaced
allDay []int
}
type timePlaced struct {
ev int
from, to int
col, cols int
}
// timeGridSlot checks and defaults the slot length.
func timeGridSlot(name string, slot int) int {
if slot == 0 {
return 30
}
if slot < 0 || 60%slot != 0 && slot%60 != 0 {
panic(fmt.Sprintf("mujica: %s SlotMinutes %d must divide an hour", name, slot))
}
return slot
}
// timeGrid draws day columns and turns gestures into changes.
func timeGrid(c *ui.Context, root *ui.Element, days []Date, events []CalendarEvent, cfg timeGridConfig, r *CalendarViewResult) {
k := Tokens(c)
d := Density(c)
hourH := d.ControlHeight() * 2
slotH := hourH * float32(cfg.slot) / 60
g := *ui.Local(root, "time-grid-gesture", func() *timeGesture { return &timeGesture{} })
morning := func() []int {
v := make([]int, len(days))
for i := range v {
v[i] = 8 * 60 / cfg.slot
}
return v
}
cursor := ui.Local(root, "time-grid-cursor", morning)
if len(*cursor) != len(days) {
*cursor = morning()
}
problem := ui.Local(root, "time-grid-problem", func() string { return "" })
scroll := ui.Local(root, "time-grid-scroll", func() *ui.ScrollState { return &ui.ScrollState{Y: hourH * 8} })
lay := eventsDerived(root, "time-grid-layout", events, cfg.version, eventDayStart(days[0], cfg.loc),
eventDayStart(days[len(days)-1].AddDays(1), cfg.loc), func(hits []int) []timeGridDay { return timeGridLayout(events, hits, days, cfg.loc) })
apply := func(ch CalendarChange, p string) {
*problem = p
if p == "" && !root.IsDisabled() {
slog.Debug("mujica: "+cfg.name+" change", "kind", ch.Kind, "id", ch.ID, "start", ch.Start, "end", ch.End)
r.change = &ch
}
}
gutter := d.ControlHeight() * 1.6
ui.Row(c).Children(func() {
ui.Box(c).Width(gutter).Shrink(0)
for _, day := range days {
wd := msg(c, "calendar.weekday_"+strconv.Itoa(int(day.Weekday())))
b := ui.ButtonBase(c).Grow(1).Basis(0).MinHeight(theme.MinHitSize).Gap(d.Unit()).Radius(theme.ControlRadius).Label(datetimeDayLabel(c, day))
internal.Ring(b, k.Focus, theme.ControlRadius)
if day == cfg.today {
b.Background(k.Selection)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Text(c, wd).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
t := ui.Text(c, strconv.Itoa(day.Day)).TextColor(k.Text).FontFeatures("tnum")
if day == cfg.today {
t.Bold().TextColor(k.AccentText)
}
})
if b.Clicked() && !b.IsDisabled() {
r.day = day
}
}
})
ui.Row(c).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Text(c, msg(c, "calendar_week_view.all_day")).Width(gutter).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).
Padding(d.Unit())
for i, day := range days {
cell := ui.Column(c).Grow(1).Basis(0).MinWidth(0).MinHeight(theme.MinHitSize).Padding(d.Unit() / 2).Gap(d.Unit() / 2)
if id, ok := ui.Drop[monthDrag](cell); ok && !cell.IsDisabled() {
for _, ev := range events {
if ev.ID == string(id) {
apply(eventShift(c, ev, monthDays(ev.Start.In(cfg.loc), day), cfg.loc))
}
}
}
cell.Children(func() {
for _, h := range lay[i].allDay {
monthChip(c, events[h], cfg.loc, CalendarMonthViewOptions{Hour12: cfg.hour12}, r, apply)
}
})
}
})
ui.Scroll(c).TrackScroll(*scroll).Height(hourH * 9).Children(func() {
ui.Row(c).Children(func() {
ui.Column(c).Width(gutter).Shrink(0).Children(func() {
for h := range 24 {
ui.Text(c, timeText(c, TimeOfDay{h, 0, 0}, timeConfig{hour12: cfg.hour12})).Height(hourH).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum").PaddingX(d.Unit())
}
})
for i, day := range days {
timeColumn(c, i, day, lay[i], events, cfg, hourH, slotH, g, &(*cursor)[i], r, apply)
}
})
})
datetimeError(c, *problem)
}
// timeGridLayout places each day's events in minutes with side-by-side columns.
func timeGridLayout(events []CalendarEvent, hits []int, days []Date, loc *time.Location) []timeGridDay {
out := make([]timeGridDay, len(days))
for i, day := range days {
from, to := eventDayStart(day, loc), eventDayStart(day.AddDays(1), loc)
var spans []eventSpan
for _, h := range hits {
ev := events[h]
if !ev.Start.Before(to) || !ev.End.After(from) {
continue
}
if ev.AllDay || ev.End.Sub(ev.Start) >= 24*time.Hour {
out[i].allDay = append(out[i].allDay, h)
continue
}
a, b := 0, 24*60
if !ev.Start.Before(from) {
a = timeWallMinutes(ev.Start.In(loc))
}
if ev.End.Before(to) {
b = timeWallMinutes(ev.End.In(loc))
}
b = max(b, a+1)
out[i].timed = append(out[i].timed, timePlaced{ev: h, from: a, to: b})
spans = append(spans, eventSpan{float64(a), float64(b)})
}
col, cols := eventColumns(spans)
for j := range out[i].timed {
out[i].timed[j].col, out[i].timed[j].cols = col[j], cols[j]
}
}
return out
}
func timeWallMinutes(t time.Time) int { return t.Hour()*60 + t.Minute() }
// timeColumn draws one day: lines, events, preview and cursor.
func timeColumn(c *ui.Context, i int, day Date, lay timeGridDay, events []CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
g *timeGesture, cursor *int, r *CalendarViewResult, apply func(CalendarChange, string)) {
k := Tokens(c)
slots := 24 * 60 / cfg.slot
col := ui.Box(c).Grow(1).Basis(0).MinWidth(0).Height(hourH*24).Focusable().
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "calendar_week_view.column"), datetimeDayLabel(c, day))).BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.Border)
disabled := col.IsDisabled()
slotOf := func(y float32) int { return max(0, min(slots-1, int(y/slotH))) }
if !disabled {
switch {
case col.Shortcut(0, ui.KeyUp):
*cursor = max(0, *cursor-1)
case col.Shortcut(0, ui.KeyDown):
*cursor = min(slots-1, *cursor+1)
case col.Shortcut(0, ui.KeyEnter):
apply(timeCreate(c, day, *cursor, *cursor+1, cfg))
}
_, py, _ := col.PointerPosition()
dx, dy, pressing := col.Dragged()
if pressing && !g.active {
*g = timeGesture{active: true, kind: CalendarCreate, day: i, anchor: slotOf(py - dy), cur: slotOf(py)}
}
if g.active && g.kind == CalendarCreate && g.day == i {
if pressing {
g.dx, g.dy = g.dx+dx, g.dy+dy
g.cur = slotOf(py)
} else {
a, b := min(g.anchor, g.cur), max(g.anchor, g.cur)+1
if math.Abs(float64(g.dy)) >= 4 {
apply(timeCreate(c, day, a, b, cfg))
}
*cursor = g.anchor
*g = timeGesture{}
}
}
}
focused := col.Focused()
cur := *cursor
creating := g.active && g.kind == CalendarCreate && g.day == i
ga, gb := min(g.anchor, g.cur), max(g.anchor, g.cur)+1
col.Draw(func(p *ui.Painter, rr ui.Rect) {
for h := 1; h < 24; h++ {
internal.Rule(p, rr.X, rr.X+rr.W, rr.Y+float32(h)*hourH, k.Border)
}
if focused {
box := ui.Rect{X: rr.X + 2, Y: rr.Y + float32(cur)*slotH + 1, W: rr.W - 4, H: slotH - 2}
p.Fill(box, k.Selection, theme.ControlRadius)
datetimeSelectedMark(p, box, k.Focus)
}
if creating {
box := ui.Rect{X: rr.X + 2, Y: rr.Y + float32(ga)*slotH, W: rr.W - 4, H: float32(gb-ga) * slotH}
p.Fill(box, k.Selection, theme.ControlRadius)
p.StrokeDashed(box, k.AccentText, theme.ControlRadius, theme.BorderWidth)
}
})
col.Children(func() {
now := cfg.now
if now.IsZero() {
now = c.Now()
}
for _, pl := range lay.timed {
timeChip(c, i, pl, events[pl.ev], cfg, hourH, slotH, g, r, apply)
}
if day == DateOf(now.In(cfg.loc)) {
CurrentTimeIndicator(c, CurrentTimeIndicatorOptions{Location: cfg.loc, Day: day, Now: cfg.now, Hour12: cfg.hour12})
}
})
}
// timeChip places an event; drag moves, bottom edge resizes.
func timeChip(c *ui.Context, i int, pl timePlaced, ev CalendarEvent, cfg timeGridConfig, hourH, slotH float32,
g *timeGesture, r *CalendarViewResult, apply func(CalendarChange, string)) {
from, to := float32(pl.from)*hourH/60, float32(pl.to)*hourH/60
mine := g.active && g.id == ev.ID && g.day == i
snap := func(v float32) int { return int(math.Round(float64(v/slotH))) * cfg.slot }
var shiftY, growY float32
if mine && g.kind == CalendarMove {
shiftY = float32(snap(g.dy)) * hourH / 60
}
if mine && g.kind == CalendarResize {
growY = float32(snap(g.dy)) * hourH / 60
}
box := ui.Box(c).Key(ev.ID).Absolute().Top(from + shiftY).Height(max(to-from+growY, theme.MinHitSize)).
LeftPercent(100 * float32(pl.col) / float32(pl.cols)).WidthPercent(100 / float32(pl.cols)).Padding(1)
box.Children(func() {
chip := EventChip(c, ev, EventChipOptions{Location: cfg.loc, Hour12: cfg.hour12, Selected: mine, Inline: to-from < hourH*0.75}).Fill()
handle := ui.Box(c).Absolute().Left(0).Right(0).Bottom(0).Height(6).Cursor(ui.CursorResizeNS).Label(fmt.Sprintf(msg(c, "calendar_week_view.resize"), ev.Title))
if chip.IsDisabled() {
return
}
if chip.Clicked() && !mine {
r.opened = ev.ID
}
for key, dm := range map[ui.Key]int{ui.KeyUp: -cfg.slot, ui.KeyDown: cfg.slot} {
if chip.Shortcut(ui.Alt, key) {
apply(timeMove(c, ev, 0, dm, cfg))
}
if chip.Shortcut(ui.Alt|ui.Shift, key) {
apply(timeResize(c, ev, dm, cfg))
}
}
for key, dd := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1} {
if chip.Shortcut(ui.Alt, key) {
apply(timeMove(c, ev, dd, 0, cfg))
}
}
for _, part := range []struct {
e *ui.Element
kind CalendarChangeKind
}{{chip, CalendarMove}, {handle, CalendarResize}} {
dx, dy, ok := part.e.Dragged()
switch {
case ok && (!g.active && (dx != 0 || dy != 0) || mine && g.kind == part.kind):
if !g.active {
*g = timeGesture{active: true, kind: part.kind, id: ev.ID, day: i}
}
g.dx, g.dy = g.dx+dx, g.dy+dy
case !ok && mine && g.kind == part.kind:
dm := snap(g.dy)
if part.kind == CalendarResize {
if dm != 0 {
apply(timeResize(c, ev, dm, cfg))
}
} else if dd := int(math.Round(float64(g.dx / max(chip.Bounds().W*float32(pl.cols), 1)))); dm != 0 || dd != 0 {
apply(timeMove(c, ev, dd, dm, cfg))
}
*g = timeGesture{}
}
}
})
}
// timeCreate makes an event over slots [a, b).
func timeCreate(c *ui.Context, day Date, a, b int, cfg timeGridConfig) (CalendarChange, string) {
s, sn := timeAt(day, a*cfg.slot, cfg.loc)
e, en := timeAt(day, b*cfg.slot, cfg.loc)
if p := eventWallProblem(c, timeDay(day, a*cfg.slot), timeWall(a*cfg.slot), cfg.loc, sn); p != "" {
return CalendarChange{}, p
}
if p := eventWallProblem(c, timeDay(day, b*cfg.slot), timeWall(b*cfg.slot), cfg.loc, en); p != "" {
return CalendarChange{}, p
}
return CalendarChange{Kind: CalendarCreate, Start: s, End: e}, ""
}
// timeMove shifts ev by days and minutes of wall time.
func timeMove(c *ui.Context, ev CalendarEvent, days, minutes int, cfg timeGridConfig) (CalendarChange, string) {
s := ev.Start.In(cfg.loc)
day := DateOf(s).AddDays(days)
m := timeWallMinutes(s) + minutes
at, n := timeAt(day, m, cfg.loc)
if p := eventWallProblem(c, timeDay(day, m), timeWall(m), cfg.loc, n); p != "" {
return CalendarChange{}, p
}
return CalendarChange{Kind: CalendarMove, ID: ev.ID, Start: at, End: at.Add(ev.End.Sub(ev.Start))}, ""
}
// timeResize moves ev's end by minutes of wall time.
func timeResize(c *ui.Context, ev CalendarEvent, minutes int, cfg timeGridConfig) (CalendarChange, string) {
e := ev.End.In(cfg.loc)
day, m := DateOf(e), timeWallMinutes(e)+minutes
at, n := timeAt(day, m, cfg.loc)
if p := eventWallProblem(c, timeDay(day, m), timeWall(m), cfg.loc, n); p != "" {
return CalendarChange{}, p
}
if !at.After(ev.Start) {
return CalendarChange{}, msg(c, "calendar_week_view.too_short")
}
return CalendarChange{Kind: CalendarResize, ID: ev.ID, Start: ev.Start, End: at}, ""
}
// timeAt resolves minute m of day as wall time.
func timeAt(day Date, m int, loc *time.Location) (time.Time, int) {
return dateTimeResolve(timeDay(day, m), timeWall(m), loc)
}
func timeDay(day Date, m int) Date {
return day.AddDays(int(math.Floor(float64(m) / (24 * 60))))
}
func timeWall(m int) TimeOfDay {
m = ((m % (24 * 60)) + 24*60) % (24 * 60)
return TimeOfDay{m / 60, m % 60, 0}
}
+80
View File
@@ -0,0 +1,80 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// CalendarWeekViewOptions configure a CalendarWeekView.
type CalendarWeekViewOptions struct {
// Location is the zone days are cut in; required.
Location *time.Location
// Version must change when events change in place.
Version int
WeekStart WeekStart
// SlotMinutes is the grid step; zero means 30.
SlotMinutes int
// Today is marked; zero uses the system clock.
Today Date
// Now places the current-time line; zero uses the system clock.
Now time.Time
Hour12 bool
Disabled bool
Label string
}
// CalendarWeekView shows *week as a time grid; drags edit events.
func CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult {
datetimeCheckDate("CalendarWeekView week", *week)
if week.IsZero() {
panic("mujica: CalendarWeekView needs a week")
}
cfg := timeGridConfig{name: "calendar_week_view", loc: eventLocation("CalendarWeekView", opts.Location), version: opts.Version,
slot: timeGridSlot("CalendarWeekView", opts.SlotMinutes), today: datetimeToday(c, opts.Today), now: opts.Now, hour12: opts.Hour12}
eventsCheck("CalendarWeekView", events)
first := calendarFirstWeekday(c, opts.WeekStart)
start := week.AddDays(-((int(week.Weekday()) - int(first) + 7) % 7))
days := make([]Date, 7)
for i := range days {
days[i] = start.AddDays(i)
}
title := weekViewTitle(c, start, start.AddDays(6))
label := opts.Label
if label == "" {
label = title
}
r := CalendarViewResult{}
root := ui.Column(c).Gap(Density(c).Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = root
root.Children(func() {
calendarViewHeader(c, title, msg(c, "calendar_week_view.prev"), msg(c, "calendar_week_view.next"), func(dir int) {
*week = week.AddDays(7 * dir)
})
timeGrid(c, root, days, events, cfg, &r)
})
return r
}
// calendarViewHeader is the title row with previous and next buttons.
func calendarViewHeader(c *ui.Context, title, prev, next string, step func(dir int)) {
k := Tokens(c)
ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() {
if b := iconAction(c, icons.Must("chevron-left"), prev); b.Clicked() && !b.IsDisabled() {
step(-1)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).TextAlign(ui.Center)
if b := iconAction(c, icons.Must("chevron-right"), next); b.Clicked() && !b.IsDisabled() {
step(1)
}
})
}
// weekViewTitle names a span of days.
func weekViewTitle(c *ui.Context, a, b Date) string {
return fmt.Sprintf(msg(c, "calendar_week_view.title"), datetimeMonthName(c, a.Month), a.Day, datetimeMonthName(c, b.Month), b.Day, b.Year)
}
+175
View File
@@ -0,0 +1,175 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type weekProbe struct {
week Date
events []CalendarEvent
opts CalendarWeekViewOptions
changes []CalendarChange
opened []string
disabled bool
}
func (p *weekProbe) tester() *ui.Tester {
if p.week.IsZero() {
p.week = oct(14)
}
p.opts.Location, p.opts.Today = calTokyo, calendarToday
p.opts.Now = time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
o := p.opts
o.Disabled = p.disabled
r := CalendarWeekView(c, &p.week, p.events, o)
if ch, ok := r.Changed(); ok {
p.changes = append(p.changes, ch)
}
if id, ok := r.Opened(); ok {
p.opened = append(p.opened, id)
}
}, 1100, 900)
}
func calDrag(tt *ui.Tester, x0, y0, x1, y1 float32) {
tt.Press(x0, y0)
for i := 1; i <= 4; i++ {
f := float32(i) / 4
tt.Move(x0+(x1-x0)*f, y0+(y1-y0)*f)
}
tt.Release(x1, y1)
}
func calAt(t *testing.T, h, m int, day int) time.Time {
t.Helper()
return time.Date(2026, time.October, day, h, m, 0, 0, calTokyo)
}
func TestCalendarWeekViewDragCreateAndKeyboardCreate(t *testing.T) {
p := &weekProbe{}
tt := p.tester()
col, ok := tt.Find("Schedule, " + datetimeTestLabel(oct(15)))
if !ok {
t.Fatal("no column")
}
hourH := float32(72)
// Drag 9:00 to 10:30; the visible top is 8:00.
y9 := col.Y + hourH + 5
calDrag(tt, col.X+20, y9, col.X+20, y9+hourH*1.5)
if len(p.changes) != 1 || p.changes[0].Kind != CalendarCreate || !p.changes[0].Start.Equal(calAt(t, 9, 0, 15)) || !p.changes[0].End.Equal(calAt(t, 11, 0, 15)) {
t.Fatalf("drag create %+v", p.changes)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(p.changes) != 2 || !p.changes[1].Start.Equal(calAt(t, 9, 30, 15)) || !p.changes[1].End.Equal(calAt(t, 10, 0, 15)) {
t.Fatalf("keyboard create %+v", p.changes)
}
}
func TestCalendarWeekViewMoveResizeAndOverlap(t *testing.T) {
p := &weekProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60), calEvent("b", 14, 9, 30, 60)}}
tt := p.tester()
a, _ := tt.Find("Event a, 09:00 – 10:00")
b, _ := tt.Find("Event b, 09:30 – 10:30")
if b.X <= a.X || b.X-a.X < a.W/2 || b.Y <= a.Y {
t.Fatalf("overlapping events should sit side by side: %v %v", a, b)
}
calDrag(tt, a.X+10, a.Y+20, a.X+10, a.Y+20+72)
if len(p.changes) != 1 || p.changes[0].Kind != CalendarMove || !p.changes[0].Start.Equal(calAt(t, 10, 0, 14)) || !p.changes[0].End.Equal(calAt(t, 11, 0, 14)) {
t.Fatalf("move %+v", p.changes)
}
h, _ := tt.Find("Resize Event b")
calDrag(tt, h.X+10, h.Y+3, h.X+10, h.Y+3+36)
if len(p.changes) != 2 || p.changes[1].Kind != CalendarResize || !p.changes[1].End.Equal(calAt(t, 11, 0, 14)) {
t.Fatalf("resize %+v", p.changes)
}
_ = tt.Click("Event a, 09:00 – 10:00")
if len(p.opened) != 1 {
t.Fatalf("click should open: %v", p.opened)
}
tt.Key(ui.Alt, ui.KeyRight)
tt.Key(ui.Alt, ui.KeyDown)
tt.Key(ui.Alt|ui.Shift, ui.KeyUp)
if len(p.changes) != 5 || !p.changes[2].Start.Equal(calAt(t, 9, 0, 15)) || !p.changes[3].Start.Equal(calAt(t, 9, 30, 14)) ||
!p.changes[4].End.Equal(calAt(t, 9, 30, 14)) {
t.Fatalf("keyboard changes %+v", p.changes[2:])
}
}
func TestCalendarWeekViewAllDayWeekStartAndNav(t *testing.T) {
ev := calEvent("h", 16, 0, 0, 24*60)
ev.AllDay, ev.Title = true, "Holiday"
p := &weekProbe{events: []CalendarEvent{ev}, opts: CalendarWeekViewOptions{WeekStart: WeekStartMonday}}
tt := p.tester()
if !tt.HasText("October 12 – October 18, 2026") || !tt.HasText("All day") || !tt.HasText("Holiday") {
t.Fatalf("texts %v", tt.Texts()[:12])
}
_ = tt.Click("Holiday, All day")
tt.Key(ui.Alt, ui.KeyRight)
if len(p.changes) != 1 || !p.changes[0].Start.Equal(calAt(t, 0, 0, 17)) || !p.changes[0].AllDay {
t.Fatalf("all-day move %+v", p.changes)
}
_ = tt.Click("Next week")
if p.week != oct(21) {
t.Fatalf("week %v", p.week)
}
}
func TestCalendarWeekViewDisabledAndInvalid(t *testing.T) {
p := &weekProbe{events: []CalendarEvent{calEvent("a", 14, 9, 0, 60)}, disabled: true}
tt := p.tester()
a, _ := tt.Find("Event a, 09:00 – 10:00")
calDrag(tt, a.X+10, a.Y+20, a.X+10, a.Y+92)
_ = tt.Click("Event a, 09:00 – 10:00")
tt.Key(ui.Alt, ui.KeyDown)
_ = tt.Click("Next week")
if len(p.changes) != 0 || len(p.opened) != 0 || p.week != oct(14) {
t.Fatal("disabled week view reacted")
}
for _, opts := range []CalendarWeekViewOptions{{SlotMinutes: 7}, {SlotMinutes: -15}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: expected panic", opts)
}
}()
q := &weekProbe{opts: opts}
q.tester()
}()
}
}
func TestCalendarWeekViewLargeData(t *testing.T) {
p := &weekProbe{}
base := time.Date(2000, 1, 1, 9, 0, 0, 0, calTokyo)
for i := range 100000 {
s := base.Add(time.Duration(i) * 3 * time.Hour)
p.events = append(p.events, CalendarEvent{ID: fmt.Sprint(i), Title: "E", Start: s, End: s.Add(time.Hour)})
}
tt := p.tester()
if n := len(tt.Texts()); n > 400 {
t.Fatalf("built %d labels for one week", n)
}
}
func TestCalendarWeekViewSnapshot(t *testing.T) {
w := oct(14)
evs := []CalendarEvent{calEvent("1", 12, 9, 0, 60), calEvent("2", 14, 9, 0, 120), calEvent("3", 14, 9, 30, 60),
calEvent("4", 15, 13, 0, 90), calEvent("5", 16, 10, 0, 45)}
evs[1].Color, evs[2].Color, evs[3].Color = EventInfo, EventSuccess, EventWarning
h := calEvent("h", 13, 0, 0, 24*60)
h.AllDay, h.Title = true, "Holiday"
evs = append(evs, h)
snapshot(t, "calendar_week_view", 1000, 860, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CalendarWeekView(c, &w, evs, CalendarWeekViewOptions{Location: calTokyo, Today: calendarToday,
Now: time.Date(2026, time.October, 14, 10, 30, 0, 0, calTokyo)})
})
})
}
+140
View File
@@ -0,0 +1,140 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// StackFrame is one call of a thread's stack, innermost first.
type StackFrame struct {
ID, Name, File string
Line int
}
// DebugThread is a thread and its stack.
type DebugThread struct {
ID, Name string
Frames []StackFrame
}
// CallStackState is the current frame and folded threads.
type CallStackState struct {
// Current is the chosen frame's ID.
Current string
// Collapsed holds folded thread IDs.
Collapsed ui.Selection[string]
rows ui.ListState
selected int
flat []callRow
seen string
folds int
started bool
}
type callRow struct{ thread, frame int }
// CallStackOptions configure a CallStack.
type CallStackOptions struct {
Disabled bool
}
// CallStack lists frames by thread; choosing sets Current.
func CallStack(c *ui.Context, threads []DebugThread, s *CallStackState, opts CallStackOptions) ChoiceResult {
if s == nil {
panic("mujica: CallStack needs a state")
}
s.flat = s.flat[:0]
at := -1
for ti, t := range threads {
s.flat = append(s.flat, callRow{ti, -1})
if s.Collapsed.Has(t.ID) {
continue
}
for fi, f := range t.Frames {
if f.ID == s.Current {
at = len(s.flat)
}
s.flat = append(s.flat, callRow{ti, fi})
}
}
// Choose the row again after outside changes.
if !s.started || s.seen != s.Current || s.folds != s.Collapsed.Len() || s.selected >= len(s.flat) {
s.started, s.seen, s.selected, s.folds = true, s.Current, at, s.Collapsed.Len()
}
d := Density(c)
var res ChoiceResult
res.Element = codePanel(c).Disabled(opts.Disabled)
disabled := res.Element.IsDisabled()
res.Element.Children(func() {
if len(threads) == 0 {
codeCaption(c, msg(c, "call_stack.empty")).Padding(d.Unit() * 4)
return
}
was := s.selected
s.rows.Selected = &s.selected
s.rows.Key = func(i int) any { return s.flat[i] }
s.rows.Label = func(i int) string { return callLabel(threads, s.flat[i]) }
list := codeRows(c, &s.rows, len(s.flat), func(i int) { callRowView(c, threads, s, s.flat[i], disabled) })
if disabled {
s.selected = was
return
}
if s.selected < 0 || s.selected >= len(s.flat) {
return
}
r := s.flat[s.selected]
t := threads[r.thread]
switch {
case r.frame >= 0 && s.selected != was:
s.Current = t.Frames[r.frame].ID
s.seen, res.changed = s.Current, true
slog.Debug("mujica: call_stack frame", "thread", t.ID, "depth", r.frame)
case r.frame < 0 && (list.Submitted() ||
!s.Collapsed.Has(t.ID) && list.Shortcut(0, ui.KeyLeft) || s.Collapsed.Has(t.ID) && list.Shortcut(0, ui.KeyRight)):
callFold(s, t.ID)
}
})
return res
}
func callFold(s *CallStackState, id string) {
if s.Collapsed.Has(id) {
s.Collapsed.Remove(id)
} else {
s.Collapsed.Add(id)
}
slog.Debug("mujica: call_stack fold", "thread", id, "folded", s.Collapsed.Has(id))
}
func callLabel(threads []DebugThread, r callRow) string {
t := threads[r.thread]
if r.frame < 0 {
return t.Name
}
f := t.Frames[r.frame]
return fmt.Sprintf("%s %s:%d", f.Name, path.Base(f.File), f.Line)
}
func callRowView(c *ui.Context, threads []DebugThread, s *CallStackState, r callRow, disabled bool) {
t := threads[r.thread]
if r.frame < 0 {
chev, key := "chevron-down", "search_panel.fold"
if s.Collapsed.Has(t.ID) {
chev, key = "chevron-right", "search_panel.unfold"
}
if iconAction(c, icons.Must(chev), fmt.Sprintf(msg(c, key), t.Name)).Clicked() && !disabled {
callFold(s, t.ID)
}
ui.Text(c, t.Name).FontWeight(600).SingleLine().Grow(1).MinWidth(0)
codeCaption(c, fmt.Sprintf(msg(c, "call_stack.frames"), len(t.Frames))).Shrink(0)
return
}
f := t.Frames[r.frame]
codeMono(c, f.Name).Grow(1).MinWidth(0).Margin(0, 0, 0, 20)
codeCaption(c, fmt.Sprintf("%s:%d", path.Base(f.File), f.Line)).Shrink(0)
}
+104
View File
@@ -0,0 +1,104 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func callStackSample() []DebugThread {
return []DebugThread{
{ID: "t1", Name: "main", Frames: []StackFrame{
{"f1", "court.Seal", "court/seal.go", 14},
{"f2", "court.Decree", "court/decree.go", 88},
{"f3", "main.main", "main.go", 9},
}},
{ID: "t2", Name: "herald worker", Frames: []StackFrame{{"f4", "herald.Run", "herald/run.go", 31}}},
}
}
type callProbe struct {
threads []DebugThread
s CallStackState
disabled bool
changed int
}
func (p *callProbe) view(c *ui.Context) {
Use(c, Settings{})
r := CallStack(c, p.threads, &p.s, CallStackOptions{Disabled: p.disabled})
r.Element.Height(320)
if r.Changed() {
p.changed++
}
}
func TestCallStackChooseFrame(t *testing.T) {
p := &callProbe{threads: callStackSample(), s: CallStackState{Current: "f1"}}
tt := ui.NewTester(p.view, 460, 340)
if p.s.selected != 1 {
t.Fatalf("current frame row %d, want 1", p.s.selected)
}
if err := tt.Click("court.Decree"); err != nil {
t.Fatal(err)
}
if p.s.Current != "f2" || p.changed != 1 {
t.Fatalf("current %q changed %d", p.s.Current, p.changed)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if p.s.Current != "f3" || p.s.selected != 4 {
t.Fatalf("keys: current %q row %d", p.s.Current, p.s.selected)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if p.s.Current != "f4" {
t.Fatalf("keys did not pass the thread header: %q", p.s.Current)
}
}
func TestCallStackFoldThread(t *testing.T) {
p := &callProbe{threads: callStackSample(), s: CallStackState{Current: "f4"}}
tt := ui.NewTester(p.view, 460, 340)
if err := tt.Click("Fold main"); err != nil {
t.Fatal(err)
}
if tt.HasText("court.Seal") || !tt.HasText("herald.Run") || p.s.Current != "f4" {
t.Fatalf("fold: %q current %q", tt.Texts(), p.s.Current)
}
if !tt.HasText("depth 3") {
t.Fatal("thread header lacks its count")
}
}
func TestCallStackCallerSetsCurrent(t *testing.T) {
p := &callProbe{threads: callStackSample()}
tt := ui.NewTester(p.view, 460, 340)
p.s.Current = "f3"
tt.Frame()
if p.s.selected != 3 || p.changed != 0 {
t.Fatalf("row %d changed %d", p.s.selected, p.changed)
}
}
func TestCallStackDisabledAndEmpty(t *testing.T) {
p := &callProbe{threads: callStackSample(), s: CallStackState{Current: "f1"}, disabled: true}
tt := ui.NewTester(p.view, 460, 340)
_ = tt.Click("court.Decree")
if p.s.Current != "f1" {
t.Fatal("disabled stack changed frame")
}
tt = ui.NewTester((&callProbe{}).view, 460, 340)
if !tt.HasText("Not paused") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestCallStackSnapshot(t *testing.T) {
snapshot(t, "call_stack", 420, 230, func(c *ui.Context) {
p := ui.Local(c.Root(), "snap", func() *callProbe { return &callProbe{threads: callStackSample(), s: CallStackState{Current: "f2"}} })
ui.Column(c).Padding(12).Children(func() {
CallStack(c, (*p).threads, &(*p).s, CallStackOptions{}).Element.Height(206)
})
})
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"image"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CameraFrame is the caller's latest camera picture.
type CameraFrame struct {
// Image is the frame; nil shows "No signal".
Image image.Image
// Seq numbers frames; a new number redraws a reused Image.
Seq uint64
}
// CameraPreviewOptions configure a CameraPreview.
type CameraPreviewOptions struct {
// Label names the camera for assistive technology.
Label string
Disabled bool
}
type cameraCache struct {
img image.Image
seq uint64
mirror bool
bmp *ui.Bitmap
}
type cameraKey struct{}
// CameraPreview shows frame at 16:9, mirrored while *mirror.
func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult {
k := Tokens(c)
old := *mirror
label := opts.Label
if label == "" {
label = msg(c, "camera_preview.label")
}
box := ui.Box(c).FillWidth().AspectRatio(16.0 / 9).Background(videoBlack).Radius(theme.CardRadius).Clip().
Center().Focusable().Role(ui.RoleImage).Label(label).Disabled(opts.Disabled)
internal.Ring(box, k.Focus, theme.CardRadius)
cache := ui.Local(box, cameraKey{}, func() cameraCache { return cameraCache{} })
box.Children(func() {
if frame.Image == nil {
box.Description(msg(c, "camera_preview.noSignal"))
ui.Column(c).Gap(8).AlignItems(ui.Center).Children(func() {
ui.Icon(c, mediaCamera).Size(28, 28).TextColor(ui.Hex("#BCAEAA"))
ui.Text(c, msg(c, "camera_preview.noSignal")).TextColor(ui.Hex("#BCAEAA"))
})
} else {
// Copies are made once per frame number, not per paint.
if cache.img != frame.Image || cache.seq != frame.Seq || cache.mirror != *mirror || cache.bmp == nil {
*cache = cameraCache{frame.Image, frame.Seq, *mirror, cameraBitmap(frame.Image, *mirror)}
}
ui.Image(c, cache.bmp).Fit(ui.Contain).Fill()
}
ui.Box(c).Absolute().Top(8).Right(8).Background(ui.RGBA(0, 0, 0, 0.5)).Radius(theme.ControlRadius).Children(func() {
b := mediaToggleAction(c, mediaMirror, msg(c, "camera_preview.mirror"), *mirror)
if b.Clicked() && !b.IsDisabled() {
*mirror = !*mirror
}
})
})
if !box.IsDisabled() && box.Shortcut(0, ui.KeyM) {
*mirror = !*mirror
}
changed := *mirror != old
if changed {
slog.Debug("mujica: camera_preview mirror", "on", *mirror)
}
return ChoiceResult{Element: box, changed: changed}
}
// cameraBitmap copies img into a bitmap, flipped when mirror.
func cameraBitmap(img image.Image, mirror bool) *ui.Bitmap {
if !mirror {
return ui.NewBitmap(img)
}
b := img.Bounds()
out := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
out.Set(b.Max.X-1-x, y-b.Min.Y, img.At(x, y))
}
}
return ui.NewBitmap(out)
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"image"
"testing"
"github.com/egoist/mygo/ui"
)
func cameraTester(frame *CameraFrame, mirror *bool, disabled bool, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(20).Width(320).Children(func() {
if CameraPreview(c, *frame, mirror, CameraPreviewOptions{Disabled: disabled}).Changed() {
*n++
}
})
}, 400, 260)
}
func TestCameraPreviewNoSignalAndMirror(t *testing.T) {
frame, mirror, n := CameraFrame{}, false, 0
tt := cameraTester(&frame, &mirror, false, &n)
if !tt.HasText("No signal") {
t.Fatalf("texts %v", tt.Texts())
}
if err := tt.Click("Mirror"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Camera") {
t.Fatal("preview not focusable")
}
tt.Key(0, ui.KeyM)
if mirror || n != 2 {
t.Fatalf("mirror %v changes %d", mirror, n)
}
frame = CameraFrame{Image: mediaTestImage(32, 18), Seq: 1}
tt.Frame()
if tt.HasText("No signal") {
t.Fatal("frame did not replace No signal")
}
}
func TestCameraPreviewDisabled(t *testing.T) {
frame, mirror, n := CameraFrame{}, false, 0
tt := cameraTester(&frame, &mirror, true, &n)
_ = tt.Click("Mirror")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyM)
if mirror || n != 0 {
t.Fatal("disabled preview flipped")
}
}
func TestCameraBitmapMirrors(t *testing.T) {
img := mediaTestImage(8, 8)
flipped := image.NewRGBA(image.Rect(0, 0, 8, 8))
for y := range 8 {
for x := range 8 {
flipped.Set(7-x, y, img.At(x, y))
}
}
got := ui.Render(func(c *ui.Context) {
ui.Image(c, cameraBitmap(img, true)).Size(8, 8)
}, 8, 8, 1)
want := ui.Render(func(c *ui.Context) {
ui.Image(c, ui.NewBitmap(flipped)).Size(8, 8)
}, 8, 8, 1)
if got.RGBAAt(1, 6) != want.RGBAAt(1, 6) || got.RGBAAt(1, 6) == ui.Render(func(c *ui.Context) {
ui.Image(c, ui.NewBitmap(img)).Size(8, 8)
}, 8, 8, 1).RGBAAt(1, 6) {
t.Fatal("mirrored bitmap is not flipped")
}
}
func TestCameraPreviewSnapshot(t *testing.T) {
img := mediaTestImage(160, 90)
m1, m2 := true, false
snapshot(t, "camera_preview", 560, 180, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(12).Children(func() {
ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{Image: img}, &m1, CameraPreviewOptions{}) })
ui.Column(c).Grow(1).Children(func() { CameraPreview(c, CameraFrame{}, &m2, CameraPreviewOptions{}) })
})
})
}
+94
View File
@@ -0,0 +1,94 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CapabilityCard explains one assistant capability with example prompts.
type CapabilityCard struct {
Title, Description string
Icon *ui.SVG
Examples []string
}
// CapabilityCardsOptions configure CapabilityCards.
type CapabilityCardsOptions struct {
// Columns is the most cards per row; zero is 3.
Columns int
Disabled bool
}
// CapabilityCardsView is built CapabilityCards and the example clicked this frame.
type CapabilityCardsView struct {
Element *ui.Element
// Example is the example prompt chosen, empty for none.
Example string
}
// CapabilityCards shows capabilities; a clicked example goes to the caller.
func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCardsOptions) CapabilityCardsView {
if opts.Columns < 0 {
panic("mujica: CapabilityCards Columns is negative")
}
cols := opts.Columns
if cols == 0 {
cols = 3
}
k := Tokens(c)
d := Density(c)
g := ui.Grid(c).Columns(cols).Gap(d.Unit() * 3).Disabled(opts.Disabled)
v := CapabilityCardsView{Element: g}
g.Children(func() {
for i, card := range cards {
if card.Title == "" {
panic("mujica: CapabilityCard needs a Title")
}
box := ui.Column(c).Key(i).Gap(d.Unit()*2).Padding(d.Unit()*4).Background(k.Surface).
Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Role(ui.RoleGroup).Label(card.Title)
box.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
if card.Icon != nil {
ui.Icon(c, card.Icon).Size(20, 20).TextColor(k.AccentText)
}
ui.Text(c, card.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
})
if card.Description != "" {
ui.Text(c, card.Description).TextColor(k.TextMuted)
}
ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Vertical).Children(func() {
for _, ex := range card.Examples {
if capabilityExample(c, k, ex) {
v.Example = ex
}
}
})
})
}
})
if v.Example != "" {
slog.Debug("mujica: capability_cards example", "len", len(v.Example))
}
return v
}
// capabilityExample is a quoted example button reporting a click.
func capabilityExample(c *ui.Context, k theme.Tokens, text string) bool {
d := Density(c)
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start).
Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Label(text)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case b.Pressed():
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
}
b.Transition(colorFade)
b.Children(func() { ui.Text(c, "“"+text+"”").TextColor(k.Text) })
return b.Clicked() && !b.IsDisabled()
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestCapabilityCardsExample(t *testing.T) {
got := ""
tt := chatTester(900, 400, func(c *ui.Context) {
if v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{}); v.Example != "" {
got = v.Example
}
})
if !tt.HasText("Banquets, seating and schedules.") {
t.Fatal("description missing")
}
if err := tt.Click("“Plan a ball for 200 guests”"); err != nil {
t.Fatal(err)
}
if got != "Plan a ball for 200 guests" {
t.Fatalf("example %q", got)
}
// Tab moves from the clicked example to the next card's.
got = ""
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if got != "Explain the minuet" {
t.Fatalf("keys chose %q", got)
}
}
func TestCapabilityCardsColumnsAndDisabled(t *testing.T) {
got := ""
var g *ui.Element
tt := chatTester(900, 600, func(c *ui.Context) {
v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{Columns: 1, Disabled: true})
g = v.Element
if v.Example != "" {
got = v.Example
}
})
_ = tt.Click("“Explain the minuet”")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if got != "" {
t.Fatalf("disabled cards chose %q", got)
}
a, _ := tt.Find("Write")
b, _ := tt.Find("Plan")
if b.Y <= a.Y || g == nil {
t.Fatalf("one column should stack cards: %v %v", a, b)
}
}
func TestCapabilityCardsNeedTitles(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
CapabilityCards(c, []CapabilityCard{{}}, CapabilityCardsOptions{})
}, 100, 100, 1)
}
func TestCapabilityCardsSnapshot(t *testing.T) {
snapshot(t, "capability_cards", 860, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { CapabilityCards(c, chatCards(), CapabilityCardsOptions{}) })
})
}
+163
View File
@@ -0,0 +1,163 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// ChangedFile is a changed file in the tree or index.
type ChangedFile struct {
Path string
Status GitStatus
// Staged puts the file in the index group.
Staged bool
}
// ChangesListState is a ChangesList's selection and grouping.
type ChangesListState struct {
list ui.ListState
cursor int
// rows maps rows to files; negatives are group headers.
rows []int
staged int
src *ChangedFile
n int
started bool
}
// Selected returns the chosen file's index, or -1.
func (s *ChangesListState) Selected() int {
if s.cursor < 0 || s.cursor >= len(s.rows) {
return -1
}
return s.rows[s.cursor]
}
// ChangesListOptions configure a ChangesList.
type ChangesListOptions struct {
Disabled bool
}
// ChangesList groups staged and unstaged files; give it a height.
func ChangesList(c *ui.Context, s *ChangesListState, files []ChangedFile, opts ChangesListOptions) GitListResult {
if !s.started {
s.started, s.cursor = true, -1
}
var first *ChangedFile
if len(files) > 0 {
first = &files[0]
}
if first != s.src || len(files) != s.n {
s.rows, s.staged = changesListRows(files)
s.src, s.n, s.cursor = first, len(files), -1
slog.Debug("mujica: changes list regroup", "files", len(files), "staged", s.staged)
}
k := Tokens(c)
var r GitListResult
s.list.Selected = &s.cursor
s.list.Header = func(i int) bool { return s.rows[i] < 0 }
s.list.Key = func(i int) any {
if f := s.rows[i]; f >= 0 {
return fmt.Sprint(files[f].Staged, files[f].Path)
}
return s.rows[i]
}
s.list.Label = func(i int) string {
if f := s.rows[i]; f >= 0 {
return files[f].Path
}
return ""
}
prev := s.cursor
outer := ui.Column(c).AlignItems(ui.Stretch).Disabled(opts.Disabled)
var e *ui.Element
outer.Children(func() {
restore := dataSelectTheme(c, 0)
defer restore()
e = ui.List(c, &s.list, len(s.rows), func(i int) { changesListRow(c, s, files, i, &r) })
e.Grow(1).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
})
r.Element, r.changed, r.submitted = outer, e.Changed(), e.Submitted()
if outer.IsDisabled() {
s.cursor, r.changed, r.submitted, r.action, r.item = prev, false, false, "", ""
}
if r.action != "" {
slog.Debug("mujica: changes list action", "action", r.action, "path", r.item)
}
return r
}
// changesListRow builds row i: a header or a file.
func changesListRow(c *ui.Context, s *ChangesListState, files []ChangedFile, i int, r *GitListResult) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
f := s.rows[i]
row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2, 0, u*3)
if f < 0 {
key, all, icon, count := "changes_list.staged", "unstage_all", "minus", s.staged
if f == -2 {
key, all, icon, count = "changes_list.unstaged", "stage_all", "plus", len(files)-s.staged
}
row.Background(k.Background).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, key), count)).Font(theme.SerifFont).FontWeight(600).
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).Grow(1)
b := iconAction(c, icons.Must(icon), msg(c, "changes_list."+all)).Disabled(count == 0)
if b.Tooltip(msg(c, "changes_list."+all)).Clicked() && !b.IsDisabled() {
r.action = all
}
})
return
}
cf := files[f]
if s.cursor == i {
row.Draw(func(p *ui.Painter, rc ui.Rect) {
p.Fill(ui.Rect{X: rc.X, Y: rc.Y + 4, W: 3, H: rc.H - 8}, k.AccentText, 1.5)
})
}
row.Children(func() {
GitStatusBadge(c, cf.Status, GitStatusBadgeOptions{})
dir := path.Dir(cf.Path)
ui.Text(c, path.Base(cf.Path)).SingleLine().Shrink(0).MaxWidth(240)
if dir != "." {
ui.Text(c, dir).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().MinWidth(0)
}
ui.Spacer(c)
id, icon := "stage", "plus"
if cf.Staged {
id, icon = "unstage", "minus"
}
b := iconAction(c, icons.Must(icon), msg(c, "changes_list."+id)+" "+cf.Path).Tooltip(msg(c, "changes_list."+id))
if b.Clicked() && !b.IsDisabled() {
r.action, r.item = id, cf.Path
}
})
}
// changesListRows orders staged files, then unstaged, each under a header.
func changesListRows(files []ChangedFile) ([]int, int) {
rows := []int{-1}
for i, f := range files {
if f.Path == "" {
panic("mujica: ChangedFile needs a Path")
}
gitStatusIndex(f.Status)
if f.Staged {
rows = append(rows, i)
}
}
staged := len(rows) - 1
rows = append(rows, -2)
for i, f := range files {
if !f.Staged {
rows = append(rows, i)
}
}
return rows, staged
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func changesSample() []ChangedFile {
return []ChangedFile{
{Path: "commit_graph.go", Status: GitAdded, Staged: true},
{Path: "internal/git.go", Status: GitModified},
{Path: "docs/old.md", Status: GitDeleted},
{Path: "notes.txt", Status: GitUntracked},
}
}
func TestChangesListGroupsAndStage(t *testing.T) {
var s ChangesListState
files := changesSample()
var acted [][2]string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := ChangesList(c, &s, files, ChangesListOptions{})
r.Element.Height(320)
if a, item, ok := r.Action(); ok {
acted = append(acted, [2]string{a, item})
}
}, 600, 360)
if !tt.HasText("Staged changes (1)") || !tt.HasText("Changes (3)") {
t.Fatalf("groups: %q", tt.Texts())
}
for _, name := range []string{"Stage internal/git.go", "Unstage commit_graph.go", "Stage all", "Unstage all"} {
if err := tt.Click(name); err != nil {
t.Fatal(err)
}
}
want := [][2]string{{"stage", "internal/git.go"}, {"unstage", "commit_graph.go"}, {"stage_all", ""}, {"unstage_all", ""}}
for i := range want {
if i >= len(acted) || acted[i] != want[i] {
t.Fatalf("actions %q, want %q", acted, want)
}
}
if err := tt.Click("old.md"); err != nil {
t.Fatal(err)
}
if got := s.Selected(); got != 2 {
t.Fatalf("selected %d, want 2", got)
}
tt.Key(0, ui.KeyDown)
if got := s.Selected(); got != 3 {
t.Fatalf("Down selected %d", got)
}
}
func TestChangesListDisabledAndEmptyGroups(t *testing.T) {
var s ChangesListState
files := []ChangedFile{{Path: "a.go", Status: GitModified}}
acted := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := ChangesList(c, &s, files, ChangesListOptions{Disabled: true})
r.Element.Height(200)
_, _, ok := r.Action()
acted = acted || ok || r.Changed()
}, 500, 240)
if !tt.HasText("Staged changes (0)") {
t.Fatalf("empty group: %q", tt.Texts())
}
_ = tt.Click("Stage a.go")
_ = tt.Click("a.go")
if acted || s.Selected() != -1 {
t.Fatal("disabled list acted")
}
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
var s ChangesListState
ChangesList(c, &s, []ChangedFile{{Status: GitAdded}}, ChangesListOptions{})
}, 300, 100, 1)
}
func TestChangesListSnapshot(t *testing.T) {
files := changesSample()
snapshot(t, "changes_list", 520, 300, func(c *ui.Context) {
s := ui.Local(c.Root(), "changes", func() *ChangesListState { return &ChangesListState{} })
ui.Column(c).Padding(16).Children(func() {
ChangesList(c, *s, files, ChangesListOptions{}).Element.Height(260)
})
})
}
+260
View File
@@ -0,0 +1,260 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ChartFrame is what every chart shares.
type ChartFrame struct {
// Label names the chart for assistive technology; required.
Label string
// Height is the plot height in DIPs; 0 means 240.
Height float32
// Status shows loading or failure in the plot's place.
Status DataStatus
Error string
// Empty replaces the built-in "No data" text.
Empty string
}
// ChartResult is a built chart and this frame's events.
type ChartResult struct {
Element *ui.Element
// Point is the hovered or focused mark, -1 for none.
Point int
changed, retry bool
toggled string
value bool
}
// Changed reports that the readout moved to another mark.
func (r ChartResult) Changed() bool { return r.changed }
// Retried reports a click on a failed load's retry.
func (r ChartResult) Retried() bool { return r.retry }
// ValueChanged reports a changed selection, path, year or pause.
func (r ChartResult) ValueChanged() bool { return r.value }
// Toggled returns the legend series switched in this frame.
func (r ChartResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" }
// chartKey is a legend entry: name, color and shape.
type chartKey struct {
Name string
Color ui.Color
Shape int
}
// chartSpec is one chart's drawing and behavior.
type chartSpec struct {
// n counts the marks keys step through; 0 is empty.
n int
keys []chartKey
// toggle lets legend entries hide and show series.
toggle bool
draw func(p *ui.Painter, r ui.Rect, active int)
// pick returns the mark at (x, y), or -1.
pick func(r ui.Rect, x, y float32) int
readout func(i int) string
// move steps from mark i; nil steps in order.
move func(i int, key ui.Key) int
// activate runs on Enter, Space or a click.
activate func(i int)
}
type chartState struct {
focus, last int
hidden map[string]bool
}
// chart is a chart being built.
type chart struct {
c *ui.Context
frame ChartFrame
st *chartState
res ChartResult
}
// newChart starts a chart; ask it which series are hidden.
func newChart(c *ui.Context, frame ChartFrame) *chart {
if frame.Label == "" || frame.Height < 0 {
panic("mujica: chart needs a Label and a non-negative Height")
}
if frame.Height == 0 {
frame.Height = 240
}
ch := &chart{c: c, frame: frame}
ch.res.Element = ui.Column(c).Gap(Density(c).Unit() * 2).MinWidth(0)
ch.st = ui.Local(ch.res.Element, "mujica.chart", func() chartState { return chartState{focus: -1, last: -1, hidden: map[string]bool{}} })
return ch
}
// hidden reports whether the legend switched series name off.
func (ch *chart) hidden(name string) bool { return ch.st.hidden[name] }
// build lays out legend, plot and readout; returns the plot.
func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) {
c, k, st := ch.c, Tokens(ch.c), ch.st
u := Density(c).Unit()
var plot *ui.Element
ch.res.Element.Children(func() {
if len(spec.keys) > 0 {
ch.legend(spec)
}
box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0)
if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.n == 0 {
st.focus = -1
box.Children(func() {
ui.Column(c).Fill().Children(func() {
ch.res.retry = dataPlaceholder(c, ch.frame.Status, ch.frame.Error, ch.frame.Empty, msg(c, "chart.empty"))
})
})
ch.res.Point = -1
ui.Box(c).Height(u * 5)
return
}
plot = box.Focusable().Label(ch.frame.Label).Role(ui.RoleGroup)
internal.Ring(plot, k.Focus, theme.ControlRadius)
st.focus = min(st.focus, spec.n-1)
ch.input(plot, spec)
active := -1
if x, y, over := plot.PointerPosition(); over && !plot.IsDisabled() {
b := plot.Bounds()
active = spec.pick(ui.Rect{W: b.W, H: b.H}, x, y)
}
if active < 0 && plot.Focused() {
active = st.focus
}
ch.res.Point = active
plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.draw(p, r, active) })
text, col := msg(c, "chart.hint"), k.TextMuted
if active >= 0 {
text, col = spec.readout(active), k.Text
}
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(u * 5)
})
ch.res.changed = ch.res.Point != st.last
st.last = ch.res.Point
return ch.res, plot
}
// input moves the keyboard mark, and takes clicks on marks.
func (ch *chart) input(plot *ui.Element, spec chartSpec) {
st := ch.st
if plot.IsDisabled() {
return
}
to := st.focus
for _, key := range []ui.Key{ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown} {
if !plot.Shortcut(0, key) {
continue
}
switch {
case st.focus < 0:
to = 0
case spec.move != nil:
to = spec.move(st.focus, key)
case key == ui.KeyLeft || key == ui.KeyUp:
to = max(st.focus-1, 0)
default:
to = min(st.focus+1, spec.n-1)
}
}
if plot.Shortcut(0, ui.KeyHome) {
to = 0
}
if plot.Shortcut(0, ui.KeyEnd) {
to = spec.n - 1
}
if plot.Shortcut(0, ui.KeyEscape) {
to = -1
}
if to != st.focus {
st.focus = to
if to >= 0 {
ch.c.Announce(spec.readout(to))
}
slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to)
}
act := st.focus >= 0 && (plot.Shortcut(0, ui.KeyEnter) || plot.Shortcut(0, ui.KeySpace))
if plot.Clicked() {
plot.Focus()
x, y, _ := plot.PointerPosition()
b := plot.Bounds()
if i := spec.pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 {
st.focus, act = i, true
}
}
if act && spec.activate != nil {
spec.activate(st.focus)
}
}
// legend shows the series keys; toggling ones are check boxes.
func (ch *chart) legend(spec chartSpec) {
c, k, st := ch.c, Tokens(ch.c), ch.st
u := Density(c).Unit()
ui.Row(c).Wrap().Gap(u * 3).Children(func() {
for _, key := range spec.keys {
ui.Row(c).Key(key.Name).Children(func() {
on := !st.hidden[key.Name]
var e *ui.Element
if spec.toggle {
e = ui.CheckboxBase(c, &on).Disabled(ch.res.Element.IsDisabled()).Label(key.Name)
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Changed() && !e.IsDisabled() {
st.hidden[key.Name] = !on
ch.res.toggled = key.Name
slog.Debug("mujica: chart toggle", "series", key.Name, "visible", on)
}
} else {
e = ui.Row(c)
}
e.MinHeight(theme.MinHitSize).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius)
col := key.Color
if !on {
col = k.TextMuted
}
e.Children(func() {
ui.Box(c).Size(12, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
internal.Marker(p, key.Shape, r.X+6, r.Y+6, 10, col)
} else {
p.Stroke(r, col, 2, 1)
}
})
t := ui.Text(c, key.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text)
if !on {
t.Strikethrough().TextColor(k.TextMuted)
}
})
})
}
})
}
// chartText draws s anchored at (x, y) by ax, ay.
func chartText(p *ui.Painter, s string, x, y, size float32, col ui.Color, ax, ay float32) (w, h float32) {
w, h = p.MeasureText(0, ui.Span{Text: s, Size: size})
p.Text(x-w*ax, y-h*ay, s, size, col)
return w, h
}
// chartInset is r less room for axis labels.
func chartInset(r ui.Rect, left, bottom float32) ui.Rect {
return ui.Rect{X: r.X + left, Y: r.Y + 8, W: max(r.W-left-12, 1), H: max(r.H-bottom-8, 1)}
}
// chartInk is Text or Surface, whichever reads better on bg.
func chartInk(k theme.Tokens, bg ui.Color) ui.Color {
if theme.Contrast(k.Text, bg) >= theme.Contrast(k.Surface, bg) {
return k.Text
}
return k.Surface
}
+108
View File
@@ -0,0 +1,108 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
// chartBuilders builds every Charts I component with a small data set.
func chartBuilders() map[string]func(c *ui.Context) ChartResult {
year := 2026
var path, path2 []string
var sel []ScatterKey
var graph NetworkGraphState
var rt RealtimeChartState
buf := NewRealtimeBuffer(10, "a")
buf.Append(rtStart, 1)
buf.Append(rtStart.Add(time.Second), 2)
target := 3.0
f := ChartFrame{Label: "Under test"}
return map[string]func(c *ui.Context) ChartResult{
"line": func(c *ui.Context) ChartResult { return LineChart(c, lineSeries(), LineChartOptions{Frame: f}) },
"area": func(c *ui.Context) ChartResult { return AreaChart(c, lineSeries(), AreaChartOptions{Frame: f}) },
"bar": func(c *ui.Context) ChartResult { return BarChart(c, barCats, barSeries(), BarChartOptions{Frame: f}) },
"pie": func(c *ui.Context) ChartResult { return PieChart(c, pieSlices, PieChartOptions{Frame: f}) },
"scat": func(c *ui.Context) ChartResult {
return ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: f})
},
"radar": func(c *ui.Context) ChartResult {
return RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: f})
},
"heat": func(c *ui.Context) ChartResult {
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: f})
},
"cal": func(c *ui.Context) ChartResult {
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: f})
},
"hist": func(c *ui.Context) ChartResult { return Histogram(c, []float64{1, 2, 3}, HistogramOptions{Frame: f}) },
"box": func(c *ui.Context) ChartResult { return BoxPlot(c, boxGroups, BoxPlotOptions{Frame: f}) },
"fall": func(c *ui.Context) ChartResult {
return WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: f})
},
"funnel": func(c *ui.Context) ChartResult { return FunnelChart(c, funnelStages, FunnelChartOptions{Frame: f}) },
"bullet": func(c *ui.Context) ChartResult {
return BulletChart(c, 2, BulletChartOptions{Frame: f, Target: &target, Bands: []float64{1, 4}})
},
"spark": func(c *ui.Context) ChartResult { return Sparkline(c, sparkValues, SparklineOptions{Frame: f}) },
"gauge": func(c *ui.Context) ChartResult {
return Gauge(c, 72, GaugeOptions{Frame: f, Max: 100, Segments: gaugeSegments})
},
"tree": func(c *ui.Context) ChartResult { return Treemap(c, &path, chartTree(), TreemapOptions{Frame: f}) },
"sun": func(c *ui.Context) ChartResult { return Sunburst(c, &path2, chartTree(), SunburstOptions{Frame: f}) },
"sankey": func(c *ui.Context) ChartResult {
return SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: f})
},
"graph": func(c *ui.Context) ChartResult {
return NetworkGraph(c, &graph, graphNodes, graphEdges, NetworkGraphOptions{Frame: f})
},
"rt": func(c *ui.Context) ChartResult {
return RealtimeChart(c, &rt, buf, RealtimeChartOptions{Frame: f, Window: time.Minute})
},
}
}
func TestChartsIgnoreInputWhenDisabled(t *testing.T) {
for name, build := range chartBuilders() {
values := 0
h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult {
r := build(c)
if r.ValueChanged() || r.Retried() {
values++
}
if _, ok := r.Toggled(); ok {
values++
}
return r
}}
tt := ui.NewTester(h.view, 560, 480)
for range 6 {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
}
b := h.res.Element.Bounds()
tt.Click("Under test")
tt.ClickAt(b.X+b.W/2, b.Y+b.H/2)
if h.res.Point != -1 || values != 0 {
t.Errorf("%s: disabled chart took input: point %d, events %d", name, h.res.Point, values)
}
}
}
func TestChartsEnterAtFirstMark(t *testing.T) {
for name, build := range chartBuilders() {
h := &chartHarness{build: build}
tt := ui.NewTester(h.view, 560, 480)
tabTo(t, tt, "Under test")
tt.Key(0, ui.KeyRight)
if h.res.Point != 0 || chartReadout(tt) == msg2(tt) {
t.Errorf("%s: first arrow read point %d", name, h.res.Point)
}
}
}
// msg2 is the hint line, read back to tell it from a readout.
func msg2(*ui.Tester) string { return "Focus the chart and use the arrow keys to read values" }
+206
View File
@@ -0,0 +1,206 @@
package mujica
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
)
// ChartSeries is named values; NaN marks a missing one.
type ChartSeries struct {
Name string
Values []float64
}
type chartMemoEntry struct {
deps any
val any
}
// chartMemo recomputes f only when the comparable deps change.
func chartMemo[T any](ch *chart, name string, deps any, f func() T) T {
m := ui.Local(ch.res.Element, "mujica.memo."+name, func() chartMemoEntry { return chartMemoEntry{} })
if m.val == nil || m.deps != deps {
m.deps, m.val = deps, f()
}
return m.val.(T)
}
// chartID identifies a slice by first element and length.
func chartID[T any](s []T) string {
if len(s) == 0 {
return "0"
}
return fmt.Sprintf("%p:%d", &s[0], len(s))
}
// chartSeriesID identifies series data and the hidden set together.
func chartSeriesID(ch *chart, series []ChartSeries) string {
var b strings.Builder
for _, s := range series {
fmt.Fprintf(&b, "%s|%s|%v;", s.Name, chartID(s.Values), ch.hidden(s.Name))
}
return b.String()
}
// chartCheckSeries panics on duplicate names or unequal lengths.
func chartCheckSeries(series []ChartSeries) int {
if len(series) == 0 {
return 0
}
n := len(series[0].Values)
seen := map[string]bool{}
for _, s := range series {
if s.Name == "" || seen[s.Name] || len(s.Values) != n {
panic("mujica: chart series need unique names and equal lengths: " + s.Name)
}
seen[s.Name] = true
}
return n
}
// chartX is an ascending axis of numbers, times or indices.
type chartX struct {
vals []float64
times bool
// d0 and d1, when d1 > d0, fix the domain.
d0, d1 float64
}
func newChartX(n int, x []float64, times []time.Time) chartX {
switch {
case x != nil && times != nil:
panic("mujica: chart takes X or Times, not both")
case x != nil && len(x) != n, times != nil && len(times) != n:
panic("mujica: chart X or Times length differs from the series")
}
a := chartX{vals: x, times: times != nil}
if times != nil {
a.vals = make([]float64, n)
for i, t := range times {
a.vals[i] = float64(t.UnixNano()) / 1e9
}
}
if a.vals == nil {
a.vals = make([]float64, n)
for i := range a.vals {
a.vals[i] = float64(i)
}
}
if !sort.Float64sAreSorted(a.vals) {
panic("mujica: chart X or Times must ascend")
}
return a
}
func (a chartX) scale(plot ui.Rect) internal.Linear {
d0, d1 := a.vals[0], a.vals[len(a.vals)-1]
if a.d1 > a.d0 {
d0, d1 = a.d0, a.d1
}
return internal.Linear{D0: d0, D1: d1, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
func (a chartX) label(i int) string {
if a.times {
return chartTime(a.vals[i]).Format("2006-01-02 15:04:05")
}
return strconv.FormatFloat(a.vals[i], 'g', 6, 64)
}
// ticks returns tick positions and labels along plot.
func (a chartX) ticks(plot ui.Rect) ([]float32, []string) {
s := a.scale(plot)
n := max(2, int(plot.W/90))
var xs []float32
var labels []string
if a.times {
ts, layout := internal.TimeTicks(chartTime(s.D0), chartTime(s.D1), n)
for _, t := range ts {
xs = append(xs, s.Map(float64(t.UnixNano())/1e9))
labels = append(labels, t.Format(layout))
}
return xs, labels
}
lo, hi, step := internal.Nice(s.D0, s.D1, n)
for _, t := range internal.Ticks(lo, hi, step) {
if t >= s.D0 && t <= s.D1 {
xs = append(xs, s.Map(t))
labels = append(labels, internal.FormatTick(t, step))
}
}
return xs, labels
}
// nearest returns the index whose x is closest to v.
func (a chartX) nearest(v float64) int {
i := sort.SearchFloat64s(a.vals, v)
if i == len(a.vals) || i > 0 && v-a.vals[i-1] < a.vals[i]-v {
i--
}
return i
}
func chartTime(sec float64) time.Time {
return time.Unix(0, int64(math.Round(sec*1e9))).UTC()
}
// chartYDomain spans visible finite values, optionally with zero.
func chartYDomain(series []ChartSeries, hidden func(string) bool, withZero bool) (lo, hi float64, ok bool) {
lo, hi = math.Inf(1), math.Inf(-1)
for _, s := range series {
if hidden(s.Name) {
continue
}
for _, v := range s.Values {
if !math.IsNaN(v) {
lo, hi = min(lo, v), max(hi, v)
}
}
}
if math.IsInf(lo, 1) {
return 0, 0, false
}
if withZero {
lo, hi = min(lo, 0), max(hi, 0)
}
return lo, hi, true
}
// chartValue formats v, or names a missing value.
func chartValue(c *ui.Context, v float64, format func(float64) string) string {
if math.IsNaN(v) {
return msg(c, "chart.missing")
}
if format != nil {
return format(v)
}
return strconv.FormatFloat(v, 'g', 6, 64)
}
// chartRuns calls f per finite run, thinned to pixel columns.
func chartRuns(vals []float64, x func(i int) float32, f func(idx []int)) {
for i := 0; i < len(vals); {
if math.IsNaN(vals[i]) {
i++
continue
}
j := i
for j < len(vals) && !math.IsNaN(vals[j]) {
j++
}
run := internal.Columns(j-i, func(k int) float32 { return x(i + k) }, func(k int) float64 { return vals[i+k] })
for k := range run {
run[k] += i
}
f(run)
i = j
}
}
+171
View File
@@ -0,0 +1,171 @@
package mujica
import (
"log/slog"
"math"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ChartNode is a hierarchy node; branches sum their leaves.
type ChartNode struct {
Name string
Value float64
Children []ChartNode
}
// total is the node's value, or its children's sum.
func (n ChartNode) total() float64 {
if len(n.Children) == 0 {
return n.Value
}
s := 0.0
for _, ch := range n.Children {
s += ch.total()
}
return s
}
// chartCheckTree panics on unnamed, duplicate-sibling or negative nodes.
func chartCheckTree(n ChartNode) {
if n.Name == "" || n.Value < 0 || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) {
panic("mujica: chart nodes need a name and a finite value ≥ 0")
}
seen := map[string]bool{}
for _, ch := range n.Children {
if seen[ch.Name] {
panic("mujica: chart node has two children named " + ch.Name)
}
seen[ch.Name] = true
chartCheckTree(ch)
}
}
// chartDescend returns the node at path, panicking if unknown.
func chartDescend(root ChartNode, path []string) ChartNode {
n := root
for _, name := range path {
i := slices.IndexFunc(n.Children, func(ch ChartNode) bool { return ch.Name == name })
if i < 0 {
panic("mujica: chart path has no node " + name)
}
n = n.Children[i]
}
return n
}
// chartPath shows Back and the breadcrumb; reports a move up.
func chartPath(c *ui.Context, root string, path *[]string, disabled bool) bool {
k := Tokens(c)
u := Density(c).Unit()
moved := false
ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Children(func() {
back := iconAction(c, icons.Must("chevron-left"), msg(c, "chart.back")).Disabled(disabled || len(*path) == 0)
if back.Clicked() && !back.IsDisabled() {
*path, moved = (*path)[:len(*path)-1], true
}
names := append([]string{root}, *path...)
for i, name := range names {
if i > 0 {
ui.Text(c, "›").TextColor(k.TextMuted)
}
if i == len(names)-1 {
ui.Text(c, name).FontWeight(600).SingleLine()
continue
}
crumb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
internal.Ring(crumb, k.Focus, theme.ControlRadius)
crumb.Children(func() { ui.Text(c, name).Underline().SingleLine() })
if crumb.Clicked() && !crumb.IsDisabled() {
*path, moved = slices.Clone((*path)[:i]), true
}
}
})
if moved {
slog.Debug("mujica: chart path", "path", strings.Join(*path, "/"))
}
return moved
}
// chartFit shortens s with an ellipsis to fit w.
func chartFit(p *ui.Painter, s string, w, size float32) string {
r := []rune(s)
for len(r) > 0 {
t := string(r)
if len(r) < len([]rune(s)) {
t += "…"
}
if tw, _ := p.MeasureText(0, ui.Span{Text: t, Size: size}); tw <= w {
return t
}
r = r[:len(r)-1]
}
return ""
}
// squarify tiles descending values into near-square rectangles.
func squarify(values []float64, r ui.Rect) []ui.Rect {
out := make([]ui.Rect, len(values))
total := 0.0
for _, v := range values {
total += v
}
if total <= 0 {
return out
}
scale := float64(r.W*r.H) / total
for i := 0; i < len(values); {
side := float64(min(r.W, r.H))
j, best := i+1, math.Inf(1)
for ; j <= len(values); j++ {
worst := squarifyWorst(values[i:j], side, scale)
if worst > best {
break
}
best = worst
}
j--
row := 0.0
for _, v := range values[i:j] {
row += v * scale
}
thick := float32(row / side)
at := float32(0)
for k := i; k < j; k++ {
l := float32(values[k]*scale) / thick
if r.W >= r.H {
out[k] = ui.Rect{X: r.X, Y: r.Y + at, W: thick, H: l}
} else {
out[k] = ui.Rect{X: r.X + at, Y: r.Y, W: l, H: thick}
}
at += l
}
if r.W >= r.H {
r.X, r.W = r.X+thick, r.W-thick
} else {
r.Y, r.H = r.Y+thick, r.H-thick
}
i = j
}
return out
}
// squarifyWorst is a row's worst aspect ratio along side.
func squarifyWorst(row []float64, side, scale float64) float64 {
sum, lo, hi := 0.0, math.Inf(1), 0.0
for _, v := range row {
a := v * scale
sum, lo, hi = sum+a, min(lo, a), max(hi, a)
}
if lo == 0 {
return math.Inf(1)
}
s2 := side * side
return max(s2*hi/(sum*sum), sum*sum/(s2*lo))
}
+48
View File
@@ -0,0 +1,48 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// ChatContainerOptions configure a ChatContainer.
type ChatContainerOptions struct {
// List configures the message area; its ID is required.
List MessageListOptions
// Input builds the input area under the messages.
Input func()
// Sidebar builds a side column, as a session list; nil shows none.
Sidebar func()
// SidebarWidth is the side column's width in DIPs; zero means 260.
SidebarWidth float32
}
// ChatContainer lays out a chat: sidebar, messages that follow the newest, and the input.
func ChatContainer(c *ui.Context, s *MessageListState, n int, opts ChatContainerOptions, row func(i int)) MessageListResult {
if opts.SidebarWidth < 0 {
panic("mujica: ChatContainer SidebarWidth must not be negative")
}
w := opts.SidebarWidth
if w == 0 {
w = 260
}
k := Tokens(c)
u := Density(c).Unit()
var r MessageListResult
shell := ui.Row(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
shell.Children(func() {
if opts.Sidebar != nil {
ui.Column(c).Width(w).Shrink(0).MinHeight(0).Background(k.Surface).
BorderWidth(0, theme.BorderWidth, 0, 0).BorderColor(k.Border).Children(opts.Sidebar)
}
ui.Column(c).Grow(1).MinWidth(0).MinHeight(0).Children(func() {
r = MessageList(c, s, n, opts.List, row)
if opts.Input != nil {
ui.Column(c).Shrink(0).Padding(u*3, u*4).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).Children(opts.Input)
}
})
})
r.Element = shell
return r
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
// chatContainerApp is a chat with a sidebar and an input.
type chatContainerApp struct {
msgs []string
state MessageListState
draft string
}
func (a *chatContainerApp) view(c *ui.Context) {
Use(c, Settings{})
ChatContainer(c, &a.state, len(a.msgs), ChatContainerOptions{
List: MessageListOptions{ID: func(i int) string { return a.msgs[i] }},
Sidebar: func() { ui.Text(c, "Sessions").Padding(12) },
Input: func() { TextInput(c, &a.draft, TextInputOptions{Label: "Prompt"}) },
}, func(i int) {
MessageBubble(c, MessageAssistant, MessageBubbleOptions{}, func() { ui.Text(c, a.msgs[i]) })
})
}
func TestChatContainerSlotsAndFollow(t *testing.T) {
a := &chatContainerApp{}
for i := range 40 {
a.msgs = append(a.msgs, "reply "+string(rune('A'+i%26))+string(rune('a'+i/26)))
}
tt := ui.NewTester(a.view, 600, 400)
tt.Frame()
if !tt.HasText("Sessions") || !tt.HasText("reply Nb") {
t.Fatalf("slots or newest message missing: %q", tt.Texts())
}
_ = tt.Click("Prompt")
tt.Type("hi")
if a.draft != "hi" {
t.Fatalf("input slot draft %q", a.draft)
}
a.msgs = append(a.msgs, "fresh")
tt.Frame()
tt.Frame()
if !tt.HasText("fresh") {
t.Fatal("new message not followed")
}
tt.Scroll(400, 150, 0, -3000)
tt.Frame()
a.msgs = append(a.msgs, "later")
tt.Frame()
if tt.HasText("later") {
t.Fatal("followed after the user scrolled up")
}
}
func TestChatContainerSnapshot(t *testing.T) {
snapshot(t, "chat_container", 560, 300, func(c *ui.Context) {
a := ui.Local(c.Root(), "app", func() *chatContainerApp {
return &chatContainerApp{msgs: []string{"Bonjour, Madame.", "How may I serve the court today?"}}
})
(*a).view(c)
})
}
+58
View File
@@ -0,0 +1,58 @@
package mujica
import (
"cmp"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// ChatEmptyKind is why a chat is empty; zero is ChatEmptyNew.
type ChatEmptyKind int
const (
// ChatEmptyNew: a new conversation with no messages.
ChatEmptyNew ChatEmptyKind = iota
// ChatEmptyNoResults: a conversation search matched nothing.
ChatEmptyNoResults
// ChatEmptyOffline: the connection is down.
ChatEmptyOffline
)
// ChatEmptyStateOptions configure a ChatEmptyState; empty texts use built-in copy.
type ChatEmptyStateOptions struct {
Kind ChatEmptyKind
// Query, for ChatEmptyNoResults, is named in the description.
Query string
Title string
Description string
// Action replaces the kind's built-in button text.
Action string
}
// ChatEmptyState explains an empty chat and offers the next step.
func ChatEmptyState(c *ui.Context, opts ChatEmptyStateOptions) EmptyStateResult {
switch opts.Kind {
case ChatEmptyNew:
return EmptyState(c, EmptyStateOptions{
Title: cmp.Or(opts.Title, msg(c, "chat_empty_state.new.title")),
Description: cmp.Or(opts.Description, msg(c, "chat_empty_state.new.description")),
Icon: icons.Must("star"),
Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.new.action")),
})
case ChatEmptyNoResults:
return EmptyState(c, EmptyStateOptions{
Kind: EmptyNoResults, Query: opts.Query, Title: opts.Title, Description: opts.Description,
Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.no_results.action")),
})
case ChatEmptyOffline:
return EmptyState(c, EmptyStateOptions{
Title: cmp.Or(opts.Title, msg(c, "chat_empty_state.offline.title")),
Description: cmp.Or(opts.Description, msg(c, "chat_empty_state.offline.description")),
Icon: icons.Must("wifi-off"),
Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.offline.action")),
})
}
panic("mujica: ChatEmptyState Kind out of range")
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestChatEmptyStateKinds(t *testing.T) {
for _, tc := range []struct {
opts ChatEmptyStateOptions
texts []string
action string
}{
{ChatEmptyStateOptions{}, []string{"Start a conversation"}, "New conversation"},
{ChatEmptyStateOptions{Kind: ChatEmptyNoResults, Query: "minuet"}, []string{"minuet"}, "Clear search"},
{ChatEmptyStateOptions{Kind: ChatEmptyOffline}, []string{"You are offline"}, "Retry"},
{ChatEmptyStateOptions{Kind: ChatEmptyOffline, Title: "Lost the court", Action: "Reconnect"}, []string{"Lost the court"}, "Reconnect"},
} {
acted := 0
tt := chatTester(420, 320, func(c *ui.Context) {
if ChatEmptyState(c, tc.opts).Action {
acted++
}
})
for _, s := range tc.texts {
if !tt.HasText(s) {
t.Errorf("%+v: %q missing from %q", tc.opts, s, tt.Texts())
}
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if err := tt.Click(tc.action); err != nil || acted != 2 {
t.Errorf("%+v: action %q acted %d (%v)", tc.opts, tc.action, acted, err)
}
}
}
func TestChatEmptyStateRejectsBadKind(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ChatEmptyState(c, ChatEmptyStateOptions{Kind: 9})
}, 100, 100, 1)
}
func TestChatEmptyStateSnapshot(t *testing.T) {
snapshot(t, "chat_empty_state", 820, 240, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(8).Children(func() {
for _, k := range []ChatEmptyKind{ChatEmptyNew, ChatEmptyNoResults, ChatEmptyOffline} {
ui.Column(c).Width(260).Children(func() { ChatEmptyState(c, ChatEmptyStateOptions{Kind: k, Query: "minuet"}) })
}
})
})
}
+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"time"
"mujicaui/icons"
)
// chatNow is the fixed clock of the chat session tests.
var chatNow = time.Date(2026, time.October, 5, 15, 30, 0, 0, time.UTC)
func chatConversations() []ChatConversation {
h := time.Hour
return []ChatConversation{
{ID: "a", Title: "Court etiquette notes", Updated: chatNow.Add(-2 * h)},
{ID: "b", Title: "Ballroom seating plan", Updated: chatNow.Add(-26 * h)},
{ID: "c", Title: "Harpsichord repair", Updated: chatNow.Add(-4 * 24 * h)},
{ID: "d", Title: "Coronation menu", Updated: chatNow.Add(-40 * 24 * h)},
{ID: "e", Title: "Royal letters", Updated: chatNow.Add(-90 * 24 * h), Pinned: true},
{ID: "f", Title: "Garden maze", Updated: chatNow.Add(-1 * h)},
}
}
func chatMessages() []SessionMessage {
return []SessionMessage{
{Role: SessionUser, Text: "How should guests be seated at the banquet?", Time: chatNow.Add(-10 * time.Minute)},
{Role: SessionAssistant, Text: "Seat by rank: the host at the head, honored guests to the right.", Time: chatNow.Add(-9 * time.Minute)},
{Role: SessionUser, Author: "Lady Ann", Text: "And the musicians?", Time: chatNow.Add(-5 * time.Minute)},
}
}
func chatCards() []CapabilityCard {
return []CapabilityCard{
{Title: "Write", Description: "Letters, speeches and verse.", Icon: icons.Must("file-text"), Examples: []string{"Draft a royal decree"}},
{Title: "Plan", Description: "Banquets, seating and schedules.", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball for 200 guests"}},
{Title: "Research", Description: "History, heraldry and music.", Icon: icons.Must("search"), Examples: []string{"Explain the minuet"}},
}
}
+119
View File
@@ -0,0 +1,119 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// Checkpoint is a saved point to roll back to.
type Checkpoint struct {
ID string
Label string
Note string
}
// CheckpointListOptions configure a CheckpointList.
type CheckpointListOptions struct {
// MaxHeight caps the scrolling list; zero means 360.
MaxHeight float32
Disabled bool
}
type checkpointListState struct {
asking bool
target string
}
// CheckpointList marks the current point and confirms rollbacks.
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult {
if current == nil {
panic("mujica: CheckpointList needs current")
}
if opts.MaxHeight < 0 {
panic("mujica: CheckpointList MaxHeight is negative")
}
seen := map[string]bool{}
for i, p := range points {
if p.ID == "" || p.Label == "" || seen[p.ID] {
panic(fmt.Sprintf("mujica: CheckpointList point %d needs a unique ID and a Label", i))
}
seen[p.ID] = true
}
maxH := opts.MaxHeight
if maxH == 0 {
maxH = 360
}
k := Tokens(c)
var r ChoiceResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "checkpoint_list.label"))
st := ui.Local(r.Element, "mujica.checkpoints", func() checkpointListState { return checkpointListState{} })
locked := r.Element.IsDisabled()
if locked {
st.asking = false // a disabled list closes its confirmation
}
r.Element.Children(func() {
if len(points) == 0 {
agentCaption(c, msg(c, "checkpoint_list.empty"))
return
}
agentRows(c, len(points), func(i int) {
if checkpointRow(c, points[i], points[i].ID == *current, locked) {
st.asking, st.target = true, points[i].ID
}
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
label := st.target
for _, p := range points {
if p.ID == st.target {
label = p.Label
}
}
res := AlertDialog(c, &st.asking, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "checkpoint_list.confirm"), label),
Description: msg(c, "checkpoint_list.warning"), ConfirmLabel: msg(c, "checkpoint_list.rollback")})
if res.Confirmed && !locked && seen[st.target] {
*current, r.changed = st.target, true
slog.Debug("mujica: checkpoint_list rollback", "to", st.target)
}
})
return r
}
// checkpointRow shows one checkpoint and reports a rollback request.
func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
k := Tokens(c)
d := Density(c)
u := d.Unit()
asked := false
row := ui.Row(c).Key(p.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3)
if current {
row.Background(k.Selection)
}
row.Children(func() {
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) {
cx, cy := internal.Snap(pt, r.X+r.W/2), internal.Snap(pt, r.Y+r.H/2)
if current {
pt.FillPath(internal.Diamond(cx, cy, 12), k.AccentText)
return
}
pt.StrokePath(internal.Diamond(cx, cy, 10), 1.5, k.ControlBorder)
})
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
ui.Text(c, p.Label).FontWeight(600).SingleLine()
if p.Note != "" {
agentCaption(c, p.Note)
}
})
if current {
agentCaption(c, msg(c, "checkpoint_list.current"))
return
}
asked = agentToggle(c, msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
})
row.Label(p.Label)
return asked
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var checkpointsSample = []Checkpoint{
{ID: "c3", Label: "Seats assigned", Note: "12 files changed"},
{ID: "c2", Label: "Menu drafted", Note: "3 files changed"},
{ID: "c1", Label: "Start"},
}
func checkpointTester(cur *string, points []Checkpoint, opts CheckpointListOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if CheckpointList(c, cur, points, opts).Changed() {
*changes++
}
}, 560, 420)
}
func TestCheckpointListRollbackConfirm(t *testing.T) {
cur, changes := "c3", 0
tt := checkpointTester(&cur, checkpointsSample, CheckpointListOptions{}, &changes)
if !tt.HasText("Current") || !tt.HasText("12 files changed") {
t.Fatalf("texts %q", tt.Texts())
}
rolls, _ := tt.Find("Roll back")
tt.ClickAt(rolls.X+4, rolls.Y+4) // Menu drafted
if !tt.HasText("Roll back to “Menu drafted”?") || !tt.Focused("Cancel") {
t.Fatalf("confirm: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter) // Enter on Cancel cancels
if cur != "c3" || changes != 0 {
t.Fatalf("Enter rolled back: %s", cur)
}
tt.ClickAt(rolls.X+4, rolls.Y+4)
_ = tt.Click("Roll back to “Menu drafted”?")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if cur != "c2" || changes != 1 {
t.Fatalf("rollback: cur %s changes %d", cur, changes)
}
}
func TestCheckpointListDisabledEmptyAndMany(t *testing.T) {
cur, n := "c3", 0
tt := checkpointTester(&cur, checkpointsSample, CheckpointListOptions{Disabled: true}, &n)
_ = tt.Click("Roll back")
if tt.HasText("Roll back to “Menu drafted”?") || cur != "c3" {
t.Fatal("disabled list asked")
}
tt = checkpointTester(&cur, nil, CheckpointListOptions{}, &n)
if !tt.HasText("No checkpoints yet") {
t.Fatalf("empty: %q", tt.Texts())
}
many := make([]Checkpoint, 100000)
for i := range many {
many[i] = Checkpoint{ID: fmt.Sprint(i), Label: fmt.Sprintf("point %d", i)}
}
tt = checkpointTester(&cur, many, CheckpointListOptions{}, &n)
if got := len(tt.Texts()); got > 300 {
t.Fatalf("%d texts built for 100000 checkpoints", got)
}
}
func TestCheckpointListSnapshot(t *testing.T) {
snapshot(t, "checkpoint_list", 520, 240, func(c *ui.Context) {
cur := "c2"
ui.Column(c).Padding(16).Children(func() {
CheckpointList(c, &cur, checkpointsSample, CheckpointListOptions{})
})
})
}
+158
View File
@@ -0,0 +1,158 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CopyResult is a block's element and whether its copy action ran this frame.
type CopyResult struct {
Element *ui.Element
copied bool
}
// Copied reports that the block wrote its content to the clipboard this frame.
func (r CopyResult) Copied() bool { return r.copied }
// CodeBlockOptions configure a CodeBlock.
type CodeBlockOptions struct {
// Language picks the highlighting: go, javascript, typescript, python, json, shell, sql.
Language string
LineNumbers bool
// Wrap and Collapsed set the toggles' first state.
Wrap, Collapsed bool
}
// codeBlockState holds the user's toggles and the copy confirmation.
type codeBlockState struct {
wrap, collapsed bool
copiedAt time.Time
}
// codeBlockCopiedFor is how long the copy button shows its check.
const codeBlockCopiedFor = 2 * time.Second
// CodeBlock shows code with a language label, highlighting, copy, collapse and wrap toggles.
func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult {
k := Tokens(c)
u := Density(c).Unit()
var r CopyResult
r.Element = ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface).Clip().MinWidth(0)
st := ui.Local(r.Element, "code", func() codeBlockState { return codeBlockState{wrap: opts.Wrap, collapsed: opts.Collapsed} })
disabled := r.Element.IsDisabled()
lines := strings.Split(strings.TrimSuffix(code, "\n"), "\n")
r.Element.Children(func() {
ui.Row(c).Padding(0, u, 0, u*3).Gap(u).AlignItems(ui.Center).Background(k.SurfaceHover).Children(func() {
lang := opts.Language
if lang == "" {
lang = msg(c, "code_block.plain")
}
ui.Text(c, lang).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().Grow(1)
if codeBlockToggle(c, "wrap-text", msg(c, "code_block.wrap"), &st.wrap) {
slog.Debug("mujica: code_block wrap", "on", st.wrap)
}
icon, name := "copy", msg(c, "code_block.copy")
if left := codeBlockCopiedFor - c.Now().Sub(st.copiedAt); left > 0 {
icon, name = "check", msg(c, "code_block.copied")
c.After(left)
}
if iconAction(c, icons.Must(icon), name).Clicked() && !disabled {
c.WriteClipboard(code)
st.copiedAt, r.copied = c.Now(), true
c.Announce(msg(c, "code_block.copied"))
slog.Debug("mujica: code_block copy", "lines", len(lines))
}
fold, foldName := "chevron-up", msg(c, "code_block.collapse")
if st.collapsed {
fold, foldName = "chevron-down", fmt.Sprintf(msg(c, "code_block.expand"), len(lines))
}
if iconAction(c, icons.Must(fold), foldName).Clicked() && !disabled {
st.collapsed = !st.collapsed
slog.Debug("mujica: code_block collapse", "on", st.collapsed)
}
})
if st.collapsed {
return
}
var body *ui.Element
if st.wrap {
body = ui.Column(c).Padding(u*2, u*3).MinWidth(0)
} else {
body = ui.ScrollHorizontal(c).Padding(u*2, u*3).AlignItems(ui.Start)
}
body.Font(theme.MonoFont).FontSize(FontSize(c, 13)).Children(func() {
ui.Column(c).MinWidth(0).Grow(1).Children(func() { codeBlockLines(c, lines, opts, st.wrap) })
})
})
return r
}
// codeBlockLines builds numbered, highlighted lines.
func codeBlockLines(c *ui.Context, lines []string, opts CodeBlockOptions, wrap bool) {
k := Tokens(c)
u := Density(c).Unit()
digits := len(fmt.Sprint(len(lines)))
inBlock := false
for i, line := range lines {
var toks []internal.Token
toks, inBlock = internal.Highlight(opts.Language, line, inBlock)
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
if opts.LineNumbers {
ui.Text(c, fmt.Sprintf("%*d", digits, i+1)).TextColor(k.TextMuted).Shrink(0).NoWrap()
}
spans := make([]ui.Span, len(toks))
for j, t := range toks {
spans[j] = ui.Span{Text: t.Text, Color: codeBlockColor(k, t.Kind), Italic: t.Kind == internal.TokComment}
}
if len(spans) == 0 {
spans = []ui.Span{{Text: " "}}
}
t := ui.RichText(c, spans...).MinWidth(0)
if wrap {
t.Grow(1)
} else {
t.NoWrap()
}
})
}
}
// codeBlockColor colors a token from the theme; comments are also italic.
func codeBlockColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
switch kind {
case internal.TokKeyword:
return k.AccentText
case internal.TokString:
return k.Success
case internal.TokNumber:
return k.Warning
case internal.TokComment, internal.TokPunct:
return k.TextMuted
case internal.TokType:
return k.Info
}
return k.Text
}
// codeBlockToggle is a pressed-state icon button; on shows a Selection face and frame.
func codeBlockToggle(c *ui.Context, icon, label string, on *bool) bool {
k := Tokens(c)
b := ui.ToggleBase(c, on).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case *on:
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).TextColor(k.AccentText)
case b.Hovered():
b.Background(k.SurfaceHover).TextColor(k.Text)
}
b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
return b.Changed()
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
const codeBlockSample = "// greet the court\nfunc greet(name string) int {\n\treturn len(\"hi \" + name) + 42\n}"
func codeBlockTester(opts CodeBlockOptions, copies *int, disabled *bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(360).Disabled(*disabled).Children(func() {
if CodeBlock(c, codeBlockSample, opts).Copied() {
*copies++
}
})
}, 400, 300)
}
func TestCodeBlockLabelNumbersAndCopy(t *testing.T) {
copies, off := 0, false
tt := codeBlockTester(CodeBlockOptions{Language: "go", LineNumbers: true}, &copies, &off)
for _, s := range []string{"go", "1", "4", "func", "greet", "42"} {
if !tt.HasText(s) {
t.Errorf("missing %q in %q", s, tt.Texts())
}
}
if err := tt.Click("Copy code"); err != nil {
t.Fatal(err)
}
if tt.Clipboard() != codeBlockSample || copies != 1 {
t.Fatalf("copied %d, clipboard matches %v", copies, tt.Clipboard() == codeBlockSample)
}
if _, ok := tt.Find("Copied"); !ok {
t.Fatal("no copied confirmation")
}
}
func TestCodeBlockCollapseAndWrapByKeyboard(t *testing.T) {
copies, off := 0, false
tt := codeBlockTester(CodeBlockOptions{Language: "go"}, &copies, &off)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !tt.Focused("Wrap lines") {
t.Fatal("Tab did not reach the wrap toggle")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if tt.HasText("greet") {
t.Fatal("collapse left the code built")
}
if err := tt.Click("Show 4 lines"); err != nil {
t.Fatal(err)
}
if !tt.HasText("greet") {
t.Fatal("expand did not show the code")
}
}
func TestCodeBlockDisabledBlocksActions(t *testing.T) {
copies, off := 0, true
tt := codeBlockTester(CodeBlockOptions{Collapsed: true}, &copies, &off)
_ = tt.Click("Copy code")
_ = tt.Click("Show 4 lines")
if copies != 0 || tt.Clipboard() != "" || tt.HasText("greet") {
t.Fatalf("disabled block acted: copies %d", copies)
}
}
func TestCodeBlockSnapshot(t *testing.T) {
snapshot(t, "code_block", 440, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CodeBlock(c, codeBlockSample, CodeBlockOptions{Language: "go", LineNumbers: true})
})
})
}
+176
View File
@@ -0,0 +1,176 @@
package mujica
import (
"math"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// codeSize is the design size of code and terminal text.
const codeSize float32 = 13
// codeFont is the scaled monospace code font.
func codeFont(c *ui.Context) ui.Font {
return ui.Font{Family: theme.MonoFont, Size: FontSize(c, codeSize)}
}
// codeMono is a single-line monospace text.
func codeMono(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine()
}
// codeCaption is muted caption text.
func codeCaption(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted).SingleLine()
}
// codeTokenColor colors a syntax token kind.
func codeTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
switch kind {
case internal.TokKeyword:
return k.AccentText
case internal.TokString:
return k.Success
case internal.TokNumber:
return k.Warning
case internal.TokComment:
return k.TextMuted
case internal.TokType:
return k.Info
}
return k.Text
}
// codeAnsiColor maps a terminal color onto the theme.
func codeAnsiColor(k theme.Tokens, a internal.AnsiColor) ui.Color {
switch a.Kind {
case internal.AnsiRGB:
return ui.RGB(a.R, a.G, a.B)
case internal.AnsiIndexed:
if a.Index < 16 {
base := [8]ui.Color{k.TextMuted, k.Danger, k.Success, k.Warning, k.Info, k.AccentText, k.Info.Mix(k.Success, 0.5), k.Text}
return base[a.Index%8]
}
if a.Index >= 232 {
v := uint8(8 + 10*int(a.Index-232))
return ui.RGB(v, v, v)
}
i := int(a.Index) - 16
step := func(n int) uint8 {
if n == 0 {
return 0
}
return uint8(55 + 40*n)
}
return ui.RGB(step(i/36), step(i/6%6), step(i%6))
}
return ui.Color{}
}
// codeAnsiSpans turns ANSI text into themed spans.
func codeAnsiSpans(c *ui.Context, line string) []ui.Span {
k := Tokens(c)
parts, _ := internal.ParseAnsi(line, internal.AnsiStyle{})
spans := make([]ui.Span, 0, len(parts))
for _, p := range parts {
s := ui.Span{Text: p.Text, Color: codeAnsiColor(k, p.Style.FG), Underline: p.Style.Underline, Strikethrough: p.Style.Strike, Italic: p.Style.Italic}
if p.Style.Bold {
s.Weight = 700
}
if bg := codeAnsiColor(k, p.Style.BG); bg.A > 0 {
s.Background = bg.Alpha(0.3)
}
spans = append(spans, s)
}
return spans
}
// codeMatchSpans marks each query match in text.
func codeMatchSpans(c *ui.Context, text, query string) []ui.Span {
if query == "" {
return []ui.Span{{Text: text}}
}
k := Tokens(c)
lower, q := strings.ToLower(text), strings.ToLower(query)
var spans []ui.Span
for {
i := strings.Index(lower, q)
if i < 0 {
break
}
if i > 0 {
spans = append(spans, ui.Span{Text: text[:i]})
}
spans = append(spans, ui.Span{Text: text[i : i+len(q)], Background: k.Selection, Underline: true, DecorationColor: k.AccentText, Weight: 600})
text, lower = text[i+len(q):], lower[i+len(q):]
}
if text != "" {
spans = append(spans, ui.Span{Text: text})
}
return spans
}
// codePanel is a framed surface holding a code tool.
func codePanel(c *ui.Context) *ui.Element {
k := Tokens(c)
return ui.Column(c).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Clip()
}
// codeHeader is a panel's top strip of tools.
func codeHeader(c *ui.Context, fn func()) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return ui.Row(c).Shrink(0).MinHeight(theme.MinHitSize+2*u).Padding(u, u*2).Gap(u*2).AlignItems(ui.Center).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(fn)
}
// codeSelectMark draws a chosen row's diamond.
func codeSelectMark(c *ui.Context, on bool) {
k := Tokens(c)
ui.Box(c).Size(8, 8).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.AccentText)
}
})
}
// codeRows builds a growing virtual list with marks.
func codeRows(c *ui.Context, s *ui.ListState, n int, row func(i int)) *ui.Element {
d := Density(c)
restore := dataSelectTheme(c, 0)
defer restore()
e := ui.List(c, s, n, func(i int) {
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
codeSelectMark(c, s.Selected != nil && *s.Selected == i)
row(i)
})
})
k := Tokens(c)
internal.Ring(e, k.Focus, theme.ControlRadius)
return e.Grow(1).MinHeight(d.ControlHeight() * 3)
}
// codeBadge is a kind letter in a framed square.
func codeBadge(c *ui.Context, letter string, col ui.Color) {
ui.Box(c).Size(18, 18).Shrink(0).Center().Radius(theme.ControlRadius).Border(theme.BorderWidth, col).
Role(ui.RoleNone).Children(func() {
ui.Text(c, letter).Font(theme.MonoFont).FontSize(FontSize(c, 11)).FontWeight(700).TextColor(col)
})
}
// codeRule draws a pixel-aligned 1 DIP line.
func codeRule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
s := float64(p.Scale())
yy := float32((math.Floor(float64(y)*s) + 0.5) / s)
p.Line(x0, yy, x1, yy, 1, c)
}
// codeTextWidth returns the width of s in the code font.
func codeTextWidth(c *ui.Context, s string) float32 {
w, _ := c.MeasureText(0, ui.Span{Text: s, Font: theme.MonoFont, Size: FontSize(c, codeSize)})
return w
}
+253
View File
@@ -0,0 +1,253 @@
package mujica
import (
"fmt"
"log/slog"
"runtime"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CodeViewerOptions configure a CodeViewer.
type CodeViewerOptions struct {
// Language picks the highlighting; empty is plain.
Language string
// Label names the view for assistive technology.
Label string
Disabled bool
}
// CodeViewerState is a CodeViewer's current line, selection, folds and place.
type CodeViewerState struct {
// Selection holds the selected line indices.
Selection ui.Selection[int]
// Folded holds the first lines of folded blocks.
Folded ui.Selection[int]
rows ui.ListState
cursor int
shown []int
inBlock []bool
ends []int
data *string
n int
lang string
folds int
started bool
}
// Line returns the current line index, or -1.
func (s *CodeViewerState) Line() int {
if s.cursor < 0 || s.cursor >= len(s.shown) {
return -1
}
return s.shown[s.cursor]
}
// Visible returns the first and last lines in view.
func (s *CodeViewerState) Visible() (first, last int) {
a, b := s.rows.Visible()
if len(s.shown) == 0 || a < 0 {
return 0, -1
}
return s.shown[min(a, len(s.shown)-1)], s.shown[min(max(b, a), len(s.shown)-1)]
}
// ScrollTo brings line to the top.
func (s *CodeViewerState) ScrollTo(line int) {
row, _ := slices.BinarySearch(s.shown, line)
s.rows.ScrollTo(row, ui.Start)
}
// GoTo makes line current, unfolding and scrolling to it.
func (s *CodeViewerState) GoTo(line int) {
for start := range s.Folded.All() {
if start < line && line <= s.ends[start] {
s.Folded.Remove(start)
}
}
s.folds = -1
s.Selection.Clear()
s.Selection.Add(line)
s.started, s.cursor = true, -2-line // resolved to a row once the rows are known
}
// CodeViewer is a read-only, foldable, virtualized code view.
func CodeViewer(c *ui.Context, lines []string, s *CodeViewerState, opts CodeViewerOptions) ChoiceResult {
if s == nil {
panic("mujica: CodeViewer needs a state")
}
if !s.started {
s.started, s.cursor = true, -1
}
codeViewerPrepare(lines, s, opts.Language)
k := Tokens(c)
d := Density(c)
var res ChoiceResult
res.Element = codePanel(c).Disabled(opts.Disabled)
res.Element.Children(func() {
if len(lines) == 0 {
codeCaption(c, msg(c, "code_viewer.empty")).Padding(d.Unit() * 4)
return
}
digits := len(fmt.Sprint(len(lines)))
gutter := codeTextWidth(c, strings.Repeat("9", digits))
s.rows.Selected = &s.cursor
s.rows.Selection = &s.Selection
s.rows.Key = func(i int) any { return s.shown[i] }
s.rows.Label = func(i int) string { return fmt.Sprintf("%d %s", s.shown[i]+1, lines[s.shown[i]]) }
restore := dataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(s.shown), func(i int) {
li := s.shown[i]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, 0).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
codeSelectMark(c, i == s.cursor)
if s.ends[li] > li {
label := msg(c, "code_viewer.fold")
if s.Folded.Has(li) {
label = msg(c, "code_viewer.unfold")
}
chev := "chevron-down"
if s.Folded.Has(li) {
chev = "chevron-right"
}
if iconAction(c, icons.Must(chev), fmt.Sprintf(label, li+1)).Clicked() && !res.Element.IsDisabled() {
codeViewerToggle(s, li)
}
} else {
ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0)
}
codeMono(c, fmt.Sprint(li+1)).Width(gutter).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
codeViewerLine(c, k, opts.Language, lines[li], s.inBlock[li])
})
})
restore()
list.Grow(1).MinHeight(d.ControlHeight() * 3).Label(opts.Label)
internal.Ring(list, k.Focus, theme.ControlRadius)
if list.Changed() {
res.changed = true
slog.Debug("mujica: code_viewer line", "line", s.Line())
}
codeViewerKeys(c, list, lines, s)
})
return res
}
// codeViewerKeys copies the selection and folds with Left and Right.
func codeViewerKeys(c *ui.Context, list *ui.Element, lines []string, s *CodeViewerState) {
if list.IsDisabled() {
return
}
copyMods := ui.Cmd
if runtime.GOOS != "darwin" {
copyMods = ui.Ctrl
}
line := s.Line()
switch {
case list.Shortcut(copyMods, ui.KeyC):
picked := slices.Sorted(s.Selection.All())
if len(picked) == 0 && line >= 0 {
picked = []int{line}
}
out := make([]string, len(picked))
for i, l := range picked {
out[i] = lines[l]
}
c.WriteClipboard(strings.Join(out, "\n"))
slog.Debug("mujica: code_viewer copy", "lines", len(out))
case list.Shortcut(0, ui.KeyLeft) && line >= 0 && s.ends[line] > line && !s.Folded.Has(line):
codeViewerToggle(s, line)
case list.Shortcut(0, ui.KeyRight) && line >= 0 && s.Folded.Has(line):
codeViewerToggle(s, line)
}
}
func codeViewerToggle(s *CodeViewerState, line int) {
if s.Folded.Has(line) {
s.Folded.Remove(line)
} else {
s.Folded.Add(line)
}
s.folds = -1
slog.Debug("mujica: code_viewer fold", "line", line, "folded", s.Folded.Has(line))
}
// codeViewerLine draws one line's syntax tokens.
func codeViewerLine(c *ui.Context, k theme.Tokens, lang, line string, inBlock bool) {
toks, _ := internal.Highlight(lang, line, inBlock)
spans := make([]ui.Span, len(toks))
for i, t := range toks {
spans[i] = ui.Span{Text: t.Text, Color: codeTokenColor(k, t.Kind), Italic: t.Kind == internal.TokComment}
}
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine().Grow(1).MinWidth(0)
}
// codeViewerPrepare rescans code and folds when they change.
func codeViewerPrepare(lines []string, s *CodeViewerState, lang string) {
var first *string
if len(lines) > 0 {
first = &lines[0]
}
if s.data != first || s.n != len(lines) || s.lang != lang {
s.data, s.n, s.lang, s.folds = first, len(lines), lang, -1
s.inBlock = slices.Grow(s.inBlock[:0], len(lines))[:len(lines)]
in := false
for i, l := range lines {
s.inBlock[i] = in
_, in = internal.Highlight(lang, l, in)
}
s.ends = codeViewerFolds(lines, s.ends)
}
if s.folds == s.Folded.Len() {
return
}
s.folds = s.Folded.Len()
s.shown = s.shown[:0]
for i := 0; i < len(lines); i++ {
s.shown = append(s.shown, i)
if s.Folded.Has(i) {
i = s.ends[i]
}
}
if s.cursor <= -2 {
s.cursor = max(slices.Index(s.shown, -2-s.cursor), 0)
s.rows.ScrollIntoView(s.cursor)
}
s.cursor = min(s.cursor, len(s.shown)-1)
}
// codeViewerFolds returns each line's indented block end.
func codeViewerFolds(lines []string, ends []int) []int {
ends = slices.Grow(ends[:0], len(lines))[:len(lines)]
indent := func(l string) int {
if strings.TrimSpace(l) == "" {
return -1
}
return len(l) - len(strings.TrimLeft(l, " \t"))
}
type open struct{ line, indent int }
var stack []open
last := -1
for i, l := range lines {
ends[i] = i
in := indent(l)
if in < 0 {
continue
}
for len(stack) > 0 && stack[len(stack)-1].indent >= in {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
ends[top.line] = last
}
stack = append(stack, open{i, in})
last = i
}
for _, o := range stack {
ends[o.line] = last
}
return ends
}
+151
View File
@@ -0,0 +1,151 @@
package mujica
import (
"fmt"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
)
var codeViewerSample = []string{
"package court",
"",
"// Seal stamps a decree.",
"func Seal(d string) int {",
"\tif d == \"\" {",
"\t\treturn 0",
"\t}",
"\treturn 42",
"}",
}
func codeViewerTester(lines []string, s *CodeViewerState, disabled bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CodeViewer(c, lines, s, CodeViewerOptions{Language: "go", Label: "Source", Disabled: disabled}).Element.Height(400)
}, 520, 420)
}
func TestCodeViewerFoldRanges(t *testing.T) {
ends := codeViewerFolds(codeViewerSample, nil)
if ends[3] != 7 || ends[4] != 5 || ends[0] != 0 || ends[8] != 8 {
t.Fatalf("fold ends %v", ends)
}
}
func TestCodeViewerFoldsAndNumbers(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, false)
if !tt.HasText("9") || !tt.HasText("\treturn 42") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("Fold line 4"); err != nil {
t.Fatal(err)
}
if tt.HasText("\treturn 42") || !tt.HasText("}") || len(s.shown) != 5 {
t.Fatalf("fold kept %d lines: %q", len(s.shown), tt.Texts())
}
if err := tt.Click("Unfold line 4"); err != nil {
t.Fatal(err)
}
if len(s.shown) != 9 {
t.Fatalf("unfold shows %d lines", len(s.shown))
}
}
func TestCodeViewerKeysCurrentLineAndCopy(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, false)
for !tt.Focused("Source") {
tt.Key(0, ui.KeyTab)
}
tt.Key(0, ui.KeyDown)
tt.Key(ui.Shift, ui.KeyDown)
tt.Key(ui.Shift, ui.KeyDown)
tt.Key(ui.Cmd, ui.KeyC)
if want := "package court\n\n// Seal stamps a decree."; tt.Clipboard() != want {
t.Fatalf("copied %q", tt.Clipboard())
}
tt.Key(0, ui.KeyDown)
if s.Line() != 3 {
t.Fatalf("current line %d, want 3", s.Line())
}
tt.Key(0, ui.KeyLeft)
if !s.Folded.Has(3) || len(s.shown) != 5 {
t.Fatal("Left did not fold the block")
}
tt.Key(0, ui.KeyRight)
if s.Folded.Has(3) {
t.Fatal("Right did not unfold the block")
}
}
func TestCodeViewerGoToOpensFold(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, false)
_ = tt.Click("Fold line 4")
s.GoTo(7)
tt.Frame()
if s.Line() != 7 || s.Folded.Has(3) {
t.Fatalf("GoTo gave line %d folded %v", s.Line(), s.Folded.Has(3))
}
}
func TestCodeViewerHighlightColors(t *testing.T) {
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
k := Tokens(c)
kw, str := codeTokenColor(k, internal.TokKeyword), codeTokenColor(k, internal.TokString)
if kw != k.AccentText || str != k.Success {
t.Errorf("keyword %v string %v", kw, str)
}
}, 10, 10)
}
func TestCodeViewerHundredThousandLines(t *testing.T) {
lines := make([]string, 100_000)
for i := range lines {
lines[i] = fmt.Sprintf("x%d := %d // line", i, i)
}
s := &CodeViewerState{}
tt := codeViewerTester(lines, s, false)
rows := 0
for _, t := range tt.Texts() {
if strings.HasPrefix(t, "x") {
rows++
}
}
if rows == 0 || rows > 40 {
t.Fatalf("built %d rows of 100000", rows)
}
s.GoTo(99_998)
tt.Frame()
if !tt.HasText("x99998 := 99998 // line") {
t.Fatal("GoTo did not scroll to the end")
}
}
func TestCodeViewerDisabledFold(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, true)
_ = tt.Click("Fold line 4")
if s.Folded.Len() != 0 {
t.Fatal("disabled viewer folded")
}
}
func TestCodeViewerSnapshot(t *testing.T) {
snapshot(t, "code_viewer", 480, 300, func(c *ui.Context) {
s := ui.Local(c.Root(), "snap", func() *CodeViewerState {
s := &CodeViewerState{}
s.GoTo(5)
return s
})
ui.Column(c).Padding(12).Children(func() {
CodeViewer(c, codeViewerSample, *s, CodeViewerOptions{Language: "go"}).Element.Height(276)
})
})
}
+147
View File
@@ -0,0 +1,147 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CommandBlockOptions configure a CommandBlock.
type CommandBlockOptions struct {
// Prompt precedes the command; empty means "$".
Prompt string
// MaxOutputHeight scrolls longer output; zero means 320 DIPs.
MaxOutputHeight float32
}
// CommandBlockResult is a built CommandBlock and its events.
type CommandBlockResult struct {
Element *ui.Element
toggled bool
copied string
}
// Toggled reports the output was shown or hidden this frame.
func (r CommandBlockResult) Toggled() bool { return r.toggled }
// Copied returns "command" or "output" when copied.
func (r CommandBlockResult) Copied() (string, bool) { return r.copied, r.copied != "" }
// CommandBlock shows a command run; *open shows its output.
func CommandBlock(c *ui.Context, run CommandRun, open *bool, opts CommandBlockOptions) CommandBlockResult {
if run.Command == "" {
panic("mujica: CommandBlock needs a Command")
}
if run.Duration < 0 || opts.MaxOutputHeight < 0 {
panic("mujica: CommandBlock Duration and MaxOutputHeight must not be negative")
}
prompt := opts.Prompt
if prompt == "" {
prompt = "$"
}
maxH := opts.MaxOutputHeight
if maxH == 0 {
maxH = 320
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
box := ui.Column(c).AlignItems(ui.Stretch).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Clip()
res := CommandBlockResult{Element: box}
box.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(0, u*2, 0, 0).MinHeight(d.ControlHeight()).Children(func() {
was := *open
tr := ui.CollapsibleBase(c, open).Trigger
if box.IsDisabled() {
*open = was
}
if *open != was {
res.toggled = true
slog.Debug("mujica: command block toggle", "open", *open)
}
tr.Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(u*2).Padding(0, u*3).
Radius(theme.ControlRadius).Label(prompt + " " + run.Command).Cursor(ui.CursorPointer)
internal.Ring(tr, k.Focus, theme.ControlRadius)
if tr.Hovered() {
tr.Background(k.SurfaceHover)
}
tr.Transition(colorFade)
turn := tr.Animate("mujica.turn", commandBlockTurn(*open), Motion(c, theme.StateDuration))
tr.Children(func() {
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).Shrink(0).Rotate(turn).TextColor(k.TextMuted)
ui.Text(c, prompt).Font(theme.MonoFont).TextColor(k.Ornament.Mix(k.Text, 0.35)).Shrink(0)
spans, _ := internal.CodeSpans(k, "shell", run.Command, false)
ui.RichText(c, spans...).Font(theme.MonoFont).SingleLine().MinWidth(0)
})
commandBlockStatus(c, run)
if iconAction(c, icons.Must("copy"), msg(c, "command_block.copy_command")).Clicked() && !box.IsDisabled() {
c.WriteClipboard(run.Command)
res.copied = "command"
}
if iconAction(c, icons.Must("file-text"), msg(c, "command_block.copy_output")).Disabled(run.Output == "").Clicked() && !box.IsDisabled() {
c.WriteClipboard(internal.StripAnsi(run.Output))
res.copied = "output"
}
})
if *open {
ui.Box(c).Height(1).Background(k.Border).Role(ui.RoleNone)
out := ui.ScrollBoth(c).MaxHeight(maxH).Padding(u*3, u*4).Background(k.Background).Label(msg(c, "command_block.output"))
out.Children(func() {
if run.Output == "" {
ui.Text(c, msg(c, "command_block.no_output")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
return
}
AnsiText(c, run.Output, AnsiTextOptions{})
})
}
})
if res.copied != "" {
slog.Debug("mujica: command block copy", "what", res.copied)
}
return res
}
// commandBlockStatus shows running, success or failure with icon and words.
func commandBlockStatus(c *ui.Context, run CommandRun) {
k := Tokens(c)
u := Density(c).Unit()
ui.Row(c).AlignItems(ui.Center).Gap(u * 1.5).Shrink(0).FontSize(FontSize(c, theme.CaptionSize)).Children(func() {
if run.Running {
Spinner(c, SpinnerOptions{Label: msg(c, "command_block.running"), ShowLabel: true, Size: 12})
return
}
icon, col := "circle-check", k.Success
if run.ExitCode != 0 {
icon, col = "circle-x", k.Danger
}
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(col).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, fmt.Sprintf(msg(c, "command_block.exit"), run.ExitCode)).FontFeatures("tnum").SingleLine()
})
ui.Text(c, commandBlockDuration(run.Duration)).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
})
}
func commandBlockTurn(open bool) float32 {
if open {
return 90
}
return 0
}
// commandBlockDuration prints milliseconds, seconds or minutes.
func commandBlockDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%d ms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1f s", d.Seconds())
}
return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func commandBlockSample() CommandRun {
return CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m mujicaui 3.2s", ExitCode: 0, Duration: 3200 * time.Millisecond}
}
func TestCommandBlockToggleAndCopy(t *testing.T) {
open, toggles := false, 0
var copied []string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommandBlock(c, commandBlockSample(), &open, CommandBlockOptions{})
if r.Toggled() {
toggles++
}
if w, ok := r.Copied(); ok {
copied = append(copied, w)
}
}, 600, 300)
if tt.HasText("mujicaui 3.2s") {
t.Fatal("closed block shows output")
}
if !tt.HasText("exit 0") || !tt.HasText("3.2 s") {
t.Fatalf("status: %q", tt.Texts())
}
if err := tt.Click("$ go test ./..."); err != nil {
t.Fatal(err)
}
if !open || toggles != 1 || !tt.HasText("ok mujicaui 3.2s") {
t.Fatalf("open %v toggles %d texts %q", open, toggles, tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if open || toggles != 2 {
t.Fatalf("Enter on the focused header: open %v toggles %d", open, toggles)
}
_ = tt.Click("Copy command")
if tt.Clipboard() != "go test ./..." {
t.Fatalf("clipboard %q", tt.Clipboard())
}
_ = tt.Click("Copy output")
if tt.Clipboard() != "ok mujicaui 3.2s" || len(copied) != 2 || copied[1] != "output" {
t.Fatalf("output copy %q %q", tt.Clipboard(), copied)
}
}
func TestCommandBlockFailureRunningDisabled(t *testing.T) {
open := true
run := CommandRun{Command: "make", Output: "boom", ExitCode: 2, Duration: 90 * time.Second}
disabled := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Disabled(disabled).Children(func() {
CommandBlock(c, run, &open, CommandBlockOptions{Prompt: "❯"})
})
}, 600, 300)
if !tt.HasText("exit 2") || !tt.HasText("1m 30s") {
t.Fatalf("failure: %q", tt.Texts())
}
run.Running = true
tt.Frame()
if !tt.HasText("Running") || tt.HasText("exit 2") {
t.Fatalf("running: %q", tt.Texts())
}
disabled = true
tt.Frame()
_ = tt.Click("❯ make")
_ = tt.Click("Copy command")
if !open || tt.Clipboard() != "" {
t.Fatalf("disabled block acted: open %v clipboard %q", open, tt.Clipboard())
}
}
func TestCommandBlockInvalid(t *testing.T) {
for _, run := range []CommandRun{{}, {Command: "x", Duration: -1}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v did not panic", run)
}
}()
open := false
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
CommandBlock(c, run, &open, CommandBlockOptions{})
}, 100, 50, 1)
}()
}
}
func TestCommandBlockSnapshot(t *testing.T) {
snapshot(t, "command_block", 620, 300, func(c *ui.Context) {
a, b := true, false
ui.Column(c).Padding(16).Gap(12).Children(func() {
CommandBlock(c, CommandRun{Command: "go test -run Git ./...", Output: "\x1b[32mok\x1b[0m \tmujicaui\t3.214s\n\x1b[33mwarn\x1b[0m slow test: TestCommitGraphVirtualized", Duration: 3214 * time.Millisecond}, &a, CommandBlockOptions{})
CommandBlock(c, CommandRun{Command: "git push origin main", ExitCode: 1, Duration: 640 * time.Millisecond, Output: "rejected"}, &b, CommandBlockOptions{})
})
})
}
+128
View File
@@ -0,0 +1,128 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CommandRun is a command run; the caller appends Output.
type CommandRun struct {
Command string
Dir string
// Output is the raw terminal output; ANSI colors are kept.
Output string
Running bool
ExitCode int
Duration time.Duration
}
// CommandExecutionCardOptions configure a CommandExecutionCard.
type CommandExecutionCardOptions struct {
// Height of the output well; 240 DIPs when zero.
Height float32
Disabled bool
Still bool
}
// commandOutput keeps parsed lines; appends parse only new lines.
type commandOutput struct {
src string
lines [][]internal.AnsiSpan
style internal.AnsiStyle
list ui.ListState
}
func (o *commandOutput) update(out string) {
if out == o.src {
return
}
if !strings.HasPrefix(out, o.src) || o.src != "" && !strings.HasSuffix(o.src, "\n") {
o.lines, o.style = nil, internal.AnsiStyle{}
o.src = ""
}
for _, line := range internal.SplitLines(out[len(o.src):]) {
var spans []internal.AnsiSpan
spans, o.style = internal.ParseAnsi(line, o.style)
o.lines = append(o.lines, spans)
}
o.src = out
}
// CommandExecutionCard shows a command run; Changed requests an abort.
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult {
if run.Command == "" {
panic("mujica: CommandExecutionCard needs a Command")
}
if opts.Height < 0 {
panic("mujica: CommandExecutionCard Height is negative")
}
h := opts.Height
if h == 0 {
h = 240
}
k := Tokens(c)
u := Density(c).Unit()
var r ChoiceResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(run.Command)
out := ui.Local(r.Element, "mujica.cmd.output", func() *commandOutput { return &commandOutput{} })
o := *out
o.list.FollowEnd = true
o.update(run.Output)
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
ui.Text(c, "$").Font(theme.MonoFont).TextColor(k.TextMuted).Shrink(0)
ui.Text(c, run.Command).Font(theme.MonoFont).FontWeight(600).Grow(1).Shrink(1).Selectable()
if run.Running {
agentMark(c, AgentRunning, 16, opts.Still)
agentCaption(c, msg(c, "agent_status.running"))
locked := r.Element.IsDisabled()
if Button(c, msg(c, "command_execution_card.abort"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() && !locked {
r.changed = true
}
return
}
commandExit(c, run.ExitCode)
agentCaption(c, agentDuration(run.Duration))
})
if run.Dir != "" {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("folder")).Size(14, 14).TextColor(k.TextMuted)
agentCaption(c, run.Dir).Font(theme.MonoFont)
})
}
well := ui.List(c, &o.list, len(o.lines), func(i int) {
ui.RichText(c, agentAnsiSpans(k, o.lines[i])...)
})
agentWell(c, well, h).Height(h).Label(msg(c, "command_execution_card.output")).Children(func() {
if len(o.lines) == 0 {
agentCaption(c, msg(c, "command_execution_card.no_output"))
}
})
})
if r.changed {
slog.Debug("mujica: command_execution_card abort", "command", run.Command)
}
return r
}
// commandExit shows the exit code by icon and word.
func commandExit(c *ui.Context, code int) {
k := Tokens(c)
tone, icon := k.Success, "circle-check"
if code != 0 {
tone, icon = k.Danger, "circle-x"
}
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
ui.Text(c, fmt.Sprintf(msg(c, "command_execution_card.exit"), code)).TextColor(tone).
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
})
}
+98
View File
@@ -0,0 +1,98 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func cmdTester(run *CommandRun, opts CommandExecutionCardOptions, aborts *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if CommandExecutionCard(c, *run, opts).Changed() {
*aborts++
}
}, 600, 420)
}
func TestCommandExecutionCardRunningAbortAndFinish(t *testing.T) {
run := CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
aborts := 0
tt := cmdTester(&run, CommandExecutionCardOptions{}, &aborts)
for _, s := range []string{"go test ./...", "~/court", "ok", " court", "Running"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
if tt.HasText("\x1b[32mok") {
t.Fatal("escape codes shown as text")
}
_ = tt.Click("Abort")
if aborts != 1 {
t.Fatalf("aborts %d", aborts)
}
run.Output += "FAIL ledger\n"
run.Running, run.ExitCode, run.Duration = false, 1, 3200*time.Millisecond
tt.Frame()
if !tt.HasText("FAIL ledger") || !tt.HasText("Exit 1") || !tt.HasText("3.2 s") || tt.HasText("Abort") {
t.Fatalf("finished: %q", tt.Texts())
}
}
func TestCommandExecutionCardAnsiAcrossLinesAndReset(t *testing.T) {
var o commandOutput
o.update("\x1b[31mred\nstill red\x1b[0m plain\n")
if len(o.lines) != 2 || o.lines[1][0].Style.FG.Index != 1 || o.lines[1][1].Style.FG.Index != 0 {
t.Fatalf("lines %+v", o.lines)
}
o.update("\x1b[31mred\nstill red\x1b[0m plain\nnext\n")
if len(o.lines) != 3 || o.lines[2][0].Text != "next" {
t.Fatalf("append: %+v", o.lines)
}
o.update("fresh\n")
if len(o.lines) != 1 || o.lines[0][0].Text != "fresh" {
t.Fatalf("replace: %+v", o.lines)
}
}
func TestCommandExecutionCardDisabledAndEmpty(t *testing.T) {
run := CommandRun{Command: "sleep 10", Running: true}
aborts := 0
tt := cmdTester(&run, CommandExecutionCardOptions{Disabled: true}, &aborts)
_ = tt.Click("Abort")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if aborts != 0 || !tt.HasText("No output") {
t.Fatalf("aborts %d texts %q", aborts, tt.Texts())
}
}
func TestCommandExecutionCardLargeOutputVirtualized(t *testing.T) {
var b strings.Builder
for i := range 100000 {
fmt.Fprintf(&b, "\x1b[2mline %d\x1b[0m\n", i)
}
run := CommandRun{Command: "yes", Output: b.String()}
n := 0
tt := cmdTester(&run, CommandExecutionCardOptions{}, &n)
if got := len(tt.Texts()); got > 200 {
t.Fatalf("%d texts built for 100000 lines", got)
}
if !tt.HasText("line 99999") {
t.Fatal("output does not follow its end")
}
}
func TestCommandExecutionCardSnapshot(t *testing.T) {
snapshot(t, "command_execution_card", 560, 300, func(c *ui.Context) {
run := CommandRun{Command: "go test ./court/...", Dir: "~/projects/court", ExitCode: 1, Duration: 4200 * time.Millisecond,
Output: "\x1b[32mok\x1b[0m court/guests 0.21s\n\x1b[1;31mFAIL\x1b[0m court/ledger 0.08s\n ledger_test.go:14: want 42 guests, got 40\n\x1b[33mwarning:\x1b[0m 1 test skipped\n"}
ui.Column(c).Padding(16).Children(func() {
CommandExecutionCard(c, run, CommandExecutionCardOptions{Height: 140})
})
})
}
+119
View File
@@ -0,0 +1,119 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// HistoryEntry is one command a shell ran.
type HistoryEntry struct {
Command string
Time time.Time
ExitCode int
}
// CommandHistoryState is the filter and place of a CommandHistory.
type CommandHistoryState struct {
Query string
rows ui.ListState
selected int
shown []int
query string
n int
ran bool
}
// CommandHistoryOptions configure a CommandHistory.
type CommandHistoryOptions struct {
Disabled bool
}
// CommandHistoryResult carries the entry to replay.
type CommandHistoryResult struct {
Element *ui.Element
// Replayed is the entry to run again, or nil.
Replayed *HistoryEntry
}
// CommandHistory lists commands newest first with search and replay.
func CommandHistory(c *ui.Context, entries []HistoryEntry, s *CommandHistoryState, opts CommandHistoryOptions) CommandHistoryResult {
if s == nil {
panic("mujica: CommandHistory needs a state")
}
var res CommandHistoryResult
res.Element = codePanel(c).Disabled(opts.Disabled)
disabled := res.Element.IsDisabled()
replay := func(i int) {
if disabled {
return
}
e := entries[i]
res.Replayed = &e
slog.Debug("mujica: command_history replay", "index", i)
}
res.Element.Children(func() {
codeHeader(c, func() {
SearchInput(c, &s.Query, SearchInputOptions{Disabled: opts.Disabled, Placeholder: msg(c, "command_history.search")}).Element.Grow(1)
})
commandHistoryFilter(entries, s)
if len(s.shown) == 0 {
text := msg(c, "command_history.empty")
if len(entries) > 0 {
text = msg(c, "command_history.none")
}
codeCaption(c, text).Padding(Density(c).Unit() * 4)
return
}
s.rows.Selected = &s.selected
s.rows.Key = func(i int) any { return s.shown[i] }
s.rows.Label = func(i int) string { return entries[s.shown[i]].Command }
list := codeRows(c, &s.rows, len(s.shown), func(i int) {
e := entries[s.shown[i]]
commandHistoryRow(c, e)
if iconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(msg(c, "command_history.replay"), e.Command)).Clicked() {
replay(s.shown[i])
}
})
if list.Submitted() && s.selected >= 0 && s.selected < len(s.shown) {
replay(s.shown[s.selected])
}
})
return res
}
// commandHistoryFilter refilters when entries or query change.
func commandHistoryFilter(entries []HistoryEntry, s *CommandHistoryState) {
if s.ran && s.n == len(entries) && s.query == s.Query {
return
}
s.ran, s.n, s.query = true, len(entries), s.Query
s.shown = s.shown[:0]
q := strings.ToLower(strings.TrimSpace(s.Query))
for i := len(entries) - 1; i >= 0; i-- {
if q == "" || strings.Contains(strings.ToLower(entries[i].Command), q) {
s.shown = append(s.shown, i)
}
}
s.selected = min(s.selected, len(s.shown)-1)
}
func commandHistoryRow(c *ui.Context, e HistoryEntry) {
k := Tokens(c)
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
codeMono(c, e.Command)
ui.Row(c).Gap(Density(c).Unit() * 2).Children(func() {
if !e.Time.IsZero() {
codeCaption(c, e.Time.Format("15:04:05"))
}
if e.ExitCode != 0 {
codeCaption(c, fmt.Sprintf(msg(c, "command_history.exit"), e.ExitCode)).TextColor(k.Danger)
}
})
})
}
+117
View File
@@ -0,0 +1,117 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func commandHistorySample() []HistoryEntry {
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
return []HistoryEntry{
{Command: "git status", Time: at},
{Command: "go test ./...", Time: at.Add(time.Minute), ExitCode: 1},
{Command: "go build", Time: at.Add(2 * time.Minute)},
}
}
func commandHistoryTester(entries []HistoryEntry, s *CommandHistoryState, disabled bool, got *[]string) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommandHistory(c, entries, s, CommandHistoryOptions{Disabled: disabled})
r.Element.Grow(1)
if r.Replayed != nil {
*got = append(*got, r.Replayed.Command)
}
}, 420, 320)
}
func TestCommandHistoryNewestFirstAndReplay(t *testing.T) {
s := &CommandHistoryState{}
var got []string
tt := commandHistoryTester(commandHistorySample(), s, false, &got)
first, _ := tt.Find("go build")
last, _ := tt.Find("git status")
if first.Y >= last.Y {
t.Fatal("newest command is not on top")
}
if !tt.HasText("exit 1") {
t.Fatal("failed command shows no exit code")
}
if err := tt.Click("Run git status again"); err != nil {
t.Fatal(err)
}
tt.ClickAt(first.X, first.Y+2)
tt.ClickAt(first.X, first.Y+2)
if len(got) != 2 || got[0] != "git status" || got[1] != "go build" {
t.Fatalf("replayed %q", got)
}
}
func TestCommandHistorySearchAndKeys(t *testing.T) {
s := &CommandHistoryState{}
var got []string
tt := commandHistoryTester(commandHistorySample(), s, false, &got)
tt.Key(0, ui.KeyTab)
tt.Type("go ")
if tt.HasText("git status") || !tt.HasText("go build") || !tt.HasText("go test ./...") {
t.Fatalf("filter shows %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0] != "go test ./..." {
t.Fatalf("keyboard replay %q", got)
}
s.Query = "zzz"
tt.Frame()
if !tt.HasText("No matching commands") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestCommandHistoryDisabledAndEmpty(t *testing.T) {
var got []string
tt := commandHistoryTester(commandHistorySample(), &CommandHistoryState{}, true, &got)
_ = tt.Click("Run git status again")
if len(got) != 0 {
t.Fatalf("disabled replayed %q", got)
}
tt = commandHistoryTester(nil, &CommandHistoryState{}, false, &got)
if !tt.HasText("No commands yet") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestCommandHistoryLargeIsVirtual(t *testing.T) {
entries := make([]HistoryEntry, 100_000)
for i := range entries {
entries[i].Command = fmt.Sprintf("echo %d", i)
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CommandHistory(c, entries, &CommandHistoryState{}, CommandHistoryOptions{})
}, 420, 320)
rows := 0
for _, s := range tt.Texts() {
if strings.HasPrefix(s, "echo ") {
rows++
}
}
if !tt.HasText("echo 99999") || rows == 0 || rows > 60 {
t.Fatalf("built %d rows of 100000", rows)
}
}
func TestCommandHistorySnapshot(t *testing.T) {
snapshot(t, "command_history", 420, 240, func(c *ui.Context) {
s := ui.Local(c.Root(), "snap", func() *CommandHistoryState { return &CommandHistoryState{} })
ui.Column(c).Padding(12).Height(240).Children(func() {
CommandHistory(c, commandHistorySample(), *s, CommandHistoryOptions{}).Element.Grow(1)
})
})
}
+204
View File
@@ -0,0 +1,204 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// CommitGraphState is a CommitGraph's selection and its lane layout.
type CommitGraphState struct {
List ListState[string]
rows []commitGraphRow
// lanes is the widest the graph gets.
lanes int
src *Commit
n int
}
// CommitGraphOptions configure a CommitGraph.
type CommitGraphOptions struct {
// TimeFormat lays out commit times; empty means "2006-01-02 15:04".
TimeFormat string
// MaxLanes caps the graph width in lanes; zero means 8.
MaxLanes int
Status DataStatus
Error string
}
// commitGraphLink joins lane from to lane to within a row.
type commitGraphLink struct{ from, to, color int }
type commitGraphRow struct {
node, color int
merge bool
// top links enter from above; bottom links leave the node.
top, bottom []commitGraphLink
}
type commitGraphLane struct {
hash string
color int
}
// CommitGraph shows commits with branch lanes; give it a height.
func CommitGraph(c *ui.Context, s *CommitGraphState, commits []Commit, opts CommitGraphOptions) GitListResult {
maxLanes := opts.MaxLanes
if maxLanes == 0 {
maxLanes = 8
}
if maxLanes < 0 {
panic("mujica: CommitGraph MaxLanes is negative")
}
var first *Commit
if len(commits) > 0 {
first = &commits[0]
}
if first != s.src || len(commits) != s.n {
s.rows, s.lanes = commitGraphLayout(commits)
s.src, s.n = first, len(commits)
slog.Debug("mujica: commit graph layout", "commits", len(commits), "lanes", s.lanes)
}
layout := gitTimeLayout(opts.TimeFormat)
lane := Density(c).Unit() * 4
width := float32(min(max(s.lanes, 1), maxLanes)) * lane
h := Density(c).ControlHeight()
v := List(c, &s.List, len(commits), ListOptions[string]{
Key: func(i int) string { return gitCommitHash(commits[i]) },
Label: func(i int) string { return commits[i].Subject },
Status: opts.Status, Error: opts.Error, Empty: msg(c, "commit_list.empty"),
}, func(i int) {
row := s.rows[i]
ui.Box(c).Size(width, h).Shrink(0).Clip().Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
commitGraphDraw(p, Tokens(c), r, row, lane)
})
commitListRow(c, commits[i], layout)
})
res := GitListResult{Element: v.Element, changed: v.Changed(), submitted: v.Submitted()}
if res.changed || res.submitted {
key, _ := s.List.Selected()
slog.Debug("mujica: commit graph choose", "hash", key, "open", res.submitted)
}
return res
}
// commitGraphLayout gives each commit a lane and parent links.
func commitGraphLayout(commits []Commit) ([]commitGraphRow, int) {
rows := make([]commitGraphRow, len(commits))
var lanes []commitGraphLane
colors, widest := 0, 0
free := func() int {
for j, l := range lanes {
if l.hash == "" {
return j
}
}
lanes = append(lanes, commitGraphLane{})
return len(lanes) - 1
}
for i, cm := range commits {
h := gitCommitHash(cm)
var row commitGraphRow
row.node = -1
for j, l := range lanes {
if l.hash == h && row.node < 0 {
row.node = j
}
}
for j, l := range lanes {
switch {
case l.hash == "":
case l.hash == h:
row.top = append(row.top, commitGraphLink{j, row.node, l.color})
default:
row.top = append(row.top, commitGraphLink{j, j, l.color})
}
}
if row.node < 0 {
row.node = free()
lanes[row.node] = commitGraphLane{h, colors % 6}
colors++
}
row.color = lanes[row.node].color
for j := range lanes {
if lanes[j].hash == h {
lanes[j].hash = ""
}
}
row.merge = len(cm.Parents) > 1
for pi, p := range cm.Parents {
if pi == 0 {
lanes[row.node] = commitGraphLane{p, row.color}
row.bottom = append(row.bottom, commitGraphLink{row.node, row.node, row.color})
continue
}
to := -1
for j, l := range lanes {
if l.hash == p {
to = j
break
}
}
if to < 0 {
to = free()
lanes[to] = commitGraphLane{p, colors % 6}
colors++
}
row.bottom = append(row.bottom, commitGraphLink{row.node, to, lanes[to].color})
}
for len(lanes) > 0 && lanes[len(lanes)-1].hash == "" {
lanes = lanes[:len(lanes)-1]
}
widest = max(widest, len(lanes), row.node+1)
rows[i] = row
}
return rows, widest
}
// commitGraphColors are the lane colors; place also tells lanes apart.
func commitGraphColors(k theme.Tokens) [6]ui.Color {
return [6]ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Danger, k.TextMuted}
}
func commitGraphDraw(p *ui.Painter, k theme.Tokens, r ui.Rect, row commitGraphRow, lane float32) {
cols := commitGraphColors(k)
x := func(j int) float32 { return internal.Snap(p, r.X+lane/2+float32(j)*lane) }
top, mid, bot := internal.Snap(p, r.Y), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, r.Y+r.H)
seg := func(l commitGraphLink, y0, y1 float32) {
var path ui.Path
xa, xb := x(l.from), x(l.to)
path.MoveTo(xa, y0)
if xa == xb {
path.LineTo(xb, y1)
} else {
m := (y0 + y1) / 2
path.CubeTo(xa, m, xb, m, xb, y1)
}
p.StrokePath(&path, 2, cols[l.color])
}
for _, l := range row.top {
if l.from == l.to && l.to != row.node {
seg(l, top, bot)
continue
}
seg(l, top, mid)
}
for _, l := range row.bottom {
seg(l, mid, bot)
}
cx, col := x(row.node), cols[row.color]
if row.merge {
p.FillPath(internal.Diamond(cx, mid, 11), k.Surface)
p.StrokePath(internal.Diamond(cx, mid, 9), 2, col)
return
}
var dot ui.Path
dot.Circle(cx, mid, 5.5)
p.FillPath(&dot, k.Surface)
var ring ui.Path
ring.Circle(cx, mid, 4)
p.FillPath(&ring, col)
}
+98
View File
@@ -0,0 +1,98 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
// commitGraphSample is main with a feature branch merged back.
func commitGraphSample() []Commit {
cs := []Commit{
{Hash: "m3", Parents: []string{"m2", "f2"}, Subject: "Merge feature", Refs: []string{"main"}},
{Hash: "f2", Parents: []string{"f1"}, Subject: "Feature work"},
{Hash: "m2", Parents: []string{"m1"}, Subject: "Fix on main"},
{Hash: "f1", Parents: []string{"m1"}, Subject: "Start feature"},
{Hash: "m1", Subject: "Initial commit"},
}
for i := range cs {
cs[i].Author = []string{"Sakiko", "Mutsumi", "Uika"}[i%3]
cs[i].When = gitTestTime.Add(-time.Duration(i) * 3 * time.Hour)
}
return cs
}
func TestCommitGraphLayoutLanes(t *testing.T) {
rows, lanes := commitGraphLayout(commitGraphSample())
if lanes != 2 {
t.Fatalf("lanes %d, want 2", lanes)
}
want := []struct {
node int
merge bool
}{{0, true}, {1, false}, {0, false}, {1, false}, {0, false}}
for i, w := range want {
if rows[i].node != w.node || rows[i].merge != w.merge {
t.Errorf("row %d: node %d merge %v, want %+v", i, rows[i].node, rows[i].merge, w)
}
}
// The merge sends a line to the feature lane; f1 joins main again at m1.
if l := rows[0].bottom; len(l) != 2 || l[1].from != 0 || l[1].to != 1 {
t.Errorf("merge links %+v", l)
}
if l := rows[4].top; len(l) != 2 || l[1].from != 1 || l[1].to != 0 {
t.Errorf("fork point links %+v", l)
}
}
func TestCommitGraphVirtualized(t *testing.T) {
commits := gitHistory(100000)
var s CommitGraphState
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CommitGraph(c, &s, commits, CommitGraphOptions{}).Element.Height(400)
}, 700, 440)
if n := len(tt.Texts()); n > 400 {
t.Fatalf("%d texts built for 100,000 commits", n)
}
rows := &s.rows[0]
tt.Scroll(300, 200, 0, 50000)
tt.Frame()
if &s.rows[0] != rows {
t.Fatal("layout recomputed without a data change")
}
if tt.HasText("Commit number 100000") {
t.Fatal("scroll did not move the window of rows")
}
}
func TestCommitGraphChooseAndKeys(t *testing.T) {
var s CommitGraphState
changes := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommitGraph(c, &s, commitGraphSample(), CommitGraphOptions{})
r.Element.Height(300)
if r.Changed() {
changes++
}
}, 700, 400)
if err := tt.Click("Fix on main"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyDown)
if key, _ := s.List.Selected(); key != "f1" || changes != 2 {
t.Fatalf("selected %q changes %d", key, changes)
}
}
func TestCommitGraphSnapshot(t *testing.T) {
commits := commitGraphSample()
snapshot(t, "commit_graph", 640, 240, func(c *ui.Context) {
s := ui.Local(c.Root(), "graph", func() *CommitGraphState { return &CommitGraphState{} })
ui.Column(c).Padding(16).Children(func() {
CommitGraph(c, *s, commits, CommitGraphOptions{}).Element.Height(200)
})
})
}
+106
View File
@@ -0,0 +1,106 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// CommitMessage is a commit's title and body.
type CommitMessage struct {
Title string
Body string
}
// CommitInputOptions configure a CommitInput.
type CommitInputOptions struct {
// TitleLimit warns past this many title characters; zero means 50.
TitleLimit int
// Busy shows a commit in progress and blocks another.
Busy bool
Disabled bool
}
// CommitInputResult is a built CommitInput and its events.
type CommitInputResult struct {
Element *ui.Element
committed, amend bool
}
// Committed reports Commit, or Cmd+Enter, this frame.
func (r CommitInputResult) Committed() bool { return r.committed }
// Amended reports Amend this frame.
func (r CommitInputResult) Amended() bool { return r.amend }
type commitInputState struct {
// pending blocks commits until Busy comes and goes.
pending, sawBusy bool
}
// CommitInput edits a commit message and commits or amends once.
func CommitInput(c *ui.Context, m *CommitMessage, opts CommitInputOptions) CommitInputResult {
limit := opts.TitleLimit
if limit == 0 {
limit = 50
}
if limit < 0 {
panic("mujica: CommitInput TitleLimit is negative")
}
k := Tokens(c)
u := Density(c).Unit()
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2).Disabled(opts.Disabled)
res := CommitInputResult{Element: col}
st := ui.Local(col, "state", func() *commitInputState { return &commitInputState{} })
s := *st
if opts.Busy {
s.sawBusy = true
} else if s.sawBusy {
s.pending, s.sawBusy = false, false
}
blocked := opts.Busy || s.pending
n := utf8.RuneCountInString(m.Title)
col.Children(func() {
title := TextInput(c, &m.Title, TextInputOptions{Placeholder: msg(c, "commit_input.title"), Label: msg(c, "commit_input.title"),
Disabled: opts.Disabled, ReadOnly: blocked,
Trailing: func() {
col := k.TextMuted
if n > limit {
col = k.Warning
}
name := fmt.Sprintf(msg(c, "commit_input.count"), n, limit)
if n > limit {
name = fmt.Sprintf(msg(c, "commit_input.long"), n-limit)
}
ui.Text(c, fmt.Sprintf("%d/%d", n, limit)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(col).Shrink(0).
Label(name).Tooltip(name)
}})
body := TextArea(c, &m.Body, TextAreaOptions{Placeholder: msg(c, "commit_input.body"), Label: msg(c, "commit_input.body"),
MinHeight: 72, MaxHeight: 200, Disabled: opts.Disabled, ReadOnly: blocked})
empty := strings.TrimSpace(m.Title) == ""
send := (title.Submitted() || body.Submitted()) && !empty
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if Button(c, msg(c, "commit_input.amend"), ButtonOptions{Variant: Secondary, Icon: icons.Must("rotate-ccw"), Disabled: blocked || empty}).Clicked() {
res.amend = true
}
if Button(c, msg(c, "commit_input.commit"), ButtonOptions{Icon: icons.Must("check"), Loading: opts.Busy, Disabled: s.pending || empty}).Clicked() {
send = true
}
})
res.committed = send && !res.amend
})
if blocked || col.IsDisabled() {
res.committed, res.amend = false, false
}
if res.committed || res.amend {
s.pending = true
slog.Debug("mujica: commit input submit", "amend", res.amend)
}
return res
}
+101
View File
@@ -0,0 +1,101 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func TestCommitInputCommitOnceWhileBusy(t *testing.T) {
m := CommitMessage{}
busy := false
commits, amends := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommitInput(c, &m, CommitInputOptions{Busy: busy})
if r.Committed() {
commits++
}
if r.Amended() {
amends++
}
}, 600, 400)
_ = tt.Click("Commit")
if commits != 0 {
t.Fatal("empty title committed")
}
if err := tt.Click("Summary"); err != nil {
t.Fatal(err)
}
tt.Type("Add git components")
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Commit")
if commits != 1 {
t.Fatalf("commits %d, want 1 before Busy clears", commits)
}
busy = true
tt.Frame()
_ = tt.Click("Commit")
_ = tt.Click("Amend")
busy = false
tt.Frame()
if commits != 1 || amends != 0 {
t.Fatalf("busy: commits %d amends %d", commits, amends)
}
_ = tt.Click("Amend")
if amends != 1 {
t.Fatalf("amends %d", amends)
}
}
func TestCommitInputTitleHint(t *testing.T) {
m := CommitMessage{Title: strings.Repeat("x", 55)}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CommitInput(c, &m, CommitInputOptions{})
}, 600, 400)
if _, ok := tt.Find("Summary is 5 characters over the suggested length"); !ok {
t.Fatal("over-length hint not named")
}
m.Title = "short"
tt.Frame()
if _, ok := tt.Find("5 of 50 characters"); !ok {
t.Fatal("count not named")
}
}
func TestCommitInputDisabledAndInvalid(t *testing.T) {
m := CommitMessage{Title: "x"}
acted := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommitInput(c, &m, CommitInputOptions{Disabled: true})
acted = acted || r.Committed() || r.Amended()
}, 600, 400)
_ = tt.Click("Commit")
_ = tt.Click("Amend")
if acted {
t.Fatal("disabled input committed")
}
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
CommitInput(c, &m, CommitInputOptions{TitleLimit: -1})
}, 300, 100, 1)
}
func TestCommitInputSnapshot(t *testing.T) {
snapshot(t, "commit_input", 520, 280, func(c *ui.Context) {
m := ui.Local(c.Root(), "msg", func() CommitMessage {
return CommitMessage{Title: "Add CommitGraph lanes with merge lines", Body: "Lanes are laid out once per history."}
})
ui.Column(c).Padding(16).Children(func() {
CommitInput(c, m, CommitInputOptions{})
})
})
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// Commit is one commit of a history, newest first.
type Commit struct {
Hash string
Parents []string
Author string
When time.Time
Subject string
// Refs are branch and tag names pointing at the commit.
Refs []string
}
// GitListResult is a built version-control list and its events.
type GitListResult struct {
Element *ui.Element
changed, submitted bool
action, item string
}
// Changed reports a new selection.
func (r GitListResult) Changed() bool { return r.changed }
// Submitted reports Enter or a double click on a row.
func (r GitListResult) Submitted() bool { return r.submitted }
// Action returns this frame's action ID and its item.
func (r GitListResult) Action() (action, item string, ok bool) {
return r.action, r.item, r.action != ""
}
// CommitListOptions configure a CommitList.
type CommitListOptions struct {
// TimeFormat lays out commit times; empty means "2006-01-02 15:04".
TimeFormat string
Status DataStatus
Error string
}
// CommitList shows commits to choose from; give it a height.
func CommitList(c *ui.Context, s *ListState[string], commits []Commit, opts CommitListOptions) GitListResult {
layout := gitTimeLayout(opts.TimeFormat)
v := List(c, s, len(commits), ListOptions[string]{
Key: func(i int) string { return gitCommitHash(commits[i]) },
Label: func(i int) string { return commits[i].Subject },
Status: opts.Status, Error: opts.Error, Empty: msg(c, "commit_list.empty"),
}, func(i int) { commitListRow(c, commits[i], layout) })
r := GitListResult{Element: v.Element, changed: v.Changed(), submitted: v.Submitted()}
if r.changed || r.submitted {
key, _ := s.Selected()
slog.Debug("mujica: commit list choose", "hash", key, "open", r.submitted)
}
return r
}
func commitListRow(c *ui.Context, cm Commit, layout string) {
k := Tokens(c)
gitRefs(c, cm.Refs)
ui.Text(c, cm.Subject).SingleLine().Grow(1).MinWidth(0)
small := FontSize(c, theme.CaptionSize)
ui.Text(c, cm.Author).FontSize(small).TextColor(k.TextMuted).SingleLine().MaxWidth(140).Shrink(0)
ui.Text(c, gitShortHash(cm.Hash)).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0)
ui.Text(c, cm.When.Format(layout)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).Shrink(0)
}
// gitRefs shows branch and tag names as small outlined pills.
func gitRefs(c *ui.Context, refs []string) {
k := Tokens(c)
u := Density(c).Unit()
for _, ref := range refs {
ui.Text(c, ref).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.AccentText).SingleLine().
Padding(0, u*1.5).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.AccentText).Shrink(0).MaxWidth(160)
}
}
func gitCommitHash(cm Commit) string {
if cm.Hash == "" {
panic("mujica: Commit needs a Hash")
}
return cm.Hash
}
// gitShortHash is the first seven characters of a hash.
func gitShortHash(h string) string {
if len(h) > 7 {
return h[:7]
}
return h
}
func gitTimeLayout(f string) string {
if f == "" {
return "2006-01-02 15:04"
}
return f
}
+91
View File
@@ -0,0 +1,91 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var gitTestTime = time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC)
// gitTestHash is a stable, hash-like hex string for commit i.
func gitTestHash(i int) string { return fmt.Sprintf("%016x%024x", uint64(i)*0x9E3779B97F4A7C15, i) }
// gitHistory is a linear history of n commits, newest first.
func gitHistory(n int) []Commit {
out := make([]Commit, n)
for i := range out {
out[i] = Commit{Hash: gitTestHash(n - i), Author: "Sakiko", When: gitTestTime.Add(-time.Duration(i) * time.Hour),
Subject: fmt.Sprintf("Commit number %d", n-i)}
if i+1 < n {
out[i].Parents = []string{gitTestHash(n - i - 1)}
}
}
return out
}
func TestCommitListChooseAndOpen(t *testing.T) {
commits := gitHistory(5)
commits[0].Refs = []string{"main"}
var s ListState[string]
changes, opens := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommitList(c, &s, commits, CommitListOptions{})
r.Element.Height(300)
if r.Changed() {
changes++
}
if r.Submitted() {
opens++
}
}, 700, 340)
if !tt.HasText("main") || !tt.HasText("1715609") || !tt.HasText("2026-10-05 09:30") {
t.Fatalf("row parts: %q", tt.Texts())
}
if err := tt.Click("Commit number 3"); err != nil {
t.Fatal(err)
}
if key, _ := s.Selected(); key != commits[2].Hash || changes != 1 {
t.Fatalf("selected %q changes %d", key, changes)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if key, _ := s.Selected(); key != commits[3].Hash || opens != 1 {
t.Fatalf("keyboard: %q opens %d", key, opens)
}
}
func TestCommitListEmptyAndInvalid(t *testing.T) {
var s ListState[string]
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CommitList(c, &s, nil, CommitListOptions{})
}, 400, 200)
if !tt.HasText("No commits") {
t.Fatalf("empty: %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("hashless commit did not panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
var s ListState[string]
CommitList(c, &s, []Commit{{Subject: "x"}}, CommitListOptions{})
}, 300, 100, 1)
}
func TestCommitListSnapshot(t *testing.T) {
commits := gitHistory(4)
commits[0].Refs = []string{"main", "v1.1"}
snapshot(t, "commit_list", 640, 220, func(c *ui.Context) {
s := ui.Local(c.Root(), "list", func() *ListState[string] { return &ListState[string]{} })
ui.Column(c).Padding(16).Children(func() {
CommitList(c, *s, commits, CommitListOptions{}).Element.Height(180)
})
})
}
+129
View File
@@ -0,0 +1,129 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// Completion is one candidate of a CompletionMenu.
type Completion struct {
Label string
Kind SymbolKind
Detail string
// Doc describes the chosen candidate below the list.
Doc string
}
// CompletionMenuState is the highlighted candidate and place of a CompletionMenu.
type CompletionMenuState struct {
Selected int
rows ui.ListState
pending ui.Key
}
// CompletionMenuOptions configure a CompletionMenu.
type CompletionMenuOptions struct {
Disabled bool
}
// CompletionMenuResult carries the accepted candidate.
type CompletionMenuResult struct {
Element *ui.Element
Accepted *Completion
}
// completionRows is how many candidates show before the list scrolls.
const completionRows = 8
// CompletionMenu shows candidates under the editor anchor.
func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult {
if anchor == nil || open == nil || s == nil {
panic("mujica: CompletionMenu needs an anchor, open and a state")
}
for _, it := range items {
if it.Kind < SymbolFunction || it.Kind > SymbolModule {
panic("mujica: Completion kind out of range")
}
}
var res CompletionMenuResult
if opts.Disabled || anchor.IsDisabled() || len(items) == 0 {
*open = false
}
if !*open {
return res
}
s.Selected = max(0, min(s.Selected, len(items)-1))
accept := func(i int) {
it := items[i]
res.Accepted = &it
*open = false
slog.Debug("mujica: completion_menu accept", "index", i)
}
move := func(d int) {
s.Selected = (s.Selected + d + len(items)) % len(items)
s.rows.ScrollIntoView(s.Selected)
}
// Take the menu keys before the editor does.
anchor.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputKeyDown || ev.Mods != 0 || !*open || anchor.IsDisabled() {
return false
}
switch ev.Key {
case ui.KeyDown, ui.KeyUp, ui.KeyTab, ui.KeyEnter, ui.KeyEscape:
s.pending = ev.Key
return true
}
return false
})
key := s.pending
s.pending = 0
switch key {
case ui.KeyDown:
move(1)
case ui.KeyUp:
move(-1)
case ui.KeyTab, ui.KeyEnter:
accept(s.Selected)
return res
case ui.KeyEscape:
*open = false
slog.Debug("mujica: completion_menu close")
return res
}
k := Tokens(c)
d := Density(c)
b := anchor.Bounds()
overlayLayer(c, open)
res.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayAnchored(c, panel, b))
panel.Margin(4, 0, 0, 0).Padding(d.Unit()).Width(popoverWide(c, 360)).MaxHeight(popoverRoom(c, b)).
Label(msg(c, "completion_menu.label")).Role(ui.RoleList)
chosen := s.Selected
s.rows.Selected = &chosen
s.rows.Label = func(i int) string { return items[i].Label }
restore := dataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(items), func(i int) {
it := items[i]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
codeSelectMark(c, i == s.Selected)
codeBadge(c, symbolBadges[it.Kind], k.AccentText)
codeMono(c, it.Label).Grow(1).MinWidth(0)
if it.Detail != "" {
codeCaption(c, it.Detail).MinWidth(0).Shrink(1)
}
})
}).MaxHeight(d.ControlHeight() * completionRows).Shrink(1)
restore()
if list.Changed() && chosen >= 0 {
accept(chosen)
}
if doc := items[s.Selected].Doc; doc != "" {
ui.Text(c, doc).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit()*2).
BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).Shrink(0)
}
})
return res
}
+103
View File
@@ -0,0 +1,103 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func completionSample() []Completion {
return []Completion{
{Label: "Seal", Kind: SymbolMethod, Detail: "func(d Decree) error", Doc: "Seal stamps a decree."},
{Label: "Search", Kind: SymbolFunction, Detail: "func(q string) []Decree"},
{Label: "Season", Kind: SymbolConstant, Detail: "= 4"},
}
}
type completionProbe struct {
text string
open bool
s CompletionMenuState
disabled bool
accepted []string
}
func (p *completionProbe) view(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
in := TextInput(c, &p.text, TextInputOptions{Label: "Code"})
if in.Changed() {
p.open = strings.HasPrefix(strings.ToLower(p.text), "se")
}
r := CompletionMenu(c, in.Input, &p.open, completionSample(), &p.s, CompletionMenuOptions{Disabled: p.disabled})
if r.Accepted != nil {
p.accepted = append(p.accepted, r.Accepted.Label)
p.text = r.Accepted.Label
}
})
}
func TestCompletionMenuKeys(t *testing.T) {
p := &completionProbe{}
tt := ui.NewTester(p.view, 480, 400)
tt.Key(0, ui.KeyTab)
tt.Type("se")
if !p.open || !tt.HasText("Seal") || !tt.HasText("Seal stamps a decree.") {
t.Fatalf("menu did not open: %q", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if p.s.Selected != 0 {
t.Fatalf("Down wrapped to %d, want 0", p.s.Selected)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyTab)
if len(p.accepted) != 1 || p.accepted[0] != "Season" || p.open {
t.Fatalf("accepted %v open %v", p.accepted, p.open)
}
if !tt.Focused("Code") {
t.Fatal("accepting moved the focus away from the editor")
}
}
func TestCompletionMenuEnterClickEscape(t *testing.T) {
p := &completionProbe{}
tt := ui.NewTester(p.view, 480, 400)
tt.Key(0, ui.KeyTab)
tt.Type("se")
tt.Key(0, ui.KeyEnter)
if len(p.accepted) != 1 || p.accepted[0] != "Seal" {
t.Fatalf("Enter accepted %v", p.accepted)
}
p.open = true
tt.Frame()
if err := tt.Click("Search"); err != nil {
t.Fatal(err)
}
if len(p.accepted) != 2 || p.accepted[1] != "Search" {
t.Fatalf("click accepted %v", p.accepted)
}
p.open = true
tt.Frame()
tt.Key(0, ui.KeyEscape)
if p.open || len(p.accepted) != 2 {
t.Fatalf("Escape: open %v accepted %v", p.open, p.accepted)
}
}
func TestCompletionMenuDisabledClosed(t *testing.T) {
p := &completionProbe{open: true, disabled: true}
tt := ui.NewTester(p.view, 480, 400)
if p.open || tt.HasText("Search") {
t.Fatal("disabled menu shows")
}
}
func TestCompletionMenuSnapshot(t *testing.T) {
snapshot(t, "completion_menu", 420, 260, func(c *ui.Context) {
p := ui.Local(c.Root(), "snap", func() *completionProbe { return &completionProbe{text: "se", open: true} })
(*p).view(c)
})
}
Loaded 100 of 1005 files, more files were not shown because too many files have changed in this diff. Show more