Merge branch 'main' into worktree-agent-ab9ba373bc47c2352
This commit is contained in:
1005 files changed
+62360
-18
No files matched your search
+149
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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() {}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"}) })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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"}},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user