diff --git a/agenda_view.go b/agenda_view.go new file mode 100644 index 0000000..e3401c1 --- /dev/null +++ b/agenda_view.go @@ -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() +} diff --git a/agenda_view_test.go b/agenda_view_test.go new file mode 100644 index 0000000..630c7bd --- /dev/null +++ b/agenda_view_test.go @@ -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}) + }) + }) +} diff --git a/agent_diff.go b/agent_diff.go new file mode 100644 index 0000000..c686101 --- /dev/null +++ b/agent_diff.go @@ -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 +} diff --git a/agent_parts.go b/agent_parts.go new file mode 100644 index 0000000..d2758a0 --- /dev/null +++ b/agent_parts.go @@ -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() +} diff --git a/agent_plan.go b/agent_plan.go new file mode 100644 index 0000000..2be4683 --- /dev/null +++ b/agent_plan.go @@ -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 + } + } +} diff --git a/agent_plan_test.go b/agent_plan_test.go new file mode 100644 index 0000000..1353cc3 --- /dev/null +++ b/agent_plan_test.go @@ -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}) }) + }) +} diff --git a/agent_progress.go b/agent_progress.go new file mode 100644 index 0000000..3fb6459 --- /dev/null +++ b/agent_progress.go @@ -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 +} diff --git a/agent_progress_test.go b/agent_progress_test.go new file mode 100644 index 0000000..5103ede --- /dev/null +++ b/agent_progress_test.go @@ -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"}) + }) + }) +} diff --git a/agent_status.go b/agent_status.go new file mode 100644 index 0000000..4384112 --- /dev/null +++ b/agent_status.go @@ -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 +} diff --git a/agent_status_test.go b/agent_status_test.go new file mode 100644 index 0000000..a642c4c --- /dev/null +++ b/agent_status_test.go @@ -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{}) + } + }) + }) +} diff --git a/agent_step_list.go b/agent_step_list.go new file mode 100644 index 0000000..710dc56 --- /dev/null +++ b/agent_step_list.go @@ -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 +} diff --git a/agent_step_list_test.go b/agent_step_list_test.go new file mode 100644 index 0000000..fa9d0b4 --- /dev/null +++ b/agent_step_list_test.go @@ -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}) }) + }) +} diff --git a/ansi_text.go b/ansi_text.go new file mode 100644 index 0000000..0795f99 --- /dev/null +++ b/ansi_text.go @@ -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 +} diff --git a/ansi_text_test.go b/ansi_text_test.go new file mode 100644 index 0000000..1107284 --- /dev/null +++ b/ansi_text_test.go @@ -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{}) + }) + }) +} diff --git a/area_chart.go b/area_chart.go new file mode 100644 index 0000000..739085d --- /dev/null +++ b/area_chart.go @@ -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 +} diff --git a/area_chart_test.go b/area_chart_test.go new file mode 100644 index 0000000..04db044 --- /dev/null +++ b/area_chart_test.go @@ -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}) + }) + }) +} diff --git a/artifact_card.go b/artifact_card.go new file mode 100644 index 0000000..87d84fe --- /dev/null +++ b/artifact_card.go @@ -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} +} diff --git a/artifact_card_test.go b/artifact_card_test.go new file mode 100644 index 0000000..409109c --- /dev/null +++ b/artifact_card_test.go @@ -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}) + }) + }) +} diff --git a/artifact_panel.go b/artifact_panel.go new file mode 100644 index 0000000..189333b --- /dev/null +++ b/artifact_panel.go @@ -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) + } + } + }) +} diff --git a/artifact_panel_test.go b/artifact_panel_test.go new file mode 100644 index 0000000..a37f441 --- /dev/null +++ b/artifact_panel_test.go @@ -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() {}}) + }) + }) +} diff --git a/artifact_version_switcher.go b/artifact_version_switcher.go new file mode 100644 index 0000000..2913f14 --- /dev/null +++ b/artifact_version_switcher.go @@ -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 +} diff --git a/artifact_version_switcher_test.go b/artifact_version_switcher_test.go new file mode 100644 index 0000000..f910887 --- /dev/null +++ b/artifact_version_switcher_test.go @@ -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{}) + }) + }) +} diff --git a/attendee_list.go b/attendee_list.go new file mode 100644 index 0000000..a1a8568 --- /dev/null +++ b/attendee_list.go @@ -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 +} diff --git a/attendee_list_test.go b/attendee_list_test.go new file mode 100644 index 0000000..1f9b350 --- /dev/null +++ b/attendee_list_test.go @@ -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}) }) + }) +} diff --git a/audio_message.go b/audio_message.go new file mode 100644 index 0000000..a97bb08 --- /dev/null +++ b/audio_message.go @@ -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) +} diff --git a/audio_message_test.go b/audio_message_test.go new file mode 100644 index 0000000..682e821 --- /dev/null +++ b/audio_message_test.go @@ -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}) + }) + }) +} diff --git a/audio_player.go b/audio_player.go new file mode 100644 index 0000000..144976a --- /dev/null +++ b/audio_player.go @@ -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() +} diff --git a/audio_player_test.go b/audio_player_test.go new file mode 100644 index 0000000..1f90d57 --- /dev/null +++ b/audio_player_test.go @@ -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()) + }) + }) +} diff --git a/audio_spectrum.go b/audio_spectrum.go new file mode 100644 index 0000000..13ebd33 --- /dev/null +++ b/audio_spectrum.go @@ -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 +} diff --git a/audio_spectrum_test.go b/audio_spectrum_test.go new file mode 100644 index 0000000..fb1d21b --- /dev/null +++ b/audio_spectrum_test.go @@ -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{}) }) + }) +} diff --git a/audio_waveform.go b/audio_waveform.go new file mode 100644 index 0000000..f22a24c --- /dev/null +++ b/audio_waveform.go @@ -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 +} diff --git a/audio_waveform_test.go b/audio_waveform_test.go new file mode 100644 index 0000000..9ccb2c0 --- /dev/null +++ b/audio_waveform_test.go @@ -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) {}}) + }) + }) +} diff --git a/availability_picker.go b/availability_picker.go new file mode 100644 index 0000000..b7dc491 --- /dev/null +++ b/availability_picker.go @@ -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) + } + } + }) +} diff --git a/availability_picker_test.go b/availability_picker_test.go new file mode 100644 index 0000000..86f47a3 --- /dev/null +++ b/availability_picker_test.go @@ -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}}}) + }) + }) +} diff --git a/bar_chart.go b/bar_chart.go new file mode 100644 index 0000000..7989ad2 --- /dev/null +++ b/bar_chart.go @@ -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) + } +} diff --git a/bar_chart_test.go b/bar_chart_test.go new file mode 100644 index 0000000..dfde4b0 --- /dev/null +++ b/bar_chart_test.go @@ -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}) + }) + }) + }) +} diff --git a/bid_ask_bar.go b/bid_ask_bar.go new file mode 100644 index 0000000..b8d23ec --- /dev/null +++ b/bid_ask_bar.go @@ -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 +} diff --git a/bid_ask_bar_test.go b/bid_ask_bar_test.go new file mode 100644 index 0000000..89084d8 --- /dev/null +++ b/bid_ask_bar_test.go @@ -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{}) }) + }) +} diff --git a/blame_view.go b/blame_view.go new file mode 100644 index 0000000..0bac5bc --- /dev/null +++ b/blame_view.go @@ -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 +} diff --git a/blame_view_test.go b/blame_view_test.go new file mode 100644 index 0000000..3120bf7 --- /dev/null +++ b/blame_view_test.go @@ -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) + }) + }) +} diff --git a/box_plot.go b/box_plot.go new file mode 100644 index 0000000..518ad02 --- /dev/null +++ b/box_plot.go @@ -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 +} diff --git a/box_plot_test.go b/box_plot_test.go new file mode 100644 index 0000000..34b3bfd --- /dev/null +++ b/box_plot_test.go @@ -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"}}) + }) + }) +} diff --git a/branch_list.go b/branch_list.go new file mode 100644 index 0000000..7e7cfe7 --- /dev/null +++ b/branch_list.go @@ -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) + } + } +} diff --git a/branch_list_test.go b/branch_list_test.go new file mode 100644 index 0000000..51afde4 --- /dev/null +++ b/branch_list_test.go @@ -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) + }) + }) +} diff --git a/branch_navigator.go b/branch_navigator.go new file mode 100644 index 0000000..b08f1b3 --- /dev/null +++ b/branch_navigator.go @@ -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 +} diff --git a/branch_navigator_test.go b/branch_navigator_test.go new file mode 100644 index 0000000..eee74eb --- /dev/null +++ b/branch_navigator_test.go @@ -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) }) + }) +} diff --git a/branch_selector.go b/branch_selector.go new file mode 100644 index 0000000..642b4ab --- /dev/null +++ b/branch_selector.go @@ -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 +} diff --git a/branch_selector_test.go b/branch_selector_test.go new file mode 100644 index 0000000..33ec927 --- /dev/null +++ b/branch_selector_test.go @@ -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") + }) +} diff --git a/breakpoint_list.go b/breakpoint_list.go new file mode 100644 index 0000000..1aab2b1 --- /dev/null +++ b/breakpoint_list.go @@ -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) + }) +} diff --git a/breakpoint_list_test.go b/breakpoint_list_test.go new file mode 100644 index 0000000..0b02a10 --- /dev/null +++ b/breakpoint_list_test.go @@ -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) + }) + }) +} diff --git a/bullet_chart.go b/bullet_chart.go new file mode 100644 index 0000000..7dbd32e --- /dev/null +++ b/bullet_chart.go @@ -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 +} diff --git a/bullet_chart_test.go b/bullet_chart_test.go new file mode 100644 index 0000000..1d8ba54 --- /dev/null +++ b/bullet_chart_test.go @@ -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}}) + }) + }) + }) +} diff --git a/calendar_day_view.go b/calendar_day_view.go new file mode 100644 index 0000000..a1856ab --- /dev/null +++ b/calendar_day_view.go @@ -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 +} diff --git a/calendar_day_view_test.go b/calendar_day_view_test.go new file mode 100644 index 0000000..8ffa166 --- /dev/null +++ b/calendar_day_view_test.go @@ -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)}) + }) + }) +} diff --git a/calendar_events.go b/calendar_events.go new file mode 100644 index 0000000..cf624e0 --- /dev/null +++ b/calendar_events.go @@ -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 +} diff --git a/calendar_heatmap.go b/calendar_heatmap.go new file mode 100644 index 0000000..1b3e542 --- /dev/null +++ b/calendar_heatmap.go @@ -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 +} diff --git a/calendar_heatmap_test.go b/calendar_heatmap_test.go new file mode 100644 index 0000000..6c417e2 --- /dev/null +++ b/calendar_heatmap_test.go @@ -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}) + }) + }) +} diff --git a/calendar_month_view.go b/calendar_month_view.go new file mode 100644 index 0000000..2d8a72b --- /dev/null +++ b/calendar_month_view.go @@ -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) +} diff --git a/calendar_month_view_test.go b/calendar_month_view_test.go new file mode 100644 index 0000000..e955450 --- /dev/null +++ b/calendar_month_view_test.go @@ -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}) + }) + }) +} diff --git a/calendar_time_grid.go b/calendar_time_grid.go new file mode 100644 index 0000000..3b241bc --- /dev/null +++ b/calendar_time_grid.go @@ -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} +} diff --git a/calendar_week_view.go b/calendar_week_view.go new file mode 100644 index 0000000..bf24605 --- /dev/null +++ b/calendar_week_view.go @@ -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) +} diff --git a/calendar_week_view_test.go b/calendar_week_view_test.go new file mode 100644 index 0000000..63a5ea4 --- /dev/null +++ b/calendar_week_view_test.go @@ -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)}) + }) + }) +} diff --git a/call_stack.go b/call_stack.go new file mode 100644 index 0000000..4a060d5 --- /dev/null +++ b/call_stack.go @@ -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) +} diff --git a/call_stack_test.go b/call_stack_test.go new file mode 100644 index 0000000..ff3610e --- /dev/null +++ b/call_stack_test.go @@ -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) + }) + }) +} diff --git a/camera_preview.go b/camera_preview.go new file mode 100644 index 0000000..599365b --- /dev/null +++ b/camera_preview.go @@ -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) +} diff --git a/camera_preview_test.go b/camera_preview_test.go new file mode 100644 index 0000000..cde85b9 --- /dev/null +++ b/camera_preview_test.go @@ -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{}) }) + }) + }) +} diff --git a/capability_cards.go b/capability_cards.go new file mode 100644 index 0000000..fb977cf --- /dev/null +++ b/capability_cards.go @@ -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() +} diff --git a/capability_cards_test.go b/capability_cards_test.go new file mode 100644 index 0000000..a80add7 --- /dev/null +++ b/capability_cards_test.go @@ -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{}) }) + }) +} diff --git a/changes_list.go b/changes_list.go new file mode 100644 index 0000000..c313e18 --- /dev/null +++ b/changes_list.go @@ -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 +} diff --git a/changes_list_test.go b/changes_list_test.go new file mode 100644 index 0000000..0cf540d --- /dev/null +++ b/changes_list_test.go @@ -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) + }) + }) +} diff --git a/chart_frame.go b/chart_frame.go new file mode 100644 index 0000000..9fdad75 --- /dev/null +++ b/chart_frame.go @@ -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 +} diff --git a/chart_frame_test.go b/chart_frame_test.go new file mode 100644 index 0000000..a678038 --- /dev/null +++ b/chart_frame_test.go @@ -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" } diff --git a/chart_series.go b/chart_series.go new file mode 100644 index 0000000..2e992bc --- /dev/null +++ b/chart_series.go @@ -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 + } +} diff --git a/chart_tree.go b/chart_tree.go new file mode 100644 index 0000000..4f51d5a --- /dev/null +++ b/chart_tree.go @@ -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)) +} diff --git a/chat_container.go b/chat_container.go new file mode 100644 index 0000000..a3ad762 --- /dev/null +++ b/chat_container.go @@ -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 +} diff --git a/chat_container_test.go b/chat_container_test.go new file mode 100644 index 0000000..ac9f9ca --- /dev/null +++ b/chat_container_test.go @@ -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) + }) +} diff --git a/chat_empty_state.go b/chat_empty_state.go new file mode 100644 index 0000000..a386f0a --- /dev/null +++ b/chat_empty_state.go @@ -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") +} diff --git a/chat_empty_state_test.go b/chat_empty_state_test.go new file mode 100644 index 0000000..c0eaa73 --- /dev/null +++ b/chat_empty_state_test.go @@ -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"}) }) + } + }) + }) +} diff --git a/chatsession_fixture_test.go b/chatsession_fixture_test.go new file mode 100644 index 0000000..4f2b199 --- /dev/null +++ b/chatsession_fixture_test.go @@ -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"}}, + } +} diff --git a/checkpoint_list.go b/checkpoint_list.go new file mode 100644 index 0000000..ff721e8 --- /dev/null +++ b/checkpoint_list.go @@ -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 +} diff --git a/checkpoint_list_test.go b/checkpoint_list_test.go new file mode 100644 index 0000000..f37f863 --- /dev/null +++ b/checkpoint_list_test.go @@ -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{}) + }) + }) +} diff --git a/code_block.go b/code_block.go new file mode 100644 index 0000000..3143923 --- /dev/null +++ b/code_block.go @@ -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() +} diff --git a/code_block_test.go b/code_block_test.go new file mode 100644 index 0000000..59d1995 --- /dev/null +++ b/code_block_test.go @@ -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}) + }) + }) +} diff --git a/code_shared.go b/code_shared.go new file mode 100644 index 0000000..9a99563 --- /dev/null +++ b/code_shared.go @@ -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 +} diff --git a/code_viewer.go b/code_viewer.go new file mode 100644 index 0000000..cfa9001 --- /dev/null +++ b/code_viewer.go @@ -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 +} diff --git a/code_viewer_test.go b/code_viewer_test.go new file mode 100644 index 0000000..8e493c2 --- /dev/null +++ b/code_viewer_test.go @@ -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) + }) + }) +} diff --git a/command_block.go b/command_block.go new file mode 100644 index 0000000..8d0c87f --- /dev/null +++ b/command_block.go @@ -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) +} diff --git a/command_block_test.go b/command_block_test.go new file mode 100644 index 0000000..589b857 --- /dev/null +++ b/command_block_test.go @@ -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{}) + }) + }) +} diff --git a/command_execution_card.go b/command_execution_card.go new file mode 100644 index 0000000..347f707 --- /dev/null +++ b/command_execution_card.go @@ -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() + }) +} diff --git a/command_execution_card_test.go b/command_execution_card_test.go new file mode 100644 index 0000000..82b0da5 --- /dev/null +++ b/command_execution_card_test.go @@ -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}) + }) + }) +} diff --git a/command_history.go b/command_history.go new file mode 100644 index 0000000..d469546 --- /dev/null +++ b/command_history.go @@ -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) + } + }) + }) +} diff --git a/command_history_test.go b/command_history_test.go new file mode 100644 index 0000000..1f5f599 --- /dev/null +++ b/command_history_test.go @@ -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) + }) + }) +} diff --git a/commit_graph.go b/commit_graph.go new file mode 100644 index 0000000..1bb1bde --- /dev/null +++ b/commit_graph.go @@ -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) +} diff --git a/commit_graph_test.go b/commit_graph_test.go new file mode 100644 index 0000000..2945268 --- /dev/null +++ b/commit_graph_test.go @@ -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) + }) + }) +} diff --git a/commit_input.go b/commit_input.go new file mode 100644 index 0000000..4ec91fe --- /dev/null +++ b/commit_input.go @@ -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 +} diff --git a/commit_input_test.go b/commit_input_test.go new file mode 100644 index 0000000..ae70439 --- /dev/null +++ b/commit_input_test.go @@ -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{}) + }) + }) +} diff --git a/commit_list.go b/commit_list.go new file mode 100644 index 0000000..161e7e6 --- /dev/null +++ b/commit_list.go @@ -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 +} diff --git a/commit_list_test.go b/commit_list_test.go new file mode 100644 index 0000000..1dd521f --- /dev/null +++ b/commit_list_test.go @@ -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) + }) + }) +} diff --git a/completion_menu.go b/completion_menu.go new file mode 100644 index 0000000..60c6ab9 --- /dev/null +++ b/completion_menu.go @@ -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 +} diff --git a/completion_menu_test.go b/completion_menu_test.go new file mode 100644 index 0000000..1ebe98a --- /dev/null +++ b/completion_menu_test.go @@ -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) + }) +} diff --git a/conflict_resolver.go b/conflict_resolver.go new file mode 100644 index 0000000..9806b4e --- /dev/null +++ b/conflict_resolver.go @@ -0,0 +1,220 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ConflictResolverState holds conflict blocks and their choices. +type ConflictResolverState struct { + text string + parts []conflictPart + choices []MergeChoice + bad int + built bool +} + +// conflictPart is plain text or a conflict block. +type conflictPart struct { + lines []string + ours, theirs []string + oursRef, theirsRef string + line int + conflict bool +} + +// Resolved returns chosen blocks and all blocks. +func (s *ConflictResolverState) Resolved() (done, total int) { + for i, p := range s.parts { + if p.conflict { + total++ + if s.choices[i] != MergeUnresolved { + done++ + } + } + } + return done, total +} + +// Result applies the choices; unresolved blocks keep markers. +func (s *ConflictResolverState) Result() string { + var out []string + for i, p := range s.parts { + if !p.conflict { + out = append(out, p.lines...) + continue + } + switch s.choices[i] { + case MergeOurs: + out = append(out, p.ours...) + case MergeTheirs: + out = append(out, p.theirs...) + case MergeBoth: + out = append(append(out, p.ours...), p.theirs...) + default: + out = append(out, p.lines...) + } + } + return strings.Join(out, "\n") +} + +// ConflictResolverOptions configure a ConflictResolver. +type ConflictResolverOptions struct { + Disabled bool +} + +// ConflictResolver resolves the git conflict blocks of text. +func ConflictResolver(c *ui.Context, s *ConflictResolverState, text string, opts ConflictResolverOptions) ChoiceResult { + if !s.built || s.text != text { + s.parts, s.bad = conflictParse(internal.SplitLines(text)) + s.choices = make([]MergeChoice, len(s.parts)) + s.text, s.built = text, true + _, total := s.Resolved() + slog.Debug("mujica: conflict resolver parse", "conflicts", total, "badLine", s.bad) + } + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 3).Disabled(opts.Disabled) + res := ChoiceResult{Element: col} + col.Children(func() { + done, total := s.Resolved() + ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "conflict_resolver.resolved"), done, total)).FontWeight(600).Role(ui.RoleStatus) + if total > 0 { + ui.Box(c).Height(4).Grow(1).Radius(2).Background(k.Border).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(done) / float32(total), H: r.H}, k.Success, 2) + }) + } + }) + if s.bad > 0 { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16) + ui.Text(c, fmt.Sprintf(msg(c, "conflict_resolver.broken"), s.bad)).Role(ui.RoleStatus) + }) + } + if total == 0 && s.bad == 0 { + ui.Text(c, msg(c, "conflict_resolver.none")).TextColor(k.TextMuted) + } + n := 0 + for i, p := range s.parts { + if !p.conflict { + continue + } + n++ + if conflictBlock(c, s, i, n) && !col.IsDisabled() { + res.changed = true + } + } + }) + return res +} + +// conflictBlock shows one conflict and its choice buttons. +func conflictBlock(c *ui.Context, s *ConflictResolverState, i, n int) bool { + k := Tokens(c) + u := Density(c).Unit() + p := s.parts[i] + choice := s.choices[i] + changed := false + card := ui.Column(c).AlignItems(ui.Stretch).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Clip() + card.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*2, u*3).Background(k.Background).Children(func() { + icon, colr, key := "triangle-alert", k.Warning, "conflict_resolver.open" + if choice != MergeUnresolved { + icon, colr, key = "circle-check", k.Success, "conflict_resolver.done" + } + ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(colr) + ui.Text(c, fmt.Sprintf(msg(c, "conflict_resolver.title"), n, p.line)).Font(theme.SerifFont).FontWeight(600) + ui.Text(c, msg(c, key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(colr) + }) + ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1)).Children(func() { + side := func(title, ref string, lines []string, col ui.Color, on bool) { + cell := ui.Column(c).Gap(u).Padding(u*2, u*3).MinWidth(0).Clip() + if on { + cell.Background(col.Mix(k.Surface, 0.88)) + } + cell.Children(func() { + ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() { + if on { + ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText) + } + ui.Text(c, title).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(col) + ui.Text(c, ref).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + }) + mergeLines(c, lines, false) + }) + } + side(msg(c, "conflict_resolver.current"), p.oursRef, p.ours, k.Info, choice == MergeOurs || choice == MergeBoth) + side(msg(c, "conflict_resolver.incoming"), p.theirsRef, p.theirs, k.Warning, choice == MergeTheirs || choice == MergeBoth) + }) + ui.Row(c).Gap(u*2).Padding(u*2, u*3).Wrap().Children(func() { + for _, opt := range []struct { + choice MergeChoice + key string + }{{MergeOurs, "take_current"}, {MergeTheirs, "take_incoming"}, {MergeBoth, "take_both"}, {MergeUnresolved, "reset"}} { + if opt.choice == MergeUnresolved && choice == MergeUnresolved { + continue + } + if mergeChoiceButton(c, msg(c, "conflict_resolver."+opt.key), choice == opt.choice && opt.choice != MergeUnresolved).Clicked() && choice != opt.choice { + s.choices[i], changed = opt.choice, true + slog.Debug("mujica: conflict resolver choose", "block", n, "choice", opt.key) + } + } + }) + }) + return changed +} + +// conflictParse splits at markers; bad is an unclosed block's line. +func conflictParse(lines []string) (parts []conflictPart, bad int) { + var plain []string + for i := 0; i < len(lines); i++ { + if !strings.HasPrefix(lines[i], "<<<<<<<") { + plain = append(plain, lines[i]) + continue + } + start := i + p := conflictPart{conflict: true, line: i + 1, oursRef: strings.TrimSpace(lines[i][7:])} + stage, j := 0, i+1 + for ; j < len(lines); j++ { + l := lines[j] + switch { + case stage == 0 && strings.HasPrefix(l, "|||||||"): + stage = 1 + case stage < 2 && l == "=======": + stage = 2 + case stage == 2 && strings.HasPrefix(l, ">>>>>>>"): + p.theirsRef = strings.TrimSpace(l[7:]) + stage = 3 + case stage == 0: + p.ours = append(p.ours, l) + case stage == 2: + p.theirs = append(p.theirs, l) + } + if stage == 3 { + break + } + } + if stage != 3 { + return []conflictPart{{lines: lines}}, start + 1 + } + if len(plain) > 0 { + parts = append(parts, conflictPart{lines: plain}) + plain = nil + } + p.lines = lines[start : j+1] + parts = append(parts, p) + i = j + } + if len(plain) > 0 { + parts = append(parts, conflictPart{lines: plain}) + } + return parts, 0 +} diff --git a/conflict_resolver_test.go b/conflict_resolver_test.go new file mode 100644 index 0000000..9318d60 --- /dev/null +++ b/conflict_resolver_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +const conflictText = "intro\n<<<<<<< HEAD\nours one\n=======\ntheirs one\n>>>>>>> feature\nmiddle\n<<<<<<< HEAD\nours two\n||||||| base\nold two\n=======\ntheirs two\n>>>>>>> feature\nend" + +func TestConflictParse(t *testing.T) { + parts, bad := conflictParse([]string{"a", "<<<<<<< HEAD", "x", "=======", "y", ">>>>>>> br", "b"}) + if bad != 0 || len(parts) != 3 || !parts[1].conflict || parts[1].oursRef != "HEAD" || parts[1].theirsRef != "br" || parts[1].line != 2 { + t.Fatalf("parts %+v bad %d", parts, bad) + } + parts, bad = conflictParse([]string{"a", "<<<<<<< HEAD", "x", "======="}) + if bad != 2 || len(parts) != 1 || parts[0].conflict { + t.Fatalf("unclosed: %+v bad %d", parts, bad) + } +} + +func TestConflictResolverAcceptAndCount(t *testing.T) { + var s ConflictResolverState + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Scroll(c).Fill().Children(func() { + if ConflictResolver(c, &s, conflictText, ConflictResolverOptions{}).Changed() { + changes++ + } + }) + }, 800, 900) + if !tt.HasText("0 of 2 conflicts resolved") || !tt.HasText("Conflict 2 · line 8") { + t.Fatalf("start: %q", tt.Texts()) + } + if err := tt.Click("Accept incoming"); err != nil { + t.Fatal(err) + } + if !tt.HasText("1 of 2 conflicts resolved") || changes != 1 { + t.Fatalf("after one: %q", tt.Texts()) + } + if err := tt.Click("Accept both"); err != nil { + t.Fatal(err) + } + if got := s.Result(); got != "intro\nours one\ntheirs one\nmiddle\n<<<<<<< HEAD\nours two\n||||||| base\nold two\n=======\ntheirs two\n>>>>>>> feature\nend" { + t.Fatalf("result %q", got) + } + if err := tt.Click("Undo"); err != nil { + t.Fatal(err) + } + if d, n := s.Resolved(); d != 0 || n != 2 || changes != 3 { + t.Fatalf("undo: %d/%d changes %d", d, n, changes) + } +} + +func TestConflictResolverBrokenAndDisabled(t *testing.T) { + var s ConflictResolverState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ConflictResolver(c, &s, "a\n<<<<<<< HEAD\nx", ConflictResolverOptions{}) + }, 600, 300) + if !tt.HasText("Conflict markers are not closed after line 2") { + t.Fatalf("broken: %q", tt.Texts()) + } + if s.Result() != "a\n<<<<<<< HEAD\nx" { + t.Fatalf("broken result %q", s.Result()) + } + var d ConflictResolverState + tt = ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ConflictResolver(c, &d, conflictText, ConflictResolverOptions{Disabled: true}) + }, 800, 900) + _ = tt.Click("Accept current") + if done, _ := d.Resolved(); done != 0 { + t.Fatal("disabled resolver accepted") + } +} + +func TestConflictResolverSnapshot(t *testing.T) { + snapshotAfter(t, "conflict_resolver", 640, 560, func(c *ui.Context) { + s := ui.Local(c.Root(), "s", func() *ConflictResolverState { return &ConflictResolverState{} }) + ui.Column(c).Padding(16).Children(func() { + ConflictResolver(c, *s, conflictText, ConflictResolverOptions{}) + }) + }, func(tt *ui.Tester) { _ = tt.Click("Accept incoming") }) +} diff --git a/conversation_export.go b/conversation_export.go new file mode 100644 index 0000000..3acf073 --- /dev/null +++ b/conversation_export.go @@ -0,0 +1,228 @@ +package mujica + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ExportFormat is an export's text form; zero is ExportMarkdown. +type ExportFormat int + +const ( + ExportMarkdown ExportFormat = iota + ExportJSON + ExportText +) + +// ExportRange is which messages an export holds; zero is ExportAll. +type ExportRange int + +const ( + ExportAll ExportRange = iota + // ExportLast holds the last State.Last messages. + ExportLast + // ExportSelected holds the messages at Options.Selected. + ExportSelected +) + +var exportRoles = map[SessionRole]string{SessionUser: "user", SessionAssistant: "assistant", SessionSystem: "system"} + +// ExportConversation writes messages as Markdown, JSON or plain text. +func ExportConversation(title string, messages []SessionMessage, format ExportFormat) string { + var b strings.Builder + switch format { + case ExportMarkdown: + fmt.Fprintf(&b, "# %s\n", title) + for _, m := range messages { + fmt.Fprintf(&b, "\n**%s**%s\n\n%s\n", exportAuthor(m), exportTime(m, " · "), m.Text) + } + case ExportJSON: + type msgJSON struct { + Role string `json:"role"` + Author string `json:"author,omitempty"` + Text string `json:"text"` + Time string `json:"time,omitempty"` + } + doc := struct { + Title string `json:"title"` + Messages []msgJSON `json:"messages"` + }{Title: title, Messages: []msgJSON{}} + for _, m := range messages { + doc.Messages = append(doc.Messages, msgJSON{exportRoles[m.Role], m.Author, m.Text, exportTime(m, "")}) + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + panic(err) + } + b.Write(out) + b.WriteByte('\n') + case ExportText: + fmt.Fprintf(&b, "%s\n", title) + for _, m := range messages { + fmt.Fprintf(&b, "\n%s%s:\n%s\n", exportAuthor(m), exportTime(m, " "), m.Text) + } + default: + panic("mujica: unknown ExportFormat") + } + return b.String() +} + +func exportAuthor(m SessionMessage) string { + if m.Author != "" { + return m.Author + } + r, ok := exportRoles[m.Role] + if !ok { + panic("mujica: SessionMessage Role out of range") + } + return strings.ToUpper(r[:1]) + r[1:] +} + +func exportTime(m SessionMessage, sep string) string { + if m.Time.IsZero() { + return "" + } + if sep == "" { + return m.Time.Format(time.RFC3339) + } + return sep + "(" + m.Time.Format("2006-01-02 15:04") + ")" +} + +// ConversationExportState is the chosen format and range. +type ConversationExportState struct { + Format ExportFormat + Range ExportRange + // Last is the count for ExportLast; zero is 10. + Last float64 + cache conversationExportCache +} + +// conversationExportCache holds the last export text until its inputs change. +type conversationExportCache struct { + key conversationExportInputs + content string +} + +type conversationExportInputs struct { + title string + format ExportFormat + rng ExportRange + last float64 + first *SessionMessage + n int + sel string + built bool +} + +// conversationExportContent rebuilds the export only when its inputs change. +func conversationExportContent(s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) string { + in := conversationExportInputs{title: opts.Title, format: s.Format, rng: s.Range, last: s.Last, n: len(messages), sel: fmt.Sprint(opts.Selected), built: true} + if len(messages) > 0 { + in.first = &messages[0] + } + if s.cache.key != in { + s.cache = conversationExportCache{in, ExportConversation(opts.Title, conversationExportPick(s, messages, opts.Selected), s.Format)} + } + return s.cache.content +} + +// ConversationExportOptions configure a ConversationExport. Title is required. +type ConversationExportOptions struct { + Title string + // Selected are message indices for ExportSelected. + Selected []int + Disabled bool +} + +// ConversationExportView is a built ConversationExport and the export asked for. +type ConversationExportView struct { + Element *ui.Element + // Exported reports Content is ready for the caller to write. + Exported bool + Content string + Format ExportFormat +} + +// ConversationExport picks format and range, previews, and hands over text. +func ConversationExport(c *ui.Context, s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) ConversationExportView { + if s == nil || opts.Title == "" { + panic("mujica: ConversationExport needs a state and a Title") + } + for _, i := range opts.Selected { + if i < 0 || i >= len(messages) { + panic("mujica: ConversationExport Selected index out of range") + } + } + if s.Last == 0 { + s.Last = 10 + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit() * 3).Disabled(opts.Disabled) + v := ConversationExportView{Element: col, Format: s.Format} + content := conversationExportContent(s, messages, opts) + col.Children(func() { + FormField(c, msg(c, "conversation_export.format"), FormFieldOptions{}, func() *ui.Element { + return RadioGroup(c, &s.Format, []RadioItem[ExportFormat]{ + {Value: ExportMarkdown, Label: "Markdown"}, {Value: ExportJSON, Label: "JSON"}, + {Value: ExportText, Label: msg(c, "conversation_export.text")}, + }, RadioGroupOptions{Horizontal: true, Label: msg(c, "conversation_export.format"), Disabled: opts.Disabled}).Element + }) + FormField(c, msg(c, "conversation_export.range"), FormFieldOptions{}, func() *ui.Element { + return ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + RadioGroup(c, &s.Range, []RadioItem[ExportRange]{ + {Value: ExportAll, Label: fmt.Sprintf(msg(c, "conversation_export.all"), len(messages))}, + {Value: ExportLast, Label: msg(c, "conversation_export.last")}, + {Value: ExportSelected, Label: fmt.Sprintf(msg(c, "conversation_export.selected"), len(opts.Selected)), Disabled: len(opts.Selected) == 0}, + }, RadioGroupOptions{Horizontal: true, Label: msg(c, "conversation_export.range"), Disabled: opts.Disabled}) + if s.Range == ExportLast { + NumberInput(c, &s.Last, NumberInputOptions{Min: 1, Max: float64(max(1, len(messages))), Step: 1, + Label: msg(c, "conversation_export.last"), Disabled: opts.Disabled}).Element.Width(120) + } + }) + }) + ui.Text(c, msg(c, "conversation_export.preview")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Scroll(c).MaxHeight(220).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border). + Radius(theme.ControlRadius).Children(func() { + ui.Text(c, content).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).Selectable() + }) + ui.Row(c).Justify(ui.End).Children(func() { + v.Exported = Button(c, msg(c, "conversation_export.export"), ButtonOptions{Icon: icons.Must("upload"), Disabled: opts.Disabled}).Clicked() + }) + }) + if v.Exported && !col.IsDisabled() { + v.Content = content + slog.Debug("mujica: conversation_export export", "format", s.Format, "bytes", len(content)) + } else { + v.Exported = false + } + return v +} + +// conversationExportPick returns the messages the range holds, in order. +func conversationExportPick(s *ConversationExportState, messages []SessionMessage, selected []int) []SessionMessage { + switch s.Range { + case ExportLast: + return messages[max(0, len(messages)-int(s.Last)):] + case ExportSelected: + var out []SessionMessage + for i := range messages { + for _, j := range selected { + if i == j { + out = append(out, messages[i]) + break + } + } + } + return out + } + return messages +} diff --git a/conversation_export_test.go b/conversation_export_test.go new file mode 100644 index 0000000..68c63aa --- /dev/null +++ b/conversation_export_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestExportConversationFormats(t *testing.T) { + msgs := chatMessages() + md := ExportConversation("Banquet", msgs, ExportMarkdown) + want := "# Banquet\n\n**User** · (2026-10-05 15:20)\n\nHow should guests be seated at the banquet?\n" + if !strings.HasPrefix(md, want) || !strings.Contains(md, "**Lady Ann**") { + t.Fatalf("markdown %q", md) + } + var doc struct { + Title string + Messages []struct{ Role, Author, Text, Time string } + } + if err := json.Unmarshal([]byte(ExportConversation("Banquet", msgs, ExportJSON)), &doc); err != nil { + t.Fatal(err) + } + if doc.Title != "Banquet" || len(doc.Messages) != 3 || doc.Messages[1].Role != "assistant" || doc.Messages[2].Author != "Lady Ann" || + doc.Messages[0].Time != "2026-10-05T15:20:00Z" { + t.Fatalf("json %+v", doc) + } + txt := ExportConversation("Banquet", msgs[:1], ExportText) + if txt != "Banquet\n\nUser (2026-10-05 15:20):\nHow should guests be seated at the banquet?\n" { + t.Fatalf("text %q", txt) + } + if got := ExportConversation("Empty", nil, ExportJSON); !strings.Contains(got, `"messages": []`) { + t.Fatalf("empty json %q", got) + } +} + +func TestConversationExportRangeAndExport(t *testing.T) { + var s ConversationExportState + var last ConversationExportView + selected := []int{2} + tt := chatTester(560, 600, func(c *ui.Context) { + if v := ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "Banquet", Selected: selected}); v.Exported { + last = v + } + }) + _ = tt.Click("JSON") + _ = tt.Click("Selected (1)") + _ = tt.Click("Export") + if !last.Exported || last.Format != ExportJSON || !strings.Contains(last.Content, "And the musicians?") || + strings.Contains(last.Content, "banquet?") { + t.Fatalf("export %+v", last) + } + _ = tt.Click("Plain text") + _ = tt.Click("Last") + s.Last = 2 + tt.Frame() + _ = tt.Click("Export") + if last.Format != ExportText || strings.Contains(last.Content, "banquet?") || !strings.Contains(last.Content, "Seat by rank") { + t.Fatalf("last-2 export %q", last.Content) + } +} + +func TestConversationExportDisabledAndChecks(t *testing.T) { + var s ConversationExportState + exported := false + tt := chatTester(560, 600, func(c *ui.Context) { + if ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "T", Disabled: true}).Exported { + exported = true + } + }) + _ = tt.Click("JSON") + _ = tt.Click("Export") + if exported || s.Format != ExportMarkdown { + t.Fatalf("disabled export acted: %v %v", exported, s.Format) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a bad selection") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "T", Selected: []int{9}}) + }, 100, 100, 1) +} + +func TestConversationExportSnapshot(t *testing.T) { + snapshot(t, "conversation_export", 560, 470, func(c *ui.Context) { + s := &ConversationExportState{} + ui.Column(c).Padding(16).Children(func() { + ConversationExport(c, s, chatMessages(), ConversationExportOptions{Title: "Banquet seating", Selected: []int{0, 2}}) + }) + }) +} diff --git a/conversation_item.go b/conversation_item.go new file mode 100644 index 0000000..2846c97 --- /dev/null +++ b/conversation_item.go @@ -0,0 +1,156 @@ +package mujica + +import ( + "cmp" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ConversationItemOptions configure a ConversationItem. +type ConversationItemOptions struct { + // Today decides the time stamp; zero uses the clock. + Today Date + Selected bool + Disabled bool + // Draggable lets the row be dropped on a ProjectList. + Draggable bool +} + +// ConversationDrag is the value a dragged conversation carries. +type ConversationDrag struct{ ID string } + +// ConversationItemView is a built ConversationItem and its requests. +type ConversationItemView struct { + Element *ui.Element + // Opened, Deleted, Exported and PinToggled are requests for the caller. + Opened, Deleted, Exported, PinToggled bool + // Renamed reports a confirmed new title, already in conv.Title. + Renamed bool +} + +type conversationItemState struct { + editing, fresh bool + draft string +} + +type conversationItemKey struct{} + +// ConversationItem is a conversation row with rename, pin, export, delete. +func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationItemOptions) ConversationItemView { + if conv == nil || conv.ID == "" { + panic("mujica: ConversationItem needs a conversation with an ID") + } + k := Tokens(c) + d := Density(c) + today := opts.Today + if today.IsZero() { + today = DateOf(c.Now()) + } + row := ui.Row(c).Key(conv.ID).FillWidth().MinHeight(d.ControlHeight()).Gap(d.Unit()).AlignItems(ui.Center). + Radius(theme.ControlRadius).Disabled(opts.Disabled) + v := ConversationItemView{Element: row} + st := ui.Local(row, conversationItemKey{}, func() conversationItemState { return conversationItemState{} }) + if row.IsDisabled() { + st.editing = false + } + if opts.Selected { + row.Background(k.Selection) + } + title := cmp.Or(conv.Title, msg(c, "conversation_list.untitled")) + rename := false + row.Children(func() { + if st.editing { + conversationItemEditor(c, k, conv, st, &v) + } else { + main := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2). + Justify(ui.Start).Radius(theme.ControlRadius).Label(title) + internal.Ring(main, k.Focus, theme.ControlRadius) + if opts.Draggable { + main.Drag(ConversationDrag{ID: conv.ID}) + } + if main.Hovered() && !opts.Selected { + main.Background(k.SurfaceHover) + } + main.Transition(colorFade) + main.Children(func() { + if conv.Pinned { + ui.Icon(c, icons.Must("star")).Size(14, 14).Shrink(0).TextColor(k.AccentText).Label(msg(c, "conversation_item.pinned")) + } + t := ui.Text(c, title).SingleLine().Grow(1).MinWidth(0) + if opts.Selected { + t.TextColor(k.AccentText) + } + ui.Text(c, dateSeparatorWhen(c, conv.Updated, today)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0) + }) + if !main.IsDisabled() { + v.Opened = main.Clicked() + rename = main.Shortcut(0, ui.KeyF2) + v.Deleted = main.Shortcut(0, ui.KeyDelete) + } + } + conversationItemMenu(c, msg(c, "conversation_item.actions"), func(m *PopupMenu) { + rename = m.Item(msg(c, "conversation_item.rename"), PopupMenuItemOptions{}) || rename + pin := msg(c, "conversation_item.pin") + if conv.Pinned { + pin = msg(c, "conversation_item.unpin") + } + v.PinToggled = m.Item(pin, PopupMenuItemOptions{}) + v.Exported = m.Item(msg(c, "conversation_item.export"), PopupMenuItemOptions{}) + m.Separator() + v.Deleted = m.Item(msg(c, "conversation_item.delete"), PopupMenuItemOptions{}) || v.Deleted + }) + }) + if rename && !row.IsDisabled() { + st.editing, st.fresh, st.draft = true, true, conv.Title + } + switch { + case v.Opened: + slog.Debug("mujica: conversation_item open", "id", conv.ID) + case v.Deleted, v.Exported, v.PinToggled: + slog.Debug("mujica: conversation_item action", "id", conv.ID, "delete", v.Deleted, "export", v.Exported, "pin", v.PinToggled) + } + return v +} + +// conversationItemEditor edits the title in place; Escape cancels. +func conversationItemEditor(c *ui.Context, k theme.Tokens, conv *ChatConversation, st *conversationItemState, v *ConversationItemView) { + d := Density(c) + in := ui.TextInputBase(c, &st.draft).Grow(1).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.AccentText). + Label(msg(c, "conversation_item.rename")) + internal.Ring(in, k.Focus, theme.ControlRadius) + switch { + case st.fresh: + st.fresh = false + in.Focus() + case in.IsDisabled(): + case in.Submitted(): + if t := strings.TrimSpace(st.draft); t != "" { + v.Renamed = t != conv.Title + conv.Title = t + } + st.editing = false + slog.Debug("mujica: conversation_item rename", "id", conv.ID, "changed", v.Renamed) + case in.Shortcut(0, ui.KeyEscape): + st.editing = false + } +} + +// conversationItemMenu is a borderless ellipsis button opening a menu. +func conversationItemMenu(c *ui.Context, label string, build func(m *PopupMenu)) *ui.Element { + b := iconAction(c, icons.Must("ellipsis"), label).Role(ui.RoleMenuButton).Shrink(0) + open := ui.Local(b, conversationItemKey{}, func() bool { return false }) + if b.IsDisabled() { + *open = false + } else if b.Clicked() || b.Shortcut(0, ui.KeyDown) { + *open = true + } + popupMenuOpen(c, b, open, build) + return b +} diff --git a/conversation_item_test.go b/conversation_item_test.go new file mode 100644 index 0000000..1d358fa --- /dev/null +++ b/conversation_item_test.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func conversationItemTester(conv *ChatConversation, opts ConversationItemOptions, got *ConversationItemView) *ui.Tester { + return chatTester(420, 360, func(c *ui.Context) { + opts.Today = DateOf(chatNow) + v := ConversationItem(c, conv, opts) + if v.Opened || v.Deleted || v.Exported || v.PinToggled || v.Renamed { + *got = v + } + }) +} + +func TestConversationItemShowsTitleAndTime(t *testing.T) { + convs := chatConversations() + var got ConversationItemView + tt := conversationItemTester(&convs[0], ConversationItemOptions{}, &got) + if !tt.HasText("Court etiquette notes") || !tt.HasText("13:30") { + t.Fatalf("texts %q", tt.Texts()) + } + tt = conversationItemTester(&convs[1], ConversationItemOptions{}, &got) + if !tt.HasText("Yesterday") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Ballroom seating plan"); err != nil { + t.Fatal(err) + } + if !got.Opened { + t.Fatal("click did not open") + } +} + +func TestConversationItemMenuActions(t *testing.T) { + for _, tc := range []struct { + item string + want func(v ConversationItemView) bool + }{ + {"Pin", func(v ConversationItemView) bool { return v.PinToggled }}, + {"Export", func(v ConversationItemView) bool { return v.Exported }}, + {"Delete", func(v ConversationItemView) bool { return v.Deleted }}, + } { + conv := chatConversations()[0] + var got ConversationItemView + tt := conversationItemTester(&conv, ConversationItemOptions{}, &got) + if err := tt.Click("Conversation actions"); err != nil { + t.Fatal(err) + } + if err := tt.Click(tc.item); err != nil { + t.Fatal(err) + } + if !tc.want(got) { + t.Errorf("%s: %+v", tc.item, got) + } + } +} + +func TestConversationItemInlineRename(t *testing.T) { + conv := chatConversations()[0] + var got ConversationItemView + tt := conversationItemTester(&conv, ConversationItemOptions{}, &got) + _ = tt.Click("Conversation actions") + _ = tt.Click("Rename") + if !tt.Focused("Rename") { + t.Fatal("rename field not focused") + } + tt.Key(0, ui.KeyEnd) + tt.Type(" II") + tt.Key(0, ui.KeyEnter) + if conv.Title != "Court etiquette notes II" || !got.Renamed { + t.Fatalf("title %q renamed %v", conv.Title, got.Renamed) + } + // F2 starts a rename; Escape keeps the title; a blank title is refused. + _ = tt.Click("Court etiquette notes II") + tt.Key(0, ui.KeyF2) + tt.Type("xyz") + tt.Key(0, ui.KeyEscape) + if conv.Title != "Court etiquette notes II" { + t.Fatalf("Escape changed the title to %q", conv.Title) + } + tt.Key(0, ui.KeyF2) + tt.Key(ui.Cmd, ui.KeyA) + tt.Key(ui.Ctrl, ui.KeyA) + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyEnter) + if conv.Title != "Court etiquette notes II" { + t.Fatalf("blank rename gave %q", conv.Title) + } +} + +func TestConversationItemDisabled(t *testing.T) { + conv := chatConversations()[0] + var got ConversationItemView + tt := conversationItemTester(&conv, ConversationItemOptions{Disabled: true}, &got) + _ = tt.Click("Court etiquette notes") + _ = tt.Click("Conversation actions") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyF2) + tt.Key(0, ui.KeyDelete) + if got.Opened || got.Deleted || tt.HasText("Rename") { + t.Fatalf("disabled item acted: %+v", got) + } +} + +func TestConversationItemSnapshot(t *testing.T) { + snapshot(t, "conversation_item", 420, 140, func(c *ui.Context) { + convs := chatConversations() + ui.Column(c).Padding(16).Gap(4).Children(func() { + ConversationItem(c, &convs[0], ConversationItemOptions{Today: DateOf(chatNow), Selected: true}) + ConversationItem(c, &convs[4], ConversationItemOptions{Today: DateOf(chatNow)}) + ConversationItem(c, &convs[3], ConversationItemOptions{Today: DateOf(chatNow), Disabled: true}) + }) + }) +} diff --git a/conversation_list.go b/conversation_list.go new file mode 100644 index 0000000..9700a1b --- /dev/null +++ b/conversation_list.go @@ -0,0 +1,198 @@ +package mujica + +import ( + "cmp" + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChatConversation is one saved conversation, as the caller stores it. +type ChatConversation struct { + ID, Title string + Updated time.Time + Pinned bool +} + +// ConversationListOptions configure a ConversationList. +type ConversationListOptions struct { + // Now dates the groups; zero uses the window clock. + Now time.Time + // Label names the list for assistive technology. + Label string + Disabled bool +} + +// ConversationListState is the chosen conversation and the list's place. +type ConversationListState struct { + // Selected is the chosen conversation's ID; empty for none. + Selected string + rows ui.ListState + cursor int + plan conversationListPlan +} + +// conversationListPlan holds grouped rows, rebuilt on new data or day. +type conversationListPlan struct { + src []ChatConversation + day Date + rows []conversationListRow + built bool +} + +// conversationListRow is a header (item < 0) or item index. +type conversationListRow struct { + group, item int +} + +// ConversationListView is a built ConversationList and this frame's events. +type ConversationListView struct { + Element *ui.Element + changed bool + submitted bool +} + +// Changed reports a newly chosen conversation, in State.Selected. +func (v ConversationListView) Changed() bool { return v.changed } + +// Submitted reports Enter or a double click on the choice. +func (v ConversationListView) Submitted() bool { return v.submitted } + +// ConversationList groups conversations by recency, building only visible rows. +func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView { + if s == nil { + panic("mujica: ConversationList needs a state") + } + now := opts.Now + if now.IsZero() { + now = c.Now() + } + conversationListPlanRows(&s.plan, items, DateOf(now)) + rows := s.plan.rows + s.cursor = slices.IndexFunc(rows, func(r conversationListRow) bool { return r.item >= 0 && items[r.item].ID == s.Selected }) + before := s.cursor + k := Tokens(c) + d := Density(c) + cursor := &s.cursor + if opts.Disabled { + // A disabled list moves a copy of the cursor. + cp := s.cursor + cursor = &cp + } + s.rows.Key = func(i int) any { + if r := rows[i]; r.item >= 0 { + return items[r.item].ID + } + return conversationListGroups[rows[i].group] + } + s.rows.Header = func(i int) bool { return rows[i].item < 0 } + s.rows.Label = func(i int) string { return conversationListTitle(c, items, rows[i]) } + s.rows.Selected = cursor + var list *ui.Element + wrap := ui.Column(c).MinHeight(0).Disabled(opts.Disabled) + wrap.Children(func() { + restore := dataSelectTheme(c, 0) + list = ui.List(c, &s.rows, len(rows), func(i int) { + r := rows[i] + if r.item < 0 { + conversationListHeader(c, k, msg(c, conversationListGroups[r.group])) + return + } + conv := items[r.item] + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) { + if conv.ID == s.Selected { + p.FillPath(internal.Diamond(b.X+b.W/2, b.Y+b.H/2, 8), k.AccentText) + } + }) + if row != nil { + ui.Row(c).Grow(1).MinWidth(0).Children(func() { row(conv) }) + return + } + ui.Text(c, conversationListTitle(c, items, r)).SingleLine().Grow(1).MinWidth(0) + }) + }).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.ControlRadius).Label(opts.Label) + restore() + internal.Ring(list, k.Focus, theme.ControlRadius) + }) + v := ConversationListView{Element: wrap} + if !wrap.IsDisabled() { + if s.cursor != before && s.cursor >= 0 && rows[s.cursor].item >= 0 { + s.Selected, v.changed = items[rows[s.cursor].item].ID, true + slog.Debug("mujica: conversation_list select", "id", s.Selected) + } + v.submitted = list.Submitted() && s.Selected != "" + } + return v +} + +func conversationListTitle(c *ui.Context, items []ChatConversation, r conversationListRow) string { + if r.item < 0 { + return msg(c, conversationListGroups[r.group]) + } + return cmp.Or(items[r.item].Title, msg(c, "conversation_list.untitled")) +} + +func conversationListHeader(c *ui.Context, k theme.Tokens, title string) { + d := Density(c) + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Center).Background(k.Surface).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).SingleLine() + }) +} + +// conversationListPlanRows regroups items when the slice or the day changed. +func conversationListPlanRows(p *conversationListPlan, items []ChatConversation, today Date) { + same := p.built && p.day == today && len(p.src) == len(items) && (len(items) == 0 || &p.src[0] == &items[0]) + if same { + return + } + p.src, p.day, p.built = items, today, true + order := make([]int, len(items)) + for i := range order { + order[i] = i + } + slices.SortStableFunc(order, func(a, b int) int { return items[b].Updated.Compare(items[a].Updated) }) + groups := [5][]int{} + for _, i := range order { + g := conversationListGroup(items[i], today) + groups[g] = append(groups[g], i) + } + p.rows = p.rows[:0] + for g, idx := range groups { + if len(idx) == 0 { + continue + } + p.rows = append(p.rows, conversationListRow{group: g, item: -1}) + for _, i := range idx { + p.rows = append(p.rows, conversationListRow{item: i}) + } + } + slog.Debug("mujica: conversation_list regroup", "count", len(items)) +} + +var conversationListGroups = [5]string{ + "conversation_list.pinned", "conversation_list.today", "conversation_list.yesterday", + "conversation_list.week", "conversation_list.older", +} + +// conversationListGroup returns the recency group index of conv. +func conversationListGroup(conv ChatConversation, today Date) int { + day := DateOf(conv.Updated) + switch { + case conv.Pinned: + return 0 + case day.Compare(today) >= 0: + return 1 + case day == today.AddDays(-1): + return 2 + case day.Compare(today.AddDays(-7)) >= 0: + return 3 + } + return 4 +} diff --git a/conversation_list_test.go b/conversation_list_test.go new file mode 100644 index 0000000..3307cd6 --- /dev/null +++ b/conversation_list_test.go @@ -0,0 +1,114 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func chatTester(w, h int, view func(c *ui.Context)) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Fill().Children(func() { view(c) }) + }, w, h) +} + +func TestConversationListGroups(t *testing.T) { + var s ConversationListState + items := chatConversations() + tt := chatTester(400, 600, func(c *ui.Context) { + ConversationList(c, &s, items, ConversationListOptions{Now: chatNow}, nil).Element.Height(560) + }) + want := []string{"Pinned", "Royal letters", "Today", "Garden maze", "Court etiquette notes", "Yesterday", + "Ballroom seating plan", "Previous 7 days", "Harpsichord repair", "Older", "Coronation menu"} + got := slices.Compact(slices.DeleteFunc(tt.Texts(), func(s string) bool { return !slices.Contains(want, s) })) + if !slices.Equal(got, want) { + t.Fatalf("rows %q", got) + } +} + +func TestConversationListSelectAndKeys(t *testing.T) { + var s ConversationListState + items := chatConversations() + changes, opened := 0, 0 + tt := chatTester(400, 600, func(c *ui.Context) { + v := ConversationList(c, &s, items, ConversationListOptions{Now: chatNow, Label: "History"}, nil) + v.Element.Height(560) + if v.Changed() { + changes++ + } + if v.Submitted() { + opened++ + } + }) + if err := tt.Click("Garden maze"); err != nil { + t.Fatal(err) + } + if s.Selected != "f" || changes != 1 { + t.Fatalf("selected %q changes %d", s.Selected, changes) + } + tt.Key(0, ui.KeyDown) + if s.Selected != "a" { + t.Fatalf("Down chose %q", s.Selected) + } + tt.Key(0, ui.KeyDown) + if s.Selected != "b" { + t.Fatalf("Down over a header chose %q", s.Selected) + } + tt.Key(0, ui.KeyEnter) + if opened != 1 { + t.Fatalf("Enter opened %d", opened) + } +} + +func TestConversationListDisabled(t *testing.T) { + var s ConversationListState + items := chatConversations() + tt := chatTester(400, 600, func(c *ui.Context) { + ConversationList(c, &s, items, ConversationListOptions{Now: chatNow, Disabled: true}, nil).Element.Height(560) + }) + _ = tt.Click("Garden maze") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if s.Selected != "" { + t.Fatalf("disabled list chose %q", s.Selected) + } +} + +func TestConversationListVirtualizesAndGroupsOnce(t *testing.T) { + items := make([]ChatConversation, 100000) + for i := range items { + items[i] = ChatConversation{ID: fmt.Sprint(i), Title: fmt.Sprintf("Conversation %d", i), Updated: chatNow.Add(-time.Duration(i) * time.Minute)} + } + var s ConversationListState + tt := chatTester(400, 600, func(c *ui.Context) { + ConversationList(c, &s, items, ConversationListOptions{Now: chatNow}, nil).Element.Height(560) + }) + if n := len(tt.Texts()); n > 80 { + t.Fatalf("built %d texts for 100000 conversations", n) + } + // A marked plan survives frames that bring no new data. + s.plan.rows[1].item = -7 + tt.Frame() + if s.plan.rows[1].item != -7 { + t.Fatal("regrouped without a data change") + } + s.plan.rows[1].item = 0 + // Today, Yesterday, Previous 7 days and Older head the rows. + if len(s.plan.rows) != len(items)+4 { + t.Fatalf("%d rows", len(s.plan.rows)) + } +} + +func TestConversationListSnapshot(t *testing.T) { + items := chatConversations() + snapshot(t, "conversation_list", 360, 420, func(c *ui.Context) { + s := &ConversationListState{Selected: "b"} + ui.Column(c).Padding(16).Children(func() { + ConversationList(c, s, items, ConversationListOptions{Now: chatNow, Label: "History"}, nil).Element.Height(380) + }) + }) +} diff --git a/conversation_search.go b/conversation_search.go new file mode 100644 index 0000000..f711f30 --- /dev/null +++ b/conversation_search.go @@ -0,0 +1,178 @@ +package mujica + +import ( + "log/slog" + "unicode" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ConversationSearchHit is one conversation the caller found for the query. +type ConversationSearchHit struct { + ID, Title string + // Snippet is matching message text, shown under the title. + Snippet string +} + +// ConversationSearchOptions configure a ConversationSearch. +type ConversationSearchOptions struct { + // Loading shows the caller's search is under way. + Loading bool + // Error describes a failed search, shown with a retry button. + Error string + Placeholder string + Label string + Disabled bool +} + +// ConversationSearchView is a built ConversationSearch and this frame's events. +type ConversationSearchView struct { + Element *ui.Element + // QueryChanged asks the caller to search for the new query. + QueryChanged bool + // Chosen is the hit opened this frame, or empty. + Chosen string + Retry bool +} + +type conversationSearchState struct { + cursor int + rows ui.ListState + enter bool +} + +type conversationSearchKey struct{} + +// ConversationSearch shows caller-supplied hits for a query, highlighting matches. +func ConversationSearch(c *ui.Context, query *string, hits []ConversationSearchHit, opts ConversationSearchOptions) ConversationSearchView { + if query == nil { + panic("mujica: ConversationSearch needs a query") + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled) + v := ConversationSearchView{Element: col} + st := ui.Local(col, conversationSearchKey{}, func() *conversationSearchState { return &conversationSearchState{cursor: -1} }) + s := *st + s.cursor = min(s.cursor, len(hits)-1) + col.Children(func() { + in := SearchInput(c, query, SearchInputOptions{Placeholder: opts.Placeholder, Label: opts.Label, Disabled: opts.Disabled}) + v.QueryChanged = in.Changed() + if in.Input.Shortcut(0, ui.KeyDown) && !in.Input.IsDisabled() && len(hits) > 0 { + s.enter, s.cursor = true, max(s.cursor, 0) + } + switch { + case opts.Error != "": + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16) + ui.Text(c, opts.Error).Role(ui.RoleStatus).Grow(1) + v.Retry = Button(c, msg(c, "conversation_search.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked() + }) + case opts.Loading && len(hits) == 0: + Spinner(c, SpinnerOptions{Label: msg(c, "conversation_search.searching"), ShowLabel: true}) + case len(hits) == 0 && *query != "": + ui.Text(c, msg(c, "conversation_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus) + case len(hits) > 0: + v.Chosen = conversationSearchList(c, s, *query, hits, opts.Loading) + } + }) + if v.QueryChanged { + s.cursor = -1 + slog.Debug("mujica: conversation_search query", "len", len(*query)) + } + if v.Chosen != "" { + slog.Debug("mujica: conversation_search choose", "id", v.Chosen) + } + return v +} + +func conversationSearchList(c *ui.Context, s *conversationSearchState, q string, hits []ConversationSearchHit, loading bool) string { + k := Tokens(c) + d := Density(c) + chosen := "" + s.rows.Key = func(i int) any { return hits[i].ID } + s.rows.Label = func(i int) string { return hits[i].Title } + s.rows.Selected = &s.cursor + // Rows share one height, so the list sizes itself early. + rowH := d.ControlHeight() + FontSize(c, theme.CaptionSize)*theme.LineHeight + 2 + restore := dataSelectTheme(c, 0) + list := ui.List(c, &s.rows, len(hits), func(i int) { + h := hits[i] + row := ui.Column(c).Height(rowH).Padding(d.Unit(), d.Unit()*2).Gap(2).Justify(ui.Center).ClipY() + if i == s.cursor { + row.DrawOver(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+3, r.Y+r.H/2, 6), k.AccentText) + }) + } + row.Children(func() { + ui.RichText(c, conversationSearchSpans(h.Title, q, k)...).SingleLine() + if h.Snippet != "" { + ui.RichText(c, conversationSearchSpans(h.Snippet, q, k)...).SingleLine().TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + } + }) + if !row.IsDisabled() && row.Clicked() { + chosen = h.ID + } + }).Height(min(360, float32(len(hits))*rowH+2*d.Unit()+2)).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.ControlRadius).Label(msg(c, "conversation_search.results")) + restore() + internal.Ring(list, k.Focus, theme.ControlRadius) + if s.enter { + s.enter = false + list.Focus() + } + if loading { + Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "conversation_search.searching")}) + } + if list.Submitted() && !list.IsDisabled() && s.cursor >= 0 && s.cursor < len(hits) { + chosen = hits[s.cursor].ID + } + return chosen +} + +// conversationSearchSpans marks each case-insensitive match of q in text. +func conversationSearchSpans(text, q string, k theme.Tokens) []ui.Span { + r := []rune(text) + var spans []ui.Span + at := 0 + for _, m := range conversationSearchMatches(text, q) { + if m[0] > at { + spans = append(spans, ui.Span{Text: string(r[at:m[0]])}) + } + spans = append(spans, ui.Span{Text: string(r[m[0]:m[1]]), Weight: 700, Background: k.Selection, Underline: true}) + at = m[1] + } + if at < len(r) || len(spans) == 0 { + spans = append(spans, ui.Span{Text: string(r[at:])}) + } + return spans +} + +// conversationSearchMatches returns rune ranges where q matches, ignoring case. +func conversationSearchMatches(text, q string) [][2]int { + t, p := []rune(text), []rune(q) + if len(p) == 0 { + return nil + } + var out [][2]int + for i := 0; i+len(p) <= len(t); { + ok := true + for j := range p { + if unicode.ToLower(t[i+j]) != unicode.ToLower(p[j]) { + ok = false + break + } + } + if ok { + out = append(out, [2]int{i, i + len(p)}) + i += len(p) + continue + } + i++ + } + return out +} diff --git a/conversation_search_test.go b/conversation_search_test.go new file mode 100644 index 0000000..2afb0f9 --- /dev/null +++ b/conversation_search_test.go @@ -0,0 +1,133 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +// chatSearchBackend answers queries one frame late, as an async caller does. +type chatSearchBackend struct { + query, asked string + hits []ConversationSearchHit + loading bool + queries []string + chosen string + err string + retries int +} + +func (b *chatSearchBackend) view(opts ConversationSearchOptions) func(c *ui.Context) { + return func(c *ui.Context) { + if b.loading && b.err == "" { + b.loading, b.hits = false, nil + for _, conv := range chatConversations() { + if strings.Contains(strings.ToLower(conv.Title), strings.ToLower(b.asked)) { + b.hits = append(b.hits, ConversationSearchHit{ID: conv.ID, Title: conv.Title, Snippet: "about " + conv.Title}) + } + } + } + opts.Loading, opts.Error = b.loading, b.err + v := ConversationSearch(c, &b.query, b.hits, opts) + if v.QueryChanged { + b.queries = append(b.queries, b.query) + b.asked, b.loading = b.query, true + c.Invalidate() + } + if v.Chosen != "" { + b.chosen = v.Chosen + } + if v.Retry { + b.retries++ + } + } +} + +func TestConversationSearchAsyncAndChoose(t *testing.T) { + b := &chatSearchBackend{} + tt := chatTester(420, 400, b.view(ConversationSearchOptions{Label: "Search conversations"})) + tt.Key(0, ui.KeyTab) + tt.Type("garden") + tt.Frame() + if len(b.queries) == 0 || b.queries[len(b.queries)-1] != "garden" { + t.Fatalf("queries %q", b.queries) + } + if !tt.HasText("Garden maze") { + t.Fatalf("results %q", tt.Texts()) + } + if err := tt.Click("Garden maze"); err != nil { + t.Fatal(err) + } + if b.chosen != "f" { + t.Fatalf("chose %q", b.chosen) + } +} + +func TestConversationSearchKeyboard(t *testing.T) { + b := &chatSearchBackend{} + tt := chatTester(420, 400, b.view(ConversationSearchOptions{Label: "Search conversations"})) + tt.Key(0, ui.KeyTab) + tt.Type("o") + tt.Frame() + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if b.chosen == "" || b.chosen != b.hits[1].ID { + t.Fatalf("keys chose %q of %v", b.chosen, b.hits) + } +} + +func TestConversationSearchStates(t *testing.T) { + b := &chatSearchBackend{query: "zzz", asked: "zzz"} + tt := chatTester(420, 400, b.view(ConversationSearchOptions{})) + if !tt.HasText("No conversations match") { + t.Fatalf("texts %q", tt.Texts()) + } + b.err = "Search is down" + tt.Frame() + if !tt.HasText("Search is down") { + t.Fatal("error missing") + } + _ = tt.Click("Retry") + if b.retries != 1 { + t.Fatalf("retries %d", b.retries) + } +} + +func TestConversationSearchHighlights(t *testing.T) { + got := conversationSearchMatches("Ballroom SEATING and seats", "seat") + if !slices.Equal(got, [][2]int{{9, 13}, {21, 25}}) { + t.Fatalf("matches %v", got) + } + if got := conversationSearchMatches("宫廷礼仪与宫廷音乐", "宫廷"); !slices.Equal(got, [][2]int{{0, 2}, {5, 7}}) { + t.Fatalf("CJK matches %v", got) + } + if conversationSearchMatches("abc", "") != nil { + t.Fatal("empty query matched") + } +} + +func TestConversationSearchDisabled(t *testing.T) { + b := &chatSearchBackend{query: "o", hits: []ConversationSearchHit{{ID: "x", Title: "Royal"}}} + tt := chatTester(420, 400, b.view(ConversationSearchOptions{Disabled: true})) + _ = tt.Click("Royal") + tt.Key(0, ui.KeyTab) + tt.Type("q") + if b.chosen != "" || b.query != "o" || len(b.queries) != 0 { + t.Fatalf("disabled search acted: %q %q %v", b.chosen, b.query, b.queries) + } +} + +func TestConversationSearchSnapshot(t *testing.T) { + snapshot(t, "conversation_search", 420, 260, func(c *ui.Context) { + q := "seat" + ui.Column(c).Padding(16).Children(func() { + ConversationSearch(c, &q, []ConversationSearchHit{ + {ID: "b", Title: "Ballroom seating plan", Snippet: "…where to seat the duke…"}, + {ID: "c", Title: "Theatre seats", Snippet: "Box seats for the opera"}, + }, ConversationSearchOptions{}) + }) + }) +} diff --git a/cost_estimator.go b/cost_estimator.go new file mode 100644 index 0000000..b70e411 --- /dev/null +++ b/cost_estimator.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CostEstimatorOptions configure a CostEstimator; prices are per million tokens. +type CostEstimatorOptions struct { + InputTokens, OutputTokens int + InputPrice, OutputPrice float64 + // Currency prefixes amounts; empty is "$". + Currency string + // Digits is the decimals shown for amounts; zero is 4. + Digits int +} + +// CostEstimate is the input, output and total cost. +type CostEstimate struct{ Input, Output, Total float64 } + +// EstimateCost prices token counts at per-million rates. +func EstimateCost(inputTokens, outputTokens int, inputPrice, outputPrice float64) CostEstimate { + in := float64(inputTokens) * inputPrice / 1e6 + out := float64(outputTokens) * outputPrice / 1e6 + return CostEstimate{in, out, in + out} +} + +// CostEstimator shows token counts and cost at caller prices. +func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element { + if opts.InputTokens < 0 || opts.OutputTokens < 0 { + panic("mujica: CostEstimator token counts must not be negative") + } + for _, p := range []float64{opts.InputPrice, opts.OutputPrice} { + if p < 0 || math.IsNaN(p) || math.IsInf(p, 0) { + panic("mujica: CostEstimator prices must be finite and not negative") + } + } + if opts.Digits < 0 { + panic("mujica: CostEstimator Digits is negative") + } + digits := opts.Digits + if digits == 0 { + digits = 4 + } + cur := opts.Currency + if cur == "" { + cur = "$" + } + money := func(v float64) string { return cur + strconv.FormatFloat(v, 'f', digits, 64) } + est := EstimateCost(opts.InputTokens, opts.OutputTokens, opts.InputPrice, opts.OutputPrice) + k := Tokens(c) + d := Density(c) + total := fmt.Sprintf(msg(c, "cost_estimator.total_label"), money(est.Total)) + g := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.FitContent(), ui.FitContent()).GapX(d.Unit()*4).GapY(d.Unit()). + Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). + Role(ui.RoleGroup).Label(total) + cell := func(s string, ink ui.Color, right bool) { + t := ui.Text(c, s).TextColor(ink).SingleLine().FontFeatures("tnum") + if right { + t.JustifySelf(ui.End) + } + } + g.Children(func() { + cell(msg(c, "cost_estimator.kind"), k.TextMuted, false) + cell(msg(c, "cost_estimator.tokens"), k.TextMuted, true) + cell(msg(c, "cost_estimator.cost"), k.TextMuted, true) + cell(msg(c, "cost_estimator.input"), k.Text, false) + cell(strconv.Itoa(opts.InputTokens), k.Text, true) + cell(money(est.Input), k.Text, true) + cell(msg(c, "cost_estimator.output"), k.Text, false) + cell(strconv.Itoa(opts.OutputTokens), k.Text, true) + cell(money(est.Output), k.Text, true) + ui.Box(c).ColumnSpan(3).Height(theme.BorderWidth).MarginY(d.Unit()).Background(k.Ornament) + ui.Text(c, msg(c, "cost_estimator.total")).Font(theme.SerifFont).TextColor(k.Text) + cell(strconv.Itoa(opts.InputTokens+opts.OutputTokens), k.Text, true) + ui.Text(c, money(est.Total)).Bold().TextColor(k.AccentText).JustifySelf(ui.End).FontFeatures("tnum") + ui.Text(c, fmt.Sprintf(msg(c, "cost_estimator.rates"), cur+strconv.FormatFloat(opts.InputPrice, 'f', -1, 64), + cur+strconv.FormatFloat(opts.OutputPrice, 'f', -1, 64))). + ColumnSpan(3).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + return g +} diff --git a/cost_estimator_test.go b/cost_estimator_test.go new file mode 100644 index 0000000..f1dfb2a --- /dev/null +++ b/cost_estimator_test.go @@ -0,0 +1,59 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCostEstimatorTable(t *testing.T) { + tt := chatTester(420, 260, func(c *ui.Context) { + CostEstimator(c, CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15}) + }) + for _, s := range []string{"12000", "3400", "15400", "$0.0360", "$0.0510", "$0.0870", "input $3, output $15"} { + if !tt.HasText(s) { + t.Errorf("%q missing from %q", s, tt.Texts()) + } + } + tt = chatTester(420, 260, func(c *ui.Context) { + CostEstimator(c, CostEstimatorOptions{InputTokens: 1e6, InputPrice: 2.5, Currency: "¥", Digits: 2}) + }) + if !tt.HasText("¥2.50") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestEstimateCost(t *testing.T) { + got := EstimateCost(1_000_000, 500_000, 3, 15) + if got.Input != 3 || got.Output != 7.5 || got.Total != 10.5 { + t.Fatalf("estimate %+v", got) + } + if z := EstimateCost(0, 0, 3, 15); z.Total != 0 { + t.Fatalf("zero tokens cost %v", z.Total) + } +} + +func TestCostEstimatorRejectsBadInput(t *testing.T) { + for _, opts := range []CostEstimatorOptions{{InputTokens: -1}, {InputPrice: -1}, {OutputPrice: math.NaN()}, {Digits: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", opts) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + CostEstimator(c, opts) + }, 100, 100, 1) + }() + } +} + +func TestCostEstimatorSnapshot(t *testing.T) { + snapshot(t, "cost_estimator", 420, 200, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + CostEstimator(c, CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15}) + }) + }) +} diff --git a/countdown.go b/countdown.go new file mode 100644 index 0000000..4afc585 --- /dev/null +++ b/countdown.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CountdownFormat chooses how the time left reads. +type CountdownFormat int + +const ( + // CountdownClock reads 01:02:03, with days as "2d" first. + CountdownClock CountdownFormat = iota + // CountdownWords reads "1 h 2 min 3 s". + CountdownWords +) + +// CountdownOptions configure a Countdown. +type CountdownOptions struct { + Format CountdownFormat + // Now fixes the clock and stops updates. + Now time.Time + Label string +} + +// CountdownResult is the countdown and its finish. +type CountdownResult struct { + Element *ui.Element + finished bool +} + +// Finished reports reaching zero, once per deadline. +func (r CountdownResult) Finished() bool { return r.finished } + +// Countdown shows the time left, ticking each second while shown. +func Countdown(c *ui.Context, deadline time.Time, opts CountdownOptions) CountdownResult { + if opts.Format < CountdownClock || opts.Format > CountdownWords { + panic(fmt.Sprintf("mujica: Countdown Format %d out of range", opts.Format)) + } + k := Tokens(c) + now := opts.Now + live := now.IsZero() + if live { + now = c.Now() + } + left := max(deadline.Sub(now), 0) + shown := left.Truncate(time.Second) + if left%time.Second != 0 { + shown += time.Second + } + label := opts.Label + if label == "" { + label = msg(c, "countdown.label") + } + text := CountdownText(c, shown, opts.Format) + e := ui.Text(c, text).Font(theme.MonoFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text).FontFeatures("tnum"). + Label(label + ", " + CountdownText(c, shown, CountdownWords)) + r := CountdownResult{Element: e} + fired := ui.Local(e, "countdown-fired", func() time.Time { return time.Time{} }) + switch { + case left == 0 && !fired.Equal(deadline): + *fired, r.finished = deadline, true + slog.Debug("mujica: countdown finished", "deadline", deadline) + c.Announce(fmt.Sprintf(msg(c, "countdown.done"), label)) + case left > 0 && live: + c.After(left % time.Second) + if left%time.Second == 0 { + c.After(time.Second) + } + } + if left == 0 { + e.TextColor(k.AccentText) + } + return r +} + +// CountdownText formats a non-negative duration in whole seconds. +func CountdownText(c *ui.Context, d time.Duration, f CountdownFormat) string { + s := int(d / time.Second) + days, h, m, sec := s/86400, s%86400/3600, s%3600/60, s%60 + if f == CountdownClock { + t := fmt.Sprintf("%02d:%02d:%02d", h, m, sec) + if days > 0 { + t = fmt.Sprintf(msg(c, "countdown.days_clock"), days) + " " + t + } + return t + } + out := "" + for i, v := range []int{days, h, m, sec} { + if v > 0 || i == 3 && out == "" { + if out != "" { + out += " " + } + out += fmt.Sprintf(msg(c, []string{"countdown.d", "countdown.h", "countdown.m", "countdown.s"}[i]), v) + } + } + return out +} diff --git a/countdown_test.go b/countdown_test.go new file mode 100644 index 0000000..470b2e0 --- /dev/null +++ b/countdown_test.go @@ -0,0 +1,69 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestCountdownFormatsAndFinishesOnce(t *testing.T) { + deadline := time.Date(2026, time.October, 5, 12, 0, 0, 0, calTokyo) + now, n := deadline.Add(-(26*time.Hour + 3*time.Minute + 4*time.Second + 500*time.Millisecond)), 0 + format := CountdownClock + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Countdown(c, deadline, CountdownOptions{Now: now, Format: format}).Finished() { + n++ + } + }, 400, 120) + if !tt.HasText("Time left, 1 d 2 h 3 min 5 s") { + t.Fatalf("label %v", tt.Texts()) + } + var clock, words string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + clock, words = CountdownText(c, 26*time.Hour+3*time.Minute+5*time.Second, CountdownClock), CountdownText(c, 0, CountdownWords) + }, 10, 10) + if clock != "1d 02:03:05" || words != "0 s" { + t.Fatalf("formats %q %q", clock, words) + } + format = CountdownWords + now = deadline.Add(time.Second) + tt.Frame() + tt.Frame() + if n != 1 || !tt.HasText("Time left, 0 s") { + t.Fatalf("finished %d times, %v", n, tt.Texts()) + } + if a := tt.Announcements(); len(a) != 1 || a[0] != "Time left: time is up." { + t.Fatalf("announcements %q", a) + } + deadline = deadline.Add(time.Minute) + now = deadline + tt.Frame() + if n != 2 { + t.Fatal("a new deadline should finish again") + } +} + +func TestCountdownInvalidFormat(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Countdown(c, time.Now(), CountdownOptions{Format: 7}) + }, 10, 10) +} + +func TestCountdownSnapshot(t *testing.T) { + deadline := time.Date(2026, time.October, 5, 12, 0, 0, 0, calTokyo) + snapshot(t, "countdown", 320, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + Countdown(c, deadline, CountdownOptions{Now: deadline.Add(-(3*time.Hour + 25*time.Second))}) + Countdown(c, deadline, CountdownOptions{Now: deadline.Add(-90 * time.Second), Format: CountdownWords}) + }) + }) +} diff --git a/cron_editor.go b/cron_editor.go new file mode 100644 index 0000000..cf36e19 --- /dev/null +++ b/cron_editor.go @@ -0,0 +1,346 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CronSchedule is a parsed five-field cron expression. +type CronSchedule struct { + fields [5]uint64 + tokens [5]string + // domAny and dowAny note a "*" day field. + domAny, dowAny bool +} + +// CronProblem is what is wrong with a cron field. +type CronProblem int + +const ( + CronFieldCount CronProblem = iota + CronEmptyItem + CronBadStep + CronNotNumber + CronOutOfRange + CronBackwards +) + +// CronError locates a problem in a cron expression. +type CronError struct { + // Field is 0 to 4, or -1 for the count. + Field int + Problem CronProblem + // Text is the offending part. + Text string +} + +var cronProblemKeys = [...]string{"cron_editor.err_count", "cron_editor.err_empty", "cron_editor.err_step", "cron_editor.err_number", + "cron_editor.err_range", "cron_editor.err_backwards"} + +func (e *CronError) Error() string { + return fmt.Sprintf("cron field %d: problem %d at %q", e.Field+1, e.Problem, e.Text) +} + +var cronBounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}} + +var cronNames = [5][]string{3: {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"}, + 4: {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}} + +// ParseCron parses standard five-field cron with names. +func ParseCron(expr string) (CronSchedule, error) { + parts := strings.Fields(expr) + var s CronSchedule + if len(parts) != 5 { + return s, &CronError{-1, CronFieldCount, strconv.Itoa(len(parts))} + } + for f, part := range parts { + s.tokens[f] = part + for _, item := range strings.Split(part, ",") { + bits, err := cronItem(f, strings.ToUpper(item)) + if err != nil { + err.Field = f + return CronSchedule{}, err + } + s.fields[f] |= bits + } + } + if s.fields[4]&(1<<7) != 0 { + s.fields[4] |= 1 + } + s.domAny, s.dowAny = parts[2] == "*", parts[4] == "*" + return s, nil +} + +// cronItem parses one list item into a bit set. +func cronItem(f int, item string) (uint64, *CronError) { + lo, hi := cronBounds[f][0], cronBounds[f][1] + if item == "" { + return 0, &CronError{Problem: CronEmptyItem} + } + rng, step := item, 1 + if i := strings.IndexByte(item, '/'); i >= 0 { + n, err := strconv.Atoi(item[i+1:]) + if err != nil || n < 1 { + return 0, &CronError{Problem: CronBadStep, Text: item[i+1:]} + } + rng, step = item[:i], n + } + a, b := lo, hi + switch { + case rng == "*": + case strings.Contains(rng, "-"): + x, y, _ := strings.Cut(rng, "-") + var e *CronError + if a, e = cronValue(f, x); e != nil { + return 0, e + } + if b, e = cronValue(f, y); e != nil { + return 0, e + } + if a > b { + return 0, &CronError{Problem: CronBackwards, Text: rng} + } + default: + var e *CronError + if a, e = cronValue(f, rng); e != nil { + return 0, e + } + if step == 1 { + b = a + } + } + var bits uint64 + for v := a; v <= b; v += step { + bits |= 1 << v + } + return bits, nil +} + +func cronValue(f int, s string) (int, *CronError) { + for i, n := range cronNames[f] { + if s == n { + return i + cronBounds[f][0], nil + } + } + v, err := strconv.Atoi(s) + if err != nil { + return 0, &CronError{Problem: CronNotNumber, Text: s} + } + if v < cronBounds[f][0] || v > cronBounds[f][1] { + return 0, &CronError{Problem: CronOutOfRange, Text: s} + } + return v, nil +} + +// CronRun is one upcoming run; Repeated marks a DST repeat. +type CronRun struct { + At time.Time + Repeated bool +} + +// Next returns up to n runs within five years. +func (s CronSchedule) Next(t time.Time, loc *time.Location, n int) []CronRun { + var out []CronRun + w := t.In(loc) + day := DateOf(w) + for i := 0; i < 5*366 && len(out) < n; i, day = i+1, day.AddDays(1) { + if !s.dayMatches(day) { + continue + } + for h := 0; h < 24 && len(out) < n; h++ { + if s.fields[1]&(1< 1}) + } + } + } + return out +} + +// dayMatches applies cron's either-day-field rule. +func (s CronSchedule) dayMatches(d Date) bool { + if s.fields[3]&(1<` + body + ``)) +} + +var debugIcons = map[DebugAction]*ui.SVG{ + DebugContinue: debugIcon(``), + DebugPause: debugIcon(``), + DebugStepOver: debugIcon(``), + DebugStepInto: debugIcon(``), + DebugStepOut: debugIcon(``), + DebugRestart: debugIcon(``), + DebugStop: debugIcon(``), +} + +// debugShortcut is an action's key, as in common IDEs. +type debugShortcut struct { + mods ui.Modifiers + key ui.Key + name string +} + +func debugShortcuts() map[DebugAction]debugShortcut { + cmd := "Ctrl" + if runtime.GOOS == "darwin" { + cmd = "⌘" + } + return map[DebugAction]debugShortcut{ + DebugContinue: {0, ui.KeyF5, "F5"}, + DebugPause: {0, ui.KeyF6, "F6"}, + DebugStepOver: {0, ui.KeyF10, "F10"}, + DebugStepInto: {0, ui.KeyF11, "F11"}, + DebugStepOut: {ui.Shift, ui.KeyF11, "Shift+F11"}, + DebugRestart: {ui.Cmd | ui.Shift, ui.KeyF5, cmd + "+Shift+F5"}, + DebugStop: {ui.Shift, ui.KeyF5, "Shift+F5"}, + } +} + +// DebugEnabled reports whether action applies in state. +func DebugEnabled(state DebugState, a DebugAction) bool { + switch a { + case DebugContinue: + return state != DebugRunning + case DebugPause: + return state == DebugRunning + case DebugStepOver, DebugStepInto, DebugStepOut: + return state == DebugPaused + case DebugRestart, DebugStop: + return state != DebugStopped + } + return false +} + +// DebugToolbar shows debugger actions enabled by state. +func DebugToolbar(c *ui.Context, state DebugState, opts DebugToolbarOptions) DebugToolbarResult { + if state < DebugStopped || state > DebugPaused { + panic("mujica: DebugState out of range") + } + keys := debugShortcuts() + var res DebugToolbarResult + res.Element = Toolbar(c, ToolbarOptions{Label: msg(c, "debug_toolbar.label")}, func() { + first := DebugContinue + if state == DebugRunning { + first = DebugPause + } + for _, a := range []DebugAction{first, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop} { + name := msg(c, debugKeys[a]) + if a == DebugContinue && state == DebugStopped { + name = msg(c, "debug_toolbar.start") + } + on := DebugEnabled(state, a) && !opts.Disabled + b := ToolbarButton(c, name, ToolbarButtonOptions{Icon: debugIcons[a], Disabled: !on}) + b.Tooltip(fmt.Sprintf("%s (%s)", name, keys[a].name)) + if b.Clicked() && on && !b.IsDisabled() { + res.Action = a + } + } + }).Disabled(opts.Disabled) + if res.Action == DebugNone && !res.Element.IsDisabled() { + for _, a := range []DebugAction{DebugContinue, DebugPause, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop} { + if k := keys[a]; DebugEnabled(state, a) && c.Shortcut(k.mods, k.key) { + res.Action = a + break + } + } + } + if res.Action != DebugNone { + slog.Debug("mujica: debug_toolbar action", "action", res.Action, "state", state) + } + return res +} + +var debugKeys = map[DebugAction]string{ + DebugContinue: "debug_toolbar.continue", DebugPause: "debug_toolbar.pause", DebugStepOver: "debug_toolbar.stepOver", + DebugStepInto: "debug_toolbar.stepInto", DebugStepOut: "debug_toolbar.stepOut", DebugRestart: "debug_toolbar.restart", DebugStop: "debug_toolbar.stop", +} diff --git a/debug_toolbar_test.go b/debug_toolbar_test.go new file mode 100644 index 0000000..be2afe1 --- /dev/null +++ b/debug_toolbar_test.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func debugTester(state *DebugState, disabled bool, got *[]DebugAction) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if a := DebugToolbar(c, *state, DebugToolbarOptions{Disabled: disabled}).Action; a != DebugNone { + *got = append(*got, a) + } + }, 480, 120) +} + +func TestDebugEnabledRules(t *testing.T) { + cases := []struct { + state DebugState + on []DebugAction + }{ + {DebugStopped, []DebugAction{DebugContinue}}, + {DebugRunning, []DebugAction{DebugPause, DebugRestart, DebugStop}}, + {DebugPaused, []DebugAction{DebugContinue, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop}}, + } + for _, tc := range cases { + n := 0 + for a := DebugContinue; a <= DebugStop; a++ { + if DebugEnabled(tc.state, a) { + n++ + } + } + if n != len(tc.on) { + t.Fatalf("state %d enables %d actions, want %d", tc.state, n, len(tc.on)) + } + for _, a := range tc.on { + if !DebugEnabled(tc.state, a) { + t.Fatalf("state %d disables %d", tc.state, a) + } + } + } +} + +func TestDebugToolbarClicksFollowState(t *testing.T) { + state := DebugPaused + var got []DebugAction + tt := debugTester(&state, false, &got) + for _, name := range []string{"Step over", "Step into", "Step out", "Continue"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + want := []DebugAction{DebugStepOver, DebugStepInto, DebugStepOut, DebugContinue} + if len(got) != 4 || got[0] != want[0] || got[3] != want[3] { + t.Fatalf("actions %v", got) + } + state = DebugRunning + tt.Frame() + _ = tt.Click("Step over") + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + if len(got) != 5 || got[4] != DebugPause { + t.Fatalf("running: actions %v", got) + } + state = DebugStopped + tt.Frame() + if _, ok := tt.Find("Start"); !ok { + t.Fatal("stopped toolbar does not offer Start") + } +} + +func TestDebugToolbarShortcuts(t *testing.T) { + state := DebugPaused + var got []DebugAction + tt := debugTester(&state, false, &got) + tt.Key(0, ui.KeyF10) + tt.Key(ui.Shift, ui.KeyF11) + tt.Key(ui.Shift, ui.KeyF5) + state = DebugStopped + tt.Frame() + tt.Key(0, ui.KeyF10) + tt.Key(0, ui.KeyF5) + want := []DebugAction{DebugStepOver, DebugStepOut, DebugStop, DebugContinue} + if len(got) != len(want) { + t.Fatalf("actions %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("actions %v, want %v", got, want) + } + } +} + +func TestDebugToolbarDisabled(t *testing.T) { + state := DebugPaused + var got []DebugAction + tt := debugTester(&state, true, &got) + _ = tt.Click("Continue") + tt.Key(0, ui.KeyF10) + if len(got) != 0 { + t.Fatalf("disabled toolbar acted: %v", got) + } +} + +func TestDebugToolbarSnapshot(t *testing.T) { + snapshot(t, "debug_toolbar", 340, 70, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { + DebugToolbar(c, DebugPaused, DebugToolbarOptions{}) + }) + }) +} diff --git a/depth_ladder.go b/depth_ladder.go new file mode 100644 index 0000000..6b76af6 --- /dev/null +++ b/depth_ladder.go @@ -0,0 +1,178 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// DepthLadderOptions configure a DepthLadder. TickSize is required. +type DepthLadderOptions struct { + TickSize float64 + // Decimals of prices and sizes; zero means 2 and 0. + Decimals, SizeDecimals int + // Rows shown; zero means 15. + Rows int + Disabled bool +} + +// DepthLadderState is the ladder's scroll from last price, in ticks. +type DepthLadderState struct { + Offset int + cache depthLadderCache +} + +type depthLadderCache struct { + key bookKey + bids, asks map[int64]float64 +} + +// DepthLadderResult is a built DepthLadder and its clicked cell. +type DepthLadderResult struct { + Element *ui.Element + price float64 + side OrderSide +} + +// Clicked returns the clicked cell's price and side. +func (r DepthLadderResult) Clicked() (price float64, side OrderSide, ok bool) { + return r.price, r.side, r.side != 0 +} + +// DepthLadder is a price ladder around last; cell clicks order. +func DepthLadder(c *ui.Context, s *DepthLadderState, last float64, book OrderBookData, opts DepthLadderOptions) DepthLadderResult { + dec := priceDecimals("DepthLadder", opts.Decimals) + if opts.SizeDecimals < 0 || opts.Rows < 0 { + panic("mujica: DepthLadder SizeDecimals and Rows cannot be negative") + } + scale := math.Pow(10, float64(dec)) + tick := int64(math.Round(opts.TickSize * scale)) + if tick < 1 || math.Abs(opts.TickSize*scale-float64(tick)) > 1e-6 { + panic(fmt.Sprintf("mujica: DepthLadder TickSize %v is not a whole number of %d-decimal units", opts.TickSize, dec)) + } + rows := opts.Rows + if rows == 0 { + rows = 15 + } + depthLadderRefresh(s, book, scale, tick) + center := int64(math.Round(last*scale/float64(tick))) * tick + k := Tokens(c) + d := Density(c) + var r DepthLadderResult + r.Element = ui.Column(c).Gap(d.Unit()).Disabled(opts.Disabled).Role(ui.RoleTable).Label(msg(c, "depth_ladder.label")) + disabled := r.Element.IsDisabled() + move := func(by int) { + if !disabled && by != 0 { + s.Offset += by + slog.Debug("mujica: depth_ladder scrolled", "offset", s.Offset) + } + } + if r.Element.Shortcut(0, ui.KeyPageUp) { + move(rows / 2) + } + if r.Element.Shortcut(0, ui.KeyPageDown) { + move(-rows / 2) + } + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "depth_ladder.bid")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).Basis(0).TextAlign(ui.End) + ui.Text(c, msg(c, "depth_ladder.price")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).Basis(0).TextAlign(ui.Center) + ui.Text(c, msg(c, "depth_ladder.ask")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).Basis(0) + }) + grid := ui.Column(c).FocusGroup(ui.Vertical | ui.Horizontal) + grid.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind != ui.InputScroll || disabled || ev.DY == 0 { + return false + } + move(int(math.Copysign(1, float64(-ev.DY)))) + return true + }) + grid.Children(func() { + top := center + int64(s.Offset+rows/2)*tick + for i := range rows { + t := top - int64(i)*tick + depthLadderRow(c, &r, s, t, t == center, scale, dec, opts.SizeDecimals) + } + }) + if s.Offset != 0 { + if Button(c, msg(c, "depth_ladder.recenter"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"), Disabled: disabled}).Clicked() { + s.Offset = 0 + } + } + }) + return r +} + +// depthLadderRow is a buy cell, the price, a sell cell. +func depthLadderRow(c *ui.Context, r *DepthLadderResult, s *DepthLadderState, t int64, current bool, scale float64, dec, sizeDec int) { + k := Tokens(c) + d := Density(c) + price := float64(t) / scale + text := statisticFormat(price, dec) + ui.Row(c).Key(t).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + cell := func(side OrderSide, size float64) { + label := fmt.Sprintf(msg(c, fmt.Sprintf("depth_ladder.order%d", side)), text) + b := ui.ButtonBase(c).Grow(1).Basis(0).Padding(0, d.Unit()*2).Label(label) + if side == SideBuy { + b.Justify(ui.End) + } else { + b.Justify(ui.Start) + } + internal.Ring(b, k.Focus, 0) + if b.Hovered() { + b.Background(trendColor(c, side.sign()).Alpha(0.14)) + } + b.Children(func() { + if size > 0 { + ui.Text(c, statisticFormat(size, sizeDec)).TextColor(trendColor(c, side.sign())).FontFeatures("tnum").FontWeight(600).SingleLine() + } + }) + if b.Clicked() && !b.IsDisabled() { + r.price, r.side = price, side + slog.Debug("mujica: depth_ladder order", "side", side) + } + } + cell(SideBuy, s.cache.bids[t]) + mid := ui.Row(c).Grow(1).Basis(0).Justify(ui.Center).AlignItems(ui.Center).Gap(d.Unit()).Background(k.Background) + if current { + mid.Background(k.Selection).Label(msg(c, "depth_ladder.last") + " " + text) + mid.DrawOver(func(p *ui.Painter, rect ui.Rect) { + internal.Frame(p, rect, 2, 4, k.AccentText) + }) + } + mid.Children(func() { + if current { + ui.Box(c).Size(8, 8).Draw(func(p *ui.Painter, rect ui.Rect) { + p.FillPath(internal.Diamond(rect.X+4, rect.Y+4, 8), k.AccentText) + }) + } + t := ui.Text(c, text).FontFeatures("tnum").SingleLine() + if current { + t.FontWeight(700) + } + }) + cell(SideSell, s.cache.asks[t]) + }) +} + +// depthLadderRefresh indexes sizes by tick when the book changed. +func depthLadderRefresh(s *DepthLadderState, book OrderBookData, scale float64, tick int64) { + key := bookKeyOf(book, float64(tick)/scale) + if s.cache.bids != nil && s.cache.key == key { + return + } + index := func(ls []BookLevel) map[int64]float64 { + m := make(map[int64]float64, len(ls)) + for _, l := range ls { + m[int64(math.Round(l.Price*scale/float64(tick)))*tick] += l.Size + } + return m + } + s.cache = depthLadderCache{key: key, bids: index(book.Bids), asks: index(book.Asks)} +} diff --git a/depth_ladder_test.go b/depth_ladder_test.go new file mode 100644 index 0000000..6af2c74 --- /dev/null +++ b/depth_ladder_test.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func depthLadderBook() OrderBookData { + return OrderBookData{Bids: []BookLevel{{100.00, 5}, {99.99, 3}}, Asks: []BookLevel{{100.01, 2}, {100.03, 7}}} +} + +func TestDepthLadderCentersAndOrders(t *testing.T) { + var s DepthLadderState + var price float64 + var side OrderSide + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + if p, sd, ok := DepthLadder(c, &s, 100.004, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Rows: 7}).Clicked(); ok { + price, side = p, sd + } + }) + top, _ := tt.Find("100.03") + mid, _ := tt.Find("Last price 100.00") + bottom, _ := tt.Find("99.97") + if !(top.Y < mid.Y && mid.Y < bottom.Y) || tt.HasText("100.04") { + t.Fatalf("not centered: %v %v %v %q", top, mid, bottom, tt.Texts()) + } + if err := tt.Click("Sell at 100.03"); err != nil { + t.Fatal(err) + } + if price != 100.03 || side != SideSell { + t.Fatalf("clicked %v %v", price, side) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyEnter) + if price != 100.03 || side != SideBuy { + t.Fatalf("keyboard %v %v", price, side) + } + if !tt.HasText("7") || !tt.HasText("5") { + t.Fatalf("sizes: %q", tt.Texts()) + } +} + +func TestDepthLadderScrollsAndRecenters(t *testing.T) { + var s DepthLadderState + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + DepthLadder(c, &s, 100, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Rows: 7}) + }) + _ = tt.Click("Buy at 100.00") + tt.Key(0, ui.KeyPageUp) + if s.Offset != 3 || !tt.HasText("100.06") { + t.Fatalf("page up: offset %d %q", s.Offset, tt.Texts()) + } + tt.Scroll(100, 200, 0, 40) + if s.Offset != 2 { + t.Fatalf("wheel: offset %d", s.Offset) + } + if err := tt.Click("Center on last price"); err != nil { + t.Fatal(err) + } + if s.Offset != 0 || tt.HasText("Center on last price") { + t.Fatalf("recenter: %d", s.Offset) + } +} + +// Only the visible rows are built, whatever the book's size. +func TestDepthLadderLargeBook(t *testing.T) { + var s DepthLadderState + book := OrderBookData{} + for i := range 100_000 { + book.Bids = append(book.Bids, BookLevel{Price: 100 - float64(i)/100, Size: 1}) + } + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + DepthLadder(c, &s, 100, book, DepthLadderOptions{TickSize: 0.01, Rows: 9}) + }) + if n := len(tt.Texts()); n > 60 { + t.Fatalf("built %d texts", n) + } + // A sentinel survives only if the index is kept. + s.cache.bids[-1] = 42 + tt.Frame() + if s.cache.bids[-1] != 42 { + t.Fatal("index rebuilt without a change") + } +} + +func TestDepthLadderDisabledAndBadTick(t *testing.T) { + var s DepthLadderState + clicked := false + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + _, _, clicked = DepthLadder(c, &s, 100, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Disabled: true}).Clicked() + }) + _ = tt.Click("Buy at 100.00") + tt.Scroll(100, 200, 0, 40) + if clicked || s.Offset != 0 { + t.Fatal("disabled ladder acted") + } + defer func() { + if recover() == nil { + t.Fatal("zero tick accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { DepthLadder(c, &s, 100, depthLadderBook(), DepthLadderOptions{}) }) +} + +func TestDepthLadderSnapshot(t *testing.T) { + snapshot(t, "depth_ladder", 340, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + s := ui.Local(c.Root(), "snap", func() DepthLadderState { return DepthLadderState{} }) + DepthLadder(c, s, 100, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Rows: 7}) + }) + }) +} diff --git a/device_selector.go b/device_selector.go new file mode 100644 index 0000000..e7b9e92 --- /dev/null +++ b/device_selector.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MediaDevice is one device; IDs are unique within a kind. +type MediaDevice struct { + ID, Name string +} + +// MediaDevices are the caller's lists; nil hides that kind. +type MediaDevices struct { + Microphones, Cameras, Speakers []MediaDevice +} + +// DeviceSelection holds each kind's chosen ID; empty is none. +type DeviceSelection struct { + Microphone, Camera, Speaker string +} + +// DeviceSelectorOptions configure a DeviceSelector. +type DeviceSelectorOptions struct { + Disabled bool +} + +// DeviceSelector chooses a microphone, camera and speaker. +func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult { + old := *sel + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).Gap(u * 3).Role(ui.RoleGroup).Label(msg(c, "device_selector.label")).Disabled(opts.Disabled) + col.Children(func() { + deviceRow(c, k, mediaMic, "device_selector.microphone", &sel.Microphone, devices.Microphones, opts.Disabled) + deviceRow(c, k, mediaCamera, "device_selector.camera", &sel.Camera, devices.Cameras, opts.Disabled) + deviceRow(c, k, mediaSpeaker, "device_selector.speaker", &sel.Speaker, devices.Speakers, opts.Disabled) + }) + changed := *sel != old + if changed { + slog.Debug("mujica: device_selector changed", "selection", *sel) + } + return ChoiceResult{Element: col, changed: changed} +} + +func deviceRow(c *ui.Context, k theme.Tokens, icon *ui.SVG, key string, id *string, list []MediaDevice, disabled bool) { + if list == nil { + return + } + options := make([]SelectOption[string], len(list)) + for i, d := range list { + if d.ID == "" { + panic("mujica: DeviceSelector device without an ID") + } + for _, o := range options[:i] { + if o.Value == d.ID { + panic("mujica: DeviceSelector duplicate device ID " + d.ID) + } + } + options[i] = SelectOption[string]{Value: d.ID, Label: d.Name} + } + if _, ok := selectLabel(options, *id); *id != "" && !ok { + panic("mujica: DeviceSelector selection " + *id + " is not in its list") + } + ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icon).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, msg(c, key)).Width(96).SingleLine() + hint := msg(c, "device_selector.choose") + if len(list) == 0 { + hint = msg(c, "device_selector.none") + } + Select(c, id, options, SelectOptions{Disabled: disabled || len(list) == 0, Label: msg(c, key), + Placeholder: hint, Empty: msg(c, "device_selector.none")}).Element.Grow(1) + }) +} diff --git a/device_selector_test.go b/device_selector_test.go new file mode 100644 index 0000000..74c8b79 --- /dev/null +++ b/device_selector_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var deviceTestList = MediaDevices{ + Microphones: []MediaDevice{{"m1", "Built-in Microphone"}, {"m2", "Studio Mic"}}, + Cameras: []MediaDevice{}, + Speakers: []MediaDevice{{"s1", "Headphones"}}, +} + +func deviceTester(sel *DeviceSelection, devices MediaDevices, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(420).Children(func() { + if DeviceSelector(c, sel, devices, DeviceSelectorOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 500, 400) +} + +func TestDeviceSelectorChoose(t *testing.T) { + sel, n := DeviceSelection{Microphone: "m1"}, 0 + tt := deviceTester(&sel, deviceTestList, false, &n) + if !tt.HasText("Built-in Microphone") || !tt.HasText("No devices found") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Built-in Microphone"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Studio Mic"); err != nil { + t.Fatal(err) + } + if sel.Microphone != "m2" || n != 1 { + t.Fatalf("selection %+v changes %d", sel, n) + } +} + +func TestDeviceSelectorHidesNilListsAndDisabled(t *testing.T) { + sel, n := DeviceSelection{}, 0 + tt := deviceTester(&sel, MediaDevices{Speakers: deviceTestList.Speakers}, true, &n) + if tt.HasText("Camera") || tt.HasText("Microphone") { + t.Fatalf("nil lists shown: %v", tt.Texts()) + } + _ = tt.Click("Choose a device") + _ = tt.Click("Headphones") + if sel.Speaker != "" || n != 0 { + t.Fatal("disabled selector chose") + } +} + +func TestDeviceSelectorBadLists(t *testing.T) { + sel := DeviceSelection{} + mediaPanics(t, "duplicate", func(c *ui.Context) { + DeviceSelector(c, &sel, MediaDevices{Cameras: []MediaDevice{{"a", "A"}, {"a", "B"}}}, DeviceSelectorOptions{}) + }) + gone := DeviceSelection{Camera: "x"} + mediaPanics(t, "unknown selection", func(c *ui.Context) { + DeviceSelector(c, &gone, MediaDevices{Cameras: []MediaDevice{{"a", "A"}}}, DeviceSelectorOptions{}) + }) +} + +func TestDeviceSelectorSnapshot(t *testing.T) { + sel := DeviceSelection{Microphone: "m2", Speaker: "s1"} + snapshot(t, "device_selector", 460, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + DeviceSelector(c, &sel, deviceTestList, DeviceSelectorOptions{}) + }) + }) +} diff --git a/diff_stat.go b/diff_stat.go new file mode 100644 index 0000000..cba272f --- /dev/null +++ b/diff_stat.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// DiffStatOptions configure a DiffStat. +type DiffStatOptions struct { + // Blocks is the ratio bar length; zero means 5. + Blocks int +} + +// DiffStat shows added and deleted counts with a ratio bar. +func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) *ui.Element { + if added < 0 || deleted < 0 { + panic("mujica: DiffStat counts must not be negative") + } + n := opts.Blocks + if n == 0 { + n = 5 + } + if n < 0 { + panic("mujica: DiffStat Blocks is negative") + } + k := Tokens(c) + u := Density(c).Unit() + plus, minus := diffStatBlocks(added, deleted, n) + row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Shrink(0). + Label(fmt.Sprintf(msg(c, "diff_stat.label"), added, deleted)) + row.Children(func() { + mono := func(s string, col ui.Color) { + ui.Text(c, s).Font(theme.MonoFont).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).SingleLine() + } + mono(fmt.Sprintf("+%d", added), k.Success) + mono(fmt.Sprintf("−%d", deleted), k.Danger) + ui.Row(c).Gap(2).Shrink(0).Role(ui.RoleNone).Children(func() { + for i := range n { + col := k.Border + switch { + case i < plus: + col = k.Success + case i < plus+minus: + col = k.Danger + } + ui.Box(c).Size(8, 8).Radius(1).Background(col) + } + }) + }) + return row +} + +// diffStatBlocks splits n blocks, one at least per nonzero side. +func diffStatBlocks(added, deleted, n int) (plus, minus int) { + total := added + deleted + if total == 0 { + return 0, 0 + } + plus = int(math.Round(float64(added) / float64(total) * float64(n))) + minus = n - plus + if added > 0 && plus == 0 { + plus, minus = 1, n-1 + } + if deleted > 0 && minus == 0 && n > 1 { + plus, minus = n-1, 1 + } + return plus, minus +} diff --git a/diff_stat_test.go b/diff_stat_test.go new file mode 100644 index 0000000..feeefc1 --- /dev/null +++ b/diff_stat_test.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestDiffStatBlocks(t *testing.T) { + for _, tc := range []struct{ a, d, n, plus, minus int }{ + {0, 0, 5, 0, 0}, + {10, 0, 5, 5, 0}, + {0, 3, 5, 0, 5}, + {6, 4, 5, 3, 2}, + {1000, 1, 5, 4, 1}, + {1, 1000, 5, 1, 4}, + {1, 1, 1, 1, 0}, + } { + p, m := diffStatBlocks(tc.a, tc.d, tc.n) + if p != tc.plus || m != tc.minus { + t.Errorf("blocks(%d,%d,%d) = %d,%d want %d,%d", tc.a, tc.d, tc.n, p, m, tc.plus, tc.minus) + } + } +} + +func TestDiffStatTextAndName(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DiffStat(c, 12, 3, DiffStatOptions{}) + }, 300, 60) + if !tt.HasText("+12") || !tt.HasText("−3") { + t.Fatalf("counts: %q", tt.Texts()) + } + if _, ok := tt.Find("12 additions, 3 deletions"); !ok { + t.Fatal("not named for assistive technology") + } + for _, tc := range []struct{ a, d, n int }{{-1, 0, 0}, {0, -1, 0}, {1, 1, -2}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v did not panic", tc) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + DiffStat(c, tc.a, tc.d, DiffStatOptions{Blocks: tc.n}) + }, 100, 50, 1) + }() + } +} + +func TestDiffStatSnapshot(t *testing.T) { + snapshot(t, "diff_stat", 360, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(10).Children(func() { + DiffStat(c, 128, 32, DiffStatOptions{}) + DiffStat(c, 4, 0, DiffStatOptions{}) + DiffStat(c, 0, 0, DiffStatOptions{Blocks: 8}) + }) + }) +} diff --git a/diff_viewer.go b/diff_viewer.go new file mode 100644 index 0000000..839c0d7 --- /dev/null +++ b/diff_viewer.go @@ -0,0 +1,225 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// DiffViewerState keeps a DiffViewer's diff, opened folds and scroll place. +type DiffViewerState struct { + list ui.ListState + from, to string + diff []internal.DiffLine + rows []diffViewerRow + expanded map[int]bool + split bool + context int + built bool + dirty bool +} + +// DiffViewerOptions configure a DiffViewer. +type DiffViewerOptions struct { + // SideBySide shows old and new in two columns. + SideBySide bool + // Context is unchanged lines kept around changes; zero means 3. + Context int + // Language picks syntax colors, as "go"; empty is plain. + Language string +} + +// diffViewerRow is a fold or one line per side. +type diffViewerRow struct { + left, right int + fold, hidden int +} + +// DiffViewer compares two texts by line; give it a height. +func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) *ui.Element { + ctx := opts.Context + if ctx == 0 { + ctx = 3 + } + if ctx < 0 { + panic("mujica: DiffViewer Context is negative") + } + if !s.built || s.from != from || s.to != to { + s.diff = internal.LineDiff(internal.SplitLines(from), internal.SplitLines(to)) + s.from, s.to, s.built, s.dirty, s.expanded = from, to, true, true, map[int]bool{} + add, del := internal.DiffStats(s.diff) + slog.Debug("mujica: diff viewer diff", "lines", len(s.diff), "added", add, "deleted", del) + } + if s.dirty || s.split != opts.SideBySide || s.context != ctx { + s.rows = diffViewerRows(s.diff, ctx, s.expanded, opts.SideBySide) + s.split, s.context, s.dirty = opts.SideBySide, ctx, false + } + k := Tokens(c) + digits := float32(len(strconv.Itoa(max(len(s.diff), 1)))) + numW := digits*8 + Density(c).Unit()*3 + e := ui.List(c, &s.list, len(s.rows), func(i int) { + r := s.rows[i] + if r.hidden > 0 { + diffViewerFold(c, s, r) + return + } + row := ui.Row(c).AlignItems(ui.Stretch).MinHeight(theme.MinHitSize) + row.Children(func() { + if !opts.SideBySide { + diffViewerCell(c, s.diff[r.left], opts.Language, numW, true) + return + } + for _, side := range []int{r.left, r.right} { + half := ui.Row(c).Grow(1).Basis(0).MinWidth(0).AlignItems(ui.Stretch).Clip() + half.Children(func() { + if side < 0 { + half.Background(k.Background) + return + } + diffViewerCell(c, s.diff[side], opts.Language, numW, false) + }) + } + }) + }) + e.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Font(theme.MonoFont) + return e +} + +// diffViewerCell shows one diff line with numbers, sign and tint. +func diffViewerCell(c *ui.Context, d internal.DiffLine, language string, numW float32, both bool) { + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + sign, col, name := " ", k.TextMuted, "" + switch d.Op { + case internal.DiffDelete: + sign, col, name = "−", k.Danger, msg(c, "diff_viewer.deleted") + case internal.DiffInsert: + sign, col, name = "+", k.Success, msg(c, "diff_viewer.added") + } + cell := ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center) + if d.Op != internal.DiffEqual { + cell.Background(col.Mix(k.Surface, 0.88)) + } + num := func(n int) { + t := "" + if n > 0 { + t = strconv.Itoa(n) + } + ui.Text(c, t).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).Width(numW).TextAlign(ui.End).Shrink(0).Padding(0, u, 0, 0) + } + cell.Children(func() { + if both || d.Op != internal.DiffInsert { + num(d.Old) + } + if both || d.Op == internal.DiffInsert { + num(d.New) + } + ui.Text(c, sign).FontWeight(700).TextColor(col).Width(u * 4).TextAlign(ui.Center).Shrink(0) + spans, _ := internal.CodeSpans(k, language, d.Text, false) + if len(spans) == 0 || d.Text == "" { + spans = []ui.Span{{Text: " "}} + } + t := ui.RichText(c, spans...).NoWrap().MinWidth(0).Padding(u*0.5, u*2, u*0.5, 0) + if name != "" { + t.Label(name + ": " + d.Text) + } + }) +} + +// diffViewerFold is the button standing for hidden unchanged lines. +func diffViewerFold(c *ui.Context, s *DiffViewerState, r diffViewerRow) { + k := Tokens(c) + u := Density(c).Unit() + b := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).MinHeight(theme.MinHitSize).Padding(0, u*3).Background(k.Background). + TextColor(k.TextMuted).Cursor(ui.CursorPointer) + label := fmt.Sprintf(msg(c, "diff_viewer.fold"), r.hidden) + b.Label(label) + internal.Ring(b, k.Focus, 0) + if b.Hovered() { + b.Background(k.SurfaceHover).TextColor(k.Text) + } + b.Transition(colorFade) + b.Children(func() { + ui.Text(c, "⋯").FontWeight(700) + ui.Text(c, label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)) + }) + if b.Clicked() && !b.IsDisabled() { + s.expanded[r.fold] = true + s.dirty = true + slog.Debug("mujica: diff viewer unfold", "at", r.fold, "lines", r.hidden) + } +} + +// diffViewerRows folds long unchanged runs and pairs split sides. +func diffViewerRows(diff []internal.DiffLine, ctx int, expanded map[int]bool, split bool) []diffViewerRow { + var rows []diffViewerRow + var dels, ins []int + flush := func() { + for j := 0; j < max(len(dels), len(ins)); j++ { + r := diffViewerRow{left: -1, right: -1} + if j < len(dels) { + r.left = dels[j] + } + if j < len(ins) { + r.right = ins[j] + } + rows = append(rows, r) + } + dels, ins = dels[:0], ins[:0] + } + line := func(i int) { + switch { + case !split: + rows = append(rows, diffViewerRow{left: i, right: -1}) + case diff[i].Op == internal.DiffDelete: + dels = append(dels, i) + case diff[i].Op == internal.DiffInsert: + ins = append(ins, i) + default: + flush() + rows = append(rows, diffViewerRow{left: i, right: i}) + } + } + for i := 0; i < len(diff); { + if diff[i].Op != internal.DiffEqual { + line(i) + i++ + continue + } + end := i + for end < len(diff) && diff[end].Op == internal.DiffEqual { + end++ + } + keepHead, keepTail := ctx, ctx + if i == 0 { + keepHead = 0 + } + if end == len(diff) { + keepTail = 0 + } + hidden := end - i - keepHead - keepTail + if hidden <= 1 || expanded[i+keepHead] { + for j := i; j < end; j++ { + line(j) + } + } else { + for j := i; j < i+keepHead; j++ { + line(j) + } + flush() + rows = append(rows, diffViewerRow{left: -1, right: -1, fold: i + keepHead, hidden: hidden}) + for j := end - keepTail; j < end; j++ { + line(j) + } + } + i = end + } + flush() + return rows +} diff --git a/diff_viewer_test.go b/diff_viewer_test.go new file mode 100644 index 0000000..702a6e7 --- /dev/null +++ b/diff_viewer_test.go @@ -0,0 +1,105 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +const diffOld = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n" +const diffNew = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tname := \"court\"\n\tfmt.Println(\"hi\", name)\n}\n" + +func TestDiffViewerRowsFoldAndPair(t *testing.T) { + var a, b []string + for i := range 30 { + a = append(a, fmt.Sprintf("line %d", i)) + } + b = append(b, a...) + b[15] = "changed" + d := internal.LineDiff(a, b) + rows := diffViewerRows(d, 3, map[int]bool{}, false) + // 12 hidden, 3 context, delete, insert, 3 context, 11 hidden. + if len(rows) != 10 || rows[0].hidden != 12 || rows[9].hidden != 11 { + t.Fatalf("unified rows %+v", rows) + } + split := diffViewerRows(d, 3, map[int]bool{}, true) + if len(split) != 9 || split[4].left < 0 || split[4].right < 0 || d[split[4].left].Op != internal.DiffDelete { + t.Fatalf("split rows %+v", split) + } + open := diffViewerRows(d, 3, map[int]bool{0: true}, false) + if len(open) != 21 || open[0].hidden != 0 { + t.Fatalf("expanded rows %d", len(open)) + } +} + +func TestDiffViewerUnfoldAndModes(t *testing.T) { + var s DiffViewerState + from := strings.Repeat("same\n", 20) + "old\n" + to := strings.Repeat("same\n", 20) + "new\n" + split := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DiffViewer(c, &s, from, to, DiffViewerOptions{SideBySide: split}).Height(760) + }, 700, 800) + if !tt.HasText("17 unchanged lines") { + t.Fatalf("fold: %q", tt.Texts()) + } + if _, ok := tt.Find("Deleted: old"); !ok { + t.Fatal("deleted line not named") + } + if _, ok := tt.Find("Added: new"); !ok { + t.Fatal("added line not named") + } + if err := tt.Click("17 unchanged lines"); err != nil { + t.Fatal(err) + } + if tt.HasText("17 unchanged lines") || !tt.HasText("17") { + t.Fatalf("unfold: %q", tt.Texts()) + } + split = true + tt.Frame() + if !tt.HasText("−") || !tt.HasText("+") { + t.Fatalf("split: %q", tt.Texts()) + } +} + +func TestDiffViewerVirtualized(t *testing.T) { + var a, b strings.Builder + for i := range 100000 { + fmt.Fprintf(&a, "line %d\n", i) + if i%1000 == 0 { + fmt.Fprintf(&b, "edit %d\n", i) + } else { + fmt.Fprintf(&b, "line %d\n", i) + } + } + from, to := a.String(), b.String() + var s DiffViewerState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DiffViewer(c, &s, from, to, DiffViewerOptions{}).Height(400) + }, 700, 440) + if n := len(tt.Texts()); n > 400 { + t.Fatalf("%d texts for 100,000 lines", n) + } + rows := &s.rows[0] + tt.Frame() + if &s.rows[0] != rows { + t.Fatal("rows rebuilt without a change") + } +} + +func TestDiffViewerSnapshot(t *testing.T) { + snapshot(t, "diff_viewer", 640, 520, func(c *ui.Context) { + s := ui.Local(c.Root(), "u", func() *DiffViewerState { return &DiffViewerState{} }) + p := ui.Local(c.Root(), "p", func() *DiffViewerState { return &DiffViewerState{} }) + ui.Column(c).Padding(16).Gap(12).Children(func() { + DiffViewer(c, *s, diffOld, diffNew, DiffViewerOptions{Language: "go", Context: 1}).Height(220) + DiffViewer(c, *p, diffOld, diffNew, DiffViewerOptions{Language: "go", SideBySide: true, Context: 1}).Height(220) + }) + }) +} diff --git a/docs/components/101-line-chart.md b/docs/components/101-line-chart.md new file mode 100644 index 0000000..aefc23b --- /dev/null +++ b/docs/components/101-line-chart.md @@ -0,0 +1,54 @@ +# 101 LineChart 折线图 + +## 用途 + +多序列折线图:数值或时间横轴,缺值(NaN)处断开,悬停与键盘读数,图例勾选框控制序列显隐。大数据按像素列抽稀绘制,10 万点保持流畅。 + +## 最小示例 + +```go +series := []mujica.ChartSeries{ + {Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}}, + {Name: "Candles", Values: []float64{5, 2, 6, 7, 1}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineChart(c, series, mujica.LineChartOptions{Frame: mujica.ChartFrame{Label: "Chapel"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func LineChart(c *ui.Context, series []ChartSeries, opts LineChartOptions) ChartResult`;`ChartSeries{Name, Values}`,各序列等长且名称唯一。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `X` | `[]float64` | nil | 点 i 的横坐标,须递增;nil 时为下标。 | +| `Times` | `[]time.Time` | nil | 时间轴;与 `X` 二选一,须递增。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数中的数值格式。 | + +## 状态 + +默认、悬停或键盘读数(竖线 + 各序列放大标记)、隐藏序列(图例划线、空心标记)、加载、失败、无数据(全部为缺值时)。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`Toggled()` 返回本帧被切换显隐的序列名。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 +- 图例勾选框:Space 切换序列显隐。 + +## 限制 + +- 数据切片以首元素地址与长度识别;原地修改数据后请传入新切片,缓存的坐标范围才会更新。 +- 每条序列除颜色外还有不同形状的标记(圆、方、菱形、三角),密集时按间隔绘制。 diff --git a/docs/components/102-area-chart.md b/docs/components/102-area-chart.md new file mode 100644 index 0000000..bf747b7 --- /dev/null +++ b/docs/components/102-area-chart.md @@ -0,0 +1,54 @@ +# 102 AreaChart 面积图 + +## 用途 + +面积图:单序列填充到基线,或多序列堆叠;基线可配置;悬停与键盘读数显示各序列原值(不是堆叠后的值)。 + +## 最小示例 + +```go +series := []mujica.ChartSeries{ + {Name: "Matins", Values: []float64{3, 4, 6, 5}}, + {Name: "Vespers", Values: []float64{2, 3, 2, 4}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: mujica.ChartFrame{Label: "Attendance"}, Stacked: true}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `X`, `Times` | | nil | 同 LineChart。 | +| `Stacked` | `bool` | false | 可见序列依次堆叠;隐藏序列不参与堆叠。 | +| `Baseline` | `float64` | `0` | 面积的起始值;坐标轴总包含基线。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`Toggled()` 同 LineChart。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 缺值处面积断开;堆叠时缺值按 0 计入后续序列。 +- 填充为半透明的序列色,边线保持实色以满足 3:1 对比。 diff --git a/docs/components/103-bar-chart.md b/docs/components/103-bar-chart.md new file mode 100644 index 0000000..d162090 --- /dev/null +++ b/docs/components/103-bar-chart.md @@ -0,0 +1,52 @@ +# 103 BarChart 柱状图 + +## 用途 + +柱状图:纵向或横向,分组或堆叠,支持负值与柱端数值标签,键盘逐柱读数。 + +## 最小示例 + +```go +categories := []string{"Nave", "Choir", "Crypt"} +series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BarChart(c, categories, series, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Tithes"}, Labels: true}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts BarChartOptions) ChartResult`;每条序列的值与类别一一对应,NaN 不画柱。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Horizontal` | `bool` | false | 类别在纵轴,柱横向延伸。 | +| `Stacked` | `bool` | false | 每个类别一根柱,正值向上、负值向下堆叠。 | +| `Labels` | `bool` | false | 在柱端写出数值(堆叠时不写)。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 选中柱以描边标出,柱内有序列形状标记。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;多序列时 `Toggled()` 报告图例切换。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):逐柱移动(按类别、再按序列),读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 极细的柱仍有 6 DIP 的悬停范围。 +- 类别标签过密时会跳过重叠的标签,读数仍包含完整类别名。 diff --git a/docs/components/104-pie-chart.md b/docs/components/104-pie-chart.md new file mode 100644 index 0000000..a2e760c --- /dev/null +++ b/docs/components/104-pie-chart.md @@ -0,0 +1,50 @@ +# 104 PieChart 饼图 + +## 用途 + +饼图与环形图:扇区按占比绘制并标注百分比;过窄的扇区可合并为“其他”;环形图中心显示总计。 + +## 最小示例 + +```go +slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: mujica.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartResult`;`ChartSlice{Label, Value}`,值须为有限且 ≥ 0。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Donut` | `bool` | false | 环形图,中心写总计。 | +| `MinAngle` | `float64` | `0` | 小于该角度(度)的扇区达到两个及以上时合并为“其他”;须在 [0, 360)。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前扇区向外凸出。总计为 0 时显示无数据。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 只有一个窄扇区时不合并,以免“其他”只代表一项。 +- 扇区内的形状标记与百分比文字按对比度自动选用正文色或表面色。 diff --git a/docs/components/105-scatter-chart.md b/docs/components/105-scatter-chart.md new file mode 100644 index 0000000..baf1af2 --- /dev/null +++ b/docs/components/105-scatter-chart.md @@ -0,0 +1,52 @@ +# 105 ScatterChart 散点图 + +## 用途 + +散点图:按分组着色并配不同形状,点大小可映射为气泡,拖动框选或用键盘选中点。 + +## 最小示例 + +```go +var selected []mujica.ScatterKey +groups := []mujica.ScatterGroup{{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScatterChart(c, &selected, groups, mujica.ScatterChartOptions{Frame: mujica.ChartFrame{Label: "Choir"}, Bubbles: true}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, opts ScatterChartOptions) ChartResult`;`ScatterGroup{Name, Points []ScatterPoint{X, Y, Size}}`,`ScatterKey{Group, Point}` 标识一个点。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Bubbles` | `bool` | false | 按 `Size` 映射点面积(直径 4–18 DIP)。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 选中的点外加圆环,读数注明“已选”。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示 `*selected` 被框选或键盘改变;多组时 `Toggled()` 报告图例切换。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 +- Enter/Space:选中或取消当前点。 +- 在绘图区拖动:框选,替换现有选择。 + +## 限制 + +- 框选替换选择,不支持按住修饰键追加。 +- 点数较多时逐点绘制,没有抽稀。 diff --git a/docs/components/106-radar-chart.md b/docs/components/106-radar-chart.md new file mode 100644 index 0000000..35a017e --- /dev/null +++ b/docs/components/106-radar-chart.md @@ -0,0 +1,49 @@ +# 106 RadarChart 雷达图 + +## 用途 + +雷达图:三个及以上维度,多序列多边形,刻度环,按维度悬停或键盘读数。 + +## 最小示例 + +```go +axes := []string{"Voice", "Latin", "Chant", "Organ"} +series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RadarChart(c, axes, series, mujica.RadarChartOptions{Frame: mujica.ChartFrame{Label: "Skills"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarChartOptions) ChartResult`;每条序列每个维度一个值,值须 ≥ 0。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Max` | `float64` | 自动取整 | 外环代表的值;0 按数据向上取整。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前维度的轴线加粗、标签变为正文色。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;多序列时 `Toggled()` 报告图例切换。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):按维度移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 缺值按 0 画在中心,读数显示“无数值”。 diff --git a/docs/components/107-heatmap-chart.md b/docs/components/107-heatmap-chart.md new file mode 100644 index 0000000..b931ea2 --- /dev/null +++ b/docs/components/107-heatmap-chart.md @@ -0,0 +1,48 @@ +# 107 HeatmapChart 热力图 + +## 用途 + +二维矩阵热力图:按值分五级着色,右侧色阶图例标出最小与最大值;单元格读数;空值单元格为虚线框。 + +## 最小示例 + +```go +rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"} +values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: mujica.ChartFrame{Label: "Hours"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts HeatmapChartOptions) ChartResult`;`values[行][列]`,NaN 为空值。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 单元格与读数格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 单元格足够大时直接写出数值,不只依赖颜色。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦热力图。 +- ←/→:同一行左右移动;↑/↓:同一列上下移动;第一次按键进入左上角。 +- Home/End:第一个/最后一个单元格;Escape:清除读数。 + +## 限制 + +- 颜色分五级,不做连续渐变。 diff --git a/docs/components/108-calendar-heatmap.md b/docs/components/108-calendar-heatmap.md new file mode 100644 index 0000000..26f6a30 --- /dev/null +++ b/docs/components/108-calendar-heatmap.md @@ -0,0 +1,50 @@ +# 108 CalendarHeatmap 日历热力图 + +## 用途 + +按日的贡献图:一列一周,按当年最大值分四级着色;年份切换;周起始可配置;逐日读数。 + +## 最小示例 + +```go +year := 2026 +values := map[mujica.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CalendarHeatmap(c, &year, values, mujica.CalendarHeatmapOptions{Frame: mujica.ChartFrame{Label: "Prayers"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts CalendarHeatmapOptions) ChartResult`;`*year` 须在 1–9999。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `WeekStart` | `WeekStart` | 跟随语言 | 每列第一行是星期几。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 | + +## 状态 + +默认、无记录日(浅色格)、四级色阶、悬停或键盘读数(描边)、加载、失败。下方图例从“少”到“多”。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示上一年/下一年按钮改变了 `*year`。 + +## 键盘操作 + +- Tab:年份按钮,再到日历。 +- ←/→:前后一周;↑/↓:前后一天;Home/End:全年第一天与最后一天。 +- 上一年/下一年按钮:Enter/Space。 + +## 限制 + +- 方格较小,只作读数目标,不是独立操作,因此不要求 28 DIP 命中区。 +- 值 ≤ 0 视为无记录。 diff --git a/docs/components/109-histogram.md b/docs/components/109-histogram.md new file mode 100644 index 0000000..3817d33 --- /dev/null +++ b/docs/components/109-histogram.md @@ -0,0 +1,50 @@ +# 109 Histogram 直方图 + +## 用途 + +分箱计数直方图:箱数或箱宽可配置,可切换为累计模式;分箱只在数据或参数变化时计算一次。 + +## 最小示例 + +```go +values := []float64{1, 2, 2, 3, 4, 5, 9, 10} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Histogram(c, values, mujica.HistogramOptions{Frame: mujica.ChartFrame{Label: "Ages"}, BinWidth: 5}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResult`;NaN 与无穷值被忽略。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Bins` | `int` | Sturges 规则 | 箱数;负数 panic。 | +| `BinWidth` | `float64` | 0 | 箱宽;设定时优先于 `Bins`,箱边取箱宽的整数倍。超过 10000 箱 panic。 | +| `Cumulative` | `bool` | false | 每箱计入不超过其上界的全部值。 | +| `Format` | `func(float64) string` | 按箱宽取精度 | 箱边格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):逐箱移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 按箱数分箱时最后一箱包含上界,读数用 `]` 表示。 diff --git a/docs/components/110-box-plot.md b/docs/components/110-box-plot.md new file mode 100644 index 0000000..bd80246 --- /dev/null +++ b/docs/components/110-box-plot.md @@ -0,0 +1,47 @@ +# 110 BoxPlot 箱线图 + +## 用途 + +箱线图:四分位箱、中位线、1.5 倍四分距内的须线与离群点,多组对比。统计只在数据变化时计算。 + +## 最小示例 + +```go +groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult`;`BoxGroup{Name, Values}`,名称必填。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 离群点画为空心圆。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):按组移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 四分位数用线性插值计算。 diff --git a/docs/components/111-waterfall-chart.md b/docs/components/111-waterfall-chart.md new file mode 100644 index 0000000..2dfb8c9 --- /dev/null +++ b/docs/components/111-waterfall-chart.md @@ -0,0 +1,52 @@ +# 111 WaterfallChart 瀑布图 + +## 用途 + +瀑布图:增减项累计,小计与总计柱显示累计值;增加为 Success 色并配 ▲,减少为 Danger 色并配 ▼,合计为中性色配 ■,柱端写出带符号的数值。 + +## 最小示例 + +```go +steps := []mujica.WaterfallStep{ + {Label: "Tithes", Value: 12}, + {Label: "Repairs", Value: -5}, + {Label: "Year", Kind: mujica.WaterfallTotal}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: mujica.ChartFrame{Label: "Ledger"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func WaterfallChart(c *ui.Context, steps []WaterfallStep, opts WaterfallChartOptions) ChartResult`;`WaterfallStep{Label, Value, Kind}`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `steps[i].Kind` | `WaterfallKind` | `WaterfallDelta` | `WaterfallDelta` 累加;`WaterfallSubtotal`、`WaterfallTotal` 显示当前累计,`Value` 须为 0。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):逐柱移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 小计与总计都显示到该步为止的累计值,不清零。 diff --git a/docs/components/112-funnel-chart.md b/docs/components/112-funnel-chart.md new file mode 100644 index 0000000..436180c --- /dev/null +++ b/docs/components/112-funnel-chart.md @@ -0,0 +1,47 @@ +# 112 FunnelChart 漏斗图 + +## 用途 + +漏斗图:阶段宽度与值成比例,右侧写出值与相对上一阶段的转化率;读数同时给出相对首阶段的比例。 + +## 最小示例 + +```go +stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: mujica.ChartFrame{Label: "Order"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func FunnelChart(c *ui.Context, stages []ChartSlice, opts FunnelChartOptions) ChartResult`;值须为有限且 ≥ 0。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 全部为 0 时显示无数据。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):逐阶段移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 上一阶段为 0 时转化率显示“—”。 diff --git a/docs/components/113-bullet-chart.md b/docs/components/113-bullet-chart.md new file mode 100644 index 0000000..aab7ffa --- /dev/null +++ b/docs/components/113-bullet-chart.md @@ -0,0 +1,51 @@ +# 113 BulletChart 子弹图 + +## 用途 + +子弹图:实际值的细条、目标值的垂直线与定性区间带,可横向或纵向。 + +## 最小示例 + +```go +target := 250.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BulletChart(c, 270, mujica.BulletChartOptions{Frame: mujica.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartResult`;值须 ≥ 0。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `72` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Target` | `*float64` | nil | 目标值,画为与刻度垂直的粗线。 | +| `Bands` | `[]float64` | nil | 各区间的上界,须为正且递增。 | +| `Max` | `float64` | 自动取整 | 刻度终点;0 按值、目标与区间取整。 | +| `Vertical` | `bool` | false | 纵向摆放。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 区间由浅到深的中性色带表示,读数写出区间范围。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):依次读实际值、目标值与各区间,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 超出 `Max` 的值画到刻度终点,读数仍为原值。 diff --git a/docs/components/114-sparkline.md b/docs/components/114-sparkline.md new file mode 100644 index 0000000..7ff932f --- /dev/null +++ b/docs/components/114-sparkline.md @@ -0,0 +1,48 @@ +# 114 Sparkline 迷你图 + +## 用途 + +行内迷你图:折线、柱形或面积,无坐标轴;末值以菱形标记并在右侧写出数值;聚焦后可逐点读数。 + +## 最小示例 + +```go +values := []float64{3, 5, 4, 7, 6, 9} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: mujica.ChartFrame{Label: "Bells"}, Kind: mujica.SparklineArea}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResult`;NaN 为缺值。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `28` | 迷你图高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Kind` | `SparklineKind` | `SparklineLine` | `SparklineLine`、`SparklineBar` 或 `SparklineArea`。 | +| `Width` | `float32` | `96` | 迷你图宽度(DIP)。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 右侧数值格式。 | + +## 状态 + +默认(显示末值)、悬停或键盘读数(右侧数值变为当前点,点外加圆环)、加载(小型加载标记)、失败(错误文字与重试图标)、无数据。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦迷你图。 +- ←/→:逐点移动;Home/End:首尾;Escape:恢复显示末值。 + +## 限制 + +- 不显示图例与提示行,以便放在表格行或文字中。 diff --git a/docs/components/115-gauge.md b/docs/components/115-gauge.md new file mode 100644 index 0000000..64360d8 --- /dev/null +++ b/docs/components/115-gauge.md @@ -0,0 +1,51 @@ +# 115 Gauge 仪表盘 + +## 用途 + +仪表盘:半圆或整圆表盘,按阈值分段着色,指针指向当前值,下方写出数值、单位和所在分段的名称,不只靠颜色表达区间。 + +## 最小示例 + +```go +segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Gauge(c, 72, mujica.GaugeOptions{Frame: mujica.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult`;值须在 `Min`–`Max` 之间。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `200` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Min`, `Max` | `float64` | 必须 `Min < Max` | 刻度范围。 | +| `Segments` | `[]GaugeSegment` | nil | 分段 `{To, Label, Tone}`,上界递增且最后一段止于 `Max`;`Tone` 为 `GaugeNeutral`、`GaugeGood`、`GaugeWarning`、`GaugeDanger`。 | +| `Unit` | `string` | 空 | 数值后的单位。 | +| `Full` | `bool` | false | 整圆表盘,从底部顺时针一周。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前分段加宽显示。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):依次读当前值与各分段,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 不做指针动画,值变化时直接切换。 diff --git a/docs/components/116-treemap.md b/docs/components/116-treemap.md new file mode 100644 index 0000000..e2347b4 --- /dev/null +++ b/docs/components/116-treemap.md @@ -0,0 +1,53 @@ +# 116 Treemap 矩形树图 + +## 用途 + +层级矩形树图:按大小平铺当前节点的子项(squarify 布局),点击或回车下钻有子项的矩形,返回按钮与面包屑回到上层;标签按面积显示并截断。 + +## 最小示例 + +```go +var path []string +root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, +}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Treemap(c, &path, root, mujica.TreemapOptions{Frame: mujica.ChartFrame{Label: "Rooms"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) ChartResult`;`ChartNode{Name, Value, Children}`,叶子的 `Value` 为大小,分支为子项之和;`*path` 是从根的子项起的名称路径。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前矩形内描边。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示下钻、返回或点击面包屑改变了 `*path`。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):按大小顺序在矩形间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 +- Enter/Space:下钻;Backspace:返回上一层;返回按钮与面包屑可用 Tab 到达。 + +## 限制 + +- 名称为空、兄弟同名或值为负时 panic;`*path` 指向不存在的节点时 panic。 +- 只显示当前节点的一层子项。 diff --git a/docs/components/117-sunburst.md b/docs/components/117-sunburst.md new file mode 100644 index 0000000..908abbb --- /dev/null +++ b/docs/components/117-sunburst.md @@ -0,0 +1,53 @@ +# 117 Sunburst 旭日图 + +## 用途 + +层级环形图:中心是当前节点及总计,外圈依次是各层后代;点击或回车下钻,路径面包屑与返回按钮回到上层。 + +## 最小示例 + +```go +var path []string +root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, +}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sunburst(c, &path, root, mujica.SunburstOptions{Frame: mujica.ChartFrame{Label: "Rooms"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOptions) ChartResult`;数据结构同 Treemap。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Depth` | `int` | `3` | 显示的层数;负数 panic。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 外层颜色向表面色淡化,但保持 3:1 对比。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示 `*path` 改变。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):按深度优先顺序在扇区间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 +- Enter/Space:下钻到当前扇区;Backspace:返回上一层。 + +## 限制 + +- 弧长不足时不写标签,名称在读数中完整给出。 diff --git a/docs/components/118-sankey-chart.md b/docs/components/118-sankey-chart.md new file mode 100644 index 0000000..f3568a3 --- /dev/null +++ b/docs/components/118-sankey-chart.md @@ -0,0 +1,49 @@ +# 118 SankeyChart 桑基图 + +## 用途 + +桑基图:节点按最长路径分列,列内顺序与传入顺序一致(稳定);流量带宽度与值成比例;悬停或键盘读到节点或流量时,高亮经过它的全部路径。 + +## 最小示例 + +```go +nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}} +links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: mujica.ChartFrame{Label: "Money"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SankeyChart(c *ui.Context, nodes []SankeyNode, links []SankeyLink, opts SankeyChartOptions) ChartResult`;`SankeyNode{ID, Label}`,`SankeyLink{From, To, Value}`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 高亮时其余流量淡化。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):先按节点、再按流量移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 + +## 限制 + +- 链接成环、端点未知或值不为正时 panic。 +- 布局只在节点或链接切片变化时计算。 diff --git a/docs/components/119-network-graph.md b/docs/components/119-network-graph.md new file mode 100644 index 0000000..5677d5e --- /dev/null +++ b/docs/components/119-network-graph.md @@ -0,0 +1,51 @@ +# 119 NetworkGraph 网络图 + +## 用途 + +网络图:节点与边,使用调用方给定的坐标或简单的力导布局;拖动节点、拖动空白处平移、滚轮或 +/- 缩放。 + +## 最小示例 + +```go +var state mujica.NetworkGraphState +nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}} +edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.NetworkGraph(c, &state, nodes, edges, mujica.NetworkGraphOptions{Frame: mujica.ChartFrame{Label: "House"}, Force: true}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges []GraphEdge, opts NetworkGraphOptions) ChartResult`;`GraphNode{ID, Label, X, Y}`,`GraphEdge{From, To}`。`NetworkGraphState` 保存拖动后的位置与视图:`Position(id)`、`Zoom()`、`ResetView()`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Force` | `bool` | false | 用力导模拟布局(固定 300 步,结果确定);最多 500 个节点。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前节点外加圆环,相连的边加粗。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。 + +## 键盘操作 + +- Tab:聚焦绘图区(图例勾选框在其前)。 +- ←/→(以及 ↑/↓):在节点间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。 +- Home/End:第一个/最后一个点;Escape:清除读数。 +- Shift+方向键:移动当前节点;+/-:缩放;0:复位视图。 + +## 限制 + +- 布局只在节点或边的切片变化时重新计算,拖动不会重新适配视图。 +- 不支持边标签与有向箭头。 diff --git a/docs/components/120-realtime-chart.md b/docs/components/120-realtime-chart.md new file mode 100644 index 0000000..edb1c70 --- /dev/null +++ b/docs/components/120-realtime-chart.md @@ -0,0 +1,53 @@ +# 120 RealtimeChart 实时图表 + +## 用途 + +实时折线图:调用方向 `RealtimeBuffer` 追加数据后刷新,图表显示截至最新数据的固定时间窗口;可暂停视图;组件自身不设计时器,隐藏或空闲时不重绘。 + +## 最小示例 + +```go +buf := mujica.NewRealtimeBuffer(600, "Bell") +start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) +for i := range 60 { + buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7)) +} +var state mujica.RealtimeChartState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RealtimeChart(c, &state, buf, mujica.RealtimeChartOptions{Frame: mujica.ChartFrame{Label: "Sound"}, Window: 30 * time.Second}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func RealtimeChart(c *ui.Context, s *RealtimeChartState, buf *RealtimeBuffer, opts RealtimeChartOptions) ChartResult`;`NewRealtimeBuffer(capacity, names...)` 保存最近 `capacity` 个样本,`Append(t, values...)` 每个序列一个值且时间不倒退。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 | +| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 | +| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 | +| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 | +| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 | +| `Window` | `time.Duration` | 必填 | 显示的时间窗口,止于最新样本(暂停时止于暂停那一刻)。 | +| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 | + +## 状态 + +默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 暂停时按钮显示“继续”,新数据继续进入缓冲区但不改变视图。 + +## 事件 + +返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示暂停或继续;`Toggled()` 报告图例切换。 + +## 键盘操作 + +- Tab:暂停按钮、图例、图表。 +- 图表读数键同 LineChart;暂停按钮:Enter/Space。 + +## 限制 + +- 数据追加在后台时,请通过 `Window.Update` 回到界面线程追加并刷新。 +- 缓冲区按块丢弃旧样本,最多占用两倍容量。 diff --git a/docs/components/161-ticker-tape.md b/docs/components/161-ticker-tape.md new file mode 100644 index 0000000..e97ec6a --- /dev/null +++ b/docs/components/161-ticker-tape.md @@ -0,0 +1,46 @@ +# 161 TickerTape + +行情滚动条。 + +## 用途 + +横向循环滚动的行情条,显示代码、价格与涨跌幅。指针悬停或键盘聚焦时暂停;减少动画时改为静态分页。 + +## 最小示例 + +```go +items := []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TickerTape(c, items, mujica.TickerTapeOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `items` | `[]TickerItem` | 必填 | 代码、价格、涨跌幅(%)。 | +| `Decimals` | `int` | 2 | 价格小数位,0 表示 2,负数 panic。 | +| `Speed` | `float32` | 40 | 每秒滚动的 DIP。 | +| `PerPage` | `int` | 4 | 减少动画时每页条数。 | +| `Label` | `string` | 内置“Market ticker” | 无障碍名称。 | + +## 状态 + +滚动、暂停(悬停或聚焦)、静态分页(减少动画)、空列表。 + +## 事件 + +无业务事件。 + +## 键盘操作 + +Tab 聚焦滚动条即暂停;减少动画时 ←/→ 翻页,也可用上一页、下一页按钮。 + +## 限制 + +- 滚动以四分之一 DIP 取整,暂停时停止请求重绘。 +- 第二份副本用于无缝循环,屏幕阅读器可能读到重复内容。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/162-quote-card.md b/docs/components/162-quote-card.md new file mode 100644 index 0000000..6df9188 --- /dev/null +++ b/docs/components/162-quote-card.md @@ -0,0 +1,43 @@ +# 162 QuoteCard + +报价卡片。 + +## 用途 + +显示代码、名称、现价、涨跌额与涨跌幅,并提供迷你图插槽。 + +## 最小示例 + +```go +quote := mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QuoteCard(c, quote, mujica.QuoteCardOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `q` | `Quote` | 必填 | Symbol 必填,空值 panic;Name、Price、Change、ChangePercent。 | +| `Decimals` | `int` | 2 | 价格与涨跌额小数位。 | +| `Chart` | `func()` | nil | 迷你图插槽,高 40 DIP,例如 Sparkline。 | + +## 状态 + +默认;涨、跌、平三种方向。 + +## 事件 + +无。 + +## 键盘操作 + +卡片本身不获取焦点;插槽内控件自行处理键盘。 + +## 限制 + +- 不计算涨跌,数值由调用方提供。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/163-price-text.md b/docs/components/163-price-text.md new file mode 100644 index 0000000..a13c7a7 --- /dev/null +++ b/docs/components/163-price-text.md @@ -0,0 +1,46 @@ +# 163 PriceText + +价格文本。 + +## 用途 + +按固定小数位与千分位显示价格;可与参考价比较着色并加箭头;价格变化时闪烁一次。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UseTrend(c, mujica.RedUp) + mujica.PriceText(c, 18234.5, mujica.PriceTextOptions{Compare: true, Reference: 18000}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `float64` | 必填 | 价格。 | +| `Decimals` | `int` | 2 | 小数位,0 表示 2,负数 panic。 | +| `NoGrouping` | `bool` | false | 不显示千分位。 | +| `Compare / Reference` | `bool / float64` | false / 0 | 按相对 Reference 的方向着色并加箭头。 | +| `NoFlash` | `bool` | false | 变化时不闪烁。 | +| `Size` | `float32` | 正文字号 | 字号(DIP),随系统文字缩放。 | + +## 状态 + +默认;上涨、下跌、持平;变化闪烁(360 ms 淡出)。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点。 + +## 限制 + +- 闪烁由帧间比较值得出:同一元素的值改变才会闪烁。减少动画时不闪烁,结束后不再请求重绘。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/164-price-change-badge.md b/docs/components/164-price-change-badge.md new file mode 100644 index 0000000..af8f3d3 --- /dev/null +++ b/docs/components/164-price-change-badge.md @@ -0,0 +1,42 @@ +# 164 PriceChangeBadge + +涨跌徽标。 + +## 用途 + +显示涨跌额与涨跌幅,带箭头、正负号与语义底色。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriceChangeBadge(c, -5.62, -1.35, mujica.PriceChangeBadgeOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `change` | `float64` | 必填 | 涨跌额。 | +| `percent` | `float64` | 必填 | 涨跌幅(%),固定 2 位小数。 | +| `Decimals` | `int` | 2 | 涨跌额小数位。 | +| `HidePercent` | `bool` | false | 只显示涨跌额。 | + +## 状态 + +上涨、下跌、持平。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;无障碍名称含方向词与数值。 + +## 限制 + +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/165-order-book.md b/docs/components/165-order-book.md new file mode 100644 index 0000000..a06c386 --- /dev/null +++ b/docs/components/165-order-book.md @@ -0,0 +1,52 @@ +# 165 OrderBook + +盘口。 + +## 用途 + +按价格显示卖档(上)与买档(下),带累计深度条与价差;可按价格步长合并档位;点击档位回传价格。 + +## 最小示例 + +```go +var state mujica.OrderBookState +book := mujica.OrderBookData{ + Bids: []mujica.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}}, + Asks: []mujica.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.OrderBook(c, &state, book, mujica.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok { + fmt.Println("order at", price, side) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*OrderBookState` | 必填 | Group 为合并步长,0 取 Groups 第一项;不在 Groups 中 panic。 | +| `book` | `OrderBookData` | 必填 | Bids、Asks 与 Version(原地修改时递增)。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 价格与数量小数位。 | +| `Depth` | `int` | 8 | 每侧档数。 | +| `Groups` | `[]float64` | nil | 可选合并步长,须为价格精度的整数倍,否则 panic。 | +| `Disabled` | `bool` | false | 禁用点击与合并选择。 | + +## 状态 + +默认、悬停、聚焦、禁用;空盘口时只显示表头。 + +## 事件 + +`OrderBookResult.Clicked()` 返回被点击档位的价格与方向。 + +## 键盘操作 + +Tab 进入档位列表,↑/↓ 在档位间移动,Enter/Space 选中价格。 + +## 限制 + +- 合并以整数价格单位计算:买档向下取整、卖档向上取整。只在数据或步长变化时重新聚合。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/166-level2-quotes.md b/docs/components/166-level2-quotes.md new file mode 100644 index 0000000..1b4d9bf --- /dev/null +++ b/docs/components/166-level2-quotes.md @@ -0,0 +1,46 @@ +# 166 Level2Quotes + +多档行情。 + +## 用途 + +并排显示买卖各 N 档的价格、数量与委托笔数;档位变化时高亮一次,并以箭头标出数量增减。 + +## 最小示例 + +```go +data := mujica.Level2Data{ + Bids: []mujica.L2Level{{Price: 100.00, Size: 500, Orders: 12}}, + Asks: []mujica.L2Level{{Price: 100.01, Size: 200, Orders: 3}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `data` | `Level2Data` | 必填 | Bids、Asks,最优价在前。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 价格与数量小数位。 | +| `Levels` | `int` | 5 | 每侧档数;不足的档位显示“—”。 | + +## 状态 + +默认、档位变化高亮(减少动画时只显示箭头)、空档。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;每行有包含档位、价格、数量和笔数的无障碍名称。 + +## 限制 + +- 价格变化视为新档位,不标增减箭头。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/167-time-and-sales.md b/docs/components/167-time-and-sales.md new file mode 100644 index 0000000..d68511f --- /dev/null +++ b/docs/components/167-time-and-sales.md @@ -0,0 +1,48 @@ +# 167 TimeAndSales + +逐笔成交。 + +## 用途 + +逐笔成交列表,最新在上,只构建可见行;大单以菱形与粗体标记;可暂停跟随。 + +## 最小示例 + +```go +var state mujica.TimeAndSalesState +trades := []mujica.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: mujica.SideBuy}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeAndSales(c, &state, trades, mujica.TimeAndSalesOptions{LargeSize: 1000}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*TimeAndSalesState` | 必填 | Paused 暂停跟随。 | +| `trades` | `[]TradePrint` | 必填 | 按时间先后追加;ID 唯一。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 价格与数量小数位。 | +| `LargeSize` | `float64` | 0 | 数量达到即标记为大单。 | +| `Height` | `float32` | 320 | 高度(DIP)。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载、失败等状态。 | + +## 状态 + +跟随、暂停(显示新成交数量)、加载、失败、空列表。 + +## 事件 + +`ChoiceResult.Changed()` 在暂停或继续时为 true。 + +## 键盘操作 + +Tab 到达暂停按钮(Enter/Space 切换)与列表;列表内 ↑/↓ 移动。 + +## 限制 + +- 基于 List 虚拟化,100,000 条成交只构建可见行。 +- 暂停时冻结显示的条数,新成交不打断阅读。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/168-depth-ladder.md b/docs/components/168-depth-ladder.md new file mode 100644 index 0000000..2421ee9 --- /dev/null +++ b/docs/components/168-depth-ladder.md @@ -0,0 +1,50 @@ +# 168 DepthLadder + +价格阶梯。 + +## 用途 + +按最小变动价位排列的价格阶梯(DOM):买量、价格、卖量三列,最新价居中并加框;点击买量或卖量单元格回调下单。 + +## 最小示例 + +```go +var state mujica.DepthLadderState +book := mujica.OrderBookData{Bids: []mujica.BookLevel{{Price: 100, Size: 5}}, Asks: []mujica.BookLevel{{Price: 100.01, Size: 2}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.DepthLadder(c, &state, 100, book, mujica.DepthLadderOptions{TickSize: 0.01}).Clicked(); ok { + fmt.Println("place", side, "at", price) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*DepthLadderState` | 必填 | Offset 为相对最新价滚动的档数。 | +| `last` | `float64` | 必填 | 最新价。 | +| `book` | `OrderBookData` | 必填 | 盘口数据。 | +| `TickSize` | `float64` | 必填 | 最小变动价位,须为价格精度的整数倍,否则 panic。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 小数位。 | +| `Rows` | `int` | 15 | 显示的行数。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、悬停、聚焦、最新价行(底色、框线与菱形)、已滚动(显示回到最新价按钮)、禁用。 + +## 事件 + +`DepthLadderResult.Clicked()` 返回价格与方向(买量列为买入,卖量列为卖出)。 + +## 键盘操作 + +Tab 进入阶梯,方向键在单元格间移动,Enter/Space 下单;PageUp/PageDown 滚动半屏;滚轮逐档滚动。 + +## 限制 + +- 只构建可见行;按价位建立的数量索引只在数据变化时重建。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/169-spread-indicator.md b/docs/components/169-spread-indicator.md new file mode 100644 index 0000000..f071535 --- /dev/null +++ b/docs/components/169-spread-indicator.md @@ -0,0 +1,41 @@ +# 169 SpreadIndicator + +价差指示。 + +## 用途 + +显示卖一减买一的价差与占中间价的百分比;超过阈值或买卖倒挂时给出带图标的文字提示。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `bid / ask` | `float64` | 必填 | 买一价与卖一价。 | +| `Decimals` | `int` | 2 | 价差小数位。 | +| `WarnPercent` | `float64` | 0 | 价差百分比达到即提示;0 不提示,负数 panic。 | + +## 状态 + +正常、价差过大、倒挂。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以状态角色读出。 + +## 限制 + +- 无。 diff --git a/docs/components/170-bid-ask-bar.md b/docs/components/170-bid-ask-bar.md new file mode 100644 index 0000000..6ef8828 --- /dev/null +++ b/docs/components/170-bid-ask-bar.md @@ -0,0 +1,41 @@ +# 170 BidAskBar + +买卖力量条。 + +## 用途 + +以分段条对比买卖成交量,两端标出占比与数量。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BidAskBar(c, 6234, 3820, mujica.BidAskBarOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `bid / ask` | `float64` | 必填 | 买量与卖量,负数或 NaN panic。 | +| `Decimals` | `int` | 0 | 数量小数位。 | +| `Label` | `string` | 内置“Bid and ask volume” | 无障碍名称。 | + +## 状态 + +默认;两者都为 0 时显示中性灰条与 50%。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以计量角色读出买卖占比。 + +## 限制 + +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/171-order-entry.md b/docs/components/171-order-entry.md new file mode 100644 index 0000000..fdbceb3 --- /dev/null +++ b/docs/components/171-order-entry.md @@ -0,0 +1,52 @@ +# 171 OrderEntry + +下单面板。 + +## 用途 + +限价、市价、止损与条件限价单的录入:方向、类型、数量、价格、触发价与有效期;同步与调用方异步校验;预估金额;提交防重入。建立在 Form 之上。 + +## 最小示例 + +```go +var state mujica.OrderEntryState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if order, ok := mujica.OrderEntry(c, &state, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2}).Submitted(); ok { + fmt.Println("place", order.Quantity) + state.EndSubmit("") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*OrderEntryState` | 必填 | Draft 为当前订单,Form 为表单状态。 | +| `Symbol` | `string` | 必填 | 代码,空值 panic。 | +| `Last` | `float64` | 0 | 最新价,用于市价预估和止损方向检查。 | +| `TickSize / LotSize` | `float64` | 0.01 / 1 | 价格与数量步长。 | +| `MaxQuantity` | `float64` | 0 | 数量上限,0 不限。 | +| `Initial` | `OrderDraft` | 买入限价 | 重置恢复的订单。 | +| `Validate` | `func(OrderDraft) string` | nil | 调用方同步规则。 | +| `Check` | `func(OrderDraft, uint64)` | nil | 异步检查(如可用资金),以 `s.Resolve(version, msg)` 回传;旧版本结果被丢弃。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、字段错误(失焦或提交后)、异步检查中、提交中(按钮加载)、表单错误、禁用。 + +## 事件 + +`OrderEntryResult.Submitted()` 返回通过全部校验的订单;处理完毕后调用 `s.EndSubmit(err)`。 + +## 键盘操作 + +Tab 依次经过方向、类型、各字段与按钮;数量与价格字段 ↑/↓ 按步长调整;提交失败聚焦第一个错误字段。 + +## 限制 + +- 止损单检查:买入触发价须高于最新价,卖出须低于。 +- 数量字段的异步检查覆盖整张订单:任一字段变化都会让旧结果失效。 diff --git a/docs/components/172-quick-trade-buttons.md b/docs/components/172-quick-trade-buttons.md new file mode 100644 index 0000000..a5e2a07 --- /dev/null +++ b/docs/components/172-quick-trade-buttons.md @@ -0,0 +1,47 @@ +# 172 QuickTradeButtons + +快速交易按钮。 + +## 用途 + +以预设数量一键市价买入或卖出;可配置在发送前就近确认。 + +## 最小示例 + +```go +quantity := 10.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if side, q, ok := mujica.QuickTradeButtons(c, &quantity, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok { + fmt.Println(side, q) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `quantity` | `*float64` | 必填 | 当前数量,须为 Presets 之一,0 取第一项。 | +| `Symbol` | `string` | 必填 | 代码。 | +| `Presets` | `[]float64` | 1, 10, 100 | 数量预设。 | +| `Decimals` | `int` | 0 | 数量小数位。 | +| `Confirm` | `bool` | false | 发送前弹出确认,默认聚焦取消。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、悬停、按下、聚焦、确认中、禁用。 + +## 事件 + +`QuickTradeResult.Traded()` 返回方向与数量。 + +## 键盘操作 + +Tab 到达数量分段与买卖按钮;Enter/Space 触发;确认浮层中 Enter 默认取消,Escape 关闭。 + +## 限制 + +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/173-position-table.md b/docs/components/173-position-table.md new file mode 100644 index 0000000..95443d8 --- /dev/null +++ b/docs/components/173-position-table.md @@ -0,0 +1,49 @@ +# 173 PositionTable + +持仓表。 + +## 用途 + +持仓列表:数量、成本、现价、市值、浮动盈亏与比例,每行提供平仓操作。基于 DataTable,以稳定行 ID 保存选择与排序。 + +## 最小示例 + +```go +var state mujica.DataTableState[string] +rows := []mujica.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.PositionTable(c, &state, rows, mujica.PositionTableOptions{}).Acted(); ok { + fmt.Println("close", id) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*DataTableState[string]` | 必填 | 表格状态。 | +| `rows` | `[]PositionRow` | 必填 | ID 必须稳定。 | +| `Decimals` | `int` | 2 | 小数位。 | +| `Version` | `int` | 0 | 原地修改行时递增。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载与失败状态。 | +| `Disabled` | `bool` | false | 禁用平仓。 | + +## 状态 + +默认、排序、选中、加载、失败、空、禁用。 + +## 事件 + +`TableActionResult.Acted()` 返回要平仓的行 ID;`Table` 为 DataTable 事件。 + +## 键盘操作 + +沿用 DataTable 的键盘操作;Tab 到达各行的平仓按钮。 + +## 限制 + +- 只构建可见行。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/174-order-table.md b/docs/components/174-order-table.md new file mode 100644 index 0000000..aa1d93d --- /dev/null +++ b/docs/components/174-order-table.md @@ -0,0 +1,48 @@ +# 174 OrderTable + +委托表。 + +## 用途 + +委托列表:方向、类型、价格、成交进度与状态,按状态筛选;进行中的委托提供撤单。基于 DataTable。 + +## 最小示例 + +```go +var state mujica.OrderTableState +rows := []mujica.OrderRow{{ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.OrderTable(c, &state, rows, mujica.OrderTableOptions{}).Acted(); ok { + fmt.Println("cancel", id) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*OrderTableState` | 必填 | Filter 为状态筛选,Table 为表格状态。 | +| `rows` | `[]OrderRow` | 必填 | Filled 不得超过 Quantity,否则 panic。 | +| `Decimals / QuantityDecimals` | `int` | 2 / 0 | 小数位。 | +| `Version` | `int` | 0 | 原地修改行时递增。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载与失败状态。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +全部、进行中、已成交、已关闭四种筛选;部分成交进度条;禁用。 + +## 事件 + +`TableActionResult.Acted()` 返回要撤销的委托 ID。 + +## 键盘操作 + +筛选分段用方向键切换;表格沿用 DataTable 键盘操作;Tab 到达撤单按钮。 + +## 限制 + +- 筛选只在数据或条件变化时执行。 diff --git a/docs/components/175-trade-history-table.md b/docs/components/175-trade-history-table.md new file mode 100644 index 0000000..429704f --- /dev/null +++ b/docs/components/175-trade-history-table.md @@ -0,0 +1,49 @@ +# 175 TradeHistoryTable + +成交记录表。 + +## 用途 + +成交记录:时间、代码、方向、价格、数量与手续费;按日期范围筛选;导出当前范围的记录交给调用方。 + +## 最小示例 + +```go +var state mujica.TradeHistoryTableState +rows := []mujica.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: mujica.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if fills, ok := mujica.TradeHistoryTable(c, &state, rows, mujica.TradeHistoryTableOptions{}).Exported(); ok { + fmt.Println("export", len(fills)) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*TradeHistoryTableState` | 必填 | Range 为日期范围,零值不筛选。 | +| `rows` | `[]FillRow` | 必填 | 成交记录。 | +| `Decimals / QuantityDecimals` | `int` | 2 / 0 | 小数位。 | +| `Version` | `int` | 0 | 原地修改行时递增。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载与失败状态。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +全部日期、指定范围、空范围、加载、失败、禁用。 + +## 事件 + +`TradeHistoryResult.Exported()` 返回导出请求时当前范围内的记录。 + +## 键盘操作 + +日期范围选择器与按钮按 Tab 顺序;表格沿用 DataTable 键盘操作。 + +## 限制 + +- 只回传数据,不生成文件:导出格式与写入由调用方负责。 +- 日期按每条记录自身时区的日期比较。 diff --git a/docs/components/176-pnl-display.md b/docs/components/176-pnl-display.md new file mode 100644 index 0000000..947e0b6 --- /dev/null +++ b/docs/components/176-pnl-display.md @@ -0,0 +1,44 @@ +# 176 PnLDisplay + +盈亏显示。 + +## 用途 + +并列显示已实现、未实现与当日盈亏,带正负号、箭头与语义色;可切换隐私遮蔽。 + +## 最小示例 + +```go +hidden := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, mujica.PnLDisplayOptions{Currency: "$"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `pnl` | `PnL` | 必填 | Realized、Unrealized、Today。 | +| `hidden` | `*bool` | 必填 | 为 true 时以“••••”遮蔽。 | +| `Decimals` | `int` | 2 | 小数位。 | +| `Currency` | `string` | "" | 金额前缀,例如 "$"。 | + +## 状态 + +正、负、零;已遮蔽。 + +## 事件 + +`ChoiceResult.Changed()` 在遮蔽切换时为 true。 + +## 键盘操作 + +Tab 到达遮蔽按钮,Enter/Space 切换。 + +## 限制 + +- 遮蔽时无障碍名称只读出“已隐藏”,不读出金额。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/177-leverage-slider.md b/docs/components/177-leverage-slider.md new file mode 100644 index 0000000..31ecb1e --- /dev/null +++ b/docs/components/177-leverage-slider.md @@ -0,0 +1,46 @@ +# 177 LeverageSlider + +杠杆滑块。 + +## 用途 + +在预设杠杆档位间选择草稿值,显示高风险与极高风险区间提示,按“确认”后才写入值。 + +## 最小示例 + +```go +leverage := 5.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LeverageSlider(c, &leverage, mujica.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() { + fmt.Println("leverage", leverage) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*float64` | 必填 | 当前杠杆,须为 Levels 之一。 | +| `Levels` | `[]float64` | 1–100 八档 | 递增档位,至少两档。 | +| `Warning / Danger` | `float64` | 0 | 高风险与极高风险起点;Danger 须大于 Warning。 | +| `Label` | `string` | 内置“Leverage” | 名称。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、聚焦、拖动、高风险、极高风险、草稿未确认、禁用。 + +## 事件 + +`ChoiceResult.Changed()` 在确认写入时为 true。 + +## 键盘操作 + +Tab 聚焦滑块:←/↓ 与 →/↑ 逐档、Home/End 到两端;Tab 到达确认按钮。 + +## 限制 + +- 滑块命中区高 44 DIP;风险区间以色带、图标和文字共同表达。 diff --git a/docs/components/178-margin-indicator.md b/docs/components/178-margin-indicator.md new file mode 100644 index 0000000..e3d3332 --- /dev/null +++ b/docs/components/178-margin-indicator.md @@ -0,0 +1,41 @@ +# 178 MarginIndicator + +保证金率指示。 + +## 用途 + +显示保证金率(维持保证金 / 权益)及其与警戒线、强平线的关系。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MarginIndicator(c, 0.86, mujica.MarginIndicatorOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ratio` | `float64` | 必填 | 保证金率,0 以上。 | +| `Warning / Liquidation` | `float64` | 0.8 / 1 | 警戒与强平线;须 0 < Warning < Liquidation。 | +| `Label` | `string` | 内置“Margin ratio” | 名称。 | + +## 状态 + +安全、追加保证金、强制平仓。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以计量角色读出比率、状态与两条线。 + +## 限制 + +- 状态同时用图标、文字与颜色表达;两条线粗细不同,并在下方以文字标注。 diff --git a/docs/components/179-risk-meter.md b/docs/components/179-risk-meter.md new file mode 100644 index 0000000..208b797 --- /dev/null +++ b/docs/components/179-risk-meter.md @@ -0,0 +1,41 @@ +# 179 RiskMeter + +风险等级。 + +## 用途 + +分段刻度显示风险等级,当前段上方有菱形标记,并显示等级名称与说明。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RiskMeter(c, 4, mujica.RiskMeterOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `level` | `int` | 必填 | 从 1 开始,超出范围 panic。 | +| `Levels` | `[]RiskLevel` | 内置五级 | 各级名称与说明,至少两级。 | +| `Label` | `string` | 内置“Risk level” | 名称。 | + +## 状态 + +各等级。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以计量角色读出“第 n 级,共 N 级”和名称。 + +## 限制 + +- 颜色只辅助:位置标记与等级文字同时表达等级。 diff --git a/docs/components/180-order-confirm-dialog.md b/docs/components/180-order-confirm-dialog.md new file mode 100644 index 0000000..00225c1 --- /dev/null +++ b/docs/components/180-order-confirm-dialog.md @@ -0,0 +1,52 @@ +# 180 OrderConfirmDialog + +下单确认对话框。 + +## 用途 + +以模态对话框展示下单摘要与风险提示,默认聚焦取消;可要求勾选确认后才能下单。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OrderConfirmDialog(c, &open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Quantity", Value: "100"}}, + Risk: "Leveraged products can lose more than the deposit.", + Acknowledge: "I understand the risk", + }) + if r.Confirmed { + fmt.Println("send order") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | 必填 | 是否显示。 | +| `Lines` | `[]OrderSummaryLine` | 必填 | 摘要行,空值 panic。 | +| `Title` | `string` | 内置“Confirm order” | 标题。 | +| `Risk` | `string` | "" | 风险提示。 | +| `Acknowledge` | `string` | "" | 二次确认复选框文字;设置后须勾选才能下单。 | +| `ConfirmLabel` | `string` | 内置“Place order” | 确认按钮文字。 | + +## 状态 + +打开、未勾选(下单按钮禁用)、已勾选。 + +## 事件 + +`AlertDialogResult.Confirmed` / `Canceled`;Escape 视为取消。 + +## 键盘操作 + +打开时焦点在取消上;Tab 在复选框与按钮间移动;Escape 取消。点击遮罩不关闭。 + +## 限制 + +- 每次打开都需重新勾选。 diff --git a/docs/components/201-chat-container.md b/docs/components/201-chat-container.md new file mode 100644 index 0000000..2c5b04d --- /dev/null +++ b/docs/components/201-chat-container.md @@ -0,0 +1,43 @@ +# 201 ChatContainer 对话容器 + +## 用途 + +整页对话布局:左侧可选侧栏插槽,右侧为消息区与底部输入区。消息区就是 MessageList:新消息到来时自动滚到底部,用户向上翻看时停止跟随,并在底部显示回到最新按钮。 + +## 最小示例 + +```go +mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{ + List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }}, + Input: func() { ui.Text(c, "Composer goes here") }, +}, func(i int) { ui.Text(c, msgs[i]) }) +``` + +签名:`func ChatContainer(c *ui.Context, s *MessageListState, n int, opts ChatContainerOptions, row func(i int)) MessageListResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*MessageListState` | 必填 | 消息区的位置与已读状态,由调用方持有。 | +| `n / row` | `int / func(i int)` | 必填 | 消息数量与第 i 条消息的构建函数;只构建可见消息。 | +| `List` | `MessageListOptions` | `ID` 必填 | 传给消息列表的选项,见 202。 | +| `Input` | `func()` | nil | 输入区插槽,位于消息区下方,带上边线。 | +| `Sidebar` | `func()` | nil | 侧栏插槽;nil 时不显示侧栏。 | +| `SidebarWidth` | `float32` | 260 | 侧栏宽度 DIP;负数 panic。 | + +## 状态 + +消息区跟随底部 / 用户上翻后停止跟随;离开底部时出现带未读数的回到最新按钮。 + +## 事件 + +返回的 `MessageListResult.Unread()` 给出离开底部后新到的消息数。 + +## 键盘操作 + +Tab 在侧栏、消息与输入区之间移动;消息区获得焦点后滚轮与 PageUp/PageDown(Linux、Windows)滚动。 + +## 限制 + +- 侧栏宽度固定,不提供拖动调整;需要时用 SplitPane(008)包裹。 diff --git a/docs/components/202-message-list.md b/docs/components/202-message-list.md new file mode 100644 index 0000000..0e2bc60 --- /dev/null +++ b/docs/components/202-message-list.md @@ -0,0 +1,42 @@ +# 202 MessageList 消息列表 + +## 用途 + +虚拟化的消息列表,基于 MyGo `ui.List`:只构建可见消息,以稳定消息 ID 保持位置;从底部开始并跟随新消息,用户上翻后停止跟随,并显示新消息提示;可按日期插入分隔。 + +## 最小示例 + +```go +r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{ + ID: func(i int) string { return ids[i] }, +}, func(i int) { ui.Text(c, "Message "+ids[i]) }) +fmt.Println("unread", r.Unread()) +``` + +签名:`func MessageList(c *ui.Context, s *MessageListState, n int, opts MessageListOptions, row func(i int)) MessageListResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ID` | `func(i int) string` | 必填 | 消息的稳定 ID;在上方插入旧消息时视野中的消息保持不动。ID 须唯一。 | +| `Date` | `func(i int) time.Time` | nil | 设置后在每个新日期的第一条消息前显示“今天 / 昨天 / 日期”分隔。 | +| `Label` | `func(i int) string` | nil | 消息的无障碍名称,也用于键入定位。 | +| `n` | `int` | 必填 | 消息数;负数 panic。 | + +## 状态 + +跟随底部;离开底部时底部居中出现 ScrollToBottomButton(含未读数);点击后回到底部并清零。 + +## 事件 + +`Unread()`:离开底部后新增的消息数。`MessageListState.ScrollToEnd()` 与 `AtEnd()` 供调用方控制。 + +## 键盘操作 + +列表获得焦点后,滚轮、方向键与 PageUp/PageDown 滚动;回到最新按钮可用 Tab 聚焦,Enter 或 Space 触发。 + +## 限制 + +- 不检查 ID 是否重复(逐帧检查 10 万条代价过高);重复 ID 会让两条消息共享状态。 +- 消息高度由 MyGo 在显示时测量,未显示消息按已测高度估算,长距离跳转时滚动条可能微调。 diff --git a/docs/components/203-message-bubble.md b/docs/components/203-message-bubble.md new file mode 100644 index 0000000..96349f0 --- /dev/null +++ b/docs/components/203-message-bubble.md @@ -0,0 +1,41 @@ +# 203 MessageBubble 消息气泡 + +## 用途 + +按角色框住一条消息:用户靠右、Selection 底色;助手靠左、Surface 底与细边;系统居中、灰色小字;错误带 Danger 边框与警示图标。头像与名称为插槽,宽度可限制。 + +## 最小示例 + +```go +mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() { + ui.Text(c, "The ball begins at nine.") +}) +``` + +签名:`func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `role` | `MessageRole` | 必填 | `MessageUser`、`MessageAssistant`、`MessageSystem`、`MessageError`。 | +| `Name` | `string` | "" | 显示在气泡上方的名称。 | +| `Avatar` | `func()` | nil | 头像插槽,用户在右侧、其他角色在左侧;系统消息不显示。 | +| `MaxWidth` | `float32` | 560 | 气泡最大宽度 DIP;负数 panic。 | +| `content` | `func()` | 必填 | 气泡内容,如 MarkdownView 或 StreamingText。 | + +## 状态 + +四种角色外观;错误角色同时使用图标、边框与文字,不只依赖颜色。 + +## 事件 + +无;交互由内容与 MessageActions 提供。 + +## 键盘操作 + +气泡本身不获取焦点;内容中的链接、按钮按 Tab 顺序访问。辅助技术读到“角色,名称”分组。 + +## 限制 + +- 气泡不自带操作栏;把 MessageActions 放在 content 中或气泡下方。 diff --git a/docs/components/204-message-header.md b/docs/components/204-message-header.md new file mode 100644 index 0000000..f545f94 --- /dev/null +++ b/docs/components/204-message-header.md @@ -0,0 +1,37 @@ +# 204 MessageHeader 消息头 + +## 用途 + +在消息上方显示模型名、时间与状态(发送中、生成中、已发送、失败),状态同时用图标和文字表达。 + +## 最小示例 + +```go +mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent}) +``` + +签名:`func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Model` | `string` | "" | 模型名,加粗;空则不显示。 | +| `Time` | `time.Time` | 零值 | 时间,按 `15:04` 显示;零值不显示。 | +| `Status` | `MessageStatus` | `MessageNoStatus` | `MessageSending`、`MessageStreaming`、`MessageSent`、`MessageFailed`;未知值 panic。 | + +## 状态 + +生成中显示菱形加载标记(减少动画时静止);失败用 Danger 色并带警示图标。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点。 + +## 限制 + +- 时间格式固定为 24 小时制时分,日期由 MessageList 的日期分隔显示。 diff --git a/docs/components/205-streaming-text.md b/docs/components/205-streaming-text.md new file mode 100644 index 0000000..552c2cf --- /dev/null +++ b/docs/components/205-streaming-text.md @@ -0,0 +1,38 @@ +# 205 StreamingText 流式文本 + +## 用途 + +显示随流式输出不断增长的文本。可平滑出字(按速率逐字显示),可显示流式光标;减少动画时直接显示全部文本。文本被替换为不以旧文本开头的内容时,从共同前缀继续。 + +## 最小示例 + +```go +mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true}) +``` + +签名:`func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `text` | `string` | 必填 | 当前收到的全部文本。 | +| `Streaming` | `bool` | false | 在文本末尾显示 AccentText 色的静态光标 ▍。 | +| `Smooth` | `bool` | false | 新到的文本按 Rate 逐字显示;关闭时立即显示。 | +| `Rate` | `float64` | 80 | 平滑出字速度,字符/秒;负数 panic。 | + +## 状态 + +出字中 / 完成;减少动画时始终为完成。光标不闪烁,避免装饰性循环。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点。辅助技术读到的名称始终是完整文本。 + +## 限制 + +- 只渲染纯文本;Markdown 内容请交给 MarkdownView(206),它的增量解析同样适合流式输出。 diff --git a/docs/components/206-markdown-view.md b/docs/components/206-markdown-view.md new file mode 100644 index 0000000..e1b35b0 --- /dev/null +++ b/docs/components/206-markdown-view.md @@ -0,0 +1,39 @@ +# 206 MarkdownView Markdown 视图 + +## 用途 + +渲染 Markdown 子集:标题、段落、粗体、斜体、删除线、行内代码、有序与无序列表(含缩进层级)、引用、链接、围栏代码块(交给 CodeBlock)、表格(交给 TableBlock)与分隔线(菱形饰线)。解析器在 `internal/markdown.go`,按块增量解析:新文本只是在旧文本后追加时,只重新解析最后一块,已完成的块保持不变。 + +## 最小示例 + +```go +if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok { + fmt.Println("open", url) +} +``` + +签名:`func MarkdownView(c *ui.Context, src string) MarkdownResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `src` | `string` | 必填 | Markdown 源文本;流式输出时每帧传入增长后的全文即可。 | + +## 状态 + +围栏未闭合的代码块按已收到的内容显示;未闭合的强调标记按字面显示,闭合后再加样式。 + +## 事件 + +`Link()` 返回本帧被点击或用 Enter 激活的链接地址;打开链接由调用方决定。 + +## 键盘操作 + +链接可用 Tab 聚焦,Enter 激活;代码块与表格的按钮按 Tab 顺序访问。 + +## 限制 + +- 只支持上述子集:不支持 HTML、图片、脚注、任务列表、Setext 标题、链接引用定义,列表项内不支持多段落。 +- 表格单元格按纯文本显示,不解析行内样式。 +- 源文本被整体替换(不是在末尾追加)时重新解析全部块。 diff --git a/docs/components/207-code-block.md b/docs/components/207-code-block.md new file mode 100644 index 0000000..87b6375 --- /dev/null +++ b/docs/components/207-code-block.md @@ -0,0 +1,39 @@ +# 207 CodeBlock 代码块 + +## 用途 + +显示代码:语言标签、可选行号、基础语法着色(`internal.Highlight`,支持 go、javascript、typescript、python、json、shell、sql),复制、折叠与自动换行切换。 + +## 最小示例 + +```go +mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true}) +``` + +签名:`func CodeBlock(c *ui.Context, code string, opts CodeBlockOptions) CopyResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Language` | `string` | ""(显示“文本”) | 着色语言;未知语言按纯文本显示。 | +| `LineNumbers` | `bool` | false | 显示右对齐的行号。 | +| `Wrap` | `bool` | false | 自动换行的初始状态;关闭时横向滚动。 | +| `Collapsed` | `bool` | false | 折叠的初始状态。 | + +## 状态 + +换行开关以 Selection 底色加边框表示开启;复制后按钮在 2 秒内显示对勾“已复制”;折叠时只显示标题栏,展开按钮写明行数。关键字、字符串、数字、类型使用不同颜色,注释同时使用斜体。 + +## 事件 + +`Copied()`:本帧把代码写入了剪贴板。 + +## 键盘操作 + +Tab 依次到换行开关、复制、折叠按钮;Space 或 Enter 触发。 + +## 限制 + +- 着色是逐行的词法着色,不做语法分析;块注释可跨行。 +- 不做虚拟化,适合消息中的代码片段,不适合上万行文件。 diff --git a/docs/components/208-table-block.md b/docs/components/208-table-block.md new file mode 100644 index 0000000..5891e29 --- /dev/null +++ b/docs/components/208-table-block.md @@ -0,0 +1,39 @@ +# 208 TableBlock 表格块 + +## 用途 + +在消息中显示小型表格:表头加粗,列按内容宽度排列并铺满消息宽度,过宽时横向滚动;可复制为 TSV。 + +## 最小示例 + +```go +if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() { + fmt.Println("copied as TSV") +} +``` + +签名:`func TableBlock(c *ui.Context, header []string, rows [][]string, opts TableBlockOptions) CopyResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `header` | `[]string` | 必填 | 表头;为空 panic。 | +| `rows` | `[][]string` | nil | 数据行;每行列数必须与表头相同,否则 panic。 | +| `Align` | `[]ui.Align` | 全部 `ui.Start` | 各列对齐:`ui.Start`、`ui.Center`、`ui.End`;多于列数 panic。 | + +## 状态 + +复制后按钮 2 秒内显示对勾。 + +## 事件 + +`Copied()`:本帧写入了 TSV(制表符分列、换行分行;单元格内的制表符与换行替换为空格)。 + +## 键盘操作 + +Tab 到复制按钮,Enter 或 Space 复制。 + +## 限制 + +- 不排序、不选择;需要这些功能请使用 DataTable(069 起)。 diff --git a/docs/components/209-image-grid.md b/docs/components/209-image-grid.md new file mode 100644 index 0000000..ae3ffa4 --- /dev/null +++ b/docs/components/209-image-grid.md @@ -0,0 +1,40 @@ +# 209 ImageGrid 图片网格 + +## 用途 + +在消息中排列 1 至 9 张图片:1 张大图,2 与 4 张两列,其余三列。点击已加载的图片打开 ImageViewer;加载中与失败状态复用 Image 组件,失败时可重试。 + +## 最小示例 + +```go +if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok { + fmt.Println("reload", i) +} +``` + +签名:`func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `images` | `[]GridImage` | 必填 | 1 至 9 张,否则 panic。 | +| `Source` | `ui.ImageSource` | nil | 已加载的位图或 SVG。 | +| `Alt` | `string` | "" | 替代说明,也是按钮名称与查看器标题。 | +| `Loading / Err` | `bool / error` | false / nil | 调用方的加载中与失败状态。 | + +## 状态 + +已加载、加载中(菱形加载标记)、失败(重试按钮);只有已加载的图片可点击。 + +## 事件 + +`Retried()` 返回本帧点击了重试的图片下标。查看器在组件内部打开与关闭。 + +## 键盘操作 + +Tab 聚焦图片,Enter 或 Space 打开查看器;查看器内的键盘操作见 ImageViewer。 + +## 限制 + +- 查看器只包含已加载的图片,序号按已加载图片计算。 diff --git a/docs/components/210-file-message.md b/docs/components/210-file-message.md new file mode 100644 index 0000000..5bfc0f5 --- /dev/null +++ b/docs/components/210-file-message.md @@ -0,0 +1,40 @@ +# 210 FileMessage 文件消息 + +## 用途 + +显示消息中的文件:按类型的图标、文件名、大小,以及打开与下载回调。 + +## 最小示例 + +```go +r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440}) +if r.Downloaded() { + fmt.Println("save treaty.pdf") +} +``` + +签名:`func FileMessage(c *ui.Context, opts FileMessageOptions) FileMessageResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Name` | `string` | 必填 | 文件名;为空 panic。 | +| `Size` | `int64` | 0 | 字节数,按 B、KB、MB、GB 显示(1024 进制);负数 panic。 | +| `Type` | `string` | "" | MIME 类型;为空时按扩展名选择图标。 | + +## 状态 + +悬停时文件区域加深;禁用时两个操作都不响应。 + +## 事件 + +`Opened()`:点击或在文件区按 Enter。`Downloaded()`:点击下载按钮。 + +## 键盘操作 + +Tab 依次到文件区与下载按钮,Enter 或 Space 触发。 + +## 限制 + +- 组件不读取文件、不下载:打开与下载由调用方完成。 diff --git a/docs/components/211-audio-message.md b/docs/components/211-audio-message.md new file mode 100644 index 0000000..f8c8879 --- /dev/null +++ b/docs/components/211-audio-message.md @@ -0,0 +1,41 @@ +# 211 AudioMessage 语音消息 + +## 用途 + +语音条:播放/暂停按钮、波形、播放进度与时长。播放由调用方驱动:组件只报告播放切换与跳转请求,调用方更新 Position 与 Playing。波形基于 MyGo `ui.SliderBase`,可拖动与键盘跳转。 + +## 最小示例 + +```go +r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos}) +if d, ok := r.Seek(); ok { + pos = d +} +``` + +签名:`func AudioMessage(c *ui.Context, opts AudioMessageOptions) AudioMessageResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | 必填 | 时长,必须大于 0。 | +| `Position` | `time.Duration` | 0 | 当前进度,0 到 Duration 之间,否则 panic。 | +| `Playing` | `bool` | false | 按钮显示暂停(播放中)或播放。 | +| `Waveform` | `[]float32` | nil | 0 到 1 的响度采样,按宽度重采样为竖条;为空画平线;越界 panic。 | + +## 状态 + +已播放的竖条为 AccentText,未播放为半透明 TextMuted,当前位置另有一条竖线,进度不只靠颜色表达;右侧显示 `m:ss / m:ss`。 + +## 事件 + +`Toggled()`:按下播放或暂停。`Seek()`:点击、拖动或按键请求的新位置。 + +## 键盘操作 + +Tab 到播放按钮(Enter 或 Space)与波形;波形上 Left/Right(Up/Down)按时长的 1% 移动,Home、End 到开头与结尾。 + +## 限制 + +- 不解码音频、不生成波形:采样由调用方提供。 diff --git a/docs/components/212-link-preview-card.md b/docs/components/212-link-preview-card.md new file mode 100644 index 0000000..c96bc48 --- /dev/null +++ b/docs/components/212-link-preview-card.md @@ -0,0 +1,42 @@ +# 212 LinkPreviewCard 链接预览卡片 + +## 用途 + +显示链接的站点、标题、描述与缩略图;元数据由调用方获取。整张卡片是一个按钮,悬停显示完整地址。 + +## 最小示例 + +```go +p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"} +if mujica.LinkPreviewCard(c, p).Clicked() { + fmt.Println("open", p.URL) +} +``` + +签名:`func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `URL` | `string` | 必填 | 链接地址,作为悬停提示。 | +| `Title` | `string` | 必填 | 标题,最多两行,AccentText 色。 | +| `Description` | `string` | "" | 描述,最多两行。 | +| `Site` | `string` | URL | 站点名;为空时显示 URL。 | +| `Thumbnail` | `ui.ImageSource` | nil | 缩略图,64×64 裁切显示。 | + +## 状态 + +悬停时底色加深;聚焦时显示焦点环。 + +## 事件 + +`Clicked()`:点击或 Enter、Space;打开链接由调用方完成。 + +## 键盘操作 + +Tab 聚焦,Enter 或 Space 触发。 + +## 限制 + +- 组件不联网:标题、描述与缩略图都由调用方提供。 diff --git a/docs/components/213-quote-reply.md b/docs/components/213-quote-reply.md new file mode 100644 index 0000000..6f7857b --- /dev/null +++ b/docs/components/213-quote-reply.md @@ -0,0 +1,38 @@ +# 213 QuoteReply 引用回复 + +## 用途 + +显示被引用消息的作者与单行摘要(多余空白折叠,过长省略);点击跳转到原消息。 + +## 最小示例 + +```go +if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() { + fmt.Println("jump to the quoted message") +} +``` + +签名:`func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `author` | `string` | 必填 | 原消息作者;为空 panic。 | +| `excerpt` | `string` | "" | 原消息摘要;换行与连续空白合并为一个空格。 | + +## 状态 + +左侧 3 DIP AccentText 竖线标明引用;悬停时底色变为 Selection。 + +## 事件 + +`Clicked()`:点击或 Enter、Space;跳转由调用方完成(如 MessageList 的 ScrollTo)。 + +## 键盘操作 + +Tab 聚焦,Enter 或 Space 触发。辅助技术读到“回复 作者:摘要”。 + +## 限制 + +- 只显示一行摘要,不渲染原消息的格式。 diff --git a/docs/components/214-message-actions.md b/docs/components/214-message-actions.md new file mode 100644 index 0000000..6828031 --- /dev/null +++ b/docs/components/214-message-actions.md @@ -0,0 +1,38 @@ +# 214 MessageActions 消息操作 + +## 用途 + +消息下方的操作工具栏:复制、重新生成、编辑、赞、踩、朗读、分享,按角色显示:用户消息为复制、编辑;助手消息为复制、重新生成、赞、踩、朗读、分享;系统消息为复制;错误消息为复制、重新生成。 + +## 最小示例 + +```go +if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok { + fmt.Println("action", a) +} +``` + +签名:`func MessageActions(c *ui.Context, role MessageRole, opts MessageActionsOptions) MessageActionsResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `role` | `MessageRole` | 必填 | 决定显示哪些操作。 | +| `Feedback` | `*MessageFeedback` | nil | 赞踩状态;nil 时不显示赞与踩。再次点击已选的赞或踩会取消。 | + +## 状态 + +赞或踩选中时为按下状态:Selection 底色、AccentText 边框与图标;辅助技术读到切换按钮的按下状态。 + +## 事件 + +`Action()` 返回本帧的操作:`ActionCopy`、`ActionRegenerate`、`ActionEdit`、`ActionLike`、`ActionDislike`、`ActionSpeak`、`ActionShare`。复制、朗读与分享由调用方执行。 + +## 键盘操作 + +整个工具栏是一个 Tab 停靠点,Left/Right 在操作间移动,Home、End 到首末;Enter 或 Space 触发。 + +## 限制 + +- 不内置复制内容与朗读:调用方收到操作后自行处理。 diff --git a/docs/components/215-message-editor.md b/docs/components/215-message-editor.md new file mode 100644 index 0000000..5d6b0d1 --- /dev/null +++ b/docs/components/215-message-editor.md @@ -0,0 +1,38 @@ +# 215 MessageEditor 消息编辑器 + +## 用途 + +编辑已发送的消息:多行文本框加取消与保存。保存时调用方据草稿产生新分支(配合 BranchNavigator)。 + +## 最小示例 + +```go +if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() { + fmt.Println("new branch from", draft) +} +``` + +签名:`func MessageEditor(c *ui.Context, draft *string, opts MessageEditorOptions) MessageEditorResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `draft` | `*string` | 必填 | 正在编辑的文本。 | +| `Original` | `string` | "" | 原文;草稿与原文相同或只有空白时不能保存。 | + +## 状态 + +保存按钮在内容无效时禁用;文本框保留输入法、撤销与剪贴板行为。 + +## 事件 + +`Saved()`:点击保存或 Cmd/Ctrl+Enter。`Canceled()`:点击取消或在文本框中按 Escape。 + +## 键盘操作 + +Cmd/Ctrl+Enter 保存,Escape 取消;Tab 依次到文本框、取消、保存。 + +## 限制 + +- 不保存历史:分支与原文由调用方保存。 diff --git a/docs/components/216-branch-navigator.md b/docs/components/216-branch-navigator.md new file mode 100644 index 0000000..8ab7bcc --- /dev/null +++ b/docs/components/216-branch-navigator.md @@ -0,0 +1,38 @@ +# 216 BranchNavigator 分支切换 + +## 用途 + +在一条消息的多个分支间切换,显示“< 2 / 3 >”。 + +## 最小示例 + +```go +if mujica.BranchNavigator(c, &branch, 3).Changed() { + fmt.Println("show branch", branch) +} +``` + +签名:`func BranchNavigator(c *ui.Context, index *int, count int) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `index` | `*int` | 必填 | 当前分支,0 起;不在 0 到 count−1 之间时 panic。 | +| `count` | `int` | 必填 | 分支数,至少 1。 | + +## 状态 + +首末分支时对应箭头禁用;切换时向辅助技术播报“第 n 个分支,共 m 个”。 + +## 事件 + +`Changed()`:分支改变。 + +## 键盘操作 + +整个控件是一个 Tab 停靠点:Left/Right 切换,Home、End 到第一个与最后一个;箭头按钮只响应指针。 + +## 限制 + +- 箭头按钮不进入 Tab 序列,避免到达首末分支时焦点停在禁用按钮上。 diff --git a/docs/components/217-error-message.md b/docs/components/217-error-message.md new file mode 100644 index 0000000..441891c --- /dev/null +++ b/docs/components/217-error-message.md @@ -0,0 +1,40 @@ +# 217 ErrorMessage 错误消息 + +## 用途 + +显示失败的回复:标题、原始错误文本(等宽、可选择复制,不改写、不截断)与重试按钮;限流时显示倒计时,倒计时结束前不能重试。 + +## 最小示例 + +```go +opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)} +if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() { + fmt.Println("send again") +} +``` + +签名:`func ErrorMessage(c *ui.Context, text string, opts ErrorMessageOptions) ErrorMessageResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `text` | `string` | 必填 | 原始错误文本,原样显示;为空 panic。 | +| `Title` | `string` | “出错了” | 概括失败原因。 | +| `RetryAt` | `time.Time` | 零值 | 限流解除时间;之前重试按钮禁用并显示“n 秒后重试”与“已限流”。 | + +## 状态 + +可重试 / 限流倒计时;Danger 边框、图标与标题文字共同表示错误。倒计时只在秒数变化时重绘。 + +## 事件 + +`Retried()`:按下重试。 + +## 键盘操作 + +Tab 到错误文本(可用 Shift+方向键选择、Cmd/Ctrl+C 复制)与重试按钮。 + +## 限制 + +- 倒计时依据窗口时钟;调用方的 RetryAt 应来自服务端的 Retry-After。 diff --git a/docs/components/218-thinking-block.md b/docs/components/218-thinking-block.md new file mode 100644 index 0000000..fb2ca98 --- /dev/null +++ b/docs/components/218-thinking-block.md @@ -0,0 +1,40 @@ +# 218 ThinkingBlock 思考过程 + +## 用途 + +可折叠的思考过程:思考中显示加载标记与实时秒数,结束后显示“思考了 n 秒”。默认折叠或展开由调用方传入的 `*open` 初值决定。基于 Collapsible。 + +## 最小示例 + +```go +mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() { + ui.Text(c, "Weigh the guest list.") +}) +``` + +签名:`func ThinkingBlock(c *ui.Context, open *bool, opts ThinkingBlockOptions, content func()) ChoiceResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | 必填 | 是否展开;其初值即默认状态。 | +| `Thinking` | `bool` | false | 思考中:显示加载标记与从 Started 起的秒数。 | +| `Started` | `time.Time` | 零值 | 思考开始时间;Thinking 时必填,否则 panic。 | +| `Elapsed` | `time.Duration` | 0 | 思考结束后的总耗时;负数 panic。 | + +## 状态 + +思考中 / 已完成;展开时内容带左侧细线、TextMuted 色。减少动画时加载标记静止,秒数仍每秒更新。 + +## 事件 + +`Changed()`:展开状态改变。 + +## 键盘操作 + +Tab 聚焦标题,Enter 或 Space 展开与折叠。 + +## 限制 + +- 思考中每秒重绘一次以更新秒数;折叠且完成后不再重绘。 diff --git a/docs/components/219-scroll-to-bottom-button.md b/docs/components/219-scroll-to-bottom-button.md new file mode 100644 index 0000000..1cd7550 --- /dev/null +++ b/docs/components/219-scroll-to-bottom-button.md @@ -0,0 +1,40 @@ +# 219 ScrollToBottomButton 回到底部按钮 + +## 用途 + +离开底部时出现在容器底部居中的圆角按钮,带未读计数;点击回到底部。MessageList 已内置,也可单独用于其他滚动区域。 + +## 最小示例 + +```go +ui.Box(c).Fill().Children(func() { + if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() { + list.ScrollToEnd() + } +}) +``` + +签名:`func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `away` | `bool` | 必填 | 是否已离开底部;false 时返回不可见的空元素,其 Clicked 为 false。 | +| `unread` | `int` | 0 | 未读数,大于 0 时显示;负数 panic。 | + +## 状态 + +出现时 160 毫秒淡入(减少动画时直接显示);悬停时底色加深。 + +## 事件 + +`Clicked()`:点击或 Enter、Space;滚动由调用方完成。 + +## 键盘操作 + +Tab 聚焦,Enter 或 Space 触发。辅助技术读到“回到最新,n 条新消息”。 + +## 限制 + +- 按钮以绝对定位贴在父元素底部,父元素需要占满滚动区域。 diff --git a/docs/components/220-typing-indicator.md b/docs/components/220-typing-indicator.md new file mode 100644 index 0000000..b0094d6 --- /dev/null +++ b/docs/components/220-typing-indicator.md @@ -0,0 +1,35 @@ +# 220 TypingIndicator 正在输入 + +## 用途 + +对方正在输入的提示:三个依次明暗变化的圆点;减少动画时改为静态文字“某某 正在输入…”。 + +## 最小示例 + +```go +mujica.TypingIndicator(c, "Herald") +``` + +签名:`func TypingIndicator(c *ui.Context, name string) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `name` | `string` | 必填 | 正在输入的一方;为空 panic。 | + +## 状态 + +动画 / 静态文字;状态角色,辅助技术读到“某某 正在输入…”。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点。 + +## 限制 + +- 动画只在显示时请求下一帧,移出界面后停止重绘。 diff --git a/docs/components/241-conversation-list.md b/docs/components/241-conversation-list.md new file mode 100644 index 0000000..c2d1703 --- /dev/null +++ b/docs/components/241-conversation-list.md @@ -0,0 +1,56 @@ +# 241 ConversationList · 会话列表 + +## 用途 + +按“置顶、今天、昨天、7 天内、更早”分组显示历史会话,只构建可见行,适合十万条以上的会话历史。 + +## 最小示例 + +```go +var state mujica.ConversationListState +convs := []mujica.ChatConversation{ + {ID: "1", Title: "Court etiquette", Updated: time.Now()}, + {ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ConversationList(c, &state, convs, mujica.ConversationListOptions{Label: "History"}, nil).Changed() { + log.Println("open", state.Selected) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView`。`row` 为 nil 时显示标题;传入时可放 `ConversationItem` 等自定义内容。 + +`ChatConversation`:`ID`(唯一)、`Title`、`Updated`、`Pinned`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Now` | `time.Time` | 零值(窗口时钟) | 分组用的当前时间。 | +| `Label` | `string` | "" | 列表的无障碍名称。 | +| `Disabled` | `bool` | false | 禁用:不能选择,退出 Tab 序列。 | + +`ConversationListState.Selected` 为当前会话 ID,调用方可直接设置。 + +## 状态 + +分组标题(衬线小字、旧金色)、普通行、悬停、选中(Selection 底色 + 左侧菱形)、聚焦(列表焦点环)、禁用。 + +## 事件 + +`Changed()`:选中了另一会话,ID 在 `State.Selected`。`Submitted()`:在选中行上按 Enter 或双击。 + +## 键盘操作 + +- Tab:聚焦列表。 +- ↑/↓、Home、End:在会话间移动并选择,跳过分组标题;输入首字母定位。 +- Enter:打开当前会话。 + +## 限制 + +- 分组与排序只在 `items` 切片(首元素地址或长度)或当天日期变化时重算。原地修改切片元素后,请传入新切片以触发重新分组。 +- 选中行由 MyGo 列表报告选中状态;分组标题由 `ListState.Header` 标记,不可选中。 +- 自定义 `row` 中若含按钮(如 `ConversationItem`),点击由按钮处理,不会改变列表选择;请在按钮事件里设置 `State.Selected`。 diff --git a/docs/components/242-conversation-item.md b/docs/components/242-conversation-item.md new file mode 100644 index 0000000..84246ff --- /dev/null +++ b/docs/components/242-conversation-item.md @@ -0,0 +1,50 @@ +# 242 ConversationItem · 会话条目 + +## 用途 + +一行会话:标题、时间、置顶标记,以及菜单中的重命名(行内编辑)、置顶、导出、删除。删除与导出只回调,由调用方执行。 + +## 最小示例 + +```go +conv := mujica.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{}) + if v.Deleted { + log.Println("delete", conv.ID) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationItemOptions) ConversationItemView`。`conv` 不能为 nil,`ID` 不能为空。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Today` | `Date` | 零值(窗口时钟) | 决定时间显示为时刻、“昨天”或日期。 | +| `Selected` | `bool` | false | 显示为当前会话。 | +| `Disabled` | `bool` | false | 禁用全部操作。 | +| `Draggable` | `bool` | false | 允许拖到 `ProjectList` 的项目上,携带 `ConversationDrag`。 | + +## 状态 + +默认、悬停、选中(Selection 底色 + 酒红标题)、置顶(星形图标)、行内编辑(输入框酒红边框)、禁用。 + +## 事件 + +`ConversationItemView`:`Opened`(点击或 Enter)、`Renamed`(新标题已写入 `conv.Title`)、`PinToggled`、`Exported`、`Deleted`。 + +## 键盘操作 + +- Enter/Space:打开会话。 +- F2:行内重命名;输入框中 Enter 确认,Escape 取消;空白标题不会被接受。 +- Delete:请求删除。 +- 菜单按钮:Enter、Space 或 ↓ 打开,方向键移动,Escape 关闭。 + +## 限制 + +- 删除没有内置确认;需要确认时由调用方弹出 AlertDialog。 +- 菜单按钮不在条目的 Tab 位之外另设快捷键。 diff --git a/docs/components/243-conversation-search.md b/docs/components/243-conversation-search.md new file mode 100644 index 0000000..4195cdc --- /dev/null +++ b/docs/components/243-conversation-search.md @@ -0,0 +1,53 @@ +# 243 ConversationSearch · 会话搜索 + +## 用途 + +按标题与内容搜索会话。查询交给调用方异步执行,结果回传后高亮匹配文字。 + +## 最小示例 + +```go +query := "seat" +hits := []mujica.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ConversationSearch(c, &query, hits, mujica.ConversationSearchOptions{}) + if v.QueryChanged { + log.Println("search", query) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ConversationSearch(c *ui.Context, query *string, hits []ConversationSearchHit, opts ConversationSearchOptions) ConversationSearchView`。 + +`ConversationSearchHit`:`ID`、`Title`、`Snippet`(匹配的消息片段)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Loading` | `bool` | false | 调用方正在搜索。 | +| `Error` | `string` | "" | 搜索失败的说明,附重试按钮。 | +| `Placeholder` | `string` | 内置“搜索” | 输入框占位。 | +| `Label` | `string` | "" | 无障碍名称。 | +| `Disabled` | `bool` | false | 禁用输入与选择。 | + +## 状态 + +空查询、搜索中(旋转标记)、有结果(匹配处加粗、下划线、Selection 底)、无结果、失败(错误文字 + 重试)、禁用。 + +## 事件 + +`QueryChanged`:查询变化,调用方据此发起搜索,结果到达后传入新的 `hits` 并调用 `c.Invalidate()`。`Chosen`:点击或 Enter 打开的会话 ID。`Retry`:点击重试。 + +## 键盘操作 + +- 输入框中 ↓:进入结果列表。 +- 列表中 ↑/↓ 移动,Enter 打开。 +- Escape:清空查询(SearchInput 行为)。 + +## 限制 + +- 匹配高亮为不区分大小写的逐字符匹配,不做分词、拼音或模糊匹配;排序完全由调用方决定。 +- 结果行固定高度(标题一行、片段一行),片段超长时截断。 diff --git a/docs/components/244-project-list.md b/docs/components/244-project-list.md new file mode 100644 index 0000000..a3ed67b --- /dev/null +++ b/docs/components/244-project-list.md @@ -0,0 +1,53 @@ +# 244 ProjectList · 项目列表 + +## 用途 + +按文件夹列出项目,展开项目可见其中的会话;把会话拖到项目上或用“移动”菜单即可改变归属。 + +## 最小示例 + +```go +var state mujica.ProjectListState +projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}} +convs := func(id string) []mujica.ChatConversation { + return []mujica.ChatConversation{{ID: "1", Title: "Menu"}} +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.ProjectList(c, &state, projects, convs, mujica.ProjectListOptions{}); v.Moved { + log.Println("move", v.MoveID, "to", v.MoveTo) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, conversations func(projectID string) []ChatConversation, opts ProjectListOptions) ProjectListView`。 + +`ChatProject`:`ID`、`Name`、`Folder`(文件夹名,空表示不在文件夹中)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | "" | 树的无障碍名称。 | +| `Disabled` | `bool` | false | 禁用选择、展开与移动。 | + +## 状态 + +文件夹(展开/收起)、项目行(悬停、选中:Selection 底 + 菱形)、拖入目标(角饰边框)、会话行(拖动时半透明)、禁用。 + +## 事件 + +`ProjectListView`:`Changed`(选中项目,ID 在 `State.Selected`)、`Opened`(打开的会话 ID)、`Moved` 与 `MoveID`/`MoveTo`(把会话移入项目,由调用方更新数据)。 + +## 键盘操作 + +- Tab:进入树;↑/↓ 在文件夹、项目、会话间移动。 +- 项目行上 →/← 展开/收起会话;Enter 选择项目。 +- 文件夹上 Enter/Space 展开或收起。 +- 会话行的菜单按钮:选择“移入项目”下的目标完成移动(拖放的键盘替代)。 + +## 限制 + +- 项目行通过 `Description` 读出“已展开/已收起”;MyGo 不向自定义元素公开 expanded 状态(ui/element.go:282),文件夹使用 `CollapsibleBase`,其展开状态可被辅助技术读到。 +- 列表未虚拟化,适合数百个项目以内。 diff --git a/docs/components/245-project-knowledge-panel.md b/docs/components/245-project-knowledge-panel.md new file mode 100644 index 0000000..158c9e5 --- /dev/null +++ b/docs/components/245-project-knowledge-panel.md @@ -0,0 +1,47 @@ +# 245 ProjectKnowledgePanel · 项目知识面板 + +## 用途 + +列出项目知识文件,提供添加、移除,并显示 token 用量与容量提示。选择与读取文件由调用方完成。 + +## 最小示例 + +```go +files := []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ProjectKnowledgePanel(c, files, mujica.ProjectKnowledgePanelOptions{Capacity: 200000}) + if v.Add { + log.Println("pick files") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKnowledgePanelOptions) ProjectKnowledgePanelView`。 + +`KnowledgeFile`:`ID`、`Name`、`Tokens`(不能为负)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Capacity` | `int` | 必填 | 知识容量(tokens),必须为正,否则 panic。 | +| `Title` | `string` | 内置“项目知识” | 面板标题。 | +| `Disabled` | `bool` | false | 禁用添加与移除。 | + +## 状态 + +正常用量(酒红进度)、≥ 80%(警告色 + 三角图标 + “即将用满”)、超出容量(危险色 + 圆形警示图标 + “超出容量”)、空列表、禁用。 + +## 事件 + +`Add`:请调用方打开文件选择。`Removed`:要移除的文件 ID。 + +## 键盘操作 + +- Tab:添加按钮,再进入文件列表;↑/↓ 在移除按钮间移动;Enter/Space 移除。 + +## 限制 + +- token 数由调用方计算;面板只做求和与比例显示。 diff --git a/docs/components/246-welcome-screen.md b/docs/components/246-welcome-screen.md new file mode 100644 index 0000000..910f58f --- /dev/null +++ b/docs/components/246-welcome-screen.md @@ -0,0 +1,46 @@ +# 246 WelcomeScreen · 欢迎页 + +## 用途 + +新会话的欢迎页:衬线问候、菱形分隔、副标题、能力卡片,以及放置起始提示的插槽。 + +## 最小示例 + +```go +cards := []mujica.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" { + log.Println("ask", v.Example) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func WelcomeScreen(c *ui.Context, opts WelcomeScreenOptions) CapabilityCardsView`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Greeting` | `string` | 必填 | 问候语,空时 panic。 | +| `Subtitle` | `string` | "" | 副标题。 | +| `Cards` | `[]CapabilityCard` | nil | 能力卡片,最多三列。 | +| `Prompts` | `func()` | nil | 起始提示插槽,例如 `SuggestionChips`。 | +| `Disabled` | `bool` | false | 禁用卡片示例与插槽内容。 | + +## 状态 + +默认、禁用(由 MyGo 置灰)。卡片与示例按钮的状态见 249 CapabilityCards。 + +## 事件 + +返回值的 `Example`:点击的示例提示。插槽内组件的事件由调用方自行读取。 + +## 键盘操作 + +- Tab 依次经过各卡片的示例与插槽内控件;卡片内 ↑/↓ 在示例间移动;Enter/Space 选择。 + +## 限制 + +- 问候语按纯文本显示,不解析 Markdown。 diff --git a/docs/components/247-suggestion-chips.md b/docs/components/247-suggestion-chips.md new file mode 100644 index 0000000..e8ae72f --- /dev/null +++ b/docs/components/247-suggestion-chips.md @@ -0,0 +1,42 @@ +# 247 SuggestionChips · 建议标签 + +## 用途 + +起始提示或追问建议。点击可填入输入框,或配置为直接发送。 + +## 最小示例 + +```go +input := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SuggestionChips(c *ui.Context, input *string, suggestions []string, opts SuggestionChipsOptions) SuggestionChipsView`。建议不能为空字符串。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Send` | `bool` | false | 点击即请求发送;默认把建议填入 `*input`。 | +| `Label` | `string` | "" | 组名称,如“追问建议”。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认(右箭头)、发送模式(上箭头)、悬停、按下、聚焦、禁用。 + +## 事件 + +`Chosen`:点击的建议。`Sent` 为 true 时调用方应立即发送 `Chosen`;否则建议已写入 `*input`。 + +## 键盘操作 + +- Tab 进入组;←/→ 在建议间移动;Enter/Space 选择。 + +## 限制 + +- 建议换行排列;数量很多时请调用方截取。 diff --git a/docs/components/248-prompt-library.md b/docs/components/248-prompt-library.md new file mode 100644 index 0000000..bd57cd9 --- /dev/null +++ b/docs/components/248-prompt-library.md @@ -0,0 +1,49 @@ +# 248 PromptLibrary · 提示模板库 + +## 用途 + +按分类与搜索浏览提示模板,预览模板,填写 `{{变量}}` 后插入。 + +## 最小示例 + +```go +var state mujica.PromptLibraryState +templates := []mujica.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.PromptLibrary(c, &state, templates, mujica.PromptLibraryOptions{}); v.Inserted != "" { + log.Println("insert", v.Inserted) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func PromptLibrary(c *ui.Context, s *PromptLibraryState, templates []PromptTemplate, opts PromptLibraryOptions) PromptLibraryView`。另有 `PromptLibraryVariables(body)` 与 `PromptLibraryFill(body, values)`。 + +`PromptTemplate`:`ID`、`Category`、`Title`(ID 与 Title 必填)、`Body`(`{{name}}` 为变量,名称可含字母、数字、`_.-`)。 + +`PromptLibraryState`:`Category`(空为全部)、`Query`、`Selected`、`Values`(变量值)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | "" | 无障碍名称。 | +| `Disabled` | `bool` | false | 禁用全部操作。 | + +## 状态 + +分类与模板行(选中:Selection 底 + 菱形)、无匹配、未选模板、预览(未填变量以 [名称] 标出,已填值酒红加粗)、插入按钮(变量未填全时禁用)、禁用。 + +## 事件 + +`Inserted`:变量已填好的模板正文,非空时由调用方插入输入框。 + +## 键盘操作 + +- Tab 依次进入分类列、搜索框、模板列、变量输入框、插入按钮;列内 ↑/↓ 移动;Enter/Space 选择。 + +## 限制 + +- 过滤只在模板切片、查询或分类变化时重算。 +- 变量为简单占位替换,不支持条件或循环。 diff --git a/docs/components/249-capability-cards.md b/docs/components/249-capability-cards.md new file mode 100644 index 0000000..0c1c886 --- /dev/null +++ b/docs/components/249-capability-cards.md @@ -0,0 +1,45 @@ +# 249 CapabilityCards · 能力卡片 + +## 用途 + +用卡片说明助手能做什么,每张卡片附示例提示,点击示例交给调用方。 + +## 最小示例 + +```go +cards := []mujica.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.CapabilityCards(c, cards, mujica.CapabilityCardsOptions{}); v.Example != "" { + log.Println("ask", v.Example) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCardsOptions) CapabilityCardsView`。 + +`CapabilityCard`:`Title`(必填)、`Description`、`Icon`、`Examples`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Columns` | `int` | 0 → 3 | 每行卡片数;负数 panic。 | +| `Disabled` | `bool` | false | 禁用示例按钮。 | + +## 状态 + +卡片(衬线标题、酒红图标)、示例按钮(悬停、按下、聚焦)、禁用。 + +## 事件 + +`Example`:点击的示例文字。 + +## 键盘操作 + +- Tab 在各卡片示例组间移动;组内 ↑/↓;Enter/Space 选择。 + +## 限制 + +- 卡片等宽分栏,不随窗口宽度自动减少列数;窄窗口时请调用方传入较小的 `Columns`。 diff --git a/docs/components/250-feedback-form.md b/docs/components/250-feedback-form.md new file mode 100644 index 0000000..3fa8600 --- /dev/null +++ b/docs/components/250-feedback-form.md @@ -0,0 +1,46 @@ +# 250 FeedbackForm · 反馈表单 + +## 用途 + +在点赞或点踩之后询问原因:原因标签多选与补充文字,然后提交或取消。 + +## 最小示例 + +```go +var value mujica.FeedbackValue +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FeedbackForm(c, &value, mujica.FeedbackFormOptions{Rating: mujica.FeedbackDown}).Submitted { + log.Println("reasons", value.Reasons) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func FeedbackForm(c *ui.Context, value *FeedbackValue, opts FeedbackFormOptions) FeedbackFormView`。`FeedbackValue`:`Reasons`、`Comment`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Rating` | `FeedbackRating` | `FeedbackUp` | 赞(`FeedbackUp`)或踩(`FeedbackDown`),决定标题与内置原因。 | +| `Reasons` | `[]string` | nil(内置) | 替换内置原因。 | +| `Submitting` | `bool` | false | 调用方正在提交:提交按钮显示加载,表单不可编辑。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +原因标签(按下:Selection 底 + 下划条)、文字框、提交中、禁用。 + +## 事件 + +`Submitted`:提交;`Cancelled`:取消或 Escape。 + +## 键盘操作 + +- Tab 依次经过原因组、文字框、取消、提交;原因组内 ←/→;Space 切换原因。 +- Escape:取消。 + +## 限制 + +- 表单不发送任何数据;提交由调用方完成。 diff --git a/docs/components/251-date-separator.md b/docs/components/251-date-separator.md new file mode 100644 index 0000000..edd1bdc --- /dev/null +++ b/docs/components/251-date-separator.md @@ -0,0 +1,39 @@ +# 251 DateSeparator · 日期分隔 + +## 用途 + +在消息流中标出新的一天:今天、昨天,其余显示本地化的完整日期。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DateSeparator(c, mujica.DateOf(time.Now()), mujica.DateSeparatorOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func DateSeparator(c *ui.Context, day Date, opts DateSeparatorOptions) *ui.Element`。`day` 必须是有效日期。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Today` | `Date` | 零值(窗口时钟) | 判断“今天/昨天”的当天。 | + +## 状态 + +细线 + 旧金色菱形 + 说明文字;无交互状态。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;辅助技术读出日期文字。 + +## 限制 + +- 日期格式沿用 Calendar 的文案(`calendar.day_label`),en-US 为“Tuesday, September 1, 2026”,zh-CN 为“2026年9月1日 星期二”。 diff --git a/docs/components/252-thinking-indicator.md b/docs/components/252-thinking-indicator.md new file mode 100644 index 0000000..355da7b --- /dev/null +++ b/docs/components/252-thinking-indicator.md @@ -0,0 +1,39 @@ +# 252 ThinkingIndicator · 思考指示 + +## 用途 + +显示助手正在思考:三个菱形依次起伏,旁边是阶段文字;减少动画时静止并加省略号。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: "Searching the archives"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Phase` | `string` | 内置“正在思考” | 阶段文字,如“正在检索资料”。 | + +## 状态 + +动画(1.2 秒一周)、减少动画(静止菱形 + “…”)。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;角色为 Status,辅助技术读出阶段文字。 + +## 限制 + +- 动画只在绘制时请求下一帧;组件隐藏后不再重绘。 diff --git a/docs/components/253-stop-generating-button.md b/docs/components/253-stop-generating-button.md new file mode 100644 index 0000000..33620d7 --- /dev/null +++ b/docs/components/253-stop-generating-button.md @@ -0,0 +1,44 @@ +# 253 StopGeneratingButton · 停止生成按钮 + +## 用途 + +生成回复时显示的停止按钮,并提供 Cmd+.(其他平台 Ctrl+.)快捷键;生成结束后不显示。 + +## 最小示例 + +```go +generating := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: generating}).Changed() { + generating = false + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func StopGeneratingButton(c *ui.Context, opts StopGeneratingButtonOptions) ChoiceResult`。不显示时返回的 `Element` 为 nil。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Generating` | `bool` | false | 正在生成时显示。 | +| `Disabled` | `bool` | false | 禁用按钮与快捷键。 | + +## 状态 + +默认(方块图标 + 文字 + 快捷键提示)、悬停、聚焦、禁用、隐藏。 + +## 事件 + +`Changed()`:请求停止。 + +## 键盘操作 + +- Enter/Space:停止。 +- Cmd+.(macOS)/ Ctrl+.(Windows、Linux):窗口级快捷键,焦点所在控件未占用该键时生效。 + +## 限制 + +- 快捷键用 MyGo 窗口快捷键注册,焦点控件若注册了同一组合键会优先获得。 diff --git a/docs/components/254-system-prompt-editor.md b/docs/components/254-system-prompt-editor.md new file mode 100644 index 0000000..27fdb0b --- /dev/null +++ b/docs/components/254-system-prompt-editor.md @@ -0,0 +1,47 @@ +# 254 SystemPromptEditor · 系统提示编辑器 + +## 用途 + +多行编辑系统提示,显示字数,可恢复默认,有未保存标记,Cmd/Ctrl+S 保存。 + +## 最小示例 + +```go +prompt, saved := "You are helpful.", "You are helpful." +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SystemPromptEditor(c, &prompt, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save { + saved = prompt + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOptions) SystemPromptEditorView`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Default` | `string` | "" | 恢复默认时写回的文字。 | +| `Saved` | `string` | "" | 最近一次保存的文字;与当前值不同即显示“未保存”。 | +| `MaxLength` | `int` | 0 | 字数上限:显示 n / 上限,超出时提示并禁止保存。 | +| `Saving` | `bool` | false | 调用方正在保存,编辑被阻止。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +已保存、未保存(警告色菱形 + 文字)、超出上限(错误提示)、保存中、禁用。 + +## 事件 + +`Save`:请求保存当前值;`Restored`:已恢复默认。 + +## 键盘操作 + +- Tab:进入文本框,再到恢复默认与保存按钮。 +- Cmd+S(macOS)/ Ctrl+S:保存(有改动且未超限时)。 + +## 限制 + +- 超出上限不会截断文字,只阻止保存。 diff --git a/docs/components/255-cost-estimator.md b/docs/components/255-cost-estimator.md new file mode 100644 index 0000000..aa9bded --- /dev/null +++ b/docs/components/255-cost-estimator.md @@ -0,0 +1,42 @@ +# 255 CostEstimator · 费用估算 + +## 用途 + +显示输入、输出 token 数及按调用方单价估算的费用。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element`。另有 `EstimateCost(inputTokens, outputTokens, inputPrice, outputPrice) CostEstimate`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `InputTokens`, `OutputTokens` | `int` | 0 | token 数,不能为负。 | +| `InputPrice`, `OutputPrice` | `float64` | 0 | 每百万 tokens 单价,须有限且不为负。 | +| `Currency` | `string` | "$" | 金额前缀。 | +| `Digits` | `int` | 0 → 4 | 金额小数位;负数 panic。 | + +## 状态 + +只读表格:输入、输出、合计(衬线标签、酒红加粗金额)与单价说明。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;整体读作“预计费用 金额”。 + +## 限制 + +- 估算按 tokens × 单价 / 1,000,000 的浮点运算;显示按 `Digits` 位小数四舍五入,不用于结算。 diff --git a/docs/components/256-shared-conversation-view.md b/docs/components/256-shared-conversation-view.md new file mode 100644 index 0000000..cda30ed --- /dev/null +++ b/docs/components/256-shared-conversation-view.md @@ -0,0 +1,48 @@ +# 256 SharedConversationView · 分享会话视图 + +## 用途 + +只读显示他人分享的会话,带分享者与时间,提供复制链接回调。 + +## 最小示例 + +```go +msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}, {Role: mujica.SessionAssistant, Text: "Good day."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SharedConversationView(c, msgs, mujica.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() { + c.WriteClipboard("https://example.com/share/1") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SharedConversationView(c *ui.Context, messages []SessionMessage, opts SharedConversationViewOptions) ChoiceResult`。 + +`SessionMessage`:`Role`(`SessionUser`、`SessionAssistant`、`SessionSystem`)、`Author`、`Text`、`Time`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 会话标题。 | +| `SharedBy` | `string` | "" | 分享者,空显示“未知”。 | +| `SharedAt` | `time.Time` | 零值 | 分享时间,零值不显示。 | +| `Disabled` | `bool` | false | 禁用复制链接。 | + +## 状态 + +标题区、只读标记(锁形图标)、用户消息(描边气泡)、助手消息、禁用。 + +## 事件 + +`Changed()`:点击“复制链接”,由调用方生成链接并写入剪贴板。 + +## 键盘操作 + +- Tab:复制链接按钮,再到消息列表;消息文字可选择复制。 + +## 限制 + +- 消息以纯文本显示,不渲染 Markdown(Markdown 渲染属于 MarkdownView 组件)。 +- 消息列表虚拟化,只构建可见消息。 diff --git a/docs/components/257-conversation-export.md b/docs/components/257-conversation-export.md new file mode 100644 index 0000000..61a5f86 --- /dev/null +++ b/docs/components/257-conversation-export.md @@ -0,0 +1,48 @@ +# 257 ConversationExport · 会话导出 + +## 用途 + +选择格式(Markdown、JSON、纯文本)与范围,预览后生成导出文字;写文件由调用方完成。 + +## 最小示例 + +```go +var state mujica.ConversationExportState +msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.ConversationExport(c, &state, msgs, mujica.ConversationExportOptions{Title: "Greeting"}); v.Exported { + log.Println(len(v.Content), "bytes to save") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ConversationExport(c *ui.Context, s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) ConversationExportView`。另有纯函数 `ExportConversation(title, messages, format) string`。 + +`ConversationExportState`:`Format`(`ExportMarkdown`、`ExportJSON`、`ExportText`)、`Range`(`ExportAll`、`ExportLast`、`ExportSelected`)、`Last`(0 → 10)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 会话标题,写入导出内容。 | +| `Selected` | `[]int` | nil | `ExportSelected` 使用的消息下标;越界 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +格式单选、范围单选(无选中消息时“已选”不可选)、最近 N 条数字框、等宽字体预览、导出按钮、禁用。 + +## 事件 + +`Exported`:导出请求;`Content` 为生成的文字,`Format` 为格式。 + +## 键盘操作 + +- Tab 依次经过格式组、范围组、数字框、预览、导出按钮;单选组内方向键选择。 + +## 限制 + +- 导出不解析消息文字:Markdown 只加标题与作者行,原文原样写入。 +- 预览只在格式、范围或消息变化时重新生成。 diff --git a/docs/components/258-regenerate-menu.md b/docs/components/258-regenerate-menu.md new file mode 100644 index 0000000..c22739d --- /dev/null +++ b/docs/components/258-regenerate-menu.md @@ -0,0 +1,44 @@ +# 258 RegenerateMenu · 重新生成菜单 + +## 用途 + +重新生成回复:同模型重试、换模型重试,以及更长、更短等改写变体。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen { + log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuView`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Models` | `[]string` | nil | 可换用的模型;为空或只有当前模型时子菜单禁用。 | +| `Current` | `string` | "" | 当前模型,不出现在子菜单中。 | +| `Variants` | `[]string` | nil(内置) | 替换内置的更长、更短、更简单、更详细。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +触发按钮(刷新图标)、菜单打开、子菜单、分组标题、禁用项。 + +## 事件 + +`Chosen` 与 `Choice`:`Kind` 为 `RegenerateAgain`、`RegenerateModel`(`Model`)或 `RegenerateVariant`(`Variant`)。 + +## 键盘操作 + +- Enter、Space 或 ↓ 打开菜单;↑/↓ 移动;→ 打开子菜单,← 返回;Enter 选择;Escape 关闭。 + +## 限制 + +- 菜单在窗口内绘制(DropdownMenu),不使用系统菜单。 diff --git a/docs/components/259-artifact-card.md b/docs/components/259-artifact-card.md new file mode 100644 index 0000000..ed8601b --- /dev/null +++ b/docs/components/259-artifact-card.md @@ -0,0 +1,45 @@ +# 259 ArtifactCard · 产物卡片 + +## 用途 + +消息中的产物卡片(代码、文档、网页),点击打开产物面板。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactCard(c, mujica.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() { + log.Println("open artifact panel") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) ChoiceResult`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Kind` | `ArtifactKind` | `ArtifactCode` | `ArtifactCode`、`ArtifactDocument`、`ArtifactWeb`;越界 panic。 | +| `Title` | `string` | 必填 | 产物标题。 | +| `Detail` | `string` | "" | 简短说明,如“Go · 120 行”。 | +| `Streaming` | `bool` | false | 仍在生成,显示旋转标记。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、悬停、聚焦、生成中、禁用。种类同时用图标与文字表示。 + +## 事件 + +`Changed()`:请求打开产物面板。 + +## 键盘操作 + +- Tab 聚焦;Enter/Space 打开。 + +## 限制 + +- 卡片不渲染产物内容,面板由调用方实现。 diff --git a/docs/components/260-chat-empty-state.md b/docs/components/260-chat-empty-state.md new file mode 100644 index 0000000..3b3852e --- /dev/null +++ b/docs/components/260-chat-empty-state.md @@ -0,0 +1,44 @@ +# 260 ChatEmptyState · 会话空状态 + +## 用途 + +会话界面的三种空状态:新会话、无搜索结果、离线,并给出下一步操作。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyOffline}).Action { + log.Println("reconnect") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ChatEmptyState(c *ui.Context, opts ChatEmptyStateOptions) EmptyStateResult`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Kind` | `ChatEmptyKind` | `ChatEmptyNew` | `ChatEmptyNew`、`ChatEmptyNoResults`、`ChatEmptyOffline`;越界 panic。 | +| `Query` | `string` | "" | 无结果时在说明中显示的查询。 | +| `Title`, `Description` | `string` | 内置 | 替换文案。 | +| `Action` | `string` | 内置 | 按钮文字:新建对话、清除搜索、重试。 | + +## 状态 + +新会话(星形图标)、无结果(搜索图标)、离线(断网图标)。 + +## 事件 + +返回值 `Action`:点击按钮。 + +## 键盘操作 + +- Tab 聚焦按钮;Enter/Space 触发。 + +## 限制 + +- 基于 EmptyState 组件,布局与其一致。 diff --git a/docs/components/261-tool-call-card.md b/docs/components/261-tool-call-card.md new file mode 100644 index 0000000..71f100f --- /dev/null +++ b/docs/components/261-tool-call-card.md @@ -0,0 +1,47 @@ +# 261 ToolCallCard 工具调用卡片 + +## 用途 + +显示一次工具调用:工具名、状态、耗时,参数与结果可折叠,JSON 自动格式化并高亮。 + +## 最小示例 + +```go +call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}", + Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult`。`call.Name` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OpenArgs` | `bool` | `false` | 参数区初始展开。 | +| `OpenResult` | `bool` | `false` | 结果区初始展开。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `ToolCall` 字段:`ID`、`Name`、`Args`(JSON 或纯文本)、`Result`、`Error`(非空时替代结果并以错误色显示)、`State`、`Duration`。 +- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。 +- 仅完成或失败时显示耗时;合法 JSON 以两格缩进格式化,非 JSON 原样显示。 +- 结果超过 200 行时虚拟化,只构建可见行。 + +## 事件 + +- `Changed()`:用户展开或折叠参数、结果。 + +## 键盘操作 + +- Tab 到“参数”“结果”折叠按钮,Enter / Space 展开或折叠。 + +## 限制 + +- 展开状态保存在卡片元素上;在列表中请用稳定 `Key` 包裹每张卡片。 +- 高亮只区分关键字、字符串、数字和标点,不做 JSON 结构校验提示。 diff --git a/docs/components/262-tool-call-group.md b/docs/components/262-tool-call-group.md new file mode 100644 index 0000000..76f405a --- /dev/null +++ b/docs/components/262-tool-call-group.md @@ -0,0 +1,47 @@ +# 262 ToolCallGroup 工具调用组 + +## 用途 + +把连续的工具调用折叠成一组,标题行显示调用次数、总耗时与失败数。 + +## 最小示例 + +```go +open := false +calls := []mujica.ToolCall{ + {ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond}, + {ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult`。`open` 为 nil、调用 `ID` 为空或重复时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | “N 次工具调用” | 替换内置标题。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- 组的状态:有调用执行中为执行中,否则有失败为失败,否则为完成。 +- 失败数以文字“N 次失败”显示。 +- 展开后每次调用用 ToolCallCard 显示。 + +## 事件 + +- `Changed()`:用户展开或折叠该组。 + +## 键盘操作 + +- Tab 到组标题,Enter / Space 展开或折叠;组内卡片各自可折叠。 + +## 限制 + +- 总耗时是各调用耗时之和,不扣除并行重叠。 diff --git a/docs/components/263-tool-approval-dialog.md b/docs/components/263-tool-approval-dialog.md new file mode 100644 index 0000000..20932bf --- /dev/null +++ b/docs/components/263-tool-approval-dialog.md @@ -0,0 +1,51 @@ +# 263 ToolApprovalDialog 工具审批对话框 + +## 用途 + +工具执行前请求批准:显示工具名、执行理由和参数预览,允许或拒绝。遵循 Dialog 与 AlertDialog 规则。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ToolApprovalDialog(c, &open, mujica.ToolApprovalOptions{ + Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.", + }) + if r.Allowed { + fmt.Println("run the tool") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) ToolApprovalResult`。`Tool` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Tool` | `string` | 必填 | 工具名。 | +| `Args` | `string` | `""` | 参数,合法 JSON 会格式化高亮。 | +| `Reason` | `string` | `""` | Agent 给出的理由,作为对话框说明。 | + +## 状态 + +- 打开时焦点在“拒绝”;点击遮罩不做任何事。 +- 参数过长时在预览区内滚动,按钮始终可见。 + +## 事件 + +- `Allowed`:点击允许,或焦点在允许上按 Enter / Space。 +- `Denied`:点击拒绝、焦点在拒绝上按 Enter、或按 Escape。 +- `Element`:对话框面板,关闭时为 nil。 + +## 键盘操作 + +- 打开时焦点在拒绝,Enter 只触发聚焦按钮,必须 Tab 到“允许”才能批准。 +- Escape 等同拒绝;Tab 在对话框内循环;关闭后焦点回到打开者。 + +## 限制 + +- 输入法组合期间按 Escape 仍会拒绝(MyGo 不公开组合状态,`ui/editor.go:73`)。 diff --git a/docs/components/264-permission-prompt.md b/docs/components/264-permission-prompt.md new file mode 100644 index 0000000..c3b4e9e --- /dev/null +++ b/docs/components/264-permission-prompt.md @@ -0,0 +1,48 @@ +# 264 PermissionPrompt 权限请求 + +## 用途 + +请求权限:允许一次、始终允许或拒绝,并说明“始终允许”覆盖的范围。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{ + Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger", + }) + if r.Choice == mujica.PermissionAllowAlways { + fmt.Println("remember the grant") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) PermissionResult`。`Title` 或 `Scope` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | 必填 | 问题。 | +| `Description` | `string` | `""` | 补充说明。 | +| `Scope` | `string` | 必填 | “始终允许”覆盖的范围。 | + +## 状态 + +- 范围说明放在带锁形图标的框内。 +- 打开时焦点在“拒绝”,点击遮罩无效。 + +## 事件 + +- `Choice`:本帧的选择,`PermissionAllowOnce`、`PermissionAllowAlways`、`PermissionDeny`;无选择为 `PermissionNone`。Escape 记为拒绝。 + +## 键盘操作 + +- 打开时焦点在拒绝;Tab 依次到“始终允许”“允许一次”;Enter 触发聚焦按钮;Escape 拒绝。 + +## 限制 + +- 组件不保存授权,记住“始终允许”由调用方负责。 diff --git a/docs/components/265-agent-step-list.md b/docs/components/265-agent-step-list.md new file mode 100644 index 0000000..5f87bed --- /dev/null +++ b/docs/components/265-agent-step-list.md @@ -0,0 +1,51 @@ +# 265 AgentStepList 执行步骤列表 + +## 用途 + +以时间线显示 Agent 的执行步骤:当前步加框标出,步骤可展开详情,失败步提供重试。 + +## 最小示例 + +```go +steps := []mujica.AgentStep{ + {ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second}, + {ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."}, + {ID: "send", Title: "Send invitations", State: mujica.AgentPending}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" { + fmt.Println("retry", r.Retry) + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) AgentStepListResult`。步骤 `ID` 为空或重复、`Title` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | `false` | 禁止展开与重试。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `AgentStep` 字段:`ID`、`Title`、`Detail`、`State`、`Duration`。 +- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。 +- 当前步是第一个执行中、等待或规划中的步骤,用选中底色、边框和“当前”文字标出。 +- 失败步下方显示“重试”。 + +## 事件 + +- `Toggled`:本帧展开或折叠的步骤 ID。 +- `Retry`:本帧请求重试的步骤 ID。 + +## 键盘操作 + +- Tab 进入列表;↑ ↓ 在步骤与重试按钮间移动;Enter / Space 展开详情或重试。 + +## 限制 + +- 步骤数量较少时使用;列表不虚拟化。 diff --git a/docs/components/266-agent-plan.md b/docs/components/266-agent-plan.md new file mode 100644 index 0000000..d2f4ec1 --- /dev/null +++ b/docs/components/266-agent-plan.md @@ -0,0 +1,49 @@ +# 266 AgentPlan 执行计划 + +## 用途 + +显示 Agent 的计划清单与进度:随 Agent 实时勾选,用户可勾选、跳过、恢复和新增项。 + +## 最小示例 + +```go +items := []mujica.PlanItem{ + {ID: "a", Text: "Read the guest list", State: mujica.AgentDone}, + {ID: "b", Text: "Assign the seats", State: mujica.AgentRunning}, + {ID: "c", Text: "Order the flowers"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult`。`items` 为 nil、项 `ID` 重复或状态不是待办、进行中、完成、跳过之一时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Title` | `string` | “计划” | 标题。 | +| `Disabled` | `bool` | `false` | 只读显示,隐藏跳过与新增。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `PlanItem` 字段:`ID`、`Text`、`State`(`AgentPending`、`AgentRunning`、`AgentDone`、`AgentSkipped`)。 +- 进度为“已完成 / 未跳过总数”,以文字和细进度条显示。 +- 跳过的项加删除线并禁用勾选;进行中的项显示“进行中”。 +- 新增项的 ID 自动生成为未使用的 `item-N`。 + +## 事件 + +- `Changed()`:用户勾选、跳过、恢复或新增了项,`*items` 已更新。 + +## 键盘操作 + +- Tab 到每项的勾选框(Space 切换)、跳过或恢复按钮;在新增输入框中按 Enter 添加。 + +## 限制 + +- 空白内容不会被添加。 diff --git a/docs/components/267-agent-status.md b/docs/components/267-agent-status.md new file mode 100644 index 0000000..15637fc --- /dev/null +++ b/docs/components/267-agent-status.md @@ -0,0 +1,41 @@ +# 267 AgentStatus Agent 状态 + +## 用途 + +显示 Agent 当前状态:规划中、执行中、等待、完成、失败,用图标形状和文字表达,不只靠颜色。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element`。未知状态 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | 内置状态词 | 替换显示的文字。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。 +- 执行中显示旋转的菱形;`Still` 或系统减少动画时改为静态图标。 + +## 事件 + +- 无事件;屏幕阅读器以状态角色读出文字。 + +## 键盘操作 + +- 不获取焦点。 + +## 限制 + +- 状态动画只在显示时重绘,隐藏后停止。 diff --git a/docs/components/268-agent-progress.md b/docs/components/268-agent-progress.md new file mode 100644 index 0000000..b25e5d1 --- /dev/null +++ b/docs/components/268-agent-progress.md @@ -0,0 +1,42 @@ +# 268 AgentProgress Agent 进度 + +## 用途 + +显示整体进度和当前动作文字;时长未知时用不确定模式。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element`。确定模式下 `value` 不在 0 到 1 时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | “进度” | 进度名称。 | +| `Action` | `string` | `""` | 当前动作,如“正在读取 ledger.md”。 | +| `Indeterminate` | `bool` | `false` | 不确定模式,忽略 value。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- 确定模式显示百分比;不确定模式显示流动条,减少动画时为静态条加“进行中”。 + +## 事件 + +- 无事件;当前动作以状态角色读出。 + +## 键盘操作 + +- 不获取焦点。 + +## 限制 + +- 进度值由调用方计算。 diff --git a/docs/components/269-sub-agent-tree.md b/docs/components/269-sub-agent-tree.md new file mode 100644 index 0000000..5349566 --- /dev/null +++ b/docs/components/269-sub-agent-tree.md @@ -0,0 +1,45 @@ +# 269 SubAgentTree 子 Agent 树 + +## 用途 + +以树显示子 Agent 层级、各自任务与状态,可展开查看下级。 + +## 最小示例 + +```go +agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{ + {ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting}, +}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult`。`ID` 为空或重复、`Name` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | `false` | 禁止展开与折叠。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `SubAgent` 字段:`ID`、`Name`、`Task`、`State`、`Children`。 +- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。 +- 有下级的行显示箭头和“N 个子智能体”。 + +## 事件 + +- `Changed()`:用户展开或折叠了某个 Agent。 + +## 键盘操作 + +- Tab 进入树;↑ ↓ 移动;→ 展开,← 折叠;Enter / Space 切换。 + +## 限制 + +- 树不虚拟化,适合几十到几百个 Agent。 diff --git a/docs/components/270-file-change-card.md b/docs/components/270-file-change-card.md new file mode 100644 index 0000000..045a63f --- /dev/null +++ b/docs/components/270-file-change-card.md @@ -0,0 +1,48 @@ +# 270 FileChangeCard 文件变更卡片 + +## 用途 + +预览一个文件的变更:增删行数、首处修改附近的差异行,提供接受、拒绝和打开完整差异的入口。 + +## 最小示例 + +```go +decision := mujica.FilePending +change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff { + fmt.Println("open the full diff") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, opts FileChangeCardOptions) FileChangeResult`。`decision` 为 nil、`Path` 为空或 `Preview` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Preview` | `int` | 8 | 从首处修改前两行起显示的差异行数。 | +| `Disabled` | `bool` | `false` | 禁止决定与打开差异。 | + +## 状态 + +- `FileChange` 字段:`Path`、`Old`、`New`;`Old` 为空表示新文件,`New` 为空表示删除。 +- 差异由 `internal.LineDiff`(Myers 行差异)计算,文本不变时只算一次。 +- 新增行带“+”、删除行带“−”和行号,颜色只是辅助。 +- 决定状态 `FilePending`、`FileAccepted`、`FileRejected` 以图标和文字显示;已决定后显示“撤销”。 + +## 事件 + +- `Decided`:`*decision` 被用户改变。 +- `OpenDiff`:请求调用方打开完整差异。 + +## 键盘操作 + +- Tab 依次到“打开差异”“拒绝”“接受”(或“撤销”),Enter / Space 触发。 + +## 限制 + +- 只做行级差异;词级高亮由 Git 组扩展。 diff --git a/docs/components/271-multi-file-diff-review.md b/docs/components/271-multi-file-diff-review.md new file mode 100644 index 0000000..b60c4cb --- /dev/null +++ b/docs/components/271-multi-file-diff-review.md @@ -0,0 +1,48 @@ +# 271 MultiFileDiffReview 多文件差异审阅 + +## 用途 + +审阅多个文件的变更:左侧文件列表,右侧所选文件的完整差异,逐文件接受或拒绝,全部接受,并显示审阅进度。 + +## 最小示例 + +```go +files := []mujica.FileChange{ + {Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}, + {Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"}, +} +decisions := make([]mujica.FileDecision, len(files)) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult`。`decisions` 与 `files` 数量不同、`Path` 为空或重复、`Height` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 420 | 组件高度。 | +| `Disabled` | `bool` | `false` | 禁止选择与决定。 | + +## 状态 + +- 进度为“已审阅 N / 总数”,以文字和细进度条显示。 +- 所选文件用选中底色和左侧竖条标出。 +- 文件超过 200 个或差异超过 200 行时虚拟化。 +- “全部接受”只接受尚未决定的文件,全部决定后禁用。 + +## 事件 + +- `Changed()`:用户改变了任一文件的决定。 + +## 键盘操作 + +- Tab 进入文件列表;↑ ↓ 移动,Enter 选择;Tab 到右侧按钮后 Enter 接受或拒绝。 + +## 限制 + +- 文件很多时方向键只在已构建的可见行间移动,滚动后再继续。 diff --git a/docs/components/272-command-execution-card.md b/docs/components/272-command-execution-card.md new file mode 100644 index 0000000..8d0ca9d --- /dev/null +++ b/docs/components/272-command-execution-card.md @@ -0,0 +1,48 @@ +# 272 CommandExecutionCard 命令执行卡片 + +## 用途 + +显示一条命令的执行:命令、工作目录、带 ANSI 颜色的输出、退出码与耗时,执行中可请求中止。 + +## 最小示例 + +```go +run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() { + fmt.Println("abort the process") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult`。`Command` 为空或 `Height` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 240 | 输出区高度。 | +| `Disabled` | `bool` | `false` | 禁止中止。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `CommandRun` 字段:`Command`、`Dir`、`Output`、`Running`、`ExitCode`、`Duration`。 +- 输出由 `internal.ParseAnsi` 解析,颜色映射到主题;样式可跨行延续;追加输出只解析新增行。 +- 输出虚拟化并跟随末尾,用户向上滚动后停止跟随。 +- 退出码以图标和“退出码 N”文字显示。 + +## 事件 + +- `Changed()`:用户点击了“中止”;终止进程由调用方完成。 + +## 键盘操作 + +- Tab 到“中止”,Enter / Space 触发;输出区可滚动。 + +## 限制 + +- 只支持 SGR 颜色与样式,其它转义序列原样显示。 +- 不运行命令,也不提供 PTY。 diff --git a/docs/components/273-screenshot-stream.md b/docs/components/273-screenshot-stream.md new file mode 100644 index 0000000..2c2b652 --- /dev/null +++ b/docs/components/273-screenshot-stream.md @@ -0,0 +1,45 @@ +# 273 ScreenshotStream Agent 画面流 + +## 用途 + +查看 Agent 的画面帧序列:默认跟随最新帧,可暂停并逐帧前后查看。帧由调用方提供。 + +## 最小示例 + +```go +var st mujica.ScreenshotStreamState +var frames []mujica.ScreenshotFrame // appended as the agent captures them +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult`。`st` 为 nil、帧没有图像或 `Height` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 280 | 画面区高度。 | +| `Disabled` | `bool` | `false` | 禁止暂停与逐帧。 | + +## 状态 + +- `ScreenshotFrame` 字段:`Image`(`ui.ImageSource`)、`Caption`、`At`。 +- `ScreenshotStreamState` 字段:`Index`、`Paused`;未暂停时 `Index` 总是最后一帧。 +- 逐帧操作会自动暂停;“回到实时”恢复跟随。 +- 没有帧时显示“等待画面”。 + +## 事件 + +- `Changed()`:用户暂停、恢复或移动了帧。 + +## 键盘操作 + +- Tab 到画面区:← → 逐帧,Space 暂停或恢复;上一帧、下一帧按钮同样可用。 + +## 限制 + +- 不解码视频,帧图像由调用方提供。 diff --git a/docs/components/274-artifact-panel.md b/docs/components/274-artifact-panel.md new file mode 100644 index 0000000..58164e4 --- /dev/null +++ b/docs/components/274-artifact-panel.md @@ -0,0 +1,47 @@ +# 274 ArtifactPanel 产物面板 + +## 用途 + +展示 Agent 生成的产物:代码高亮显示,文档与网页由调用方的预览插槽渲染,可在预览与源码间切换,提供复制与导出。 + +## 最小示例 + +```go +art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported { + fmt.Println("save the file") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) ArtifactPanelResult`。`Title` 为空、`Kind` 越界、文档或网页缺少 `Preview`、`Height` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Preview` | `func()` | `nil` | 预览插槽;文档与网页必填。 | +| `Height` | `float32` | 320 | 内容区高度。 | +| `Disabled` | `bool` | `false` | 禁止复制、导出与切换。 | + +## 状态 + +- `Artifact` 字段:`Title`、`Kind`(`ArtifactCode`、`ArtifactDocument`、`ArtifactWeb`)、`Language`、`Content`。 +- 预览与源码切换用选中底色加勾号标出当前项。 +- 复制后两秒内按钮显示“已复制”。 + +## 事件 + +- `Copied`:内容已写入剪贴板。 +- `Exported`:请求调用方导出。 + +## 键盘操作 + +- Tab 到切换组,← → 移动,Enter / Space 选择;Tab 到复制、导出。 + +## 限制 + +- 不内置网页渲染,网页预览须由调用方提供(无 WebView)。 diff --git a/docs/components/275-artifact-version-switcher.md b/docs/components/275-artifact-version-switcher.md new file mode 100644 index 0000000..0cabdb2 --- /dev/null +++ b/docs/components/275-artifact-version-switcher.md @@ -0,0 +1,45 @@ +# 275 ArtifactVersionSwitcher 产物版本切换 + +## 用途 + +在产物的多个版本之间切换,并提供与上一版本比较差异的入口。 + +## 最小示例 + +```go +current := 1 +versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactVersionSwitcher(c, ¤t, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare { + fmt.Println("diff v1 and v2") + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVersion, opts ArtifactVersionSwitcherOptions) ArtifactVersionResult`。没有版本、`*current` 越界或版本 `Label` 为空时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | `false` | 禁止切换与比较。 | + +## 状态 + +- 显示版本名、“N / 总数”和说明;第一个版本时“较早版本”禁用,最后一个时“较新版本”禁用。 +- 非第一个版本显示“与上一版本比较”。 + +## 事件 + +- `Changed`:`*current` 改变。 +- `Compare`:请求比较当前版本与上一版本。 + +## 键盘操作 + +- 焦点在组件内时 ← → 切换版本;Tab 到各按钮,Enter / Space 触发。 + +## 限制 + +- 差异视图由调用方打开,可用 FileChangeCard 或 MultiFileDiffReview 呈现。 diff --git a/docs/components/276-sandbox-status.md b/docs/components/276-sandbox-status.md new file mode 100644 index 0000000..495f6da --- /dev/null +++ b/docs/components/276-sandbox-status.md @@ -0,0 +1,43 @@ +# 276 SandboxStatus 沙箱状态 + +## 用途 + +显示沙箱的运行状态、资源占用和剩余时间。 + +## 最小示例 + +```go +info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning, + Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element`。状态未知、资源名为空、`Limit` 不大于 0 或 `Used` 不在 0 到 `Limit` 之间时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `SandboxInfo` 字段:`Name`、`State`(`SandboxStarting`、`SandboxRunning`、`SandboxStopped`、`SandboxFailed`)、`Resources`、`Expires`。 +- 每项资源显示“已用 / 上限 单位”与细条;达到 90% 时加警示图标和“接近上限”说明。 +- 设置 `Expires` 且运行中时显示剩余时间,每秒刷新;到时显示“已到时”。 + +## 事件 + +- 无事件。 + +## 键盘操作 + +- 不获取焦点。 + +## 限制 + +- 倒计时依赖界面帧,窗口隐藏时 MyGo 暂停帧,重新显示后立即校正。 diff --git a/docs/components/277-mcp-server-list.md b/docs/components/277-mcp-server-list.md new file mode 100644 index 0000000..82a5d34 --- /dev/null +++ b/docs/components/277-mcp-server-list.md @@ -0,0 +1,46 @@ +# 277 MCPServerList MCP 服务器列表 + +## 用途 + +列出 MCP 服务器:连接状态、传输方式、工具数,提供启动、停止与重新连接回调。 + +## 最小示例 + +```go +servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop { + fmt.Println("stop", r.Server) + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions) MCPServerListResult`。`ID` 为空或重复、`Name` 为空、状态未知、`Tools` 为负、`MaxHeight` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `MaxHeight` | `float32` | 360 | 列表最大高度,超出后滚动。 | +| `Disabled` | `bool` | `false` | 禁止所有操作。 | +| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- `MCPServer` 字段:`ID`、`Name`、`Transport`、`State`(`MCPStopped`、`MCPConnecting`、`MCPConnected`、`MCPError`)、`Tools`、`Error`。 +- 已连接时显示“N 个工具”;连接失败时显示错误文字与“重新连接”。 +- 超过 200 台服务器时虚拟化。 + +## 事件 + +- `Action`:本帧请求 `MCPStart`、`MCPStop` 或 `MCPReconnect`,`Server` 为对应 ID。 + +## 键盘操作 + +- Tab 依次到各行按钮,Enter / Space 触发。 + +## 限制 + +- 组件只发出请求,连接管理由调用方完成。 diff --git a/docs/components/278-memory-panel.md b/docs/components/278-memory-panel.md new file mode 100644 index 0000000..e9ff905 --- /dev/null +++ b/docs/components/278-memory-panel.md @@ -0,0 +1,47 @@ +# 278 MemoryPanel 记忆面板 + +## 用途 + +列出 Agent 的记忆条目,支持搜索、行内编辑和确认后删除。 + +## 最小示例 + +```go +entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" { + fmt.Println("forget", r.Deleted) + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) MemoryPanelResult`。`entries` 为 nil、条目 `ID` 为空或 `MaxHeight` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `MaxHeight` | `float32` | 360 | 列表最大高度。 | +| `Disabled` | `bool` | `false` | 只读,禁止搜索、编辑与删除。 | + +## 状态 + +- `MemoryEntry` 字段:`ID`、`Text`、`Source`。 +- 搜索不区分大小写,匹配内容与来源;只在搜索词或条目变化时重新筛选。 +- 编辑时出现文本框和保存、取消;删除前用 Popconfirm 确认,确认按钮为危险样式且默认聚焦取消。 +- 条目超过 200 条时虚拟化。 + +## 事件 + +- `Edited`:本帧保存的条目 ID,`*entries` 已更新。 +- `Deleted`:本帧删除的条目 ID,已从 `*entries` 移除。 + +## 键盘操作 + +- Tab 到搜索框、各条的编辑与删除;删除确认中 Escape 取消。 + +## 限制 + +- 调用方原地修改条目文本而不改变切片长度时,筛选结果到下次搜索变化才更新。 diff --git a/docs/components/279-checkpoint-list.md b/docs/components/279-checkpoint-list.md new file mode 100644 index 0000000..3854182 --- /dev/null +++ b/docs/components/279-checkpoint-list.md @@ -0,0 +1,46 @@ +# 279 CheckpointList 检查点列表 + +## 用途 + +列出 Agent 运行的检查点,标出当前点,回滚到其它检查点前要求确认。 + +## 最小示例 + +```go +current := "c2" +points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CheckpointList(c, ¤t, points, mujica.CheckpointListOptions{}).Changed() { + fmt.Println("restore", current) + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult`。`current` 为 nil、`ID` 为空或重复、`Label` 为空、`MaxHeight` 为负时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `MaxHeight` | `float32` | 360 | 列表最大高度。 | +| `Disabled` | `bool` | `false` | 禁止回滚。 | + +## 状态 + +- 当前点用实心菱形、选中底色和“当前”文字标出;其它点为空心菱形。 +- 回滚用 AlertDialog 确认:默认聚焦取消,Enter 不会直接回滚。 +- 超过 200 个检查点时虚拟化。 + +## 事件 + +- `Changed()`:确认回滚后 `*current` 已改为目标 ID。 + +## 键盘操作 + +- Tab 到各“回滚”,Enter 打开确认;确认框内 Tab 到“回滚”再按 Enter。 + +## 限制 + +- 恢复文件与状态由调用方完成。 diff --git a/docs/components/280-human-input-request.md b/docs/components/280-human-input-request.md new file mode 100644 index 0000000..288fbba --- /dev/null +++ b/docs/components/280-human-input-request.md @@ -0,0 +1,52 @@ +# 280 HumanInputRequest 人工输入请求 + +## 用途 + +Agent 向用户提问:显示问题、可选答案与自由回答,并提示剩余时间与超时。 + +## 最小示例 + +```go +answer := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.HumanInputRequest(c, &answer, mujica.HumanInputRequestOptions{ + Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true, + }) + if r.Submitted { + fmt.Println("reply", answer) + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult`。`answer` 为 nil、`Question` 为空、既无选项又不允许自由回答时 panic。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Question` | `string` | 必填 | 问题。 | +| `Options` | `[]string` | `nil` | 可选答案。 | +| `AllowFree` | `bool` | `false` | 允许自由回答。 | +| `Deadline` | `time.Time` | 零值 | 截止时间;零值不限时。 | +| `Disabled` | `bool` | `false` | 禁止回答。 | + +## 状态 + +- 选择选项会清空自由回答;输入自由回答会取代所选选项。 +- 剩余时间每秒刷新;到时显示“智能体已停止等待”并禁用。 +- 回答为空白时“发送回答”禁用。 + +## 事件 + +- `Submitted`:用户发送了 `*answer`。 +- `TimedOut`:截止时间在本帧到达(只报告一次)。 + +## 键盘操作 + +- Tab 进入选项组,方向键选择;Tab 到输入框,Enter 发送;或 Tab 到“发送回答”。 + +## 限制 + +- 超时只在界面帧中检测,窗口隐藏期间推迟到重新显示。 diff --git a/docs/components/301-diff-viewer.md b/docs/components/301-diff-viewer.md new file mode 100644 index 0000000..e002e9f --- /dev/null +++ b/docs/components/301-diff-viewer.md @@ -0,0 +1,52 @@ +# 301 DiffViewer 差异查看器 + +## 用途 + +按行比较两段文本,统一或并排显示:行号、增删高亮、折叠较长的未变更区;大文件只构建可见行。 + +## 最小示例 + +```go +var state mujica.DiffViewerState +from := "func main() {\n\tprintln(\"hi\")\n}\n" +to := "func main() {\n\tprintln(\"hello\")\n}\n" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DiffViewer(c, &state, from, to, mujica.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func DiffViewer(c *ui.Context, s *DiffViewerState, from, to string, opts DiffViewerOptions) *ui.Element` + +## 参数 + +`DiffViewerOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| SideBySide | bool | false | 并排两栏;否则统一一栏。 | +| Context | int | 0(即 3) | 变更前后保留的未变更行数;为负 panic。 | +| Language | string | "" | 语法高亮语言,如 go;空为纯文本。 | + +`DiffViewerState` 由调用方持有,保存差异、已展开的折叠与滚动位置。差异只在 from 或 to 改变时重新计算(Myers 行差异)。给返回元素一个高度。 + +## 状态 + +- 删除行:Danger 浅色底与 − 号;新增行:Success 浅色底与 + 号;不只靠颜色。 +- 统一视图显示新旧两列行号;并排视图每侧一列,缺行的一侧为空底。 +- 超过 2×Context 的未变更区折叠为“N 行未更改”按钮,点击展开,悬停 120 ms 过渡。 + +## 事件 + +- 无;展开折叠保存在状态中。 + +## 键盘操作 + +- 列表可获得焦点并滚动;折叠按钮用 Tab 到达,Enter 或 Space 展开。 + +## 限制 + +- 只做行级差异;词级差异见 InlineDiff。 +- 长行不换行,超出部分裁剪。 +- 文本换成新字符串后折叠全部重置。 diff --git a/docs/components/302-inline-diff.md b/docs/components/302-inline-diff.md new file mode 100644 index 0000000..f3e5410 --- /dev/null +++ b/docs/components/302-inline-diff.md @@ -0,0 +1,44 @@ +# 302 InlineDiff 行内差异 + +## 用途 + +并列显示一行的旧版与新版,标出词级的删除与新增。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.InlineDiff(c, "The court meets at noon", "The court meets at dusk", mujica.InlineDiffOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) *ui.Element` + +## 参数 + +`InlineDiffOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Mono | bool | false | 使用等宽字体,适合代码。 | + +词按字母数字、空白与单个标点切分;差异只在 from 或 to 改变时重新计算。 + +## 状态 + +- 旧行以 − 开头,删除的词带浅色底与删除线;新行以 + 开头,新增的词带浅色底与下划线。 +- 两行分别以“原文”“新文”加全文朗读。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无:纯展示。 + +## 限制 + +- 只比较一行;多行请用 DiffViewer。 diff --git a/docs/components/303-three-way-merge.md b/docs/components/303-three-way-merge.md new file mode 100644 index 0000000..c03a2be --- /dev/null +++ b/docs/components/303-three-way-merge.md @@ -0,0 +1,53 @@ +# 303 ThreeWayMerge 三方合并 + +## 用途 + +以本地、基准、远端三栏和结果栏显示三方合并,逐块采纳本地、远端、两者或基准。 + +## 最小示例 + +```go +var state mujica.ThreeWayMergeState +base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ThreeWayMerge(c, &state, base, local, remote, mujica.ThreeWayMergeOptions{}) + r.Element.Height(280) + if done, total := state.Resolved(); r.Changed() && done == total { + _ = state.Result() // write the merged file + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ThreeWayMerge(c *ui.Context, s *ThreeWayMergeState, base, ours, theirs string, opts ThreeWayMergeOptions) ChoiceResult` + +## 参数 + +`ThreeWayMergeOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Disabled | bool | false | 禁用采纳。 | + +`ThreeWayMergeState.Result()` 返回合并文本,未解决的冲突保留冲突标记;`Resolved()` 返回已解决与总冲突数。分块只在三段文本改变时计算。给返回的 `Element` 一个高度。 + +## 状态 + +- 只有一侧修改或两侧相同修改的块自动合并,来源一侧以浅色底标出。 +- 冲突块在结果栏显示 Danger 色“未解决”与警示图标。 +- 已采纳的按钮为 Selection 底、AccentText 边框与对勾。 +- 较长的未变更块只显示首尾各两行。 + +## 事件 + +- `Changed()`:某个冲突的选择改变。 + +## 键盘操作 + +- 列表可获得焦点并滚动;采纳按钮用 Tab 到达,Enter 或 Space 选择。 + +## 限制 + +- 按行合并,不做词级合并。 +- 相邻(中间没有未变更行)的两侧修改视为冲突。 diff --git a/docs/components/304-conflict-resolver.md b/docs/components/304-conflict-resolver.md new file mode 100644 index 0000000..8f7fed8 --- /dev/null +++ b/docs/components/304-conflict-resolver.md @@ -0,0 +1,51 @@ +# 304 ConflictResolver 冲突解决 + +## 用途 + +读取带 git 冲突标记的文本,列出冲突块,逐块采纳当前、传入或两者,并显示已解决计数。 + +## 最小示例 + +```go +var state mujica.ConflictResolverState +text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ConflictResolver(c, &state, text, mujica.ConflictResolverOptions{}).Changed() { + _ = state.Result() // save the file once every block is resolved + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ConflictResolver(c *ui.Context, s *ConflictResolverState, text string, opts ConflictResolverOptions) ChoiceResult` + +## 参数 + +`ConflictResolverOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Disabled | bool | false | 禁用采纳。 | + +`ConflictResolverState.Result()` 返回替换后的文本,未解决的块保留原标记;`Resolved()` 返回已解决与总数。只在文本改变时重新解析。 + +## 状态 + +- 顶部显示“已解决 n / m 个冲突”与 Success 色进度条。 +- 未解决块标题为警示图标与“未解决”,已解决为对勾与“已解决”。 +- 被采纳的一侧显示浅色底与对勾,按钮带对勾;已解决块提供“撤销”。 +- 标记未闭合时显示 Danger 色提示与起始行号,不列出冲突。 + +## 事件 + +- `Changed()`:某块的选择改变。 + +## 键盘操作 + +- Tab 依次经过各块按钮,Enter 或 Space 采纳。 + +## 限制 + +- 支持 diff3 样式的 ||||||| 基准段,但不显示它。 +- 冲突块不虚拟化;适合单个文件的冲突数量。 diff --git a/docs/components/305-changes-list.md b/docs/components/305-changes-list.md new file mode 100644 index 0000000..54ae845 --- /dev/null +++ b/docs/components/305-changes-list.md @@ -0,0 +1,66 @@ +# 305 ChangesList 更改列表 + +## 用途 + +把工作区更改分为“已暂存”与“更改”两组,逐文件或整组暂存、取消暂存,并选择文件查看差异。只构建可见行。 + +## 最小示例 + +```go +var state mujica.ChangesListState +files := []mujica.ChangedFile{ + {Path: "main.go", Status: mujica.GitModified, Staged: true}, + {Path: "notes.txt", Status: mujica.GitUntracked}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ChangesList(c, &state, files, mujica.ChangesListOptions{}) + r.Element.Height(240) + if action, path, ok := r.Action(); ok { + _, _ = action, path // run git add or git restore --staged + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ChangesList(c *ui.Context, s *ChangesListState, files []ChangedFile, opts ChangesListOptions) GitListResult` + +## 参数 + +`ChangedFile` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Path | string | 必填 | 文件路径;为空时 panic。 | +| Status | GitStatus | GitModified | 状态,见 GitStatusBadge;未知值 panic。 | +| Staged | bool | false | 放入已暂存组。 | + +`ChangesListOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Disabled | bool | false | 禁用:不暂存、不改变选择。 | + +`ChangesListState` 由调用方持有,`Selected()` 返回所选文件在 `files` 中的下标,无则 -1。`files` 换成新切片或长度变化时重新分组,分组不在每帧计算。给返回的 `Element` 一个高度。 + +## 状态 + +- 组标题为衬线小字与计数,空组的整组按钮禁用。 +- 每行显示 GitStatusBadge(字母、边框与颜色)、文件名与目录。 +- 选中行:Selection 背景加左侧 AccentText 竖线。 +- 悬停与按下:图标按钮 SurfaceHover / Selection 背景。 + +## 事件 + +- `Action()` 返回 `"stage"`、`"unstage"`(附路径)或 `"stage_all"`、`"unstage_all"`。 +- `Changed()` 选择变化;`Submitted()` 双击或 Enter。 + +## 键盘操作 + +- Tab 进入列表;↑/↓、Home/End 移动选择,跳过组标题;输入文件名开头字母跳转;Enter 提交。 +- 行内暂存按钮可用 Tab 到达,Enter 或 Space 执行。 + +## 限制 + +- 不读取仓库:文件列表与暂存操作都由调用方完成。 +- 重命名的旧路径不单独显示。 diff --git a/docs/components/306-commit-input.md b/docs/components/306-commit-input.md new file mode 100644 index 0000000..4676a1a --- /dev/null +++ b/docs/components/306-commit-input.md @@ -0,0 +1,62 @@ +# 306 CommitInput 提交输入 + +## 用途 + +编辑提交标题与正文,标题长度提示,提交与修订(amend),并防止重复提交。 + +## 最小示例 + +```go +msg := mujica.CommitMessage{} +busy := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommitInput(c, &msg, mujica.CommitInputOptions{Busy: busy}) + if r.Committed() || r.Amended() { + busy = true // run git commit, then clear busy + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CommitInput(c *ui.Context, m *CommitMessage, opts CommitInputOptions) CommitInputResult` + +## 参数 + +`CommitMessage` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Title | string | "" | 提交标题;为空时提交与修订禁用。 | +| Body | string | "" | 提交正文。 | + +`CommitInputOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| TitleLimit | int | 0(即 50) | 建议的标题长度;为负 panic。 | +| Busy | bool | false | 提交进行中:提交按钮显示加载,输入只读。 | +| Disabled | bool | false | 整体禁用。 | + +返回 `CommitInputResult`,含 `Element`。 + +## 状态 + +- 标题右侧显示“已用/上限”,超出时变为 Warning 色并朗读超出字数。 +- 提交后进入待定状态:按钮与输入锁定,直到调用方把 Busy 设为 true 再恢复 false。 +- Busy 时提交按钮保留尺寸并显示加载。 + +## 事件 + +- `Committed()`:点击提交、标题内 Enter 或正文内 Cmd+Enter。 +- `Amended()`:点击修订。 + +## 键盘操作 + +- Tab 依次经过标题、正文、修订、提交。 +- 标题中 Enter、正文中 Cmd+Enter(非 macOS 为 Ctrl+Enter)提交。 + +## 限制 + +- 防重入依赖调用方在提交期间设置 Busy;从未设置 Busy 时组件保持锁定。 +- 不做提交信息格式校验。 diff --git a/docs/components/307-commit-graph.md b/docs/components/307-commit-graph.md new file mode 100644 index 0000000..82398ea --- /dev/null +++ b/docs/components/307-commit-graph.md @@ -0,0 +1,67 @@ +# 307 CommitGraph 提交图谱 + +## 用途 + +按从新到旧的顺序显示提交,左侧绘制泳道、分支与合并线;只构建可见行,可承载十万级提交。 + +## 最小示例 + +```go +var state mujica.CommitGraphState +commits := []mujica.Commit{ + {Hash: "c3", Parents: []string{"c2", "b1"}, Subject: "Merge branch", Refs: []string{"main"}}, + {Hash: "b1", Parents: []string{"c1"}, Subject: "Feature"}, + {Hash: "c2", Parents: []string{"c1"}, Subject: "Fix"}, + {Hash: "c1", Subject: "Initial commit"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CommitGraph(c, &state, commits, mujica.CommitGraphOptions{}).Element.Height(240) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CommitGraph(c *ui.Context, s *CommitGraphState, commits []Commit, opts CommitGraphOptions) GitListResult` + +## 参数 + +`Commit` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Hash | string | 必填 | 提交哈希;为空时 panic。 | +| Parents | []string | nil | 父提交;两个以上为合并提交。 | +| Author | string | "" | 作者。 | +| When | time.Time | 零值 | 提交时间。 | +| Subject | string | "" | 提交标题。 | +| Refs | []string | nil | 指向该提交的分支与标签。 | + +`CommitGraphOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 | +| MaxLanes | int | 0(即 8) | 图谱最多显示的泳道数,超出部分裁剪;为负 panic。 | +| Status | DataStatus | DataReady | 加载、追加、失败状态。 | +| Error | string | "" | 失败说明。 | + +`CommitGraphState.List` 保存选择(`Selected()` 返回哈希)。泳道布局在提交切片或长度变化时计算一次,不在每帧计算。提交须按子提交在前的拓扑顺序给出。给返回的 `Element` 一个高度。 + +## 状态 + +- 普通提交为实心圆点,合并提交为空心菱形,形状区分而不只靠颜色。 +- 泳道颜色取自 AccentText、Info、Success、Warning、Danger、TextMuted,按位置区分。 +- 选中行:Selection 背景与对勾。 + +## 事件 + +- `Changed()` 选择变化;`Submitted()` 双击或 Enter 打开提交。 + +## 键盘操作 + +- Tab 进入列表;↑/↓、Home/End 移动选择;输入标题开头字母跳转;Enter 打开。 + +## 限制 + +- 原地修改提交切片而不改变其地址与长度时,布局不会重算;请传入新切片。 +- 泳道数超过 MaxLanes 时,右侧泳道被裁剪。 diff --git a/docs/components/308-commit-list.md b/docs/components/308-commit-list.md new file mode 100644 index 0000000..1cc292b --- /dev/null +++ b/docs/components/308-commit-list.md @@ -0,0 +1,53 @@ +# 308 CommitList 提交列表 + +## 用途 + +显示提交列表:标题、引用、作者、短哈希与时间,选择后查看。只构建可见行。 + +## 最小示例 + +```go +var state mujica.ListState[string] +commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommitList(c, &state, commits, mujica.CommitListOptions{}) + r.Element.Height(240) + if hash, ok := state.Selected(); ok && r.Submitted() { + _ = hash // show the commit + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CommitList(c *ui.Context, s *ListState[string], commits []Commit, opts CommitListOptions) GitListResult` + +## 参数 + +`CommitListOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 | +| Status | DataStatus | DataReady | 加载、追加、失败状态。 | +| Error | string | "" | 失败说明。 | + +`Commit` 见 CommitGraph。选择保存在 `ListState`,以哈希为键。给返回的 `Element` 一个高度。 + +## 状态 + +- 引用以 AccentText 描边小标签显示;短哈希为等宽 AccentText。 +- 空列表显示“没有提交”;加载与失败使用列表的统一占位。 +- 选中行:Selection 背景与对勾。 + +## 事件 + +- `Changed()` 选择变化;`Submitted()` 双击或 Enter。 + +## 键盘操作 + +- Tab 进入列表;↑/↓、Home/End 移动选择;输入标题开头字母跳转;Enter 打开。 + +## 限制 + +- 时间按调用方给定格式显示,不做相对时间。 diff --git a/docs/components/309-branch-selector.md b/docs/components/309-branch-selector.md new file mode 100644 index 0000000..e567aec --- /dev/null +++ b/docs/components/309-branch-selector.md @@ -0,0 +1,64 @@ +# 309 BranchSelector 分支选择器 + +## 用途 + +按钮显示当前分支,点击弹出可搜索的本地与远端分支列表;可选“新建分支”入口。 + +## 最小示例 + +```go +branch := "main" +branches := []mujica.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BranchSelector(c, &branch, branches, mujica.BranchSelectorOptions{AllowCreate: true}) + if name, ok := r.Created(); ok { + _ = name // run git switch -c name + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func BranchSelector(c *ui.Context, selected *string, branches []Branch, opts BranchSelectorOptions) BranchSelectorResult` + +## 参数 + +`Branch` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Name | string | 必填 | 唯一名称,远端分支带远端名,如 origin/main;为空时 panic。 | +| Remote | bool | false | 归入远端组。 | +| Current | bool | false | 当前检出的分支。 | +| Upstream | string | "" | 上游分支。 | +| Ahead, Behind | int | 0 | 领先、落后的提交数;为负 panic。 | + +`BranchSelectorOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| AllowCreate | bool | false | 搜索词不是已有分支名时,列表末尾提供“新建分支”。 | +| Disabled | bool | false | 禁用;打开中的弹层会关闭。 | + +返回 `BranchSelectorResult`,含 `Element`。过滤结果在搜索词或分支切片变化时才重新计算。 + +## 状态 + +- 弹层使用统一浮层外观,160 ms 淡入,尺寸限制在窗口内,内容滚动。 +- 本地、远端分组标题为衬线小字;当前分支加粗并带对勾。 +- 高亮项:Selection 背景,键盘移动时滚入视野。 + +## 事件 + +- `Changed()`:选择了另一个分支,`*selected` 已更新。 +- `Created()`:用户要求新建的分支名(已去除首尾空白)。 + +## 键盘操作 + +- Enter 或 Space 打开;焦点进入搜索框。 +- ↑/↓ 移动高亮并循环,Enter 选择,Escape 关闭并把焦点还给按钮。 + +## 限制 + +- 不执行 git:选择与新建都由调用方处理。 +- 搜索为大小写不敏感的子串匹配。 diff --git a/docs/components/310-branch-list.md b/docs/components/310-branch-list.md new file mode 100644 index 0000000..cec3aa1 --- /dev/null +++ b/docs/components/310-branch-list.md @@ -0,0 +1,62 @@ +# 310 BranchList 分支列表 + +## 用途 + +分支列表:标出当前分支,显示上游与领先、落后数,每行提供调用方定义的操作按钮。 + +## 最小示例 + +```go +var state mujica.ListState[string] +branches := []mujica.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}} +actions := []mujica.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BranchList(c, &state, branches, mujica.BranchListOptions{Actions: actions}) + r.Element.Height(200) + if action, name, ok := r.Action(); ok { + _, _ = action, name // run the action on the branch + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func BranchList(c *ui.Context, s *ListState[string], branches []Branch, opts BranchListOptions) GitListResult` + +## 参数 + +`BranchListOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Actions | []GitAction | nil | 每行的图标操作;ID、Label、Icon 必填且 ID 唯一,否则 panic。 | +| Status | DataStatus | DataReady | 加载、追加、失败状态。 | +| Error | string | "" | 失败说明。 | + +`GitAction` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| ID | string | 必填 | `Action()` 返回的标识。 | +| Label | string | 必填 | 按钮名称与提示。 | +| Icon | string | 必填 | icons/ 中的图标名。 | + +`Branch` 见 BranchSelector。选择以分支名为键。给返回的 `Element` 一个高度。 + +## 状态 + +- 当前分支:对勾、加粗与“当前”文字。 +- 领先 ↑n、落后 ↓n 以箭头与数字显示,朗读完整说明。 + +## 事件 + +- `Action()` 返回操作 ID 与分支名。 +- `Changed()`、`Submitted()` 同 CommitList。 + +## 键盘操作 + +- ↑/↓、Home/End 移动选择;行内操作按钮用 Tab 到达,Enter 或 Space 执行。 + +## 限制 + +- 操作由调用方执行;组件不确认危险操作,需要时在回调里使用 Popconfirm 等组件。 diff --git a/docs/components/311-tag-list.md b/docs/components/311-tag-list.md new file mode 100644 index 0000000..13d9b89 --- /dev/null +++ b/docs/components/311-tag-list.md @@ -0,0 +1,59 @@ +# 311 TagList 标签列表 + +## 用途 + +标签列表:名称、注释与目标提交,每行可跳转到提交。 + +## 最小示例 + +```go +var state mujica.ListState[string] +tags := []mujica.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TagList(c, &state, tags, mujica.TagListOptions{}) + r.Element.Height(200) + if _, hash, ok := r.Action(); ok { + _ = hash // show the tagged commit + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func TagList(c *ui.Context, s *ListState[string], tags []GitTag, opts TagListOptions) GitListResult` + +## 参数 + +`GitTag` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Name | string | 必填 | 标签名,唯一。 | +| Target | string | 必填 | 目标提交哈希。 | +| Annotation | string | "" | 注释;轻量标签留空。 | + +`TagListOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Status | DataStatus | DataReady | 加载、追加、失败状态。 | +| Error | string | "" | 失败说明。 | + +Name 或 Target 为空时 panic。给返回的 `Element` 一个高度。 + +## 状态 + +- 注释为 TextMuted 单行,超长省略。 + +## 事件 + +- `Action()` 返回 `"jump"` 与目标哈希。 +- `Changed()`、`Submitted()` 同 CommitList。 + +## 键盘操作 + +- ↑/↓、Home/End 移动选择;“跳转到提交”按钮用 Tab 到达。 + +## 限制 + +- 不读取标签:数据由调用方提供。 diff --git a/docs/components/312-stash-list.md b/docs/components/312-stash-list.md new file mode 100644 index 0000000..f7901bd --- /dev/null +++ b/docs/components/312-stash-list.md @@ -0,0 +1,58 @@ +# 312 StashList 贮藏列表 + +## 用途 + +贮藏列表,每行提供应用、弹出与删除。 + +## 最小示例 + +```go +var state mujica.ListState[string] +stashes := []mujica.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.StashList(c, &state, stashes, mujica.StashListOptions{}) + r.Element.Height(200) + if action, ref, ok := r.Action(); ok { + _, _ = action, ref // run git stash apply, pop or drop + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func StashList(c *ui.Context, s *ListState[string], stashes []Stash, opts StashListOptions) GitListResult` + +## 参数 + +`Stash` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Ref | string | 必填 | 如 stash@{0};为空时 panic。 | +| Message | string | "" | 贮藏说明。 | +| Branch | string | "" | 创建时所在分支。 | + +`StashListOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Status | DataStatus | DataReady | 加载、追加、失败状态。 | +| Error | string | "" | 失败说明。 | + +给返回的 `Element` 一个高度。 + +## 状态 + +- Ref 为等宽 AccentText;三个操作为带名称与提示的图标按钮。 + +## 事件 + +- `Action()` 返回 `"apply"`、`"pop"` 或 `"drop"` 与 Ref。 + +## 键盘操作 + +- ↑/↓ 移动选择;操作按钮用 Tab 到达,Enter 或 Space 执行。 + +## 限制 + +- 删除不带确认;需要时由调用方在回调里确认。 diff --git a/docs/components/313-blame-view.md b/docs/components/313-blame-view.md new file mode 100644 index 0000000..ba5eada --- /dev/null +++ b/docs/components/313-blame-view.md @@ -0,0 +1,62 @@ +# 313 BlameView 逐行追溯 + +## 用途 + +代码逐行显示最后修改的作者与提交;同一提交的连续行合并显示,悬停显示提交详情。只构建可见行。 + +## 最小示例 + +```go +var state mujica.BlameViewState +lines := []mujica.BlameLine{ + {Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "package main"}, + {Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "func main() {}"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BlameView(c, &state, lines, mujica.BlameViewOptions{Language: "go"}).Height(240) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func BlameView(c *ui.Context, s *BlameViewState, lines []BlameLine, opts BlameViewOptions) *ui.Element` + +## 参数 + +`BlameLine` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Hash | string | 必填 | 提交哈希;为空时 panic。 | +| Author | string | "" | 作者。 | +| When | time.Time | 零值 | 提交时间。 | +| Summary | string | "" | 提交标题,悬停时显示。 | +| Text | string | "" | 该行代码。 | + +`BlameViewOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Language | string | "" | 语法高亮语言,如 go;空为纯文本。 | +| TimeFormat | string | "2006-01-02" | 日期格式。 | + +`BlameViewState` 由调用方持有;块注释状态在数据或语言变化时计算一次。给返回元素一个高度。 + +## 状态 + +- 每段同提交行只在首行显示短哈希、作者与日期,段之间有细分隔线。 +- 左栏悬停为 SurfaceHover,600 ms 后显示提示:完整哈希、作者、日期与标题。 +- 代码使用统一的语法颜色,注释为斜体。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 列表可获得焦点并滚动;输入行首文字跳转到该行。 + +## 限制 + +- 长行不换行,超出部分裁剪。 +- 悬停详情使用 MyGo 提示,只能用指针触发;每行左栏的名称同样朗读作者与提交。 diff --git a/docs/components/314-file-history.md b/docs/components/314-file-history.md new file mode 100644 index 0000000..5aab49a --- /dev/null +++ b/docs/components/314-file-history.md @@ -0,0 +1,53 @@ +# 314 FileHistory 文件历史 + +## 用途 + +单个文件的提交历史,每条提交提供“查看差异”入口。 + +## 最小示例 + +```go +var state mujica.ListState[string] +commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.FileHistory(c, &state, "main.go", commits, mujica.FileHistoryOptions{}) + r.Element.Height(240) + if _, hash, ok := r.Action(); ok { + _ = hash // open the diff of main.go at hash + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func FileHistory(c *ui.Context, s *ListState[string], path string, commits []Commit, opts FileHistoryOptions) GitListResult` + +## 参数 + +`FileHistoryOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 | +| Status | DataStatus | DataReady | 加载、追加、失败状态。 | +| Error | string | "" | 失败说明。 | + +path 为空时 panic。给返回的 `Element` 一个高度,列表填满其余空间。 + +## 状态 + +- 标题行显示历史图标、等宽路径与“历史”。 +- 空历史显示“此文件没有历史”。 + +## 事件 + +- `Action()` 返回 `"diff"` 与提交哈希。 +- `Changed()`、`Submitted()` 同 CommitList。 + +## 键盘操作 + +- ↑/↓、Home/End 移动选择;“查看差异”按钮用 Tab 到达。 + +## 限制 + +- 不跟踪重命名:提交列表由调用方提供。 diff --git a/docs/components/315-pull-request-card.md b/docs/components/315-pull-request-card.md new file mode 100644 index 0000000..a5ad61a --- /dev/null +++ b/docs/components/315-pull-request-card.md @@ -0,0 +1,62 @@ +# 315 PullRequestCard 拉取请求卡片 + +## 用途 + +概览一个拉取请求:标题、作者、状态、检查、评审人与差异统计。 + +## 最小示例 + +```go +pr := mujica.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main", + ChecksPassed: 3, Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{}).Changed() { + // open the pull request + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func PullRequestCard(c *ui.Context, pr PullRequest, opts PullRequestCardOptions) ChoiceResult` + +## 参数 + +`PullRequest` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Number | int | 必填 | 编号,须为正。 | +| Title | string | 必填 | 标题。 | +| Author | string | "" | 作者。 | +| State | PullRequestState | PullRequestOpen | Open、Draft、Merged、Closed;未知值 panic。 | +| Head, Base | string | "" | 源分支与目标分支,都有值时显示。 | +| ChecksPassed, ChecksFailed, ChecksPending | int | 0 | 检查计数;为负 panic。 | +| Reviewers | []Reviewer | nil | 评审人与状态(Pending、Approved、ChangesRequested)。 | +| Added, Deleted, Files | int | 0 | 差异统计。 | + +`PullRequestCardOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Disabled | bool | false | 禁用打开。 | + +返回 `ChoiceResult`。 + +## 状态 + +- 状态徽章同时使用图标、文字与描边颜色。 +- 检查以图标加数字显示,朗读完整说明;为 0 的类别不显示。 +- 评审人以头像加角标图标表示状态,名称与状态写入提示和朗读文本。 + +## 事件 + +- `Changed()`:点击标题或在标题上按 Enter,表示打开。 + +## 键盘操作 + +- Tab 到标题链接,Enter 或 Space 打开。 + +## 限制 + +- 数据由调用方获取;组件不刷新检查状态。 diff --git a/docs/components/316-review-comment.md b/docs/components/316-review-comment.md new file mode 100644 index 0000000..6ed4b9a --- /dev/null +++ b/docs/components/316-review-comment.md @@ -0,0 +1,66 @@ +# 316 ReviewComment 评审评论 + +## 用途 + +行级评审讨论:显示文件与行号、评论列表,支持回复、解决与重新打开。 + +## 最小示例 + +```go +thread := mujica.ReviewThread{File: "main.go", Line: 12, Messages: []mujica.ReviewMessage{ + {Author: "Uika", When: time.Now(), Body: "Can this return early?"}, +}} +draft := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ReviewComment(c, thread, &draft, mujica.ReviewCommentOptions{}) + if text, ok := r.Replied(); ok { + thread.Messages = append(thread.Messages, mujica.ReviewMessage{Author: "Sakiko", When: time.Now(), Body: text}) + } + if r.Resolved() || r.Reopened() { + thread.Resolved = !thread.Resolved + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ReviewComment(c *ui.Context, thread ReviewThread, draft *string, opts ReviewCommentOptions) ReviewCommentResult` + +## 参数 + +`ReviewThread` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| File | string | 必填 | 文件路径。 | +| Line | int | 必填 | 行号,须为正。 | +| Resolved | bool | false | 已解决:隐藏回复框,显示“重新打开”。 | +| Messages | []ReviewMessage | 必填 | 至少一条评论(作者、时间、正文)。 | + +`ReviewCommentOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| TimeFormat | string | "2006-01-02 15:04" | 时间格式。 | +| Disabled | bool | false | 禁用回复、解决与重新打开。 | + +`draft` 保存回复草稿,发送后清空。 + +## 状态 + +- 已解决:标题栏显示 Success 色图标与“已解决”。 +- 回复按钮在草稿为空时禁用。 + +## 事件 + +- `Replied()`:发送的回复文本(去除首尾空白)。 +- `Resolved()`、`Reopened()`:对应按钮。 + +## 键盘操作 + +- Tab 经过回复框与按钮;回复框内 Cmd+Enter(非 macOS 为 Ctrl+Enter)发送。 + +## 限制 + +- 评论正文为纯文本,不渲染 Markdown。 +- 线程状态由调用方更新。 diff --git a/docs/components/317-diff-stat.md b/docs/components/317-diff-stat.md new file mode 100644 index 0000000..fc4143b --- /dev/null +++ b/docs/components/317-diff-stat.md @@ -0,0 +1,45 @@ +# 317 DiffStat 差异统计 + +## 用途 + +显示 +新增 −删除 行数,以及按比例填充的方块条。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DiffStat(c, 120, 8, mujica.DiffStatOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func DiffStat(c *ui.Context, added, deleted int, opts DiffStatOptions) *ui.Element` + +## 参数 + +`DiffStatOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Blocks | int | 0(即 5) | 比例条方块数;为负 panic。 | + +added、deleted 为负时 panic。 + +## 状态 + +- 数字带 + 与 − 符号,不只靠颜色;非零一侧至少占一格。 +- 无变化时方块为 Border 色。 +- 朗读为“新增 n 行,删除 m 行”。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无:纯展示,不获取焦点。 + +## 限制 + +- 无。 diff --git a/docs/components/318-git-status-badge.md b/docs/components/318-git-status-badge.md new file mode 100644 index 0000000..c4f7ac1 --- /dev/null +++ b/docs/components/318-git-status-badge.md @@ -0,0 +1,44 @@ +# 318 GitStatusBadge Git 状态标记 + +## 用途 + +以字母 M、A、D、U、R、C 加描边与颜色标记文件状态。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.GitStatusBadge(c, mujica.GitModified, mujica.GitStatusBadgeOptions{ShowLabel: true}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) *ui.Element` + +## 参数 + +`GitStatusBadgeOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| ShowLabel | bool | false | 在字母旁写出状态名称;否则作为提示。 | + +`GitStatus`:GitModified、GitAdded、GitDeleted、GitUntracked、GitRenamed、GitCopied;`Letter()` 返回字母。未知值 panic。 + +## 状态 + +- 字母为等宽粗体,颜色依状态:修改 Warning、添加 Success、删除 Danger、未跟踪与重命名 Info、复制 AccentText。 +- 名称写入朗读文本与提示,不只靠颜色。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无:纯展示。 + +## 限制 + +- 无。 diff --git a/docs/components/319-command-block.md b/docs/components/319-command-block.md new file mode 100644 index 0000000..ee28831 --- /dev/null +++ b/docs/components/319-command-block.md @@ -0,0 +1,62 @@ +# 319 CommandBlock 命令块 + +## 用途 + +把一次命令执行显示为一块:提示符与高亮的命令、退出码、耗时与可折叠的 ANSI 输出,可复制命令或输出。 + +## 最小示例 + +```go +open := true +run := mujica.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommandBlock(c, run, &open, mujica.CommandBlockOptions{}) + if what, ok := r.Copied(); ok { + _ = what // "command" or "output" is on the clipboard + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CommandBlock(c *ui.Context, run CommandRun, open *bool, opts CommandBlockOptions) CommandBlockResult` + +## 参数 + +`CommandRun` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Command | string | 必填 | 命令;为空时 panic。 | +| Output | string | "" | 输出,可含 ANSI 转义。 | +| ExitCode | int | 0 | 退出码。 | +| Duration | time.Duration | 0 | 耗时;为负 panic。 | +| Running | bool | false | 运行中:显示加载标记,不显示退出码与耗时。 | + +`CommandBlockOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Prompt | string | "$" | 命令前的提示符。 | +| MaxOutputHeight | float32 | 0(即 320) | 输出区最大高度,超出滚动;为负 panic。 | + +`open` 控制输出是否展开。 + +## 状态 + +- 成功:Success 色对勾与“退出码 0”;失败:Danger 色叉号与退出码。 +- 展开箭头 120 ms 转动,减少动画时直接切换。 +- 输出区为 Background 底色,等宽字体,横纵滚动。 + +## 事件 + +- `Toggled()`:展开或折叠。 +- `Copied()`:`"command"` 或 `"output"`;复制的输出已去除 ANSI 转义。 + +## 键盘操作 + +- Tab 到标题行,Enter 或 Space 展开或折叠;复制按钮用 Tab 到达。 + +## 限制 + +- 不运行命令:输出与状态由调用方提供。 diff --git a/docs/components/320-ansi-text.md b/docs/components/320-ansi-text.md new file mode 100644 index 0000000..a5255f7 --- /dev/null +++ b/docs/components/320-ansi-text.md @@ -0,0 +1,45 @@ +# 320 AnsiText ANSI 文本 + +## 用途 + +渲染终端输出:16 色、256 色、真彩,以及粗体、斜体、下划线、删除线、暗淡与反显。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", mujica.AnsiTextOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func AnsiText(c *ui.Context, text string, opts AnsiTextOptions) *ui.Element` + +## 参数 + +`AnsiTextOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Wrap | bool | false | 按宽度换行;否则每行保持完整。 | + +样式跨行延续。 + +## 状态 + +- 16 色映射为在两种主题 Surface 上都达到 4.5:1 的颜色;256 色与真彩按原值显示。 +- 默认前景为 Text;反显以 Surface 为前景。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无:纯展示。 + +## 限制 + +- 只解释 SGR(m 结尾)序列;光标移动、清屏等其他 CSI 序列原样保留为文字,并以 slog.Debug 记录。 +- 256 色与真彩不做对比度调整。 diff --git a/docs/components/321-terminal.md b/docs/components/321-terminal.md new file mode 100644 index 0000000..9c06f2c --- /dev/null +++ b/docs/components/321-terminal.md @@ -0,0 +1,52 @@ +# 321 Terminal · 终端 + +## 用途 + +显示终端网格:调用方把 PTY 输出写入 `TerminalState.Write`,组件解析 ANSI 样式(`internal.ParseAnsi`)、光标移动与清屏,保留滚动缓冲,支持鼠标选择复制,并把键盘输入编码后交给调用方写回 PTY。PTY 本身由调用方提供。 + +## 最小示例 + +```go +var term mujica.TerminalState +term.Write("\x1b[1m~/court\x1b[0m $ ls\r\n\x1b[34mdecrees\x1b[0m ledger.txt\r\n$ ") +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Terminal(c, &term, mujica.TerminalOptions{ + Input: func(data string) { fmt.Printf("to pty: %q\n", data) }, + Resized: func(cols, rows int) { fmt.Println("pty size", cols, rows) }, + }).Size(380, 200) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element`。`TerminalState` 零值是 80×24 空屏,由调用方持有;`Write(data)` 写入输出(须在界面线程,例如经 `Window.Update`),`Resize(cols, rows)`、`Lines()`、`LineText(i)`、`Select(a, b)`、`Selection()`、`ScrollBack(n)`、`Offset()`、`Cursor()`、`Size()` 供程序读取与控制。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Input` | `func(data string)` | nil | 用户输入编码后的字节:文字原样,Enter 为 `\r`,退格 `\x7f`,方向键 `ESC [ A–D`,Ctrl+字母为控制码。 | +| `Resized` | `func(cols, rows int)` | nil | 网格尺寸随元素大小变化时回调,用于同步 PTY 窗口大小。 | +| `Scrollback` | `int` | 1000 | 滚动缓冲最多保留的行数;负数 panic。 | +| `Label` | `string` | 内置“终端” | 无障碍名称。 | +| `Disabled` | `bool` | false | 不接收任何输入。 | + +## 状态 + +默认(Surface 底、细边框)、聚焦(外侧 2 DIP 焦点环,光标为 AccentText 实心块并反白字符)、未聚焦(光标只描边)、选择(Selection 背景)、回看滚动缓冲(隐藏光标)、输入法组合(光标处显示带下划线的组合文字)、禁用。 + +## 事件 + +`Input`:每次按键、输入文字或粘贴;`Resized`:网格尺寸变化。复制写入系统剪贴板。 + +## 键盘操作 + +- 焦点在终端时,文字、Enter、Backspace、Tab、Shift+Tab、Escape、方向键、Home/End、Delete、PageUp/PageDown 与 Ctrl+字母都发送给 PTY。 +- 复制:macOS 上 Cmd+C,其他平台 Ctrl+Shift+C(复制选中文字);粘贴:Cmd+V / Ctrl+Shift+V。 +- Shift+PageUp / Shift+PageDown:翻看滚动缓冲;滚轮同样可用;任意输入回到底部。 + +## 限制 + +- 状态机覆盖常用序列:SGR 样式、CUU/CUD/CUF/CUB、CHA、CUP、ED(0/1/2/3)、EL、CR/LF/BS/TAB 与 OSC(忽略)。其余 CSI 序列只记录 Debug 日志后忽略;不支持备用屏幕、滚动区域、鼠标上报与宽字符占两格。 +- 需要完整终端仿真时请使用 MyGo 的 `plugins/terminal`(基于 libghostty-vt)。 +- 字形用 `ui.Shape` 逐格排布并按行缓存,等宽字体由系统回退决定。 diff --git a/docs/components/322-terminal-tabs.md b/docs/components/322-terminal-tabs.md new file mode 100644 index 0000000..47e9d2d --- /dev/null +++ b/docs/components/322-terminal-tabs.md @@ -0,0 +1,52 @@ +# 322 TerminalTabs · 终端标签 + +## 用途 + +管理多个终端的标签:切换、重命名、关闭;仍有进程运行(`Busy`)的终端关闭前弹出确认。 + +## 最小示例 + +```go +tabs := []mujica.TerminalTab{{ID: "a", Title: "zsh"}, {ID: "b", Title: "build", Busy: true}} +active := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TerminalTabs(c, &tabs, &active, mujica.TerminalTabsOptions{}, func(id string) { + ui.Text(c, "terminal "+id) + }) + if r.Closed != "" { + fmt.Println("close pty", r.Closed) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func TerminalTabs(c *ui.Context, tabs *[]TerminalTab, active *string, opts TerminalTabsOptions, content func(id string)) TerminalTabsResult`。`*tabs` 与 `*active` 由调用方持有,组件直接修改:重命名改 `Title`,关闭删除元素并把 `active` 移到相邻标签。`active` 不是任何标签 ID 时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `TerminalTab.ID` | `string` | 必填 | 稳定标识,也是事件值。 | +| `TerminalTab.Title` | `string` | 必填 | 标签文字。 | +| `TerminalTab.Busy` | `bool` | false | 进程仍在运行:显示小圆点并在关闭前确认。 | +| `Disabled` | `bool` | false | 不响应切换、重命名与关闭。 | +| `content` | `func(id string)` | 必填 | 构建当前标签的内容。 | + +## 状态 + +当前标签(Selection 背景、粗体与底部 AccentText 线)、悬停、键盘焦点环、正在运行(圆点与无障碍描述“进程运行中”)、重命名输入框、关闭确认对话框、无标签时的空状态、禁用。 + +## 事件 + +`Changed`:当前标签变化;`Renamed`:被重命名的标签 ID;`Closed`:被关闭的标签 ID(调用方应关闭对应 PTY)。 + +## 键盘操作 + +- ←/→、Home/End:在标签间移动并切换。 +- F2 或双击:重命名;Enter 确认,Escape 取消。 +- Delete:关闭焦点所在标签;运行中的标签先确认,确认框默认聚焦“取消”。 + +## 限制 + +- 不提供新建按钮与拖动排序;新建由调用方向 `*tabs` 追加。 diff --git a/docs/components/323-terminal-search.md b/docs/components/323-terminal-search.md new file mode 100644 index 0000000..36c840b --- /dev/null +++ b/docs/components/323-terminal-search.md @@ -0,0 +1,48 @@ +# 323 TerminalSearch · 终端搜索 + +## 用途 + +在终端的滚动缓冲与屏幕中查找文字,显示匹配数,上下跳转并把当前匹配滚动到可见处。 + +## 最小示例 + +```go +var term mujica.TerminalState +term.Write("build ok\r\nerror: missing seal\r\n") +var search mujica.TerminalSearchState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Gap(8).Children(func() { + mujica.TerminalSearch(c, &term, &search, mujica.TerminalSearchOptions{}) + mujica.Terminal(c, &term, mujica.TerminalOptions{}).Height(160) + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func TerminalSearch(c *ui.Context, term *TerminalState, s *TerminalSearchState, opts TerminalSearchOptions) ChoiceResult`。匹配在查询或终端内容变化时重算一次,不逐帧计算;匹配结果写入 `term`,由同一 `TerminalState` 的 `Terminal` 绘制下划线,当前匹配加框。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `TerminalSearchState.Query` | `string` | "" | 查找文字,不区分大小写。 | +| `TerminalSearchState.Current` | `int` | 0 | 当前匹配序号,从 0 开始。 | +| `Disabled` | `bool` | false | 不能输入与跳转。 | + +## 状态 + +无查询、有匹配(“第 n 项,共 m 项”)、无匹配(“无匹配”)、禁用;终端中其余匹配为 AccentText 下划线,当前匹配加 AccentText 框。 + +## 事件 + +`Changed()`:查询变化或跳到另一匹配。 + +## 键盘操作 + +- Enter / Shift+Enter:下一项 / 上一项;macOS 上 Cmd+G / Shift+Cmd+G,其他平台 F3 / Shift+F3。 +- 上一项、下一项按钮可用 Tab 到达。 + +## 限制 + +- 只做纯文字匹配,不支持正则与整词;跨行文字不会匹配。 diff --git a/docs/components/324-command-history.md b/docs/components/324-command-history.md new file mode 100644 index 0000000..69be25f --- /dev/null +++ b/docs/components/324-command-history.md @@ -0,0 +1,50 @@ +# 324 CommandHistory · 命令历史 + +## 用途 + +按时间倒序列出执行过的命令,可搜索,并通过回调重新运行。 + +## 最小示例 + +```go +history := []mujica.HistoryEntry{{Command: "git status"}, {Command: "go test ./...", ExitCode: 1}} +var state mujica.CommandHistoryState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommandHistory(c, history, &state, mujica.CommandHistoryOptions{}) + r.Element.Height(240) + if r.Replayed != nil { + fmt.Println("run", r.Replayed.Command) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CommandHistory(c *ui.Context, entries []HistoryEntry, s *CommandHistoryState, opts CommandHistoryOptions) CommandHistoryResult`。列表虚拟化,筛选只在条目数或查询变化时重算。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `HistoryEntry.Command` | `string` | 必填 | 命令文字。 | +| `HistoryEntry.Time` | `time.Time` | 零值不显示 | 执行时间。 | +| `HistoryEntry.ExitCode` | `int` | 0 | 非零时显示“退出码 n”。 | +| `CommandHistoryState.Query` | `string` | "" | 搜索文字,不区分大小写。 | +| `Disabled` | `bool` | false | 不能搜索与重放。 | + +## 状态 + +默认、选中(Selection 背景与菱形标记)、失败命令(Danger 退出码)、无历史、无匹配、禁用。 + +## 事件 + +`Replayed`:要重新运行的条目,没有则为 nil。 + +## 键盘操作 + +- Tab 依次到达搜索框与列表;↑/↓、Home/End 选择;输入字母按命令定位。 +- Enter 或双击:重放当前条目;每行的重放按钮可用 Tab 到达。 + +## 限制 + +- 只追加或整体替换 `entries` 会被识别;原地修改中间条目后请传入新切片。 diff --git a/docs/components/325-log-viewer.md b/docs/components/325-log-viewer.md new file mode 100644 index 0000000..720a92e --- /dev/null +++ b/docs/components/325-log-viewer.md @@ -0,0 +1,48 @@ +# 325 LogViewer · 日志查看器 + +## 用途 + +显示日志:按级别过滤、跟随尾部、关键字高亮,十万行也只构建可见行。 + +## 最小示例 + +```go +lines := []mujica.LogLine{{Level: mujica.LogInfo, Text: "court opened"}, {Level: mujica.LogError, Text: "seal missing"}} +var state mujica.LogViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LogViewer(c, lines, &state, mujica.LogViewerOptions{}).Element.Height(240) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func LogViewer(c *ui.Context, lines []LogLine, s *LogViewerState, opts LogViewerOptions) ChoiceResult`。新追加的行增量过滤;级别变化时重新过滤一次,不逐帧计算。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `LogLine.Level` | `LogLevel` | `LogDebug` | `LogDebug`、`LogInfo`、`LogWarn`、`LogError`;越界 panic。 | +| `LogLine.Time`, `Text` | `time.Time`, `string` | — | 时间(零值不显示)与内容。 | +| `LogViewerState.Hidden` | `[4]bool` | 全显示 | 被隐藏的级别。 | +| `LogViewerState.Query` | `string` | "" | 高亮关键字,不区分大小写。 | +| `LogViewerState.Follow` | `bool` | 首次为 true | 跟随尾部;向上滚动后 MyGo 暂停跟随,滚回底部恢复。 | +| `Disabled` | `bool` | false | 不能改过滤与跟随。 | + +## 状态 + +级别按钮按下表示显示;级别同时用文字(DEBUG/INFO/WARN/ERROR)与颜色区分;高亮为 Selection 背景、粗体与下划线;无日志空状态;禁用。 + +## 事件 + +`Changed()`:级别过滤或跟随开关变化。 + +## 键盘操作 + +- Tab 依次到达级别按钮、高亮输入框、跟随按钮与日志列表;Space 切换按钮。 +- 列表中方向键、PageUp/PageDown、Home/End 滚动。 + +## 限制 + +- 只识别向末尾追加;替换为更短的切片会重新过滤。 +- 高亮不过滤行,只标出匹配。 diff --git a/docs/components/326-process-list.md b/docs/components/326-process-list.md new file mode 100644 index 0000000..d0523e5 --- /dev/null +++ b/docs/components/326-process-list.md @@ -0,0 +1,50 @@ +# 326 ProcessList · 进程列表 + +## 用途 + +以表格列出进程,可按名称、PID、CPU、内存排序;结束进程前必须确认,确认后通过结果交给调用方执行。 + +## 最小示例 + +```go +procs := []mujica.Process{{PID: 7, Name: "archivist", CPU: 42, Memory: 50 << 20}, {PID: 9, Name: "herald", CPU: 3, Memory: 8 << 20}} +var state mujica.ProcessListState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ProcessList(c, procs, &state, mujica.ProcessListOptions{}) + r.Element.Height(240) + if r.End != nil { + fmt.Println("kill", r.End.PID) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ProcessList(c *ui.Context, procs []Process, s *ProcessListState, opts ProcessListOptions) ProcessListResult`。排序只在切片或排序方式变化时进行;刷新数据请传入新切片。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Process.PID`, `Name` | `int`, `string` | — | 进程号与名称;PID 是行的稳定键。 | +| `Process.CPU` | `float64` | 0 | 占用百分比。 | +| `Process.Memory` | `uint64` | 0 | 常驻内存字节数,显示为 KB/MB/GB。 | +| `ProcessListState.Sort` | `ui.SortOrder` | CPU 降序 | 列为 `name`、`pid`、`cpu`、`memory`,其他值 panic。 | +| `Disabled` | `bool` | false | 不能排序与结束进程。 | + +## 状态 + +选中行(Selection 背景与菱形)、排序列箭头、确认对话框(默认聚焦“取消”)、无进程、禁用。 + +## 事件 + +`End`:用户确认要结束的进程,没有则为 nil。 + +## 键盘操作 + +- Tab 到达表格;↑/↓、Home/End 选择;Delete 结束所选进程(先确认)。 +- 点击列头排序,再点反向;每行结束按钮可用 Tab 到达。 + +## 限制 + +- 组件不发送信号,只回报确认结果。 diff --git a/docs/components/327-code-viewer.md b/docs/components/327-code-viewer.md new file mode 100644 index 0000000..b91d213 --- /dev/null +++ b/docs/components/327-code-viewer.md @@ -0,0 +1,46 @@ +# 327 CodeViewer · 代码查看器 + +## 用途 + +只读代码视图:行号、基础语法着色(`internal.Highlight`)、按缩进折叠、当前行、选择复制;大文件只构建可见行。 + +## 最小示例 + +```go +lines := []string{"func Seal(d string) int {", "\treturn len(d)", "}"} +var state mujica.CodeViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CodeViewer(c, lines, &state, mujica.CodeViewerOptions{Language: "go"}).Element.Height(240) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CodeViewer(c *ui.Context, lines []string, s *CodeViewerState, opts CodeViewerOptions) ChoiceResult`。块注释状态与折叠范围在代码变化时扫描一次。`s.Line()` 返回当前行,`s.GoTo(line)` 跳转并展开遮住它的折叠,`s.Visible()` 返回可见行范围,`s.ScrollTo(line)` 只滚动;`s.Selection` 保存选中行,`s.Folded` 保存折叠起始行。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Language` | `string` | ""(纯文本) | `go`、`javascript`、`typescript`、`python`、`json`、`shell`、`sql`。 | +| `Label` | `string` | "" | 无障碍名称。 | +| `Disabled` | `bool` | false | 不能折叠。 | + +## 状态 + +当前行(Selection 背景与菱形)、选中行(Selection 背景)、可折叠行(向下箭头)、已折叠(向右箭头)、空代码、禁用。关键字 AccentText、字符串 Success、数字 Warning、类型 Info、注释 TextMuted 斜体。 + +## 事件 + +`Changed()`:当前行或选择变化。 + +## 键盘操作 + +- ↑/↓、Home/End:移动当前行;Shift+方向键扩展选择;Cmd/Ctrl+A 全选。 +- Cmd+C(macOS)/ Ctrl+C:复制选中行,未选则复制当前行。 +- ←:折叠当前行开始的块;→:展开。折叠按钮可用 Tab 到达。 + +## 限制 + +- 折叠按缩进推断,不解析语法;空行不结束块。 +- 只读,不提供编辑与行内(字符级)选择。 diff --git a/docs/components/328-minimap.md b/docs/components/328-minimap.md new file mode 100644 index 0000000..60a0a9e --- /dev/null +++ b/docs/components/328-minimap.md @@ -0,0 +1,45 @@ +# 328 Minimap · 代码缩略图 + +## 用途 + +以细条绘制代码缩略图,框出编辑器可见区域;点击或拖动跳转,键盘可移动。 + +## 最小示例 + +```go +lines := []string{"package court", "", "func Seal() {}", "func Herald() {}"} +view := mujica.MinimapView{Top: 0, Visible: 2} +draw := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Minimap(c, lines, &view, mujica.MinimapOptions{Language: "go"}).Changed() { + fmt.Println("scroll editor to", view.Top) + } +} +ui.Render(draw, 400, 300, 1) +``` + +## 参数 + +`func Minimap(c *ui.Context, lines []string, view *MinimapView, opts MinimapOptions) ChoiceResult`。`MinimapView{Top, Visible}` 是编辑器当前首行与可见行数,由调用方与编辑器同步(可用 `CodeViewerState.Visible` 与 `ScrollTo`)。每行 2 DIP、每字符 1 DIP;文件比缩略图高时缩略图随视图滚动,只绘制可见的行。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Language` | `string` | "" | 着色语言,同 CodeViewer。 | +| `Width` | `float32` | 96 | 宽度(DIP);负数 panic。 | +| `Disabled` | `bool` | false | 不能跳转。 | + +## 状态 + +可见区为 Selection 半透明填充加 AccentText 边框;聚焦时外侧焦点环;禁用。 + +## 事件 + +`Changed()`:用户移动了视图,读取 `view.Top`。 + +## 键盘操作 + +- Tab 聚焦;↑/↓ 移动一行,PageUp/PageDown 移动一屏,Home/End 到开头与结尾。 + +## 限制 + +- 缩略图按行独立着色,不跟踪跨行的块注释。 diff --git a/docs/components/329-find-widget.md b/docs/components/329-find-widget.md new file mode 100644 index 0000000..2617d9b --- /dev/null +++ b/docs/components/329-find-widget.md @@ -0,0 +1,51 @@ +# 329 FindWidget · 查找替换 + +## 用途 + +在文本中查找与替换:区分大小写、整词、正则表达式,显示匹配数并上下跳转。 + +## 最小示例 + +```go +text := "the seal and the seal" +state := mujica.FindState{Query: "seal", Replacement: "signet", ShowReplace: true} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FindWidget(c, &text, &state, mujica.FindWidgetOptions{}).Replaced > 0 { + fmt.Println(text) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func FindWidget(c *ui.Context, text *string, s *FindState, opts FindWidgetOptions) FindWidgetResult`。匹配在文本、查询或选项变化时重算;`s.Matches()` 返回各匹配的字节范围。替换直接修改 `*text`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `FindState.Query`, `Replacement` | `string` | "" | 查找与替换文字;正则模式下替换支持 `$1` 分组。 | +| `MatchCase`, `WholeWord`, `Regex` | `bool` | false | 区分大小写、整词、正则。 | +| `ShowReplace` | `bool` | false | 展开替换行。 | +| `Current` | `int` | 0 | 当前匹配。 | +| `ReadOnly` | `bool` | false | 隐藏替换。 | +| `Disabled` | `bool` | false | 不能输入与替换。 | + +## 状态 + +选项按钮按下状态、匹配计数、无匹配、正则错误(Danger 文字说明具体错误)、替换按钮在无匹配时禁用、禁用。 + +## 事件 + +`Moved`:查询、选项或当前匹配变化;`Replaced`:本帧替换的数量。 + +## 键盘操作 + +- Enter / Shift+Enter:下一项 / 上一项;macOS Cmd+G,其他平台 F3。 +- 替换输入框中 Enter:替换当前项。 +- Tab 依次到达展开按钮、查找框、上下按钮、三个选项与替换控件。 + +## 限制 + +- 使用 Go 的 RE2 正则语法,不支持回溯引用。 +- 不在编辑器中绘制高亮,调用方用 `Matches()` 自行标记。 diff --git a/docs/components/330-search-panel.md b/docs/components/330-search-panel.md new file mode 100644 index 0000000..c9dc6e9 --- /dev/null +++ b/docs/components/330-search-panel.md @@ -0,0 +1,51 @@ +# 330 SearchPanel · 多文件搜索 + +## 用途 + +展示多文件搜索结果:按文件分组、可折叠,匹配预览带高亮;提供包含与排除模式输入。 + +## 最小示例 + +```go +results := []mujica.SearchFileResult{{Path: "court/seal.go", Matches: []mujica.SearchMatch{{Line: 12, Text: "func Seal() {", Start: 5, End: 9}}}} +state := mujica.SearchPanelState{Query: "seal"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SearchPanel(c, results, &state, mujica.SearchPanelOptions{}) + r.Element.Height(280) + if r.Opened != nil { + fmt.Println("open", r.Opened.Path, r.Opened.Line) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SearchPanel(c *ui.Context, results []SearchFileResult, s *SearchPanelState, opts SearchPanelOptions) SearchPanelResult`。搜索由调用方执行:查询或模式变化时 `Changed` 为 true,完成后传入新的 `results`。行列表虚拟化。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `SearchFileResult.Path` | `string` | 必填 | 文件路径,也是折叠键。 | +| `SearchMatch.Line`, `Text` | `int`, `string` | — | 行号与行文字。 | +| `SearchMatch.Start`, `End` | `int` | — | 匹配在 `Text` 中的字节范围;越界 panic。 | +| `SearchPanelState.Query`, `Include`, `Exclude` | `string` | "" | 查询与包含、排除模式(格式由调用方解释)。 | +| `Status`, `Error` | `DataStatus`, `string` | `DataReady` | 搜索中或失败。 | +| `Disabled` | `bool` | false | 不能输入、折叠与打开。 | + +## 状态 + +结果摘要、文件标题(折叠箭头与匹配数)、匹配行(Selection 背景与粗体下划线标出匹配)、选中行(Selection 与菱形)、搜索中、失败(可重试)、未输入、无结果、禁用。 + +## 事件 + +`Changed`:查询或模式变化,或点击重试;`Opened`:用户打开的匹配(路径与行号)。 + +## 键盘操作 + +- Tab 依次到达查询、包含、排除与结果列表。 +- ↑/↓ 选择;Enter 或双击:打开匹配,或折叠/展开文件;文件行上 ← 折叠、→ 展开。 + +## 限制 + +- 长行只显示匹配前 40 个字节。 diff --git a/docs/components/331-problems-panel.md b/docs/components/331-problems-panel.md new file mode 100644 index 0000000..cea7623 --- /dev/null +++ b/docs/components/331-problems-panel.md @@ -0,0 +1,49 @@ +# 331 ProblemsPanel · 问题面板 + +## 用途 + +列出错误、警告与信息,按文件分组并按行列排序,可按级别过滤,打开问题时回调跳转。 + +## 最小示例 + +```go +problems := []mujica.Problem{{Severity: mujica.ProblemError, File: "court/seal.go", Line: 12, Col: 5, Message: "undefined: Decree"}} +var state mujica.ProblemsPanelState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ProblemsPanel(c, problems, &state, mujica.ProblemsPanelOptions{}) + r.Element.Height(240) + if r.Opened != nil { + fmt.Println("go to", r.Opened.File, r.Opened.Line) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ProblemsPanel(c *ui.Context, problems []Problem, s *ProblemsPanelState, opts ProblemsPanelOptions) ProblemsPanelResult`。分组排序只在数据、过滤或折叠变化时进行。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Problem.Severity` | `ProblemSeverity` | `ProblemError` | `ProblemError`、`ProblemWarning`、`ProblemInfo`;越界 panic。 | +| `Problem.File`, `Line`, `Col` | `string`, `int` | — | 位置。 | +| `Problem.Message`, `Source` | `string` | — | 说明与来源(如编译器名)。 | +| `ProblemsPanelState.Hidden` | `[3]bool` | 全显示 | 被隐藏的级别。 | +| `Disabled` | `bool` | false | 不能过滤、折叠与打开。 | + +## 状态 + +级别按钮带图标与计数;问题行用不同形状的图标(圆叉、三角、圆 i)与颜色共同标识级别,并有无障碍名称;选中行 Selection 与菱形;无问题;禁用。 + +## 事件 + +`Opened`:用户打开的问题。 + +## 键盘操作 + +- Tab 到达级别按钮与列表;↑/↓ 选择;Enter 或双击打开问题,在文件行上折叠/展开;← 折叠、→ 展开。 + +## 限制 + +- 同一文件的问题按行、列排序,文件按路径排序。 diff --git a/docs/components/332-output-panel.md b/docs/components/332-output-panel.md new file mode 100644 index 0000000..5349439 --- /dev/null +++ b/docs/components/332-output-panel.md @@ -0,0 +1,48 @@ +# 332 OutputPanel · 输出面板 + +## 用途 + +显示多个输出通道之一,跟随尾部,支持 ANSI 颜色;清空请求通过结果交给调用方。 + +## 最小示例 + +```go +channels := []mujica.OutputChannel{{Name: "Build", Lines: []string{"compiling court", "\x1b[32mok\x1b[0m"}}} +var state mujica.OutputPanelState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OutputPanel(c, channels, &state, mujica.OutputPanelOptions{}) + r.Element.Height(240) + if r.Cleared != "" { + channels[state.Channel].Lines = nil + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func OutputPanel(c *ui.Context, channels []OutputChannel, s *OutputPanelState, opts OutputPanelOptions) OutputPanelResult`。通道为空或 `Channel` 越界时 panic。行列表虚拟化。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OutputChannel.Name`, `Lines` | `string`, `[]string` | — | 通道名与内容,行内可含 ANSI 样式。 | +| `OutputPanelState.Channel` | `int` | 0 | 当前通道。 | +| `OutputPanelState.Follow` | `bool` | 首次为 true | 跟随尾部。 | +| `Disabled` | `bool` | false | 不能切换、清空与改跟随。 | + +## 状态 + +通道下拉、跟随按钮按下、清空按钮、无输出、禁用。 + +## 事件 + +`Changed`:切换了通道;`Cleared`:要清空的通道名,调用方清空其 `Lines`。 + +## 键盘操作 + +- Tab 到达通道下拉、跟随按钮、清空按钮与输出列表;下拉中方向键选择。 + +## 限制 + +- 只渲染 SGR 样式,其余控制序列原样保留(与 `internal.ParseAnsi` 一致)。 diff --git a/docs/components/333-symbol-outline.md b/docs/components/333-symbol-outline.md new file mode 100644 index 0000000..f04dbfa --- /dev/null +++ b/docs/components/333-symbol-outline.md @@ -0,0 +1,53 @@ +# 333 SymbolOutline · 符号大纲 + +## 用途 + +以树显示文件中的符号,带类型标记,可筛选,并跟随编辑器光标选中所在符号。 + +## 最小示例 + +```go +symbols := []mujica.Symbol{{Name: "Court", Kind: mujica.SymbolType, Line: 3, EndLine: 40, Children: []mujica.Symbol{ + {Name: "Seal", Kind: mujica.SymbolMethod, Line: 10, EndLine: 20}, +}}} +var state mujica.SymbolOutlineState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SymbolOutline(c, symbols, &state, mujica.SymbolOutlineOptions{CursorLine: 12}) + r.Element.Height(240) + if r.Jumped != nil { + fmt.Println("go to line", r.Jumped.Line) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SymbolOutline(c *ui.Context, symbols []Symbol, s *SymbolOutlineState, opts SymbolOutlineOptions) SymbolOutlineResult`。符号树在数据变化时编号一次。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Symbol.Name`, `Detail` | `string` | — | 名称与补充说明(如签名)。 | +| `Symbol.Kind` | `SymbolKind` | `SymbolFunction` | 函数 f、方法 m、类型 T、接口 I、字段 p、变量 v、常量 c、模块 M;越界 panic。 | +| `Symbol.Line`, `EndLine` | `int` | — | 所在行范围,从 1 开始。 | +| `SymbolOutlineState.Query` | `string` | "" | 筛选文字;匹配项与其祖先保留并自动展开。 | +| `CursorLine` | `int` | 0(不跟随) | 变化时选中包含该行的最内层符号并展开祖先。 | +| `Disabled` | `bool` | false | 不能跳转。 | + +## 状态 + +类型以字母徽记加颜色区分;选中行 Selection 与菱形;筛选高亮;无符号;禁用。 + +## 事件 + +`Jumped`:用户要跳转的符号。 + +## 键盘操作 + +- Tab 到达筛选框与树;↑/↓ 移动,→ 展开或进入子项,← 折叠或回到父项;输入字母按名称定位。 +- Enter 或双击:跳转到符号。 + +## 限制 + +- 光标跟随只在 `CursorLine` 变化时生效,之后用户可自由移动选择。 diff --git a/docs/components/334-completion-menu.md b/docs/components/334-completion-menu.md new file mode 100644 index 0000000..cbc27a1 --- /dev/null +++ b/docs/components/334-completion-menu.md @@ -0,0 +1,50 @@ +# 334 CompletionMenu · 补全菜单 + +## 用途 + +在编辑器下方显示补全候选:类型徽记、详情与说明;方向键选择,Tab 或 Enter 接受,焦点始终留在编辑器。 + +## 最小示例 + +```go +text := "se" +open := true +items := []mujica.Completion{{Label: "Seal", Kind: mujica.SymbolMethod, Detail: "func()"}, {Label: "Search", Kind: mujica.SymbolFunction}} +var state mujica.CompletionMenuState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + in := mujica.TextInput(c, &text, mujica.TextInputOptions{Label: "Code"}) + if r := mujica.CompletionMenu(c, in.Input, &open, items, &state, mujica.CompletionMenuOptions{}); r.Accepted != nil { + text = r.Accepted.Label + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult`。`anchor` 是编辑器元素(如 `InputResult.Input`);打开期间组件替换它的 `HandleInput` 以先拿到菜单按键。候选为空、禁用或编辑器禁用时自动关闭。列表虚拟化,最多显示 8 行后滚动;窗口下方空间不足时在上方弹出。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Completion.Label` | `string` | 必填 | 候选文字。 | +| `Completion.Kind` | `SymbolKind` | `SymbolFunction` | 类型徽记;越界 panic。 | +| `Completion.Detail`, `Doc` | `string` | "" | 右侧详情与当前候选的说明。 | +| `CompletionMenuState.Selected` | `int` | 0 | 当前候选。 | +| `Disabled` | `bool` | false | 关闭菜单。 | + +## 状态 + +当前候选 Selection 背景与菱形;弹层 160 ms 淡入(减少动画时直接显示)。 + +## 事件 + +`Accepted`:被接受的候选,同时 `*open` 置为 false。 + +## 键盘操作 + +- ↓/↑:循环移动;Tab 或 Enter:接受;Escape:关闭。点击候选也接受。 + +## 限制 + +- 打开期间编辑器自身设置的 `HandleInput` 会被覆盖。 diff --git a/docs/components/335-debug-toolbar.md b/docs/components/335-debug-toolbar.md new file mode 100644 index 0000000..7560c0b --- /dev/null +++ b/docs/components/335-debug-toolbar.md @@ -0,0 +1,48 @@ +# 335 DebugToolbar · 调试工具栏 + +## 用途 + +调试会话的继续/暂停、单步跳过、单步进入、单步跳出、重启与停止,按会话状态禁用不适用的按钮,并注册常用快捷键。 + +## 最小示例 + +```go +state := mujica.DebugPaused +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + switch mujica.DebugToolbar(c, state, mujica.DebugToolbarOptions{}).Action { + case mujica.DebugContinue: + state = mujica.DebugRunning + case mujica.DebugStop: + state = mujica.DebugStopped + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func DebugToolbar(c *ui.Context, state DebugState, opts DebugToolbarOptions) DebugToolbarResult`。`DebugEnabled(state, action)` 给出禁用规则:运行中只能暂停、重启、停止;暂停时可继续、单步、重启、停止;停止时只能“开始”(`DebugContinue`)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `state` | `DebugState` | — | `DebugStopped`、`DebugRunning`、`DebugPaused`;越界 panic。 | +| `Disabled` | `bool` | false | 全部按钮与快捷键无效。 | + +## 状态 + +运行中第一个按钮显示暂停,否则显示继续(停止时名为“开始”);不适用的按钮禁用;按钮悬停、按下、焦点环与带快捷键的提示。 + +## 事件 + +`Action`:本帧请求的 `DebugAction`,无则为 `DebugNone`。 + +## 键盘操作 + +- F5 继续/开始,F6 暂停,F10 单步跳过,F11 单步进入,Shift+F11 单步跳出,Cmd/Ctrl+Shift+F5 重启,Shift+F5 停止(窗口内全局,焦点控件优先处理)。 +- 工具栏是一个 Tab 停靠点,←/→ 在按钮间移动。 + +## 限制 + +- 快捷键固定,不可配置;同一窗口只放一个调试工具栏。 +- 图标为自绘 SVG。 diff --git a/docs/components/336-breakpoint-list.md b/docs/components/336-breakpoint-list.md new file mode 100644 index 0000000..488efca --- /dev/null +++ b/docs/components/336-breakpoint-list.md @@ -0,0 +1,49 @@ +# 336 BreakpointList · 断点列表 + +## 用途 + +列出断点:启用开关、跳到源码位置、行内编辑条件断点。 + +## 最小示例 + +```go +bps := []mujica.Breakpoint{{ID: "a", File: "court/seal.go", Line: 12, Enabled: true}, {ID: "b", File: "main.go", Line: 9, Condition: "n > 3"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BreakpointList(c, &bps, mujica.BreakpointListOptions{}) + r.Element.Height(200) + if r.Toggled != "" || r.Edited != "" { + fmt.Println("sync breakpoints with the debugger") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func BreakpointList(c *ui.Context, bps *[]Breakpoint, opts BreakpointListOptions) BreakpointListResult`。组件直接修改 `*bps` 中的 `Enabled` 与 `Condition`;ID 为空或重复时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Breakpoint.ID` | `string` | 必填 | 稳定标识。 | +| `Breakpoint.File`, `Line` | `string`, `int` | — | 位置。 | +| `Breakpoint.Enabled` | `bool` | false | 是否启用。 | +| `Breakpoint.Condition` | `string` | "" | 条件表达式,非空时显示“当 …”。 | +| `Disabled` | `bool` | false | 不能修改与跳转。 | + +## 状态 + +启用为实心圆点,停用为空心圆点并降低文字色;条件以 Warning 等宽文字显示;编辑中的输入框;无断点;禁用。 + +## 事件 + +`Toggled`、`Edited`、`Opened`:本帧被切换、改条件、要打开的断点 ID。 + +## 键盘操作 + +- Tab 依次到达复选框、位置按钮与编辑条件按钮;Space 切换启用;Enter 打开位置。 +- 条件输入框:Enter 确认,Escape 取消。 + +## 限制 + +- 不提供删除与新增;由调用方修改 `*bps`。 diff --git a/docs/components/337-call-stack.md b/docs/components/337-call-stack.md new file mode 100644 index 0000000..a5a2b69 --- /dev/null +++ b/docs/components/337-call-stack.md @@ -0,0 +1,52 @@ +# 337 CallStack · 调用栈 + +## 用途 + +按线程分组显示调用栈帧,标出当前帧;选择帧即切换当前帧。 + +## 最小示例 + +```go +threads := []mujica.DebugThread{{ID: "t1", Name: "main", Frames: []mujica.StackFrame{ + {ID: "f1", Name: "court.Seal", File: "court/seal.go", Line: 14}, + {ID: "f2", Name: "main.main", File: "main.go", Line: 9}, +}}} +state := mujica.CallStackState{Current: "f1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CallStack(c, threads, &state, mujica.CallStackOptions{}) + r.Element.Height(200) + if r.Changed() { + fmt.Println("show frame", state.Current) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CallStack(c *ui.Context, threads []DebugThread, s *CallStackState, opts CallStackOptions) ChoiceResult`。调用方修改 `s.Current` 时选中行随之移动。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `DebugThread.ID`, `Name` | `string` | — | 线程标识与名称。 | +| `StackFrame.ID`, `Name`, `File`, `Line` | `string`, `int` | — | 帧标识、函数名与位置,最内层在前。 | +| `CallStackState.Current` | `string` | "" | 当前帧 ID。 | +| `CallStackState.Collapsed` | `ui.Selection[string]` | 空 | 折叠的线程 ID。 | +| `Disabled` | `bool` | false | 不能切换与折叠。 | + +## 状态 + +线程标题(折叠箭头与栈深度)、当前帧(Selection 背景与菱形)、未暂停空状态、禁用。 + +## 事件 + +`Changed()`:用户选了另一帧,读取 `s.Current`。 + +## 键盘操作 + +- Tab 到达列表;↑/↓ 移动,到达帧即切换当前帧;线程行上 Enter 折叠/展开,← 折叠、→ 展开。 + +## 限制 + +- 不区分线程的运行状态;需要时写入线程名。 diff --git a/docs/components/338-variables-panel.md b/docs/components/338-variables-panel.md new file mode 100644 index 0000000..c68a88c --- /dev/null +++ b/docs/components/338-variables-panel.md @@ -0,0 +1,49 @@ +# 338 VariablesPanel · 变量面板 + +## 用途 + +以树显示调试变量,可展开结构,标出自上次停止以来变化的值,并在行内修改叶子变量的值。 + +## 最小示例 + +```go +vars := []mujica.Variable{{Name: "decree", Value: "{…}", Children: []mujica.Variable{{Name: "id", Value: "42", Changed: true}}}, {Name: "count", Value: "7"}} +var state mujica.VariablesPanelState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.VariablesPanel(c, vars, &state, mujica.VariablesPanelOptions{}) + r.Element.Height(200) + if r.Edited != nil { + fmt.Println("set", r.Edited.Path, "=", r.Edited.Value) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VariablesPanel(c *ui.Context, vars []Variable, s *VariablesPanelState, opts VariablesPanelOptions) VariablesPanelResult`。变量按名称以点连接成路径作为键,同层重名 panic。修改值通过结果交给调用方写回调试器并传入新值。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Variable.Name`, `Value`, `Type` | `string` | — | 名称、显示值与类型。 | +| `Variable.Changed` | `bool` | false | 值自上次停止后变化。 | +| `Variable.Children` | `[]Variable` | nil(叶子) | 成员;非 nil 时可展开。 | +| `Disabled` | `bool` | false | 不能编辑。 | + +## 状态 + +变化的值以菱形标记、粗体与 AccentText 显示并带无障碍描述“已变化”;编辑中的输入框;选中行 Selection;无变量;禁用。 + +## 事件 + +`Edited`:新值与变量路径。 + +## 键盘操作 + +- Tab 到达树;↑/↓ 移动,→ 展开,← 折叠。 +- 叶子上 Enter、F2 或双击:编辑;Enter 确认,Escape 取消。 + +## 限制 + +- 子项不支持按需加载;需要时调用方在展开后补充 `Children`。 diff --git a/docs/components/339-hex-viewer.md b/docs/components/339-hex-viewer.md new file mode 100644 index 0000000..3a4a448 --- /dev/null +++ b/docs/components/339-hex-viewer.md @@ -0,0 +1,44 @@ +# 339 HexViewer · 十六进制查看器 + +## 用途 + +以偏移、十六进制与 ASCII 三列对照显示字节;两列共享同一选择与光标,只构建可见行。 + +## 最小示例 + +```go +data := []byte("MUJICA court ledger\x00\x01") +var state mujica.HexViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HexViewer(c, data, &state, mujica.HexViewerOptions{}).Element.Height(160) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func HexViewer(c *ui.Context, data []byte, s *HexViewerState, opts HexViewerOptions) ChoiceResult`。每行 16 字节;`s.Selection()` 返回选中范围;光标或锚点越界 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `HexViewerState.Cursor`, `Anchor` | `int` | 0 | 光标字节与 Shift 选择的起点。 | +| `Disabled` | `bool` | false | 不能移动与选择。 | + +## 状态 + +选中字节在两列都显示 Selection 背景,光标字节在两列都加 AccentText 框;不可打印字节显示为点;聚焦时外侧焦点环;空数据;禁用。 + +## 事件 + +`Changed()`:光标或选择变化。 + +## 键盘操作 + +- Tab 聚焦;←/→ 移动一字节,↑/↓ 移动一行,Home/End 到行首行尾;加 Shift 扩展选择。 +- Cmd+C(macOS)/ Ctrl+C:以空格分隔的十六进制复制选中字节。 +- 点击任一列选择字节,Shift+点击扩展。 + +## 限制 + +- 只读,不支持编辑与搜索。 diff --git a/docs/components/340-flamegraph.md b/docs/components/340-flamegraph.md new file mode 100644 index 0000000..21f12c8 --- /dev/null +++ b/docs/components/340-flamegraph.md @@ -0,0 +1,50 @@ +# 340 Flamegraph · 火焰图 + +## 用途 + +以层叠框绘制性能剖析:点击放大子树,搜索高亮匹配帧,悬停或键盘移动时显示并朗读耗时读数。 + +## 最小示例 + +```go +root := &mujica.FlameNode{Name: "main", Value: 100, Children: []mujica.FlameNode{ + {Name: "court.Seal", Value: 60}, {Name: "ledger.Write", Value: 30}, +}} +var state mujica.FlamegraphState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Flamegraph(c, root, &state, mujica.FlamegraphOptions{Unit: "ms"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Flamegraph(c *ui.Context, root *FlameNode, s *FlamegraphState, opts FlamegraphOptions) ChoiceResult`。帧宽按耗时占比;放大的帧占满宽度,祖先以淡色整宽显示。子帧耗时之和大于父帧或出现负值时 panic。帧颜色按名称取自主题调色板(AccentText、Info、Success、Warning、加深的 Ornament、TextMuted,均对 Surface 至少 3:1)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `FlameNode.Name`, `Value`, `Children` | `string`, `float64`, `[]FlameNode` | — | 帧名、含子帧的总耗时与子帧。 | +| `FlamegraphState.Zoom`, `Focus` | `[]int` | nil(根) | 放大与键盘焦点所在帧的子序号路径。 | +| `FlamegraphState.Query` | `string` | "" | 搜索高亮。 | +| `Unit` | `string` | "" | 读数中的单位。 | +| `Status`, `Error` | `DataStatus`, `string` | `DataReady` | 加载中或失败,与图同高显示。 | +| `Disabled` | `bool` | false | 不能放大与移动。 | + +## 状态 + +帧为浅色底加同色边框与名称;搜索匹配加 AccentText 粗框,其余帧变淡;焦点帧带焦点环;读数行显示名称、耗时与占比;加载、失败、无数据。 + +## 事件 + +`Changed()`:放大或焦点变化(失败状态下点击“重试”也为 true)。 + +## 键盘操作 + +- Tab 聚焦图表;↑ 到父帧,↓ 到第一个子帧,←/→ 在兄弟帧间移动;Enter 放大当前帧,Escape 缩小一级。 +- 读数随焦点更新并通过屏幕阅读器朗读。 + +## 限制 + +- 窄于 1 DIP 的帧不绘制,窄于 24 DIP 的帧不写名称;用键盘可到达所有帧。 +- 帧行高 28 DIP,窄帧的点击区域可能小于 28 DIP,请用键盘或先放大。 diff --git a/docs/components/341-image-cropper.md b/docs/components/341-image-cropper.md new file mode 100644 index 0000000..bef9a87 --- /dev/null +++ b/docs/components/341-image-cropper.md @@ -0,0 +1,55 @@ +# 341 ImageCropper 图片裁剪 + +## 用途 + +在图片上拖出裁剪框,可锁定宽高比、按 90 度旋转,输出裁剪矩形。 + +## 最小示例 + +```go +crop := mujica.ImageCrop{} // empty: starts fitted to the image +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult` + +`src` 必填,是标准库 `image.Image`(组件要自己转动像素)。`ImageCrop.Rect` 是旋转 `Rotation` 度之后那张图上的像素矩形;`Rotation` 只能是 0、90、180、270。`Rect` 为空时自动设为最大的居中框。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Aspect` | `float64` | 0 | 锁定宽除以高;0 为自由比例。`Rect` 不符合比例时 panic。 | +| `Height` | `float32` | 0(即 320) | 舞台高度。 | +| `Disabled` | `bool` | false | 禁用拖动、按键与旋转。 | +| `Label` | `string` | "Crop area" | 舞台的无障碍名称。 | + +## 状态 + +- 默认:框外区域压暗,框线与三分线为象牙色,四角有菱形手柄。 +- 聚焦:舞台显示 2 DIP 焦点环;下方读数同时作为无障碍描述。 +- 禁用:MyGo 统一调暗,不响应输入。 + +## 事件 + +`Changed()` 在框移动、缩放或旋转后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| 方向键 | 移动裁剪框,每次图片宽度的 1% | +| Shift+方向键 | 右、下放大,左、上缩小;锁定比例时保持比例 | +| R / Shift+R | 向右 / 向左旋转 90 度 | + +## 限制 + +- MyGo 的 Painter 不能旋转位图(只有单色图标能转,ui/svg.go:98),所以组件在图片或角度变化时复制一份转好的像素;超大图片会在那一帧花时间。 +- 锁定比例时旋转会重新取最大的居中框,原选区不保留。 +- 拖动角点时,按下点距角 14 DIP 以内才算抓住手柄。 diff --git a/docs/components/342-image-annotator.md b/docs/components/342-image-annotator.md new file mode 100644 index 0000000..c947050 --- /dev/null +++ b/docs/components/342-image-annotator.md @@ -0,0 +1,59 @@ +# 342 ImageAnnotator 图片标注 + +## 用途 + +在图片上画矩形、箭头和文字标注,可选择、移动和删除,并导出标注数据。 + +## 最小示例 + +```go +photo := ui.NewBitmap(examplePhoto) +var notes []mujica.Annotation +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult` + +`func ExportAnnotations(notes []Annotation) ([]byte, error)` 把标注写成 JSON(kind、x0、y0、x1、y1、text)。 + +`Annotation` 的坐标是图片像素;文字标注以 `From` 为左上角,`Text` 不能为空。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 320) | 舞台高度。 | +| `Disabled` | `bool` | false | 禁用绘制、选择和删除。 | +| `Label` | `string` | "Annotations" | 舞台的无障碍名称。 | + +## 状态 + +- 工具:选择、矩形、箭头、文字,用分段控件切换。 +- 选中:虚线框加菱形标记,不只靠颜色。 +- 标注用暗红墨线加浅色描边,在任何图片上都看得清。 + +## 事件 + +`Changed()` 在新增、移动、改字或删除标注后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| [ / ] | 选中上一个 / 下一个标注 | +| 方向键 | 移动选中的标注,每次图片宽度的 1% | +| Delete / Backspace | 删除选中的标注 | +| Escape | 取消选择 | + +## 限制 + +- 文字工具:先在工具栏输入文字,再点击放置;选中文字标注时同一输入框可改字。 +- 标注不能缩放,只能移动;要改大小请删除后重画。 +- 文字的命中区域按每字 8 像素估算。 diff --git a/docs/components/343-image-thumbnail.md b/docs/components/343-image-thumbnail.md new file mode 100644 index 0000000..eaf0c67 --- /dev/null +++ b/docs/components/343-image-thumbnail.md @@ -0,0 +1,54 @@ +# 343 ImageThumbnail 缩略图 + +## 用途 + +固定尺寸、按比例裁切的可点击缩略图,带选中、加载中和加载失败状态。 + +## 最小示例 + +```go +photo := ui.NewBitmap(examplePhoto) +selected := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult` + +`src` 为 nil 时显示占位图标。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Width` | `float32` | 0(即 96) | 宽度。 | +| `Aspect` | `float32` | 0(即 1) | 宽除以高,图片按 Cover 裁切填满。 | +| `Selected` | `bool` | false | 选中:2 DIP 强调边框加右上角菱形。 | +| `Loading` | `bool` | false | 显示加载标记,尺寸不变。 | +| `Err` | `error` | nil | 显示失败图标;错误文字作为提示与描述。 | +| `Alt` | `string` | 必填 | 替代说明,为空时 panic。 | +| `Disabled` | `bool` | false | 禁用点击。 | + +## 状态 + +默认、悬停(边框加深)、聚焦、选中、加载、失败、禁用,所有状态尺寸相同。 + +## 事件 + +`Changed()` 表示本帧被点击,或聚焦时按了 Enter / Space。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦缩略图 | +| Enter / Space | 激活 | + +## 限制 + +- 选中状态由调用方保存;组件只报告点击。 diff --git a/docs/components/344-lightbox.md b/docs/components/344-lightbox.md new file mode 100644 index 0000000..5c5103a --- /dev/null +++ b/docs/components/344-lightbox.md @@ -0,0 +1,53 @@ +# 344 Lightbox 灯箱 + +## 用途 + +全窗口浏览图片:左右切换、显示说明文字,Escape 关闭后焦点回到触发控件。 + +## 最小示例 + +```go +images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} +open, index := false, 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult` + +与 ImageViewer 共用 `ViewerImage`(`Source`、`Alt`)。`Alt` 同时是说明文字。关闭时 `Element` 为 nil。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | — | 是否显示;Escape、点击关闭或背景时设为 false。 | +| `index` | `*int` | — | 当前图片;越界 panic。 | +| `images` | `[]ViewerImage` | — | 至少一张,`Source` 须为 `*ui.Bitmap` 或 `*ui.SVG`。 | + +## 状态 + +- 黑色遮罩铺满窗口,图片按 Contain 居中;首尾时对应切换按钮禁用。 +- 打开时按弹层时长淡入,减少动画时直接出现。 + +## 事件 + +`Changed()` 在切换图片后为 true,并朗读“第 n 张,共 m 张”。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 上一张 / 下一张 | +| Home / End | 第一张 / 最后一张 | +| Escape | 关闭并把焦点还给打开它的控件 | + +## 限制 + +- 不提供缩放与平移;需要时请用 ImageViewer。 diff --git a/docs/components/345-video-player.md b/docs/components/345-video-player.md new file mode 100644 index 0000000..7a9beca --- /dev/null +++ b/docs/components/345-video-player.md @@ -0,0 +1,59 @@ +# 345 VideoPlayer 视频播放器 + +## 用途 + +播放器外壳:画面由调用方提供,组件绘制状态并通过回调报告播放、暂停、跳转、倍速、字幕、全屏和画中画。 + +## 最小示例 + +```go +st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element` + +`VideoState`:`Frame`(当前画面,nil 显示“暂无视频”)、`Position`、`Duration`、`Buffered`、`Playing`、`Rate`、`Subtitle`、`SubtitlesOn`、`Fullscreen`、`PiP`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 播放、暂停、跳转。 | +| `OnRate` | `func(float64)` | nil | 倍速;nil 时不显示倍速选择。 | +| `OnSubtitles / OnFullscreen / OnPiP` | `func(bool)` | nil | 开关回调;nil 时隐藏对应按钮。 | +| `Disabled` | `bool` | false | 禁用全部控件。 | +| `Label` | `string` | "Video" | 无障碍名称。 | + +## 状态 + +- 画面区 16:9,黑底;字幕开启且有字幕时在底部显示。 +- 字幕、画中画、全屏按钮开启时有选中底色和下划线。 + +## 事件 + +全部通过回调报告;组件不改动 `st`。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 先聚焦画面,再到进度条和各按钮 | +| Space / K | 画面聚焦时播放或暂停 | +| ← / → | 画面聚焦时后退 / 前进 5 秒 | +| F | 切换全屏 | +| C | 切换字幕 | + +## 限制 + +- 组件不解码视频,也不实现全屏或画中画窗口,只报告用户的请求。 +- 帧率取决于调用方更新 `Frame` 并调用 `c.Invalidate()` 的频率。 diff --git a/docs/components/346-video-scrubber.md b/docs/components/346-video-scrubber.md new file mode 100644 index 0000000..6ada0d3 --- /dev/null +++ b/docs/components/346-video-scrubber.md @@ -0,0 +1,56 @@ +# 346 VideoScrubber 视频时间轴 + +## 用途 + +可拖动的时间轴:显示已缓冲区间,悬停显示时间和缩略图插槽。 + +## 最小示例 + +```go +pos := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) ChoiceResult` + +`*pos` 必须在 0 到 `Duration` 之间,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | 必填 | 时间轴长度,须大于 0。 | +| `Buffered` | `[]MediaRange` | nil | 已缓冲区间,以浅色填充。 | +| `Preview` | `func(at time.Duration)` | nil | 悬停时在插槽里构建该时间的缩略图。 | +| `PreviewWidth / PreviewHeight` | `float32` | 160×90 | 插槽尺寸。 | +| `Step` | `time.Duration` | 5 秒 | 方向键每次移动的时长。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "Timeline" | 无障碍名称;以滑块角色报告范围与数值。 | + +## 状态 + +默认、悬停(显示时间气泡)、拖动、聚焦(菱形手柄外圈)、禁用。 + +## 事件 + +`Changed()` 在拖动、点击或按键改变 `*pos` 后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / ↓ | 后退一个 Step | +| → / ↑ | 前进一个 Step | +| Home / End | 开头 / 结尾 | + +## 限制 + +- 位置按毫秒取整。 +- 悬停插槽只在指针位于时间轴上时显示,键盘操作时不显示。 diff --git a/docs/components/347-video-thumbnail-strip.md b/docs/components/347-video-thumbnail-strip.md new file mode 100644 index 0000000..e36b6ef --- /dev/null +++ b/docs/components/347-video-thumbnail-strip.md @@ -0,0 +1,51 @@ +# 347 VideoThumbnailStrip 缩略帧条 + +## 用途 + +横向缩略帧条:标出当前帧,点击或按键跳转。 + +## 最小示例 + +```go +frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} +current := 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts VideoThumbnailStripOptions) ChoiceResult` + +`StripFrame` 有 `Image`(可为 nil)和 `At`。`*current` 越界 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `FrameWidth` | `float32` | 0(即 96) | 每格宽度,高度按 16:9。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +- 只构建放得下的那几格,窗口跟随当前帧移动;10 万帧也只建十几个元素。 +- 当前帧:强调边框加菱形;每格左下角显示时间。 + +## 事件 + +`Changed()` 在当前帧变化后为 true;调用方据此跳转播放器。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦帧条 | +| ← / → | 上一帧 / 下一帧 | +| Home / End | 第一帧 / 最后一帧 | + +## 限制 + +- 不提供横向滚动条,用两侧按钮或键盘移动。 diff --git a/docs/components/348-audio-player.md b/docs/components/348-audio-player.md new file mode 100644 index 0000000..426e71d --- /dev/null +++ b/docs/components/348-audio-player.md @@ -0,0 +1,51 @@ +# 348 AudioPlayer 音频播放器 + +## 用途 + +音频播放外壳:播放、暂停、进度与时长,播放本身由调用方驱动。 + +## 最小示例 + +```go +st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioPlayer(c *ui.Context, st AudioState, opts AudioPlayerOptions) *ui.Element` + +`AudioState`:`Title`、`Position`、`Duration`、`Playing`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPlay / OnPause / OnSeek` | `func` | 必填 | 缺少任一回调时 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +播放中显示暂停按钮,暂停时显示播放按钮;`Duration` 为 0 时不显示进度条。 + +## 事件 + +通过回调报告,组件不改动 `st`。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 播放按钮、进度条 | +| Space | 组件内任意位置播放或暂停(按钮聚焦时由按钮处理) | +| ← / → | 进度条聚焦时跳转 5 秒 | + +## 限制 + +- 组件不播放声音。 diff --git a/docs/components/349-audio-waveform.md b/docs/components/349-audio-waveform.md new file mode 100644 index 0000000..674f8ad --- /dev/null +++ b/docs/components/349-audio-waveform.md @@ -0,0 +1,58 @@ +# 349 AudioWaveform 音频波形 + +## 用途 + +绘制调用方提供的采样波形,覆盖播放进度,可拖动选择区间。 + +## 最小示例 + +```go +samples := make([]float32, 48000) // from your decoder, -1 to 1 +pos, sel := time.Duration(0), mujica.MediaRange{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioWaveform(c *ui.Context, samples []float32, opts AudioWaveformOptions) ChoiceResult` + +`samples` 取值 -1 到 1。数据就绪时 `Duration` 须大于 0,`OnSeek` 必填。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Duration` | `time.Duration` | — | 音频长度。 | +| `Position` | `time.Duration` | 0 | 已播放部分。 | +| `OnSeek` | `func(time.Duration)` | — | 点击或方向键跳转。 | +| `Selection` | `*MediaRange` | nil | 非 nil 时可拖动或 Shift+方向键选择区间。 | +| `Height` | `float32` | 0(即 96) | 高度。 | +| `Status / Error` | `DataStatus / string` | DataReady | 加载中、失败(须给 Error)与无数据时显示同尺寸占位。 | +| `Label` | `string` | "Waveform" | 无障碍名称。 | + +## 状态 + +- 已播放部分用强调色,其余用说明色,并有播放线和菱形标记,不只靠颜色区分。 +- 选区用 Selection 底色加两条边线。 + +## 事件 + +`Changed()` 在选区变化后为 true;跳转通过 `OnSeek` 报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 跳转 ±5 秒 | +| Shift+← / → | 从播放位置开始或调整选区终点 | +| Escape | 清除选区 | + +## 限制 + +- 峰值按像素列计算,只在数据或宽度变化时重算,不会每帧计算。 +- 数据以首元素地址和长度识别;原地修改采样请换一个切片。 diff --git a/docs/components/350-audio-spectrum.md b/docs/components/350-audio-spectrum.md new file mode 100644 index 0000000..b7e2994 --- /dev/null +++ b/docs/components/350-audio-spectrum.md @@ -0,0 +1,49 @@ +# 350 AudioSpectrum 音频频谱 + +## 用途 + +把调用方提供的频带数值画成柱状频谱;组件自己不做动画,隐藏时不重绘。 + +## 最小示例 + +```go +bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AudioSpectrum(c *ui.Context, bands []float32, opts AudioSpectrumOptions) *ui.Element` + +每个频带取 0 到 1,超出范围 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 120) | 高度。 | +| `BandLabel` | `func(i int) string` | nil | 读数中频带的名称,如“1 kHz”;nil 时编号。 | +| `Status / Error` | `DataStatus / string` | DataReady | 加载、失败与无数据占位,尺寸相同。 | +| `Label` | `string` | "Spectrum" | 无障碍名称。 | + +## 状态 + +聚焦后用方向键选中的频带以正文色显示,带虚线框和读数,不只靠颜色。 + +## 事件 + +无事件;方向键读数会通过 `Announce` 朗读。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦频谱 | +| ← / → | 上一个 / 下一个频带 | +| Home / End | 第一个 / 最后一个频带 | + +## 限制 + +- 刷新节奏由调用方决定:更新数据后调用 `c.Invalidate()`。 diff --git a/docs/components/351-volume-control.md b/docs/components/351-volume-control.md new file mode 100644 index 0000000..ef6eb3d --- /dev/null +++ b/docs/components/351-volume-control.md @@ -0,0 +1,46 @@ +# 351 VolumeControl 音量控制 + +## 用途 + +音量滑块与静音按钮。 + +## 最小示例 + +```go +volume, muted := 0.8, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func VolumeControl(c *ui.Context, volume *float64, muted *bool, opts VolumeControlOptions) ChoiceResult` + +`*volume` 取 0 到 1,超出 panic;步长 5%。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +静音或音量为 0 时显示静音图标;静音按钮开启时有选中底色和下划线。静音时调高音量会自动取消静音。 + +## 事件 + +`Changed()` 在音量或静音变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 音量 ±5% | +| Home / End | 0% / 100% | +| M | 组件内切换静音 | + +## 限制 + +- 组件不调节系统音量,只改调用方的值。 diff --git a/docs/components/352-playback-speed-control.md b/docs/components/352-playback-speed-control.md new file mode 100644 index 0000000..9a7e829 --- /dev/null +++ b/docs/components/352-playback-speed-control.md @@ -0,0 +1,48 @@ +# 352 PlaybackSpeedControl 倍速选择 + +## 用途 + +在 0.5× 到 2× 之间选择播放速度。 + +## 最小示例 + +```go +rate := 1.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult` + +`*rate` 必须是 `Rates` 中的一项,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Rates` | `[]float64` | 0.5, 0.75, 1, 1.25, 1.5, 2 | 升序的正数。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +基于 Select,状态与键盘同 Select。 + +## 事件 + +`Changed()` 在选择新倍速后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Enter / Space | 打开列表 | +| ↑ / ↓ | 移动 | +| Enter | 确认 | +| Escape | 关闭 | + +## 限制 + +- 无。 diff --git a/docs/components/353-media-controls.md b/docs/components/353-media-controls.md new file mode 100644 index 0000000..3d35038 --- /dev/null +++ b/docs/components/353-media-controls.md @@ -0,0 +1,53 @@ +# 353 MediaControls 媒体控制 + +## 用途 + +上一首、播放/暂停、下一首、循环、随机,一组可用方向键切换焦点的按钮。 + +## 最小示例 + +```go +st := mujica.MediaControlsState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element` + +`MediaControlsState`:`Playing`、`Loop`(`LoopOff`、`LoopAll`、`LoopOne`)、`Shuffle`、`NoPrevious`、`NoNext`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnPrevious / OnPlay / OnPause / OnNext` | `func()` | 必填 | 按钮回调。 | +| `OnLoop` | `func(LoopMode)` | 必填 | 收到下一个循环模式:不循环 → 列表 → 单曲。 | +| `OnShuffle` | `func(bool)` | 必填 | 随机开关。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +循环与随机开启时有选中底色和下划线,单曲循环换用带“1”的图标;循环按钮的名称说明当前模式。 + +## 事件 + +全部通过回调报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 进入按钮组 | +| ← / → | 在按钮间移动焦点 | +| Enter / Space | 按下 | + +## 限制 + +- 无。 diff --git a/docs/components/354-playlist.md b/docs/components/354-playlist.md new file mode 100644 index 0000000..85bac64 --- /dev/null +++ b/docs/components/354-playlist.md @@ -0,0 +1,52 @@ +# 354 Playlist 播放列表 + +## 用途 + +播放列表:标出当前项,可拖动排序、移除,只构建可见行。 + +## 最小示例 + +```go +items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} +current := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult` + +`PlaylistItem`:`ID`(唯一且非空,否则 panic)、`Title`、`Subtitle`、`Duration`。组件直接修改 `*items` 完成排序和移除。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Current` | `string` | "" | 正在播放项的 ID,显示播放标记和强调文字。 | +| `Height` | `float32` | 0(即 320) | 列表高度。 | +| `Disabled` | `bool` | false | 禁用选择、排序和移除。 | + +## 状态 + +基于 List:选中行使用 Selection 底色加勾选标记;空列表显示提示。 + +## 事件 + +`PlaylistResult`:`Played` 为 Enter 或双击选择的 ID;`Removed` 为被移除的 ID;`Moved` 表示顺序变化。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ↑ / ↓ | 移动选择 | +| Enter | 播放选中项 | +| Alt+↑ / Alt+↓ | 上移 / 下移选中项 | +| Delete / Backspace | 移除选中项 | + +## 限制 + +- 拖动排序由 MyGo List 的 Reorder 提供,拖动时显示插入线。 diff --git a/docs/components/355-subtitle-editor.md b/docs/components/355-subtitle-editor.md new file mode 100644 index 0000000..eb921bc --- /dev/null +++ b/docs/components/355-subtitle-editor.md @@ -0,0 +1,52 @@ +# 355 SubtitleEditor 字幕编辑器 + +## 用途 + +逐条编辑字幕的开始、结束时间和文本,校验时间先后与重叠。 + +## 最小示例 + +```go +cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult` + +`func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue` 返回结束早于开始(`SubtitleEndBeforeStart`)和与上一条重叠(`SubtitleOverlap`)的条目,供调用方阻止保存。时间格式为 `m:ss.mmm` 或 `h:mm:ss.mmm`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Height` | `float32` | 0(即 360) | 列表高度。 | +| `Disabled` | `bool` | false | 禁用编辑、添加和删除。 | + +## 状态 + +- 无法解析的时间保留在输入框中,并显示格式提示;有效后才写回。 +- 时间问题在该行下方用错误色说明,并报告给辅助技术。 +- 只构建可见行,10 万条也保持流畅。 + +## 事件 + +`Changed()` 在任一字段、添加或删除后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在一行的开始、结束、文本、删除之间移动 | +| Enter | 提交输入 | + +## 限制 + +- 虚拟化只构建可见行,Tab 不会跳到未显示的行;请先滚动。 +- 组件不排序字幕;顺序由调用方决定。 diff --git a/docs/components/356-screen-recorder-controls.md b/docs/components/356-screen-recorder-controls.md new file mode 100644 index 0000000..d0dcb96 --- /dev/null +++ b/docs/components/356-screen-recorder-controls.md @@ -0,0 +1,51 @@ +# 356 ScreenRecorderControls 录屏控制 + +## 用途 + +开始、暂停、继续、停止录制并显示计时;录制本身由调用方实现。 + +## 最小示例 + +```go +st := mujica.RecorderState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element` + +`RecorderState`:`Status`(`RecorderIdle`、`RecorderRecording`、`RecorderPaused`)、`Elapsed`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `OnStart / OnPause / OnResume / OnStop` | `func()` | 必填 | 缺少任一回调时 panic。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +就绪、录制中(红点)、已暂停;状态同时用文字表示,不只靠圆点颜色。录制中的圆点不闪烁。 + +## 事件 + +全部通过回调报告。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在按钮间移动 | +| Enter / Space | 按下 | + +## 限制 + +- 计时由调用方更新 `Elapsed`。 diff --git a/docs/components/357-camera-preview.md b/docs/components/357-camera-preview.md new file mode 100644 index 0000000..e7d4aa5 --- /dev/null +++ b/docs/components/357-camera-preview.md @@ -0,0 +1,48 @@ +# 357 CameraPreview 摄像头预览 + +## 用途 + +显示调用方提供的摄像头帧,无帧时显示“无信号”,可切换镜像。 + +## 最小示例 + +```go +frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame +mirror := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CameraPreview(c *ui.Context, frame CameraFrame, mirror *bool, opts CameraPreviewOptions) ChoiceResult` + +`CameraFrame`:`Image`(标准库 `image.Image`,nil 为无信号)、`Seq`(帧序号,复用同一图像缓冲时用新序号触发重绘)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | "Camera" | 无障碍名称。 | +| `Disabled` | `bool` | false | 禁用镜像切换。 | + +## 状态 + +16:9 黑底;镜像按钮开启时有选中底色和下划线。 + +## 事件 + +`Changed()` 在镜像切换后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 聚焦预览 | +| M | 切换镜像 | + +## 限制 + +- MyGo 的 Painter 不能水平翻转位图(ui/paint.go:834),所以镜像时组件每个新帧复制一份翻转后的像素;高分辨率帧会占用 CPU。 +- 组件不打开摄像头。 diff --git a/docs/components/358-mic-level-meter.md b/docs/components/358-mic-level-meter.md new file mode 100644 index 0000000..29152ea --- /dev/null +++ b/docs/components/358-mic-level-meter.md @@ -0,0 +1,45 @@ +# 358 MicLevelMeter 麦克风电平 + +## 用途 + +分段电平条,带峰值保持与 dB 读数。 + +## 最小示例 + +```go +level := float32(0.4) // the input's peak, 0 to 1 of full scale +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element` + +`level` 为满刻度的 0 到 1,超出 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Hold` | `time.Duration` | 1.5 秒 | 峰值保持时长。 | +| `Label` | `string` | "Microphone level" | 无障碍名称;描述中读出当前与峰值 dB。 | + +## 状态 + +24 段:前 70% 为成功色,接着警告色,最后 10% 为危险色;峰值段为空心标记;右侧显示 dB 数值,不只靠颜色。 + +## 事件 + +无。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| — | 不获取焦点 | + +## 限制 + +- 保持结束时只请求一次重绘,空闲时不持续重绘。 diff --git a/docs/components/359-device-selector.md b/docs/components/359-device-selector.md new file mode 100644 index 0000000..06f202b --- /dev/null +++ b/docs/components/359-device-selector.md @@ -0,0 +1,51 @@ +# 359 DeviceSelector 设备选择 + +## 用途 + +选择麦克风、摄像头、扬声器,设备列表由调用方提供。 + +## 最小示例 + +```go +devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, +} +sel := mujica.DeviceSelection{Microphone: "mic-1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func DeviceSelector(c *ui.Context, sel *DeviceSelection, devices MediaDevices, opts DeviceSelectorOptions) ChoiceResult` + +`MediaDevices` 的某类列表为 nil 时不显示该类;为空切片时显示禁用的“未找到设备”。设备 ID 重复,或已选 ID 不在列表中时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用全部选择。 | + +## 状态 + +每类一行:图标、名称和 Select。 + +## 事件 + +`Changed()` 在任一类选择变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| Tab | 在各选择框间移动 | +| Enter / Space | 打开列表 | +| ↑ / ↓ | 移动 | +| 输入字母 | 跳到匹配的设备 | + +## 限制 + +- 组件不枚举设备,也不监听设备插拔;调用方更新列表即可。 diff --git a/docs/components/360-image-compare.md b/docs/components/360-image-compare.md new file mode 100644 index 0000000..cc03a72 --- /dev/null +++ b/docs/components/360-image-compare.md @@ -0,0 +1,48 @@ +# 360 ImageCompare 前后对比 + +## 用途 + +前后两张图片叠放,用滑块分隔对比。 + +## 最小示例 + +```go +before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) +split := 0.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult` + +`*split` 取 0 到 1,表示分隔线位置;超出 panic。两张图都须为 `*ui.Bitmap` 或 `*ui.SVG`,盒子按 `before` 的宽高比。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `BeforeLabel / AfterLabel` | `string` | "Before" / "After" | 两侧标签,也组成无障碍名称。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +分隔线与菱形手柄;两侧标签始终显示,不只靠画面区分。 + +## 事件 + +`Changed()` 在分隔位置变化后为 true。 + +## 键盘操作 + +| 按键 | 作用 | +| --- | --- | +| ← / → | 移动 1% | +| Home / End | 最左 / 最右 | +| 拖动 | 任意位置拖动分隔线 | + +## 限制 + +- 两张图都按 Cover 铺满盒子,尺寸不同时会裁切。 diff --git a/docs/components/421-calendar-month-view.md b/docs/components/421-calendar-month-view.md new file mode 100644 index 0000000..d889eb4 --- /dev/null +++ b/docs/components/421-calendar-month-view.md @@ -0,0 +1,57 @@ +# 421 CalendarMonthView · 月视图 + +## 用途 + +按月显示事件格。每天最多显示若干行事件,其余折叠为“+N”,点击后在浮层中列出;事件可拖到其他日期改期。同一天时间重叠的事件并排放在同一行。 + +## 最小示例 + +```go +loc := time.Local +events := exampleEvents(loc) +month := mujica.YearMonth{Year: 2026, Month: time.October} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc}) + if ch, ok := r.Changed(); ok { + fmt.Println("move", ch.ID, "to", ch.Start) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CalendarMonthView(c *ui.Context, month *YearMonth, events []CalendarEvent, opts CalendarMonthViewOptions) CalendarViewResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 | +| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 | +| `MaxPerDay` | `int` | 0(即 3) | 每天显示的事件行数,超出部分折叠为“+N”。 | +| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、悬停、聚焦(焦点环)、今日(圆形底色 + 加粗)、非本月(弱化)、拖动经过的日期(选中底色 + 内嵌框)、禁用;改期失败时下方显示错误。 + +## 事件 + +`Opened()` 返回被激活的事件 ID;`DayChosen()` 返回点击的日期;`Changed()` 返回 `CalendarMove` 变更(保持墙上时间与时长)。上一月/下一月直接修改 `*month`。 + +## 键盘操作 + +- Tab 到达日期数字与事件块,方向键在其间移动。 +- Enter 或 Space 打开事件或选择日期。 +- 事件块上 Alt+←/→ 移动一天,Alt+↑/↓ 移动一周。 +- Escape 关闭“+N”浮层。 + +## 限制 + +- 拖动改期使用 MyGo 的 `Drag`/`Drop`;拖动预览是 MyGo 绘制的元素副本。 +- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。 +- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。 diff --git a/docs/components/422-calendar-week-view.md b/docs/components/422-calendar-week-view.md new file mode 100644 index 0000000..b59e941 --- /dev/null +++ b/docs/components/422-calendar-week-view.md @@ -0,0 +1,58 @@ +# 422 CalendarWeekView · 周视图 + +## 用途 + +一周的时间网格,顶部为全天行。在空白处拖动新建事件,拖动事件块移动,拖动底边调整时长;重叠事件按列并排。 + +## 最小示例 + +```go +loc := time.Local +events := exampleEvents(loc) +week := mujica.Date{Year: 2026, Month: time.October, Day: 14} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15}) + if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate { + fmt.Println("new event", ch.Start, ch.End) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CalendarWeekView(c *ui.Context, week *Date, events []CalendarEvent, opts CalendarWeekViewOptions) CalendarViewResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 | +| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 | +| `SlotMinutes` | `int` | 0(即 30) | 拖动与键盘的时间格长度,须整除一小时。 | +| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 | +| `Now` | `time.Time` | 零值(系统时钟) | 当前时间线的位置。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、悬停、聚焦列(时间格光标:选中底色 + 内嵌框)、拖动中(新建区域虚线框、被拖事件内嵌框)、今日表头、当前时间线、禁用;无法表示的时间显示错误。 + +## 事件 + +`Changed()` 返回 `CalendarCreate`、`CalendarMove` 或 `CalendarResize`;`Opened()` 返回点击的事件;`DayChosen()` 返回点击的表头日期。 + +## 键盘操作 + +- Tab 聚焦某一天的列,↑/↓ 移动时间格光标,Enter 新建一格长的事件。 +- 事件块上 Alt+↑/↓ 按格移动,Alt+←/→ 移动一天,Alt+Shift+↑/↓ 调整结束时间。 + +## 限制 + +- 调整时长的底边手柄只有 6 DIP 高,低于 28 DIP 命中区域要求;同样的操作可用事件块上的 Alt+Shift+↑/↓ 完成。 +- 网格高 24 小时,默认滚动到 8:00;跨越 24 小时以上的事件放在全天行。 +- 时间按墙上时间排布,夏令时当天的重复或缺失小时不改变格高。 +- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。 +- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。 diff --git a/docs/components/423-calendar-day-view.md b/docs/components/423-calendar-day-view.md new file mode 100644 index 0000000..e5e67e0 --- /dev/null +++ b/docs/components/423-calendar-day-view.md @@ -0,0 +1,53 @@ +# 423 CalendarDayView · 日视图 + +## 用途 + +单日时间网格;重叠事件按列并排,交互与周视图相同。 + +## 最小示例 + +```go +loc := time.Local +events := exampleEvents(loc) +day := mujica.Date{Year: 2026, Month: time.October, Day: 14} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok { + fmt.Println("open", id) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CalendarDayView(c *ui.Context, day *Date, events []CalendarEvent, opts CalendarDayViewOptions) CalendarViewResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 | +| `SlotMinutes` | `int` | 0(即 30) | 拖动与键盘的时间格长度,须整除一小时。 | +| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 | +| `Now` | `time.Time` | 零值(系统时钟) | 当前时间线的位置。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +同周视图。 + +## 事件 + +同周视图:`Changed()`、`Opened()`、`DayChosen()`;前一天/后一天修改 `*day`。 + +## 键盘操作 + +- 同周视图。 + +## 限制 + +- 与周视图共用网格实现。 +- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。 +- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。 diff --git a/docs/components/424-agenda-view.md b/docs/components/424-agenda-view.md new file mode 100644 index 0000000..ec0f37f --- /dev/null +++ b/docs/components/424-agenda-view.md @@ -0,0 +1,53 @@ +# 424 AgendaView · 日程列表 + +## 用途 + +按日分组的日程列表,日期标题在滚动时固定在顶部;只构建可见行,适合大量事件。 + +## 最小示例 + +```go +loc := time.Local +events := exampleEvents(loc) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14} + if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok { + fmt.Println("open", id) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AgendaView(c *ui.Context, events []CalendarEvent, opts AgendaViewOptions) CalendarViewResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 | +| `From` | `Date` | 零值(今天) | 列表的第一天。 | +| `Days` | `int` | 0(即 30) | 列出的天数。 | +| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、悬停、聚焦、今日标题(强调色 + “今天”)、空状态、禁用。 + +## 事件 + +`Opened()` 返回被激活事件的 ID。 + +## 键盘操作 + +- Tab 进入列表,↑/↓ 在事件间移动,Enter 打开。 + +## 限制 + +- 跨越多天的事件在每一天下各列一次。 +- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。 +- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。 diff --git a/docs/components/425-year-view.md b/docs/components/425-year-view.md new file mode 100644 index 0000000..52b4c49 --- /dev/null +++ b/docs/components/425-year-view.md @@ -0,0 +1,52 @@ +# 425 YearView · 年视图 + +## 用途 + +十二个月的小日历,每天下方用 1~3 个点表示事件密度,读屏会读出事件数量。 + +## 最小示例 + +```go +loc := time.Local +events := exampleEvents(loc) +year := 2026 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok { + fmt.Println("go to", day) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func YearView(c *ui.Context, year *int, events []CalendarEvent, opts YearViewOptions) CalendarViewResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Version` | `int` | 0 | 事件在原切片内被修改时须改变;换新切片会被自动发现。 | +| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 | +| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、悬停、聚焦、今日(底色 + 加粗)、密度点(1~2 项一点、3~5 项两点、6 项以上三点)、禁用。 + +## 事件 + +`DayChosen()` 返回被激活的日期;上一年/下一年及跨年移动修改 `*year`。 + +## 键盘操作 + +- Tab 进入日期,←/→ 移动一天,↑/↓ 移动一周,PageUp/PageDown 移动一月,可跨年。 +- Enter 选择。 + +## 限制 + +- 密度不只靠颜色:用点数区分,并写入标签。 +- 时间按调用方给定的 `Location` 计算与显示;夏令时跳过或重复的墙上时间会报告,不自动移动。 +- 事件由调用方持有:视图只报告移动、调整与新建(`Changed()`),由调用方写回数据。 diff --git a/docs/components/426-event-chip.md b/docs/components/426-event-chip.md new file mode 100644 index 0000000..1f716e1 --- /dev/null +++ b/docs/components/426-event-chip.md @@ -0,0 +1,47 @@ +# 426 EventChip · 事件块 + +## 用途 + +显示单个事件:左侧色条、标题与时间,超出时截断为省略号。 + +## 最小示例 + +```go +loc := time.Local +ev := exampleEvents(loc)[0] +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() { + fmt.Println("open", ev.ID) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Inline` | `bool` | false | 开始时间与标题放在一行。 | +| `Selected` | `bool` | false | 选中:选中底色 + 内嵌框。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | + +## 状态 + +默认、悬停与按下(色调加深)、聚焦、选中、禁用。 + +## 事件 + +返回的元素可用 `.Clicked()` 判断激活。 + +## 键盘操作 + +- Tab 聚焦,Enter 或 Space 激活。 + +## 限制 + +- 事件颜色来自主题:AccentText、Info、Success、Warning、Danger、TextMuted,均在两套主题下对 Surface 达到 3:1;Ornament 在浅色主题下仅 2.8:1,不纳入。 diff --git a/docs/components/427-event-editor.md b/docs/components/427-event-editor.md new file mode 100644 index 0000000..18300fc --- /dev/null +++ b/docs/components/427-event-editor.md @@ -0,0 +1,49 @@ +# 427 EventEditor · 事件编辑器 + +## 用途 + +编辑事件的标题、时间、地点、参与者、提醒与重复规则。编辑的是副本,点击保存并校验通过后才写回。 + +## 最小示例 + +```go +loc := time.Local +ev := exampleEvents(loc)[0] +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc}) + if r.Saved() { + fmt.Println("saved", ev.Title, ev.Start) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) EventEditorResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、字段错误(保存后显示:缺少标题、结束早于开始、重复规则问题)、全天模式(只选日期)、禁用。 + +## 事件 + +`Saved()`:校验通过并写回 `*ev`;`Canceled()`:丢弃草稿。 + +## 键盘操作 + +- Tab 在字段间移动,各字段保留自身按键;保存与取消为按钮。 + +## 限制 + +- 全天事件保存时规整为当天零点到次日零点。 +- 参与者按姓名添加,新参与者状态为“未回复”。 diff --git a/docs/components/428-event-popover.md b/docs/components/428-event-popover.md new file mode 100644 index 0000000..4f8639f --- /dev/null +++ b/docs/components/428-event-popover.md @@ -0,0 +1,52 @@ +# 428 EventPopover · 事件浮层 + +## 用途 + +在锚点旁显示事件摘要:时间、地点、重复、参与者与提醒,并提供编辑与删除入口。 + +## 最小示例 + +```go +loc := time.Local +ev := exampleEvents(loc)[0] +open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}) + if chip.Clicked() { + open = true + } + r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc}) + if r.Delete() { + fmt.Println("delete", ev.ID) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `ReadOnly` | `bool` | false | 隐藏编辑与删除。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | + +## 状态 + +打开(160 ms 淡入,减少动画时直接显示)、只读、禁用(自动关闭)。 + +## 事件 + +`Edit()`、`Delete()` 报告点击并关闭浮层;删除只报告,确认由调用方决定。 + +## 键盘操作 + +- Tab 到达关闭、删除与编辑;Escape 或点击外部关闭。 + +## 限制 + +- 浮层尺寸受窗口限制,内容超出时滚动。 diff --git a/docs/components/429-current-time-indicator.md b/docs/components/429-current-time-indicator.md new file mode 100644 index 0000000..eb9f42b --- /dev/null +++ b/docs/components/429-current-time-indicator.md @@ -0,0 +1,44 @@ +# 429 CurrentTimeIndicator · 当前时间线 + +## 用途 + +填满父元素,按当前时间在当天中的比例画一条横线,用于时间网格。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(200, 240).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CurrentTimeIndicator(c *ui.Context, opts CurrentTimeIndicatorOptions) *ui.Element` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Day` | `Date` | 零值(今天) | 父元素自上而下代表的日期;不是今天时不显示。 | +| `Now` | `time.Time` | 零值(系统时钟) | 当前时间;固定后不再定时刷新,便于测试。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | + +## 状态 + +显示(Danger 色线与菱形)、不在当天时隐藏。 + +## 事件 + +无事件;读屏通过标签读出当前时间。 + +## 键盘操作 + +- 不可聚焦。 + +## 限制 + +- 仅在被构建(可见)时用 `c.After` 预约下一分钟的重绘;隐藏后不再预约,自动停止。 diff --git a/docs/components/430-recurrence-editor.md b/docs/components/430-recurrence-editor.md new file mode 100644 index 0000000..e8c754a --- /dev/null +++ b/docs/components/430-recurrence-editor.md @@ -0,0 +1,46 @@ +# 430 RecurrenceEditor · 重复规则编辑器 + +## 用途 + +选择每天、每周、每月、每年或自定义间隔,每周可选星期,并设置结束条件(永不、次数、日期)。 + +## 最小示例 + +```go +rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}}) + if r.Changed() && r.Problem() == "" { + fmt.Println(mujica.RecurrenceSummary(c, rule)) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func RecurrenceEditor(c *ui.Context, rule *Recurrence, opts RecurrenceEditorOptions) RecurrenceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Start` | `Date` | 零值 | 首次发生日期;结束日期不能早于它,每周规则默认取其星期。 | +| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、自定义(显示间隔与单位)、每周(星期按钮)、结束条件、问题提示(无星期、次数小于 1、结束日期过早)、禁用。 + +## 事件 + +`Changed()` 报告规则变化;`Problem()` 返回当前不可保存的原因;`RecurrenceSummary` 生成本地化描述。 + +## 键盘操作 + +- Tab 在重复选择、间隔、星期与结束条件之间移动,各控件保留自身按键。 + +## 限制 + +- 只描述规则,不展开具体日期。 diff --git a/docs/components/431-availability-picker.md b/docs/components/431-availability-picker.md new file mode 100644 index 0000000..98c219f --- /dev/null +++ b/docs/components/431-availability-picker.md @@ -0,0 +1,49 @@ +# 431 AvailabilityPicker · 空闲时段选择 + +## 用途 + +按天与时间的网格,拖动涂抹自己的空闲时段,并显示其他人在每个时段的空闲人数。 + +## 最小示例 + +```go +days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}} +mine := [][]bool{make([]bool, 18), make([]bool, 18)} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() { + fmt.Println("availability changed") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AvailabilityPicker(c *ui.Context, mine *[][]bool, opts AvailabilityPickerOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Days` | `[]Date` | 必填 | 网格的列。 | +| `From / To` | `int` | 9 / 18 | 起止小时,0 ≤ From < To ≤ 24。 | +| `SlotMinutes` | `int` | 0(即 30) | 时段长度。 | +| `Others` | `[]AvailabilityPerson` | nil | 其他人的空闲表,维度须与值一致。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、自己空闲(选中底色 + 勾)、他人空闲(数量 + 条形)、全员可行(加粗强调)、键盘光标(内嵌框)、禁用。 + +## 事件 + +`Changed()` 报告自己的空闲表变化。 + +## 键盘操作 + +- Tab 聚焦网格,方向键移动光标,Space 或 Enter 切换。 + +## 限制 + +- 值的维度为 [天][时段],与选项不符时 panic。 diff --git a/docs/components/432-timezone-select.md b/docs/components/432-timezone-select.md new file mode 100644 index 0000000..90662d6 --- /dev/null +++ b/docs/components/432-timezone-select.md @@ -0,0 +1,46 @@ +# 432 TimezoneSelect · 时区选择 + +## 用途 + +按城市名或偏移搜索 IANA 时区,常用时区置顶,显示当前偏移。 + +## 最小示例 + +```go +zone := "Asia/Shanghai" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() { + fmt.Println("zone", zone) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Zones` | `[]string` | 内置世界列表 | 可选时区。 | +| `Common` | `[]string` | 内置常用列表 | 置顶的常用时区。 | +| `Now` | `time.Time` | 零值(系统时钟) | 计算偏移所用的时刻(夏令时会改变偏移)。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +关闭、打开(搜索框 + 分组列表)、高亮、已选(勾)、无结果、禁用。 + +## 事件 + +`Changed()` 报告选中的时区变化。 + +## 键盘操作 + +- Enter、Space 或 ↓ 打开;输入搜索;↑/↓ 移动高亮;Enter 选择;Escape 关闭。 + +## 限制 + +- 内嵌 `time/tzdata`,离线可用;未知时区名 panic。 diff --git a/docs/components/433-reminder-picker.md b/docs/components/433-reminder-picker.md new file mode 100644 index 0000000..497de8c --- /dev/null +++ b/docs/components/433-reminder-picker.md @@ -0,0 +1,45 @@ +# 433 ReminderPicker · 提醒选择 + +## 用途 + +多选事件前的提醒时间,按选项顺序保存。 + +## 最小示例 + +```go +reminders := []time.Duration{15 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() { + fmt.Println("reminders", reminders) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ReminderPicker(c *ui.Context, value *[]time.Duration, opts ReminderPickerOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Choices` | `[]time.Duration` | 0、5 分、15 分、30 分、1 时、1 天、1 周 | 可选的提前量,须为整分钟且不重复。 | +| `Max` | `int` | 0(不限) | 最多选择的数量,达到后其余选项禁用。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、选中(选中底色 + 下划线标记)、达到上限的禁用项、禁用。 + +## 事件 + +`Changed()` 报告选择变化。 + +## 键盘操作 + +- Tab 进入列表,↑/↓ 移动,Space 切换。 + +## 限制 + +- 只支持事件开始前的提醒;提前量须为整分钟,否则 panic。 diff --git a/docs/components/434-attendee-list.md b/docs/components/434-attendee-list.md new file mode 100644 index 0000000..7d22502 --- /dev/null +++ b/docs/components/434-attendee-list.md @@ -0,0 +1,44 @@ +# 434 AttendeeList · 参与者列表 + +## 用途 + +列出参与者、头像、邮箱与回复状态(文字 + 图标),顶部汇总各状态人数。 + +## 最小示例 + +```go +people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok { + people = append(people[:i], people[i+1:]...) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func AttendeeList(c *ui.Context, attendees []Attendee, opts AttendeeListOptions) AttendeeListResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Removable` | `bool` | false | 除组织者外显示移除按钮。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +接受、待定、拒绝、未回复(各有图标与文字)、组织者、可选、空状态、禁用。 + +## 事件 + +`Removed()` 返回被移除者的下标,由调用方从切片中删除。 + +## 键盘操作 + +- Tab 到达各移除按钮,Enter 移除。 + +## 限制 + +- 回复状态不由组件修改。 diff --git a/docs/components/435-week-picker.md b/docs/components/435-week-picker.md new file mode 100644 index 0000000..bd8f730 --- /dev/null +++ b/docs/components/435-week-picker.md @@ -0,0 +1,47 @@ +# 435 WeekPicker · 周选择器 + +## 用途 + +选择整周:悬停时预览整周,值为该周第一天。 + +## 最小示例 + +```go +var week mujica.Date +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() { + fmt.Println("week of", week) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func WeekPicker(c *ui.Context, value *Date, opts WeekPickerOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `WeekStart` | `WeekStart` | `WeekStartLocale` | 周起始日:跟随语言或周日、周一、周六。 | +| `Min / Max` | `Date` | 零值 | 所选周必须落在范围内。 | +| `Today` | `Date` | 零值(系统时钟) | 标记为今天的日期。 | +| `Clearable` | `bool` | false | 显示清空按钮。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +关闭、打开(整周预览带、已选周)、禁用。 + +## 事件 + +`Changed()` 报告选中周变化。 + +## 键盘操作 + +- Enter、Space 或 ↓ 打开;方向键移动;Enter 选择所在周;Escape 关闭。 + +## 限制 + +- 值不是周起始日时 panic;更换周起始日后由调用方重设值。 diff --git a/docs/components/436-duration-picker.md b/docs/components/436-duration-picker.md new file mode 100644 index 0000000..cd5034c --- /dev/null +++ b/docs/components/436-duration-picker.md @@ -0,0 +1,46 @@ +# 436 DurationPicker · 时长选择器 + +## 用途 + +以时、分、秒三个数字框编辑时长,并检查范围。 + +## 最小示例 + +```go +length := 90 * time.Minute +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour} + if mujica.DurationPicker(c, &length, opts).Changed() { + fmt.Println("length", length) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func DurationPicker(c *ui.Context, value *time.Duration, opts DurationPickerOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Seconds` | `bool` | false | 显示秒字段;关闭时值须为整分钟。 | +| `Min / Max` | `time.Duration` | 0 / 99 时 59 分 59 秒 | 允许的范围。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +默认、聚焦、超出范围(错误说明,值不写入)、禁用。 + +## 事件 + +`Changed()` 报告合法的新时长。 + +## 键盘操作 + +- Tab 在三个字段间移动;↑/↓ 步进;键入数字后 Enter。 + +## 限制 + +- 超出范围的输入保留并报告,不会被夹取。 diff --git a/docs/components/437-cron-editor.md b/docs/components/437-cron-editor.md new file mode 100644 index 0000000..8861cb4 --- /dev/null +++ b/docs/components/437-cron-editor.md @@ -0,0 +1,51 @@ +# 437 CronEditor · Cron 编辑器 + +## 用途 + +编辑标准 5 字段 cron 表达式,逐字段解释、就地报告错误,并预览接下来的运行时间。 + +## 最小示例 + +```go +expr := "0 9 * * MON-FRI" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() { + if _, err := mujica.ParseCron(expr); err == nil { + fmt.Println("schedule", expr) + } + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func CronEditor(c *ui.Context, expr *string, opts CronEditorOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Now` | `time.Time` | 零值(系统时钟) | 预览的起点。 | +| `Runs` | `int` | 0(即 5) | 预览的次数。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +有效(字段卡片 + 运行时间)、错误(出错字段红框 + 说明,不显示预览)、禁用。 + +## 事件 + +`Changed()` 报告表达式文本变化;`ParseCron` 可单独校验并计算 `Next`。 + +## 键盘操作 + +- 在表达式框中输入;解释与预览随输入更新。 + +## 限制 + +- 支持列表、范围、步长、`*` 与月份/星期英文缩写;日与星期同时受限时任一匹配即运行(cron 惯例)。 +- 夏令时跳过的时间不运行;重复的时间取第一次并标注。 +- 只搜索未来五年。 diff --git a/docs/components/438-relative-time.md b/docs/components/438-relative-time.md new file mode 100644 index 0000000..7e218bd --- /dev/null +++ b/docs/components/438-relative-time.md @@ -0,0 +1,42 @@ +# 438 RelativeTime · 相对时间 + +## 用途 + +把时间显示为“3 分钟前”“2 天后”,并在提示与标签中给出精确时间。 + +## 最小示例 + +```go +posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Location` | `*time.Location` | 必填 | 切分日期与显示时间所用的时区;为 nil 时 panic,不做隐式 UTC 转换。 | +| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 | +| `Hour12` | `bool` | false | 12 小时制显示时间。 | + +## 状态 + +刚刚、分钟、小时、天、月、年。 + +## 事件 + +无事件。 + +## 键盘操作 + +- 不可聚焦;精确时间写在标签与提示中。 + +## 限制 + +- 刷新频率随距离变化:2 分钟内每秒、2 小时内每分钟、更远每小时;仅在可见时用 `c.After` 预约,隐藏即停止。 diff --git a/docs/components/439-countdown.md b/docs/components/439-countdown.md new file mode 100644 index 0000000..31b8db2 --- /dev/null +++ b/docs/components/439-countdown.md @@ -0,0 +1,44 @@ +# 439 Countdown · 倒计时 + +## 用途 + +显示距截止时间的剩余时间,归零时报告一次并朗读提示。 + +## 最小示例 + +```go +deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() { + fmt.Println("happy new year") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Countdown(c *ui.Context, deadline time.Time, opts CountdownOptions) CountdownResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Format` | `CountdownFormat` | `CountdownClock` | 时钟格式 01:02:03 或文字格式“1 小时 2 分 3 秒”。 | +| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +计时中、归零(强调色)。 + +## 事件 + +`Finished()` 在每个截止时间归零的那一帧报告一次。 + +## 键盘操作 + +- 不可聚焦;归零时通过 `Announce` 通知读屏。 + +## 限制 + +- 仅在可见时每秒用 `c.After` 预约重绘,隐藏或归零后停止。 diff --git a/docs/components/440-stopwatch.md b/docs/components/440-stopwatch.md new file mode 100644 index 0000000..b74f9de --- /dev/null +++ b/docs/components/440-stopwatch.md @@ -0,0 +1,44 @@ +# 440 Stopwatch · 秒表 + +## 用途 + +秒表:开始、暂停、继续、计次与复位,计次列表显示单圈与累计时间。 + +## 最小示例 + +```go +var sw mujica.StopwatchState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() { + fmt.Println("laps", len(sw.Laps)) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Stopwatch(c *ui.Context, s *StopwatchState, opts StopwatchOptions) ChoiceResult` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Now` | `time.Time` | 零值(系统时钟) | 固定当前时间并停止刷新。 | +| `Disabled` | `bool` | false | 禁用:不响应点击、拖动与快捷键,弹层随之关闭。 | +| `Label` | `string` | 内置文案 | 辅助技术读出的名称。 | + +## 状态 + +未开始、运行中、暂停(显示“继续”)、有计次、禁用。 + +## 事件 + +`Changed()` 报告开始、暂停、计次或复位;`StopwatchState.Elapsed` 与 `Laps` 读取结果。 + +## 键盘操作 + +- Tab 到达开始/暂停、计次与复位,Enter 或 Space 按下。 + +## 限制 + +- 运行且可见时每 100 ms 用 `c.After` 预约重绘;暂停或隐藏后停止。 diff --git a/duration_picker.go b/duration_picker.go new file mode 100644 index 0000000..9cbec32 --- /dev/null +++ b/duration_picker.go @@ -0,0 +1,100 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" +) + +// DurationPickerOptions configure a DurationPicker. +type DurationPickerOptions struct { + // Seconds adds a seconds field. + Seconds bool + // Min and Max bound the duration; zero Max means 99:59:59. + Min, Max time.Duration + Disabled bool + Label string +} + +// DurationPicker edits hours, minutes and seconds within a range. +func DurationPicker(c *ui.Context, value *time.Duration, opts DurationPickerOptions) ChoiceResult { + hi := opts.Max + if hi == 0 { + hi = 99*time.Hour + 59*time.Minute + 59*time.Second + } + unit := time.Minute + if opts.Seconds { + unit = time.Second + } + switch { + case opts.Min < 0 || hi < opts.Min: + panic(fmt.Sprintf("mujica: DurationPicker range %v..%v is not valid", opts.Min, hi)) + case *value < 0 || *value%unit != 0: + panic(fmt.Sprintf("mujica: DurationPicker value %v is negative or finer than the fields", *value)) + } + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "duration_picker.label") + } + col := ui.Column(c).Gap(d.Unit()).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + draft := ui.Local(col, "duration-draft", func() [3]float64 { return durationParts(*value) }) + seen := ui.Local(col, "duration-seen", func() time.Duration { return *value }) + if *seen != *value { + *draft, *seen = durationParts(*value), *value + } + changed, edited := false, false + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + fields := []struct { + key string + max float64 + }{{"duration_picker.hours", math.Floor(hi.Hours())}, {"duration_picker.minutes", 59}, {"duration_picker.seconds", 59}} + for i, f := range fields { + if i == 2 && !opts.Seconds { + break + } + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() { + ui.Box(c).Width(d.ControlHeight() * 3.2).Children(func() { + r := NumberInput(c, &draft[i], NumberInputOptions{Min: 0, Max: f.max, Label: msg(c, f.key), Disabled: opts.Disabled}) + edited = edited || r.Changed() + }) + ui.Text(c, msg(c, f.key+"_unit")).TextColor(Tokens(c).TextMuted) + }) + } + }) + total := time.Duration(draft[0])*time.Hour + time.Duration(draft[1])*time.Minute + time.Duration(draft[2])*time.Second + problem := "" + if total < opts.Min || total > hi { + problem = fmt.Sprintf(msg(c, "duration_picker.range"), DurationText(c, opts.Min), DurationText(c, hi)) + } + if edited && problem == "" && total != *value && !col.IsDisabled() { + slog.Debug("mujica: duration_picker change", "from", *value, "to", total) + *value, *seen, changed = total, total, true + } + datetimeError(c, problem) + }) + return ChoiceResult{Element: col, changed: changed} +} + +func durationParts(v time.Duration) [3]float64 { + return [3]float64{float64(v / time.Hour), float64(v % time.Hour / time.Minute), float64(v % time.Minute / time.Second)} +} + +// DurationText words a duration, as "1 h 30 min". +func DurationText(c *ui.Context, v time.Duration) string { + p := durationParts(v) + s := "" + for i, key := range []string{"duration_picker.hours_unit", "duration_picker.minutes_unit", "duration_picker.seconds_unit"} { + if p[i] > 0 || i == 1 && s == "" && p[2] == 0 { + if s != "" { + s += " " + } + s += fmt.Sprintf("%d %s", int(p[i]), msg(c, key)) + } + } + return s +} diff --git a/duration_picker_test.go b/duration_picker_test.go new file mode 100644 index 0000000..391f708 --- /dev/null +++ b/duration_picker_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func durationTester(v *time.Duration, opts *DurationPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if DurationPicker(c, v, *opts).Changed() { + *n++ + } + }, 640, 200) +} + +func TestDurationPickerEditsFields(t *testing.T) { + v, n := 90*time.Minute, 0 + opts := &DurationPickerOptions{Seconds: true} + tt := durationTester(&v, opts, &n) + _ = tt.Click("Hours") + tt.Key(0, ui.KeyUp) + _ = tt.Click("Seconds") + tt.Command("selectAll") + tt.Type("45") + tt.Key(0, ui.KeyEnter) + if v != 2*time.Hour+30*time.Minute+45*time.Second || n != 2 { + t.Fatalf("value %v (%d)", v, n) + } +} + +func TestDurationPickerRangeReported(t *testing.T) { + v, n := 30*time.Minute, 0 + opts := &DurationPickerOptions{Min: 15 * time.Minute, Max: 2 * time.Hour} + tt := durationTester(&v, opts, &n) + _ = tt.Click("Hours") + tt.Key(0, ui.KeyUp) + if v != 90*time.Minute || n != 1 { + t.Fatalf("1 h 30 min should fit the range: %v", v) + } + tt.Key(0, ui.KeyUp) + if v != 90*time.Minute || !tt.HasText("Choose a duration from 15 min to 2 h.") { + t.Fatalf("over Max accepted: %v %v", v, tt.Texts()) + } + _ = tt.Click("Minutes") + tt.Command("selectAll") + tt.Type("0") + tt.Key(0, ui.KeyEnter) + if v != 2*time.Hour || n != 2 || tt.HasText("Choose a duration") { + t.Fatalf("fixing the minutes: %v", v) + } + if tt.HasText("Seconds") { + t.Fatal("seconds field without Seconds") + } +} + +func TestDurationPickerDisabledAndInvalid(t *testing.T) { + v, n := time.Hour, 0 + tt := durationTester(&v, &DurationPickerOptions{Disabled: true}, &n) + _ = tt.Click("Hours") + tt.Key(0, ui.KeyUp) + if v != time.Hour || n != 0 { + t.Fatal("disabled picker changed") + } + for _, c := range []struct { + v time.Duration + opts DurationPickerOptions + }{{30 * time.Second, DurationPickerOptions{}}, {0, DurationPickerOptions{Min: time.Hour, Max: time.Minute}}, {-time.Minute, DurationPickerOptions{}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v %+v: expected panic", c.v, c.opts) + } + }() + durationTester(&c.v, &c.opts, &n) + }() + } +} + +func TestDurationPickerSnapshot(t *testing.T) { + v := 2*time.Hour + 15*time.Minute + 30*time.Second + snapshot(t, "duration_picker", 640, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { DurationPicker(c, &v, DurationPickerOptions{Seconds: true}) }) + }) +} diff --git a/error_message.go b/error_message.go new file mode 100644 index 0000000..64847d3 --- /dev/null +++ b/error_message.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ErrorMessageOptions configure an ErrorMessage. +type ErrorMessageOptions struct { + // Title sums the failure up; empty uses the built-in "Something went wrong". + Title string + // RetryAt is when a rate limit lifts; until then retry waits with a countdown. + RetryAt time.Time +} + +// ErrorMessageResult is the error's element and a retry the user asked for. +type ErrorMessageResult struct { + Element *ui.Element + retried bool +} + +// Retried reports a press of retry. +func (r ErrorMessageResult) Retried() bool { return r.retried } + +// ErrorMessage shows a failed reply with the original error text, selectable, and retry. +func ErrorMessage(c *ui.Context, text string, opts ErrorMessageOptions) ErrorMessageResult { + if text == "" { + panic("mujica: ErrorMessage needs the error text") + } + k := Tokens(c) + u := Density(c).Unit() + title := opts.Title + if title == "" { + title = msg(c, "error_message.title") + } + wait := opts.RetryAt.Sub(c.Now()) + var r ErrorMessageResult + r.Element = ui.Column(c).Gap(u*2).Padding(u*3, u*4).MaxWidth(560).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Danger).Role(ui.RoleGroup).Label(title) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0).TextColor(k.Danger) + ui.Text(c, title).FontWeight(600).TextColor(k.Danger) + }) + ui.Text(c, text).Selectable().Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text) + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() { + label := msg(c, "error_message.retry") + if wait > 0 { + secs := int(math.Ceil(wait.Seconds())) + label = fmt.Sprintf(msg(c, "error_message.wait"), secs) + // Wake when the shown second changes. + c.After(wait - time.Duration(secs-1)*time.Second) + } + if Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw"), Disabled: wait > 0}).Clicked() && !disabled { + r.retried = true + slog.Debug("mujica: error_message retry") + } + if wait > 0 { + ui.Text(c, msg(c, "error_message.limited")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + }) + return r +} diff --git a/error_message_test.go b/error_message_test.go new file mode 100644 index 0000000..08afeac --- /dev/null +++ b/error_message_test.go @@ -0,0 +1,69 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +const errorMessageText = `429 Too Many Requests: {"error":"rate_limit","retry_after":30}` + +func TestErrorMessageKeepsTextAndRetries(t *testing.T) { + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ErrorMessage(c, errorMessageText, ErrorMessageOptions{}).Retried() { + retries++ + } + }, 500, 200) + if !tt.HasText(errorMessageText) || !tt.HasText("Something went wrong") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Retry") + if retries != 1 { + t.Fatalf("retries %d", retries) + } +} + +func TestErrorMessageRateLimitCountdown(t *testing.T) { + retries := 0 + until := time.Now().Add(2500 * time.Millisecond) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ErrorMessage(c, errorMessageText, ErrorMessageOptions{Title: "Rate limited by the API", RetryAt: until}).Retried() { + retries++ + } + }, 500, 200) + if !tt.HasText("Retry in 3 s") { + t.Fatalf("countdown missing: %q", tt.Texts()) + } + _ = tt.Click("Retry in 3 s") + if retries != 0 { + t.Fatal("retried during the rate limit") + } + until = time.Now().Add(-time.Second) + tt.Frame() + _ = tt.Click("Retry") + if retries != 1 { + t.Fatalf("retries %d after the limit lifted", retries) + } +} + +func TestErrorMessageNeedsText(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without error text") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ErrorMessage(c, "", ErrorMessageOptions{}) + }, 100, 100) +} + +func TestErrorMessageSnapshot(t *testing.T) { + snapshot(t, "error_message", 520, 150, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { ErrorMessage(c, errorMessageText, ErrorMessageOptions{}) }) + }) +} diff --git a/event_chip.go b/event_chip.go new file mode 100644 index 0000000..cb81b1c --- /dev/null +++ b/event_chip.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// EventChipOptions configure an EventChip. +type EventChipOptions struct { + // Location is the zone the time shows in; required. + Location *time.Location + Hour12 bool + // Inline puts the start time and title on one line. + Inline bool + Selected bool + Disabled bool +} + +// EventChip is a clickable, truncating event block. +func EventChip(c *ui.Context, ev CalendarEvent, opts EventChipOptions) *ui.Element { + loc := eventLocation("EventChip", opts.Location) + k := Tokens(c) + d := Density(c) + col := eventColor(k, ev.Color) + when := eventTimeText(c, ev, loc, opts.Hour12) + title := ev.Title + if title == "" { + title = msg(c, "event_chip.untitled") + } + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Justify(ui.Start).AlignItems(ui.Start). + Radius(theme.ControlRadius).Padding(d.Unit(), d.Unit()*2).PaddingX(d.Unit() * 2).Gap(d.Unit()). + Label(title + ", " + when).Disabled(opts.Disabled).Clip() + internal.Ring(b, k.Focus, theme.ControlRadius) + bg := col.Alpha(0.14).Over(k.Surface) + switch { + case opts.Selected: + bg = k.Selection + case b.Pressed(): + bg = col.Alpha(0.24).Over(k.Surface) + case b.Hovered(): + bg = col.Alpha(0.2).Over(k.Surface) + } + b.Background(bg).Transition(colorFade) + if !opts.Inline { + b.Column() + } + b.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, col, 0) + }) + selected := opts.Selected + b.DrawOver(func(p *ui.Painter, r ui.Rect) { + if selected { + datetimeSelectedMark(p, r, col) + } + }) + b.Children(func() { + small := FontSize(c, theme.CaptionSize) + if opts.Inline { + ui.Text(c, eventStartText(c, ev, loc, opts.Hour12)).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(0).FontFeatures("tnum") + } + ui.Text(c, title).FontSize(small).Bold().TextColor(k.Text).SingleLine().Ellipsis("…").Shrink(1) + if !opts.Inline { + ui.Text(c, when).FontSize(small).TextColor(k.TextMuted).SingleLine().Ellipsis("…").FontFeatures("tnum") + } + }) + return b +} + +// eventStartText is the start time alone, or "All day". +func eventStartText(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) string { + if ev.AllDay { + return msg(c, "event_chip.all_day") + } + s := ev.Start.In(loc) + return timeText(c, TimeOfDay{s.Hour(), s.Minute(), 0}, timeConfig{hour12: hour12}) +} diff --git a/event_chip_test.go b/event_chip_test.go new file mode 100644 index 0000000..ba6fbf7 --- /dev/null +++ b/event_chip_test.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +var calTokyo = func() *time.Location { + loc, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + panic(err) + } + return loc +}() + +// calEvent is an event on calendarToday's month in Tokyo. +func calEvent(id string, day, h, m, mins int) CalendarEvent { + s := time.Date(2026, time.October, day, h, m, 0, 0, calTokyo) + return CalendarEvent{ID: id, Title: "Event " + id, Start: s, End: s.Add(time.Duration(mins) * time.Minute)} +} + +func TestEventChipShowsZoneTimeAndClicks(t *testing.T) { + ev := calEvent("a", 6, 9, 30, 90) + ev.Start, ev.End = ev.Start.UTC(), ev.End.UTC() + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if EventChip(c, ev, EventChipOptions{Location: calTokyo}).Clicked() { + n++ + } + }, 300, 200) + if !tt.HasText("09:30 – 11:00") || !tt.HasText("Event a") { + t.Fatalf("chip should show Tokyo wall time: %v", tt.Texts()) + } + label := "Event a, 09:30 – 11:00" + _ = tt.Click(label) + tt.Key(0, ui.KeyEnter) + if n != 2 { + t.Fatalf("clicks %d", n) + } +} + +func TestEventChipTruncatesAndAllDay(t *testing.T) { + ev := calEvent("b", 6, 0, 0, 24*60) + ev.AllDay, ev.Title = true, strings.Repeat("Long title ", 20) + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(120).Children(func() { + w = EventChip(c, ev, EventChipOptions{Location: calTokyo, Inline: true}).Bounds().W + }) + }, 300, 200) + tt.Frame() + if w > 120 || !tt.HasText("All day") { + t.Fatalf("width %v texts %v", w, tt.Texts()) + } +} + +func TestEventChipDisabledAndInvalid(t *testing.T) { + ev := calEvent("c", 6, 9, 0, 30) + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if EventChip(c, ev, EventChipOptions{Location: calTokyo, Disabled: true}).Clicked() { + n++ + } + }, 300, 200) + _ = tt.Click("Event c, 09:00 – 09:30") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("disabled chip clicked") + } + for _, f := range []func(){ + func() { EventChip(nil, ev, EventChipOptions{}) }, + func() { ev.Color = 9; eventColor(theme.Light(), ev.Color) }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("expected panic") + } + }() + f() + }() + } +} + +func TestEventPaletteContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + for i, col := range eventPalette(k) { + if r := theme.Contrast(col, k.Surface); r < 3 { + t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r) + } + } + } +} + +func TestEventColumns(t *testing.T) { + col, cols := eventColumns([]eventSpan{{0, 2}, {1, 3}, {1.5, 2.5}, {2, 4}, {5, 6}}) + want := []int{0, 1, 2, 0, 0} + wantN := []int{3, 3, 3, 3, 1} + for i := range want { + if col[i] != want[i] || cols[i] != wantN[i] { + t.Fatalf("columns %v of %v", col, cols) + } + } +} + +func TestEventChipSnapshot(t *testing.T) { + evs := []CalendarEvent{calEvent("1", 6, 9, 0, 60), calEvent("2", 6, 13, 0, 30), calEvent("3", 6, 15, 0, 45)} + evs[1].Color, evs[2].Color = EventSuccess, EventWarning + snapshot(t, "event_chip", 420, 150, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(8).Children(func() { + EventChip(c, evs[0], EventChipOptions{Location: calTokyo}) + EventChip(c, evs[1], EventChipOptions{Location: calTokyo, Selected: true}) + EventChip(c, evs[2], EventChipOptions{Location: calTokyo, Inline: true}) + }) + }) +} diff --git a/event_editor.go b/event_editor.go new file mode 100644 index 0000000..66ce4da --- /dev/null +++ b/event_editor.go @@ -0,0 +1,171 @@ +package mujica + +import ( + "log/slog" + "slices" + "strings" + "time" + + "github.com/egoist/mygo/ui" +) + +// EventEditorOptions configure an EventEditor. +type EventEditorOptions struct { + // Location is the zone times are edited in; required. + Location *time.Location + Hour12 bool + WeekStart WeekStart + Disabled bool + Label string +} + +// EventEditorResult is the editor and its save or cancel. +type EventEditorResult struct { + Element *ui.Element + saved bool + canceled bool +} + +// Saved reports a valid draft written to the event. +func (r EventEditorResult) Saved() bool { return r.saved } + +// Canceled reports that the user discarded the draft. +func (r EventEditorResult) Canceled() bool { return r.canceled } + +// eventEditorState is the draft and whether Save was tried. +type eventEditorState struct { + draft CalendarEvent + tried bool + origin string +} + +// EventEditor edits a copy of *ev; Save checks and writes. +func EventEditor(c *ui.Context, ev *CalendarEvent, opts EventEditorOptions) EventEditorResult { + loc := eventLocation("EventEditor", opts.Location) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "event_editor.label") + } + r := EventEditorResult{} + root := ui.Column(c).Gap(d.Unit() * 4).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + st := ui.Local(root, "event-editor", func() *eventEditorState { return nil }) + if *st == nil || (*st).origin != ev.ID { + *st = &eventEditorState{draft: eventCopy(*ev), origin: ev.ID} + } + s := *st + dr := &s.draft + problems := eventEditorProblems(c, *dr, s.tried) + root.Children(func() { + FormField(c, msg(c, "event_editor.title"), FormFieldOptions{Required: true, Error: problems["title"]}, func() *ui.Element { + return TextInput(c, &dr.Title, TextInputOptions{Label: msg(c, "event_editor.title"), Disabled: opts.Disabled, Error: problems["title"]}).Input + }) + Checkbox(c, &dr.AllDay, msg(c, "event_editor.all_day"), CheckboxOptions{Disabled: opts.Disabled}) + ui.Row(c).Gap(d.Unit() * 3).Wrap().AlignItems(ui.Start).Children(func() { + for _, f := range []struct { + key string + t *time.Time + }{{"event_editor.starts", &dr.Start}, {"event_editor.ends", &dr.End}} { + FormField(c, msg(c, f.key), FormFieldOptions{Error: problems[f.key]}, func() *ui.Element { + if dr.AllDay { + day := DateOf(f.t.In(loc)) + r := DatePicker(c, &day, DatePickerOptions{Label: msg(c, f.key), WeekStart: opts.WeekStart, Disabled: opts.Disabled}) + if r.Changed() { + *f.t = eventDayStart(day, loc) + } + return r.Input + } + return DateTimePicker(c, f.t, DateTimePickerOptions{Location: loc, Hour12: opts.Hour12, MinuteStep: 5, + WeekStart: opts.WeekStart, Label: msg(c, f.key), Disabled: opts.Disabled}).Element + }) + } + }) + FormField(c, msg(c, "event_editor.where"), FormFieldOptions{}, func() *ui.Element { + return TextInput(c, &dr.Location, TextInputOptions{Label: msg(c, "event_editor.where"), Disabled: opts.Disabled}).Input + }) + FormField(c, msg(c, "event_editor.guests"), FormFieldOptions{Description: msg(c, "event_editor.guests_help")}, func() *ui.Element { + names := make([]string, len(dr.Attendees)) + for i, a := range dr.Attendees { + names[i] = a.Name + } + p := TagsInput(c, &names, TagsInputOptions{Label: msg(c, "event_editor.guests"), Disabled: opts.Disabled}) + if p.Changed() { + dr.Attendees = eventAttendees(dr.Attendees, names) + } + return p.Input + }) + if len(dr.Attendees) > 0 { + AttendeeList(c, dr.Attendees, AttendeeListOptions{Disabled: opts.Disabled}) + } + FormField(c, msg(c, "event_editor.reminders"), FormFieldOptions{}, func() *ui.Element { + return ReminderPicker(c, &dr.Reminders, ReminderPickerOptions{Disabled: opts.Disabled}).Element + }) + var rec RecurrenceResult + FormField(c, msg(c, "recurrence_editor.repeat"), FormFieldOptions{}, func() *ui.Element { + rec = RecurrenceEditor(c, &dr.Repeat, RecurrenceEditorOptions{Start: DateOf(dr.Start.In(loc)), WeekStart: opts.WeekStart, Disabled: opts.Disabled}) + return rec.Element + }) + datetimeFooter(c, func() { + if Button(c, msg(c, "calendar.cancel"), ButtonOptions{Variant: Secondary}).Clicked() && !root.IsDisabled() { + slog.Debug("mujica: event_editor cancel", "id", ev.ID) + *st, r.canceled = nil, true + } + if Button(c, msg(c, "event_editor.save"), ButtonOptions{}).Clicked() && !root.IsDisabled() { + s.tried = true + if len(eventEditorProblems(c, *dr, true)) == 0 && rec.Problem() == "" { + slog.Debug("mujica: event_editor save", "id", ev.ID) + out := eventCopy(*dr) + if out.AllDay { + first, last := DateOf(out.Start.In(loc)), DateOf(out.End.Add(-time.Nanosecond).In(loc)) + out.Start, out.End = eventDayStart(first, loc), eventDayStart(last.AddDays(1), loc) + } + *ev, r.saved = out, true + } else { + slog.Debug("mujica: event_editor invalid", "id", ev.ID) + } + } + }) + }) + return r +} + +// eventEditorProblems checks a draft once Save was tried. +func eventEditorProblems(c *ui.Context, ev CalendarEvent, tried bool) map[string]string { + p := map[string]string{} + if !tried { + return p + } + if strings.TrimSpace(ev.Title) == "" { + p["title"] = msg(c, "event_editor.need_title") + } + if ev.Start.IsZero() { + p["event_editor.starts"] = msg(c, "event_editor.need_start") + } + if !ev.End.After(ev.Start) { + p["event_editor.ends"] = msg(c, "event_editor.end_order") + } + return p +} + +// eventAttendees keeps known attendees and adds new ones. +func eventAttendees(old []Attendee, names []string) []Attendee { + out := make([]Attendee, 0, len(names)) + for _, n := range names { + i := slices.IndexFunc(old, func(a Attendee) bool { return a.Name == n }) + if i >= 0 { + out = append(out, old[i]) + } else { + out = append(out, Attendee{Name: n}) + } + } + return out +} + +// eventCopy clones an event's slices for a draft. +func eventCopy(ev CalendarEvent) CalendarEvent { + ev.Attendees = slices.Clone(ev.Attendees) + ev.Reminders = slices.Clone(ev.Reminders) + ev.Repeat.Weekdays = slices.Clone(ev.Repeat.Weekdays) + return ev +} diff --git a/event_editor_test.go b/event_editor_test.go new file mode 100644 index 0000000..0c14ffe --- /dev/null +++ b/event_editor_test.go @@ -0,0 +1,112 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type editorProbe struct { + ev CalendarEvent + saves, cancels int + disabled bool +} + +func (p *editorProbe) tester() *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Scroll(c).Fill().Padding(16).Children(func() { + r := EventEditor(c, &p.ev, EventEditorOptions{Location: calTokyo, Disabled: p.disabled}) + if r.Saved() { + p.saves++ + } + if r.Canceled() { + p.cancels++ + } + }) + }, 900, 1400) +} + +func TestEventEditorValidatesAndSaves(t *testing.T) { + p := &editorProbe{ev: CalendarEvent{ID: "n", Start: calAt(t, 9, 0, 14), End: calAt(t, 9, 0, 13)}} + tt := p.tester() + _ = tt.Click("Save") + if p.saves != 0 || !tt.HasText("Enter a title.") || !tt.HasText("The end must be after the start.") { + t.Fatalf("invalid draft saved: %v", tt.Texts()) + } + _ = tt.Click("Title") + tt.Type("Masquerade") + _ = tt.Click("Location") + tt.Type("Hall") + _ = tt.Click("Guests") + tt.Type("Mutsumi") + tt.Key(0, ui.KeyEnter) + _ = tt.Click("15 minutes before") + _ = tt.Click("2026-10-13 09:00 JST") + _ = tt.Click(datetimeTestLabel(oct(14))) + _ = tt.Click("Time: Hours") + tt.Key(0, ui.KeyUp) + _ = tt.Click("Confirm") + if p.ev.Title != "" { + t.Fatal("the caller's event changed before Save") + } + _ = tt.Click("Save") + ev := p.ev + if p.saves != 1 || ev.Title != "Masquerade" || ev.Location != "Hall" || len(ev.Attendees) != 1 || ev.Attendees[0].Name != "Mutsumi" || + len(ev.Reminders) != 1 || !ev.End.Equal(calAt(t, 10, 0, 14)) { + t.Fatalf("saved %+v", ev) + } +} + +func TestEventEditorAllDayRepeatCancel(t *testing.T) { + p := &editorProbe{ev: calEvent("e", 14, 9, 0, 60)} + tt := p.tester() + _ = tt.Click("All day") + _ = tt.Click("Does not repeat") + _ = tt.Click("Weekly") + _ = tt.Click("Save") + if p.saves != 1 || !p.ev.AllDay || p.ev.Repeat.Unit != RepeatWeekly || p.ev.Repeat.Weekdays[0] != time.Wednesday || + !p.ev.Start.Equal(calAt(t, 0, 0, 14)) || !p.ev.End.Equal(calAt(t, 0, 0, 15)) { + t.Fatalf("saved %+v", p.ev) + } + _ = tt.Click("Title") + tt.Type(" draft") + _ = tt.Click("Cancel") + if p.cancels != 1 || p.ev.Title != "Event e" { + t.Fatalf("cancel kept %q", p.ev.Title) + } + tt.Frame() + _ = tt.Click("Save") + if p.ev.Title != "Event e" { + t.Fatal("canceled draft came back") + } +} + +func TestEventEditorDisabled(t *testing.T) { + p := &editorProbe{ev: calEvent("e", 14, 9, 0, 60), disabled: true} + tt := p.tester() + _ = tt.Click("Title") + tt.Type("x") + _ = tt.Click("Save") + _ = tt.Click("Cancel") + if p.saves != 0 || p.cancels != 0 || p.ev.Title != "Event e" { + t.Fatal("disabled editor acted") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without Location") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + EventEditor(c, &p.ev, EventEditorOptions{}) + }, 10, 10) +} + +func TestEventEditorSnapshot(t *testing.T) { + ev := calRichEvent() + snapshot(t, "event_editor", 760, 1100, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { EventEditor(c, &ev, EventEditorOptions{Location: calTokyo}) }) + }) +} diff --git a/event_popover.go b/event_popover.go new file mode 100644 index 0000000..bbb243f --- /dev/null +++ b/event_popover.go @@ -0,0 +1,114 @@ +package mujica + +import ( + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// EventPopoverOptions configure an EventPopover. +type EventPopoverOptions struct { + // Location is the zone times show in; required. + Location *time.Location + Hour12 bool + // ReadOnly hides the edit and delete actions. + ReadOnly bool + Disabled bool +} + +// EventPopoverResult is the popover and the action the user chose. +type EventPopoverResult struct { + Element *ui.Element + edit bool + del bool +} + +// Edit reports a click on Edit. +func (r EventPopoverResult) Edit() bool { return r.edit } + +// Delete reports a click on Delete. +func (r EventPopoverResult) Delete() bool { return r.del } + +// EventPopover summarizes an event beside anchor while *open. +func EventPopover(c *ui.Context, anchor *ui.Element, open *bool, ev CalendarEvent, opts EventPopoverOptions) EventPopoverResult { + loc := eventLocation("EventPopover", opts.Location) + k := Tokens(c) + d := Density(c) + var r EventPopoverResult + if *open && (opts.Disabled || anchor.IsDisabled()) { + slog.Debug("mujica: event_popover close", "disabled", true) + *open = false + } + r.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { + overlayStyle(c, panel, overlayEnter(c, panel)) + ww, wh := c.Size() + panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 4).Gap(d.Unit() * 2).Width(min(d.ControlHeight()*9, ww-16)).MaxHeight(wh - 16). + Role(ui.RoleDialog).Label(ev.Title) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(10, 10).Shrink(0).Draw(func(p *ui.Painter, rr ui.Rect) { + p.FillPath(internal.Diamond(rr.X+rr.W/2, rr.Y+rr.H/2, rr.W), eventColor(k, ev.Color)) + }) + ui.Text(c, ev.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1).Shrink(1) + if iconAction(c, icons.Must("x"), msg(c, "event_popover.close")).Clicked() { + *open = false + } + }) + ui.Scroll(c).Shrink(1).Children(func() { + ui.Column(c).Gap(d.Unit() * 2).Children(func() { + for _, line := range eventPopoverLines(c, ev, loc, opts.Hour12) { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() { + ui.Icon(c, icons.Must(line[0])).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, line[1]).TextColor(k.Text).Grow(1).Shrink(1) + }) + } + }) + }) + if opts.ReadOnly { + return + } + datetimeFooter(c, func() { + if Button(c, msg(c, "event_popover.delete"), ButtonOptions{Variant: Danger}).Clicked() { + slog.Debug("mujica: event_popover delete", "id", ev.ID) + r.del, *open = true, false + } + if Button(c, msg(c, "event_popover.edit"), ButtonOptions{Variant: Secondary}).Clicked() { + slog.Debug("mujica: event_popover edit", "id", ev.ID) + r.edit, *open = true, false + } + }) + }) + return r +} + +// eventPopoverLines lists icon and text pairs for the summary. +func eventPopoverLines(c *ui.Context, ev CalendarEvent, loc *time.Location, hour12 bool) [][2]string { + s := ev.Start.In(loc) + lines := [][2]string{{"clock", datetimeDayLabel(c, DateOf(s)) + " · " + eventTimeText(c, ev, loc, hour12)}} + if ev.Location != "" { + lines = append(lines, [2]string{"house", ev.Location}) + } + if ev.Repeat.Unit != RepeatNone { + lines = append(lines, [2]string{"refresh-cw", RecurrenceSummary(c, ev.Repeat)}) + } + if len(ev.Attendees) > 0 { + var names []string + for _, a := range ev.Attendees { + names = append(names, a.Name) + } + lines = append(lines, [2]string{"user", calendarCount(c, "event_popover.guests", len(names)) + ": " + strings.Join(names, ", ")}) + } + if len(ev.Reminders) > 0 { + var rs []string + for _, d := range ev.Reminders { + rs = append(rs, ReminderText(c, d)) + } + lines = append(lines, [2]string{"bell", strings.Join(rs, msg(c, "recurrence_editor.list_sep"))}) + } + return lines +} diff --git a/event_popover_test.go b/event_popover_test.go new file mode 100644 index 0000000..cbd1ec2 --- /dev/null +++ b/event_popover_test.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func calRichEvent() CalendarEvent { + ev := calEvent("r", 14, 19, 0, 120) + ev.Title, ev.Location, ev.Color = "Masquerade rehearsal", "Rehearsal hall", EventInfo + ev.Attendees = calAttendees[:2] + ev.Reminders = []time.Duration{15 * time.Minute, time.Hour} + ev.Repeat = Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Wednesday}} + return ev +} + +type popoverProbe struct { + open bool + opts EventPopoverOptions + edits, deletes int +} + +func (p *popoverProbe) tester() *ui.Tester { + p.opts.Location = calTokyo + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(16).Children(func() { + b := Button(c, "Details", ButtonOptions{Variant: Secondary}) + if b.Clicked() { + p.open = true + } + r := EventPopover(c, b, &p.open, calRichEvent(), p.opts) + if r.Edit() { + p.edits++ + } + if r.Delete() { + p.deletes++ + } + }) + }, 600, 500) +} + +func TestEventPopoverSummaryAndActions(t *testing.T) { + p := &popoverProbe{} + tt := p.tester() + _ = tt.Click("Details") + for _, s := range []string{"Masquerade rehearsal", "Wednesday, October 14, 2026 · 19:00 – 21:00", "Rehearsal hall", + "Weekly on Wednesday", "2 guests: Sakiko Togawa, Mutsumi Wakaba", "15 minutes before, 1 hour before"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %v", s, tt.Texts()) + } + } + _ = tt.Click("Edit") + if p.edits != 1 || p.open { + t.Fatal("Edit should report and close") + } + _ = tt.Click("Details") + tt.Key(0, ui.KeyTab) + _ = tt.Click("Delete") + if p.deletes != 1 || p.open { + t.Fatal("Delete should report and close") + } + _ = tt.Click("Details") + tt.Key(0, ui.KeyEscape) + if p.open { + t.Fatal("Escape should close") + } +} + +func TestEventPopoverReadOnlyAndDisabled(t *testing.T) { + p := &popoverProbe{opts: EventPopoverOptions{ReadOnly: true}} + tt := p.tester() + _ = tt.Click("Details") + if tt.HasText("Edit") || tt.HasText("Delete") || !tt.HasText("Rehearsal hall") { + t.Fatal("read-only popover shows actions") + } + p.opts.ReadOnly, p.opts.Disabled = false, true + tt.Frame() + if p.open || tt.HasText("Rehearsal hall") { + t.Fatal("disabling should close the popover") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without Location") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + open := true + EventPopover(c, ui.Box(c), &open, calRichEvent(), EventPopoverOptions{}) + }, 10, 10) +} + +func TestEventPopoverSnapshot(t *testing.T) { + snapshot(t, "event_popover", 460, 400, func(c *ui.Context) { + ui.Row(c).Padding(16).Children(func() { + b := Button(c, "Details", ButtonOptions{Variant: Secondary}) + // Open once the anchor is laid out. + frames := ui.Local(c.Root(), "frames", func() int { return 0 }) + *frames++ + if *frames == 1 { + c.Invalidate() + } + open := *frames > 1 + EventPopover(c, b, &open, calRichEvent(), EventPopoverOptions{Location: calTokyo}) + }) + }) +} diff --git a/example_agent_test.go b/example_agent_test.go new file mode 100644 index 0000000..0d17a41 --- /dev/null +++ b/example_agent_test.go @@ -0,0 +1,243 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleToolCallCard() { + call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}", + Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToolCallGroup() { + open := false + calls := []mujica.ToolCall{ + {ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond}, + {ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToolApprovalDialog() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ToolApprovalDialog(c, &open, mujica.ToolApprovalOptions{ + Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.", + }) + if r.Allowed { + fmt.Println("run the tool") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePermissionPrompt() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{ + Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger", + }) + if r.Choice == mujica.PermissionAllowAlways { + fmt.Println("remember the grant") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAgentStepList() { + steps := []mujica.AgentStep{ + {ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second}, + {ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."}, + {ID: "send", Title: "Send invitations", State: mujica.AgentPending}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" { + fmt.Println("retry", r.Retry) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAgentPlan() { + items := []mujica.PlanItem{ + {ID: "a", Text: "Read the guest list", State: mujica.AgentDone}, + {ID: "b", Text: "Assign the seats", State: mujica.AgentRunning}, + {ID: "c", Text: "Order the flowers"}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAgentStatus() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAgentProgress() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSubAgentTree() { + agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{ + {ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting}, + }}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFileChangeCard() { + decision := mujica.FilePending + change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff { + fmt.Println("open the full diff") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMultiFileDiffReview() { + files := []mujica.FileChange{ + {Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}, + {Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"}, + } + decisions := make([]mujica.FileDecision, len(files)) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommandExecutionCard() { + run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() { + fmt.Println("abort the process") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScreenshotStream() { + var st mujica.ScreenshotStreamState + var frames []mujica.ScreenshotFrame // appended as the agent captures them + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleArtifactPanel() { + art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported { + fmt.Println("save the file") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleArtifactVersionSwitcher() { + current := 1 + versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactVersionSwitcher(c, ¤t, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare { + fmt.Println("diff v1 and v2") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSandboxStatus() { + info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning, + Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMCPServerList() { + servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop { + fmt.Println("stop", r.Server) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMemoryPanel() { + entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" { + fmt.Println("forget", r.Deleted) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCheckpointList() { + current := "c2" + points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CheckpointList(c, ¤t, points, mujica.CheckpointListOptions{}).Changed() { + fmt.Println("restore", current) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHumanInputRequest() { + answer := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.HumanInputRequest(c, &answer, mujica.HumanInputRequestOptions{ + Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true, + }) + if r.Submitted { + fmt.Println("reply", answer) + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_calendar_test.go b/example_calendar_test.go new file mode 100644 index 0000000..badbc93 --- /dev/null +++ b/example_calendar_test.go @@ -0,0 +1,262 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +func ExampleCalendarMonthView() { + loc := time.Local + events := exampleEvents(loc) + month := mujica.YearMonth{Year: 2026, Month: time.October} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc}) + if ch, ok := r.Changed(); ok { + fmt.Println("move", ch.ID, "to", ch.Start) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCalendarWeekView() { + loc := time.Local + events := exampleEvents(loc) + week := mujica.Date{Year: 2026, Month: time.October, Day: 14} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15}) + if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate { + fmt.Println("new event", ch.Start, ch.End) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCalendarDayView() { + loc := time.Local + events := exampleEvents(loc) + day := mujica.Date{Year: 2026, Month: time.October, Day: 14} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok { + fmt.Println("open", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAgendaView() { + loc := time.Local + events := exampleEvents(loc) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14} + if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok { + fmt.Println("open", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleYearView() { + loc := time.Local + events := exampleEvents(loc) + year := 2026 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok { + fmt.Println("go to", day) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEventChip() { + loc := time.Local + ev := exampleEvents(loc)[0] + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() { + fmt.Println("open", ev.ID) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEventEditor() { + loc := time.Local + ev := exampleEvents(loc)[0] + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc}) + if r.Saved() { + fmt.Println("saved", ev.Title, ev.Start) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEventPopover() { + loc := time.Local + ev := exampleEvents(loc)[0] + open := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}) + if chip.Clicked() { + open = true + } + r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc}) + if r.Delete() { + fmt.Println("delete", ev.ID) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCurrentTimeIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(200, 240).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRecurrenceEditor() { + rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}}) + if r.Changed() && r.Problem() == "" { + fmt.Println(mujica.RecurrenceSummary(c, rule)) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAvailabilityPicker() { + days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}} + mine := [][]bool{make([]bool, 18), make([]bool, 18)} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() { + fmt.Println("availability changed") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTimezoneSelect() { + zone := "Asia/Shanghai" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() { + fmt.Println("zone", zone) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleReminderPicker() { + reminders := []time.Duration{15 * time.Minute} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() { + fmt.Println("reminders", reminders) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAttendeeList() { + people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok { + people = append(people[:i], people[i+1:]...) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleWeekPicker() { + var week mujica.Date + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() { + fmt.Println("week of", week) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDurationPicker() { + length := 90 * time.Minute + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour} + if mujica.DurationPicker(c, &length, opts).Changed() { + fmt.Println("length", length) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCronEditor() { + expr := "0 9 * * MON-FRI" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() { + if _, err := mujica.ParseCron(expr); err == nil { + fmt.Println("schedule", expr) + } + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRelativeTime() { + posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCountdown() { + deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() { + fmt.Println("happy new year") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStopwatch() { + var sw mujica.StopwatchState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() { + fmt.Println("laps", len(sw.Laps)) + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_charts1_test.go b/example_charts1_test.go new file mode 100644 index 0000000..90433bb --- /dev/null +++ b/example_charts1_test.go @@ -0,0 +1,221 @@ +package mujica_test + +import ( + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleLineChart() { + series := []mujica.ChartSeries{ + {Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}}, + {Name: "Candles", Values: []float64{5, 2, 6, 7, 1}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineChart(c, series, mujica.LineChartOptions{Frame: mujica.ChartFrame{Label: "Chapel"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAreaChart() { + series := []mujica.ChartSeries{ + {Name: "Matins", Values: []float64{3, 4, 6, 5}}, + {Name: "Vespers", Values: []float64{2, 3, 2, 4}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: mujica.ChartFrame{Label: "Attendance"}, Stacked: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBarChart() { + categories := []string{"Nave", "Choir", "Crypt"} + series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BarChart(c, categories, series, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Tithes"}, Labels: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePieChart() { + slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: mujica.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScatterChart() { + var selected []mujica.ScatterKey + groups := []mujica.ScatterGroup{{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScatterChart(c, &selected, groups, mujica.ScatterChartOptions{Frame: mujica.ChartFrame{Label: "Choir"}, Bubbles: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRadarChart() { + axes := []string{"Voice", "Latin", "Chant", "Organ"} + series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RadarChart(c, axes, series, mujica.RadarChartOptions{Frame: mujica.ChartFrame{Label: "Skills"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHeatmapChart() { + rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"} + values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: mujica.ChartFrame{Label: "Hours"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCalendarHeatmap() { + year := 2026 + values := map[mujica.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CalendarHeatmap(c, &year, values, mujica.CalendarHeatmapOptions{Frame: mujica.ChartFrame{Label: "Prayers"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHistogram() { + values := []float64{1, 2, 2, 3, 4, 5, 9, 10} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Histogram(c, values, mujica.HistogramOptions{Frame: mujica.ChartFrame{Label: "Ages"}, BinWidth: 5}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBoxPlot() { + groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleWaterfallChart() { + steps := []mujica.WaterfallStep{ + {Label: "Tithes", Value: 12}, + {Label: "Repairs", Value: -5}, + {Label: "Year", Kind: mujica.WaterfallTotal}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: mujica.ChartFrame{Label: "Ledger"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFunnelChart() { + stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: mujica.ChartFrame{Label: "Order"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBulletChart() { + target := 250.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BulletChart(c, 270, mujica.BulletChartOptions{Frame: mujica.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSparkline() { + values := []float64{3, 5, 4, 7, 6, 9} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: mujica.ChartFrame{Label: "Bells"}, Kind: mujica.SparklineArea}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleGauge() { + segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Gauge(c, 72, mujica.GaugeOptions{Frame: mujica.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTreemap() { + var path []string + root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, + }} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Treemap(c, &path, root, mujica.TreemapOptions{Frame: mujica.ChartFrame{Label: "Rooms"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSunburst() { + var path []string + root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, + }} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sunburst(c, &path, root, mujica.SunburstOptions{Frame: mujica.ChartFrame{Label: "Rooms"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSankeyChart() { + nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}} + links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: mujica.ChartFrame{Label: "Money"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleNetworkGraph() { + var state mujica.NetworkGraphState + nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}} + edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.NetworkGraph(c, &state, nodes, edges, mujica.NetworkGraphOptions{Frame: mujica.ChartFrame{Label: "House"}, Force: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRealtimeChart() { + buf := mujica.NewRealtimeBuffer(600, "Bell") + start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + for i := range 60 { + buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7)) + } + var state mujica.RealtimeChartState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RealtimeChart(c, &state, buf, mujica.RealtimeChartOptions{Frame: mujica.ChartFrame{Label: "Sound"}, Window: 30 * time.Second}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_chatmsg_test.go b/example_chatmsg_test.go new file mode 100644 index 0000000..63b8ed2 --- /dev/null +++ b/example_chatmsg_test.go @@ -0,0 +1,222 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleChatContainer() { + var chat mujica.MessageListState + msgs := []string{"Hello", "Bonjour"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{ + List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }}, + Input: func() { ui.Text(c, "Composer goes here") }, + }, func(i int) { ui.Text(c, msgs[i]) }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMessageList() { + var list mujica.MessageListState + ids := []string{"m1", "m2", "m3"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{ + ID: func(i int) string { return ids[i] }, + }, func(i int) { ui.Text(c, "Message "+ids[i]) }) + fmt.Println("unread", r.Unread()) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMessageBubble() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() { + ui.Text(c, "The ball begins at nine.") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMessageHeader() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStreamingText() { + reply := "The decree reads" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMarkdownView() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok { + fmt.Println("open", url) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCodeBlock() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTableBlock() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() { + fmt.Println("copied as TSV") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageGrid() { + pic := ui.MustParseSVG([]byte(``)) + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok { + fmt.Println("reload", i) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFileMessage() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440}) + if r.Downloaded() { + fmt.Println("save treaty.pdf") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioMessage() { + pos := 5 * time.Second + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos}) + if d, ok := r.Seek(); ok { + pos = d + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLinkPreviewCard() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"} + if mujica.LinkPreviewCard(c, p).Clicked() { + fmt.Println("open", p.URL) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQuoteReply() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() { + fmt.Println("jump to the quoted message") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMessageActions() { + rating := mujica.FeedbackNone + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok { + fmt.Println("action", a) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMessageEditor() { + draft := "Hello" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() { + fmt.Println("new branch from", draft) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBranchNavigator() { + branch := 1 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.BranchNavigator(c, &branch, 3).Changed() { + fmt.Println("show branch", branch) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleErrorMessage() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)} + if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() { + fmt.Println("send again") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleThinkingBlock() { + open := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() { + ui.Text(c, "Weigh the guest list.") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScrollToBottomButton() { + var list mujica.MessageListState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Fill().Children(func() { + if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() { + list.ScrollToEnd() + } + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTypingIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TypingIndicator(c, "Herald") + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_chatsession_test.go b/example_chatsession_test.go new file mode 100644 index 0000000..53a5849 --- /dev/null +++ b/example_chatsession_test.go @@ -0,0 +1,231 @@ +package mujica_test + +import ( + "log" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +func ExampleConversationList() { + var state mujica.ConversationListState + convs := []mujica.ChatConversation{ + {ID: "1", Title: "Court etiquette", Updated: time.Now()}, + {ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ConversationList(c, &state, convs, mujica.ConversationListOptions{Label: "History"}, nil).Changed() { + log.Println("open", state.Selected) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleConversationItem() { + conv := mujica.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{}) + if v.Deleted { + log.Println("delete", conv.ID) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleConversationSearch() { + query := "seat" + hits := []mujica.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ConversationSearch(c, &query, hits, mujica.ConversationSearchOptions{}) + if v.QueryChanged { + log.Println("search", query) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleProjectList() { + var state mujica.ProjectListState + projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}} + convs := func(id string) []mujica.ChatConversation { + return []mujica.ChatConversation{{ID: "1", Title: "Menu"}} + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.ProjectList(c, &state, projects, convs, mujica.ProjectListOptions{}); v.Moved { + log.Println("move", v.MoveID, "to", v.MoveTo) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleProjectKnowledgePanel() { + files := []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ProjectKnowledgePanel(c, files, mujica.ProjectKnowledgePanelOptions{Capacity: 200000}) + if v.Add { + log.Println("pick files") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleWelcomeScreen() { + cards := []mujica.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" { + log.Println("ask", v.Example) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSuggestionChips() { + input := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePromptLibrary() { + var state mujica.PromptLibraryState + templates := []mujica.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.PromptLibrary(c, &state, templates, mujica.PromptLibraryOptions{}); v.Inserted != "" { + log.Println("insert", v.Inserted) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCapabilityCards() { + cards := []mujica.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.CapabilityCards(c, cards, mujica.CapabilityCardsOptions{}); v.Example != "" { + log.Println("ask", v.Example) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFeedbackForm() { + var value mujica.FeedbackValue + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FeedbackForm(c, &value, mujica.FeedbackFormOptions{Rating: mujica.FeedbackDown}).Submitted { + log.Println("reasons", value.Reasons) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDateSeparator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DateSeparator(c, mujica.DateOf(time.Now()), mujica.DateSeparatorOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleThinkingIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: "Searching the archives"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStopGeneratingButton() { + generating := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: generating}).Changed() { + generating = false + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSystemPromptEditor() { + prompt, saved := "You are helpful.", "You are helpful." + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SystemPromptEditor(c, &prompt, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save { + saved = prompt + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCostEstimator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSharedConversationView() { + msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}, {Role: mujica.SessionAssistant, Text: "Good day."}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SharedConversationView(c, msgs, mujica.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() { + c.WriteClipboard("https://example.com/share/1") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleConversationExport() { + var state mujica.ConversationExportState + msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.ConversationExport(c, &state, msgs, mujica.ConversationExportOptions{Title: "Greeting"}); v.Exported { + log.Println(len(v.Content), "bytes to save") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRegenerateMenu() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen { + log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleArtifactCard() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactCard(c, mujica.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() { + log.Println("open artifact panel") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChatEmptyState() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyOffline}).Action { + log.Println("reconnect") + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_code_test.go b/example_code_test.go new file mode 100644 index 0000000..10451d2 --- /dev/null +++ b/example_code_test.go @@ -0,0 +1,276 @@ +package mujica_test + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleTerminal() { + var term mujica.TerminalState + term.Write("\x1b[1m~/court\x1b[0m $ ls\r\n\x1b[34mdecrees\x1b[0m ledger.txt\r\n$ ") + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Terminal(c, &term, mujica.TerminalOptions{ + Input: func(data string) { fmt.Printf("to pty: %q\n", data) }, + Resized: func(cols, rows int) { fmt.Println("pty size", cols, rows) }, + }).Size(380, 200) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTerminalTabs() { + tabs := []mujica.TerminalTab{{ID: "a", Title: "zsh"}, {ID: "b", Title: "build", Busy: true}} + active := "a" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TerminalTabs(c, &tabs, &active, mujica.TerminalTabsOptions{}, func(id string) { + ui.Text(c, "terminal "+id) + }) + if r.Closed != "" { + fmt.Println("close pty", r.Closed) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTerminalSearch() { + var term mujica.TerminalState + term.Write("build ok\r\nerror: missing seal\r\n") + var search mujica.TerminalSearchState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Gap(8).Children(func() { + mujica.TerminalSearch(c, &term, &search, mujica.TerminalSearchOptions{}) + mujica.Terminal(c, &term, mujica.TerminalOptions{}).Height(160) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommandHistory() { + history := []mujica.HistoryEntry{{Command: "git status"}, {Command: "go test ./...", ExitCode: 1}} + var state mujica.CommandHistoryState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommandHistory(c, history, &state, mujica.CommandHistoryOptions{}) + r.Element.Height(240) + if r.Replayed != nil { + fmt.Println("run", r.Replayed.Command) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLogViewer() { + lines := []mujica.LogLine{{Level: mujica.LogInfo, Text: "court opened"}, {Level: mujica.LogError, Text: "seal missing"}} + var state mujica.LogViewerState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LogViewer(c, lines, &state, mujica.LogViewerOptions{}).Element.Height(240) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleProcessList() { + procs := []mujica.Process{{PID: 7, Name: "archivist", CPU: 42, Memory: 50 << 20}, {PID: 9, Name: "herald", CPU: 3, Memory: 8 << 20}} + var state mujica.ProcessListState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ProcessList(c, procs, &state, mujica.ProcessListOptions{}) + r.Element.Height(240) + if r.End != nil { + fmt.Println("kill", r.End.PID) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCodeViewer() { + lines := []string{"func Seal(d string) int {", "\treturn len(d)", "}"} + var state mujica.CodeViewerState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CodeViewer(c, lines, &state, mujica.CodeViewerOptions{Language: "go"}).Element.Height(240) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMinimap() { + lines := []string{"package court", "", "func Seal() {}", "func Herald() {}"} + view := mujica.MinimapView{Top: 0, Visible: 2} + draw := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Minimap(c, lines, &view, mujica.MinimapOptions{Language: "go"}).Changed() { + fmt.Println("scroll editor to", view.Top) + } + } + ui.Render(draw, 400, 300, 1) +} + +func ExampleFindWidget() { + text := "the seal and the seal" + state := mujica.FindState{Query: "seal", Replacement: "signet", ShowReplace: true} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FindWidget(c, &text, &state, mujica.FindWidgetOptions{}).Replaced > 0 { + fmt.Println(text) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSearchPanel() { + results := []mujica.SearchFileResult{{Path: "court/seal.go", Matches: []mujica.SearchMatch{{Line: 12, Text: "func Seal() {", Start: 5, End: 9}}}} + state := mujica.SearchPanelState{Query: "seal"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SearchPanel(c, results, &state, mujica.SearchPanelOptions{}) + r.Element.Height(280) + if r.Opened != nil { + fmt.Println("open", r.Opened.Path, r.Opened.Line) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleProblemsPanel() { + problems := []mujica.Problem{{Severity: mujica.ProblemError, File: "court/seal.go", Line: 12, Col: 5, Message: "undefined: Decree"}} + var state mujica.ProblemsPanelState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ProblemsPanel(c, problems, &state, mujica.ProblemsPanelOptions{}) + r.Element.Height(240) + if r.Opened != nil { + fmt.Println("go to", r.Opened.File, r.Opened.Line) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOutputPanel() { + channels := []mujica.OutputChannel{{Name: "Build", Lines: []string{"compiling court", "\x1b[32mok\x1b[0m"}}} + var state mujica.OutputPanelState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OutputPanel(c, channels, &state, mujica.OutputPanelOptions{}) + r.Element.Height(240) + if r.Cleared != "" { + channels[state.Channel].Lines = nil + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSymbolOutline() { + symbols := []mujica.Symbol{{Name: "Court", Kind: mujica.SymbolType, Line: 3, EndLine: 40, Children: []mujica.Symbol{ + {Name: "Seal", Kind: mujica.SymbolMethod, Line: 10, EndLine: 20}, + }}} + var state mujica.SymbolOutlineState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SymbolOutline(c, symbols, &state, mujica.SymbolOutlineOptions{CursorLine: 12}) + r.Element.Height(240) + if r.Jumped != nil { + fmt.Println("go to line", r.Jumped.Line) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCompletionMenu() { + text := "se" + open := true + items := []mujica.Completion{{Label: "Seal", Kind: mujica.SymbolMethod, Detail: "func()"}, {Label: "Search", Kind: mujica.SymbolFunction}} + var state mujica.CompletionMenuState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + in := mujica.TextInput(c, &text, mujica.TextInputOptions{Label: "Code"}) + if r := mujica.CompletionMenu(c, in.Input, &open, items, &state, mujica.CompletionMenuOptions{}); r.Accepted != nil { + text = r.Accepted.Label + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDebugToolbar() { + state := mujica.DebugPaused + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + switch mujica.DebugToolbar(c, state, mujica.DebugToolbarOptions{}).Action { + case mujica.DebugContinue: + state = mujica.DebugRunning + case mujica.DebugStop: + state = mujica.DebugStopped + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBreakpointList() { + bps := []mujica.Breakpoint{{ID: "a", File: "court/seal.go", Line: 12, Enabled: true}, {ID: "b", File: "main.go", Line: 9, Condition: "n > 3"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BreakpointList(c, &bps, mujica.BreakpointListOptions{}) + r.Element.Height(200) + if r.Toggled != "" || r.Edited != "" { + fmt.Println("sync breakpoints with the debugger") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCallStack() { + threads := []mujica.DebugThread{{ID: "t1", Name: "main", Frames: []mujica.StackFrame{ + {ID: "f1", Name: "court.Seal", File: "court/seal.go", Line: 14}, + {ID: "f2", Name: "main.main", File: "main.go", Line: 9}, + }}} + state := mujica.CallStackState{Current: "f1"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CallStack(c, threads, &state, mujica.CallStackOptions{}) + r.Element.Height(200) + if r.Changed() { + fmt.Println("show frame", state.Current) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVariablesPanel() { + vars := []mujica.Variable{{Name: "decree", Value: "{…}", Children: []mujica.Variable{{Name: "id", Value: "42", Changed: true}}}, {Name: "count", Value: "7"}} + var state mujica.VariablesPanelState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.VariablesPanel(c, vars, &state, mujica.VariablesPanelOptions{}) + r.Element.Height(200) + if r.Edited != nil { + fmt.Println("set", r.Edited.Path, "=", r.Edited.Value) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleHexViewer() { + data := []byte("MUJICA court ledger\x00\x01") + var state mujica.HexViewerState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HexViewer(c, data, &state, mujica.HexViewerOptions{}).Element.Height(160) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFlamegraph() { + root := &mujica.FlameNode{Name: "main", Value: 100, Children: []mujica.FlameNode{ + {Name: "court.Seal", Value: 60}, {Name: "ledger.Write", Value: 30}, + }} + var state mujica.FlamegraphState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Flamegraph(c, root, &state, mujica.FlamegraphOptions{Unit: "ms"}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_git_test.go b/example_git_test.go new file mode 100644 index 0000000..51e60e0 --- /dev/null +++ b/example_git_test.go @@ -0,0 +1,263 @@ +package mujica_test + +import ( + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleDiffViewer() { + var state mujica.DiffViewerState + from := "func main() {\n\tprintln(\"hi\")\n}\n" + to := "func main() {\n\tprintln(\"hello\")\n}\n" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DiffViewer(c, &state, from, to, mujica.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleInlineDiff() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.InlineDiff(c, "The court meets at noon", "The court meets at dusk", mujica.InlineDiffOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleThreeWayMerge() { + var state mujica.ThreeWayMergeState + base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ThreeWayMerge(c, &state, base, local, remote, mujica.ThreeWayMergeOptions{}) + r.Element.Height(280) + if done, total := state.Resolved(); r.Changed() && done == total { + _ = state.Result() // write the merged file + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleConflictResolver() { + var state mujica.ConflictResolverState + text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ConflictResolver(c, &state, text, mujica.ConflictResolverOptions{}).Changed() { + _ = state.Result() // save the file once every block is resolved + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChangesList() { + var state mujica.ChangesListState + files := []mujica.ChangedFile{ + {Path: "main.go", Status: mujica.GitModified, Staged: true}, + {Path: "notes.txt", Status: mujica.GitUntracked}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ChangesList(c, &state, files, mujica.ChangesListOptions{}) + r.Element.Height(240) + if action, path, ok := r.Action(); ok { + _, _ = action, path // run git add or git restore --staged + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommitInput() { + msg := mujica.CommitMessage{} + busy := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommitInput(c, &msg, mujica.CommitInputOptions{Busy: busy}) + if r.Committed() || r.Amended() { + busy = true // run git commit, then clear busy + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommitGraph() { + var state mujica.CommitGraphState + commits := []mujica.Commit{ + {Hash: "c3", Parents: []string{"c2", "b1"}, Subject: "Merge branch", Refs: []string{"main"}}, + {Hash: "b1", Parents: []string{"c1"}, Subject: "Feature"}, + {Hash: "c2", Parents: []string{"c1"}, Subject: "Fix"}, + {Hash: "c1", Subject: "Initial commit"}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CommitGraph(c, &state, commits, mujica.CommitGraphOptions{}).Element.Height(240) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommitList() { + var state mujica.ListState[string] + commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommitList(c, &state, commits, mujica.CommitListOptions{}) + r.Element.Height(240) + if hash, ok := state.Selected(); ok && r.Submitted() { + _ = hash // show the commit + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBranchSelector() { + branch := "main" + branches := []mujica.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BranchSelector(c, &branch, branches, mujica.BranchSelectorOptions{AllowCreate: true}) + if name, ok := r.Created(); ok { + _ = name // run git switch -c name + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBranchList() { + var state mujica.ListState[string] + branches := []mujica.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}} + actions := []mujica.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BranchList(c, &state, branches, mujica.BranchListOptions{Actions: actions}) + r.Element.Height(200) + if action, name, ok := r.Action(); ok { + _, _ = action, name // run the action on the branch + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTagList() { + var state mujica.ListState[string] + tags := []mujica.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TagList(c, &state, tags, mujica.TagListOptions{}) + r.Element.Height(200) + if _, hash, ok := r.Action(); ok { + _ = hash // show the tagged commit + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStashList() { + var state mujica.ListState[string] + stashes := []mujica.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.StashList(c, &state, stashes, mujica.StashListOptions{}) + r.Element.Height(200) + if action, ref, ok := r.Action(); ok { + _, _ = action, ref // run git stash apply, pop or drop + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBlameView() { + var state mujica.BlameViewState + lines := []mujica.BlameLine{ + {Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "package main"}, + {Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "func main() {}"}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BlameView(c, &state, lines, mujica.BlameViewOptions{Language: "go"}).Height(240) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleFileHistory() { + var state mujica.ListState[string] + commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.FileHistory(c, &state, "main.go", commits, mujica.FileHistoryOptions{}) + r.Element.Height(240) + if _, hash, ok := r.Action(); ok { + _ = hash // open the diff of main.go at hash + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePullRequestCard() { + pr := mujica.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main", + ChecksPassed: 3, Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{}).Changed() { + // open the pull request + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleReviewComment() { + thread := mujica.ReviewThread{File: "main.go", Line: 12, Messages: []mujica.ReviewMessage{ + {Author: "Uika", When: time.Now(), Body: "Can this return early?"}, + }} + draft := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ReviewComment(c, thread, &draft, mujica.ReviewCommentOptions{}) + if text, ok := r.Replied(); ok { + thread.Messages = append(thread.Messages, mujica.ReviewMessage{Author: "Sakiko", When: time.Now(), Body: text}) + } + if r.Resolved() || r.Reopened() { + thread.Resolved = !thread.Resolved + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDiffStat() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DiffStat(c, 120, 8, mujica.DiffStatOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleGitStatusBadge() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.GitStatusBadge(c, mujica.GitModified, mujica.GitStatusBadgeOptions{ShowLabel: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCommandBlock() { + open := true + run := mujica.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommandBlock(c, run, &open, mujica.CommandBlockOptions{}) + if what, ok := r.Copied(); ok { + _ = what // "command" or "output" is on the clipboard + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAnsiText() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", mujica.AnsiTextOptions{}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_media_test.go b/example_media_test.go new file mode 100644 index 0000000..52b53f1 --- /dev/null +++ b/example_media_test.go @@ -0,0 +1,246 @@ +package mujica_test + +import ( + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// examplePhoto stands in for a decoded picture. +var examplePhoto = image.NewRGBA(image.Rect(0, 0, 320, 180)) + +func ExampleImageCropper() { + crop := mujica.ImageCrop{} // empty: starts fitted to the image + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageAnnotator() { + photo := ui.NewBitmap(examplePhoto) + var notes []mujica.Annotation + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageThumbnail() { + photo := ui.NewBitmap(examplePhoto) + selected := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLightbox() { + images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} + open, index := false, 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoPlayer() { + st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoScrubber() { + pos := 30 * time.Second + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVideoThumbnailStrip() { + frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} + current := 0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioPlayer() { + st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioWaveform() { + samples := make([]float32, 48000) // from your decoder, -1 to 1 + pos, sel := time.Duration(0), mujica.MediaRange{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleAudioSpectrum() { + bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleVolumeControl() { + volume, muted := 0.8, false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePlaybackSpeedControl() { + rate := 1.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMediaControls() { + st := mujica.MediaControlsState{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePlaylist() { + items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} + current := "a" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSubtitleEditor() { + cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleScreenRecorderControls() { + st := mujica.RecorderState{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCameraPreview() { + frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame + mirror := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMicLevelMeter() { + level := float32(0.4) // the input's peak, 0 to 1 of full scale + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDeviceSelector() { + devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, + } + sel := mujica.DeviceSelection{Microphone: "mic-1"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleImageCompare() { + before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) + split := 0.5 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/example_trading_test.go b/example_trading_test.go new file mode 100644 index 0000000..6c32d4f --- /dev/null +++ b/example_trading_test.go @@ -0,0 +1,221 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleTickerTape() { + items := []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TickerTape(c, items, mujica.TickerTapeOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQuoteCard() { + quote := mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QuoteCard(c, quote, mujica.QuoteCardOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePriceText() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UseTrend(c, mujica.RedUp) + mujica.PriceText(c, 18234.5, mujica.PriceTextOptions{Compare: true, Reference: 18000}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePriceChangeBadge() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriceChangeBadge(c, -5.62, -1.35, mujica.PriceChangeBadgeOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderBook() { + var state mujica.OrderBookState + book := mujica.OrderBookData{ + Bids: []mujica.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}}, + Asks: []mujica.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.OrderBook(c, &state, book, mujica.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok { + fmt.Println("order at", price, side) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLevel2Quotes() { + data := mujica.Level2Data{ + Bids: []mujica.L2Level{{Price: 100.00, Size: 500, Orders: 12}}, + Asks: []mujica.L2Level{{Price: 100.01, Size: 200, Orders: 3}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTimeAndSales() { + var state mujica.TimeAndSalesState + trades := []mujica.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: mujica.SideBuy}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeAndSales(c, &state, trades, mujica.TimeAndSalesOptions{LargeSize: 1000}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDepthLadder() { + var state mujica.DepthLadderState + book := mujica.OrderBookData{Bids: []mujica.BookLevel{{Price: 100, Size: 5}}, Asks: []mujica.BookLevel{{Price: 100.01, Size: 2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.DepthLadder(c, &state, 100, book, mujica.DepthLadderOptions{TickSize: 0.01}).Clicked(); ok { + fmt.Println("place", side, "at", price) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSpreadIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBidAskBar() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BidAskBar(c, 6234, 3820, mujica.BidAskBarOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderEntry() { + var state mujica.OrderEntryState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if order, ok := mujica.OrderEntry(c, &state, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2}).Submitted(); ok { + fmt.Println("place", order.Quantity) + state.EndSubmit("") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQuickTradeButtons() { + quantity := 10.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if side, q, ok := mujica.QuickTradeButtons(c, &quantity, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok { + fmt.Println(side, q) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePositionTable() { + var state mujica.DataTableState[string] + rows := []mujica.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.PositionTable(c, &state, rows, mujica.PositionTableOptions{}).Acted(); ok { + fmt.Println("close", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderTable() { + var state mujica.OrderTableState + rows := []mujica.OrderRow{{ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.OrderTable(c, &state, rows, mujica.OrderTableOptions{}).Acted(); ok { + fmt.Println("cancel", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTradeHistoryTable() { + var state mujica.TradeHistoryTableState + rows := []mujica.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: mujica.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if fills, ok := mujica.TradeHistoryTable(c, &state, rows, mujica.TradeHistoryTableOptions{}).Exported(); ok { + fmt.Println("export", len(fills)) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePnLDisplay() { + hidden := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, mujica.PnLDisplayOptions{Currency: "$"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLeverageSlider() { + leverage := 5.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LeverageSlider(c, &leverage, mujica.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() { + fmt.Println("leverage", leverage) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMarginIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MarginIndicator(c, 0.86, mujica.MarginIndicatorOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRiskMeter() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RiskMeter(c, 4, mujica.RiskMeterOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderConfirmDialog() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OrderConfirmDialog(c, &open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Quantity", Value: "100"}}, + Risk: "Leveraged products can lose more than the deposit.", + Acknowledge: "I understand the risk", + }) + if r.Confirmed { + fmt.Println("send order") + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_agent.go b/examples/gallery/pages_agent.go index f1cb354..ead48c3 100644 --- a/examples/gallery/pages_agent.go +++ b/examples/gallery/pages_agent.go @@ -1,3 +1,282 @@ package main -var agentPages = []page{} +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const agentCategory = "Agent" + +var agentPages = []page{ + {ID: "261", Name: "ToolCallCard", ZhName: "工具调用卡片", Category: agentCategory, Demo: toolCallCardDemo, Code: toolCallCardCode, + Keys: "Tab 到参数、结果折叠按钮,Enter / Space 展开或折叠。"}, + {ID: "262", Name: "ToolCallGroup", ZhName: "工具调用组", Category: agentCategory, Demo: toolCallGroupDemo, Code: toolCallGroupCode, + Keys: "Tab 到组标题,Enter / Space 展开或折叠。"}, + {ID: "263", Name: "ToolApprovalDialog", ZhName: "工具审批对话框", Category: agentCategory, Demo: toolApprovalDemo, Code: toolApprovalDialogCode, + Keys: "打开时焦点在拒绝;Enter 只触发聚焦按钮,Tab 到允许才能批准;Escape 拒绝。"}, + {ID: "264", Name: "PermissionPrompt", ZhName: "权限请求", Category: agentCategory, Demo: permissionDemo, Code: permissionPromptCode, + Keys: "打开时焦点在拒绝;Tab 到始终允许、允许一次;Escape 拒绝。"}, + {ID: "265", Name: "AgentStepList", ZhName: "执行步骤列表", Category: agentCategory, Demo: agentStepListDemo, Code: agentStepListCode, + Keys: "↑ ↓ 在步骤与重试间移动;Enter 展开详情或重试。"}, + {ID: "266", Name: "AgentPlan", ZhName: "执行计划", Category: agentCategory, Demo: agentPlanDemo, Code: agentPlanCode, + Keys: "Space 勾选;Tab 到跳过或恢复;在输入框中按 Enter 新增。"}, + {ID: "267", Name: "AgentStatus", ZhName: "Agent 状态", Category: agentCategory, Demo: agentStatusDemo, Code: agentStatusCode, + Keys: "不获取焦点;屏幕阅读器读出状态文字。"}, + {ID: "268", Name: "AgentProgress", ZhName: "Agent 进度", Category: agentCategory, Demo: agentProgressDemo, Code: agentProgressCode, + Keys: "不获取焦点;当前动作以状态读出。"}, + {ID: "269", Name: "SubAgentTree", ZhName: "子 Agent 树", Category: agentCategory, Demo: subAgentTreeDemo, Code: subAgentTreeCode, + Keys: "↑ ↓ 移动;→ 展开,← 折叠;Enter / Space 切换。"}, + {ID: "270", Name: "FileChangeCard", ZhName: "文件变更卡片", Category: agentCategory, Demo: fileChangeCardDemo, Code: fileChangeCardCode, + Keys: "Tab 到打开差异、拒绝、接受或撤销,Enter / Space 触发。"}, + {ID: "271", Name: "MultiFileDiffReview", ZhName: "多文件差异审阅", Category: agentCategory, Demo: multiFileDiffDemo, Code: multiFileDiffReviewCode, + Keys: "文件列表中 ↑ ↓ 移动,Enter 选择;Tab 到接受、拒绝、全部接受。"}, + {ID: "272", Name: "CommandExecutionCard", ZhName: "命令执行卡片", Category: agentCategory, Demo: commandExecutionDemo, Code: commandExecutionCardCode, + Keys: "Tab 到中止,Enter / Space 请求中止;输出区可滚动。"}, + {ID: "273", Name: "ScreenshotStream", ZhName: "Agent 画面流", Category: agentCategory, Demo: screenshotStreamDemo, Code: screenshotStreamCode, + Keys: "画面区中 ← → 逐帧,Space 暂停或恢复。"}, + {ID: "274", Name: "ArtifactPanel", ZhName: "产物面板", Category: agentCategory, Demo: artifactPanelDemo, Code: artifactPanelCode, + Keys: "切换组中 ← → 移动、Enter 选择;Tab 到复制、导出。"}, + {ID: "275", Name: "ArtifactVersionSwitcher", ZhName: "产物版本切换", Category: agentCategory, Demo: artifactVersionDemo, Code: artifactVersionSwitcherCode, + Keys: "焦点在组件内时 ← → 切换版本;Tab 到比较按钮。"}, + {ID: "276", Name: "SandboxStatus", ZhName: "沙箱状态", Category: agentCategory, Demo: sandboxStatusDemo, Code: sandboxStatusCode, + Keys: "不获取焦点;剩余时间每秒刷新。"}, + {ID: "277", Name: "MCPServerList", ZhName: "MCP 服务器列表", Category: agentCategory, Demo: mcpServerListDemo, Code: mcpServerListCode, + Keys: "Tab 到各行的启动、停止、重新连接,Enter / Space 触发。"}, + {ID: "278", Name: "MemoryPanel", ZhName: "记忆面板", Category: agentCategory, Demo: memoryPanelDemo, Code: memoryPanelCode, + Keys: "Tab 到搜索、编辑、删除;删除确认默认聚焦取消,Escape 取消。"}, + {ID: "279", Name: "CheckpointList", ZhName: "检查点列表", Category: agentCategory, Demo: checkpointListDemo, Code: checkpointListCode, + Keys: "Tab 到回滚,Enter 打开确认;确认框默认聚焦取消。"}, + {ID: "280", Name: "HumanInputRequest", ZhName: "人工输入请求", Category: agentCategory, Demo: humanInputDemo, Code: humanInputRequestCode, + Keys: "方向键选择选项;输入框中 Enter 发送;Tab 到发送回答。"}, +} + +// agentStates are the states a demo can pick from. +var agentStates = []string{"Pending", "Planning", "Running", "Waiting", "Done", "Failed", "Skipped"} + +// agentStatePick is a select over agentStates, logging changes. +func agentStatePick(c *ui.Context, log func(string), v *string) mujica.AgentState { + if ui.Select(c, v, agentStates).Changed() { + log("state = " + *v) + } + for i, s := range agentStates { + if s == *v { + return mujica.AgentState(i) + } + } + panic("gallery: unknown state " + *v) +} + +type toolCallDemoState struct { + state string + failed bool + still bool +} + +func toolCallCardDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "261", func() toolCallDemoState { return toolCallDemoState{state: "Done"} }) + var st mujica.AgentState + overlayControls(c, func() { + st = agentStatePick(c, log, &s.state) + overlayCheck(c, log, &s.failed, "Error") + overlayCheck(c, log, &s.still, "Still") + }) + call := mujica.ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`, + Result: `{"lines":40,"truncated":false}`, State: st, Duration: 420 * time.Millisecond} + if s.failed { + call.Error = "permission denied: court/ledger.md" + } + if mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{Still: s.still}).Changed() { + log("folded or unfolded a section") + } +} + +func toolCallGroupDemo(c *ui.Context, log func(string)) { + open := ui.Local(c.Root(), "262", func() bool { return true }) + calls := []mujica.ToolCall{ + {ID: "a", Name: "list_dir", Args: `{"path":"court"}`, Result: `["ledger.md","menu.md"]`, State: mujica.AgentDone, Duration: 300 * time.Millisecond}, + {ID: "b", Name: "read_file", Args: `{"path":"court/wine.md"}`, Error: "not found", State: mujica.AgentFailed, Duration: 1200 * time.Millisecond}, + {ID: "c", Name: "grep", Args: `{"pattern":"Sakiko"}`, Result: `{"matches":3}`, State: mujica.AgentDone, Duration: 500 * time.Millisecond}, + } + if mujica.ToolCallGroup(c, open, calls, mujica.ToolCallGroupOptions{}).Changed() { + log(fmt.Sprintf("group open = %v", *open)) + } +} + +func toolApprovalDemo(c *ui.Context, log func(string)) { + open := ui.Local(c.Root(), "263", func() bool { return false }) + if mujica.Button(c, "Run delete_files", mujica.ButtonOptions{}).Clicked() { + *open = true + log("asked for approval") + } + r := mujica.ToolApprovalDialog(c, open, mujica.ToolApprovalOptions{Tool: "delete_files", + Args: `{"glob":"build/*.tmp","dry_run":false}`, Reason: "The agent wants to clean temporary files from the build folder."}) + if r.Allowed { + log("allowed") + } + if r.Denied { + log("denied") + } +} + +type permissionDemoState struct { + open bool + grant string +} + +func permissionDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "264", func() permissionDemoState { return permissionDemoState{grant: "none"} }) + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + if mujica.Button(c, "Ask to write", mujica.ButtonOptions{}).Clicked() { + s.open = true + log("asked for permission") + } + ui.Text(c, "Grant: "+s.grant) + }) + r := mujica.PermissionPrompt(c, &s.open, mujica.PermissionPromptOptions{Title: "Write to the ledger?", + Description: "The agent wants to record tonight's guests.", Scope: "Write access to ~/court/ledger and its subfolders"}) + if r.Choice != mujica.PermissionNone { + s.grant = [...]string{"none", "once", "always", "denied"}[r.Choice] + log("choice = " + s.grant) + } +} + +type stepDemoState struct { + steps []mujica.AgentStep + disabled bool +} + +func agentStepListDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "265", func() stepDemoState { + return stepDemoState{steps: []mujica.AgentStep{ + {ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: 800 * time.Millisecond, Detail: "42 guests, 3 arrive late."}, + {ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Duration: 2 * time.Second, Detail: "Table 4 is over capacity."}, + {ID: "send", Title: "Send the invitations", State: mujica.AgentPending}, + }} + }) + overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") }) + r := mujica.AgentStepList(c, s.steps, mujica.AgentStepListOptions{Disabled: s.disabled}) + if r.Toggled != "" { + log("toggled " + r.Toggled) + } + if r.Retry != "" { + log("retry " + r.Retry) + for i := range s.steps { + if s.steps[i].ID == r.Retry { + s.steps[i].State, s.steps[i].Detail = mujica.AgentRunning, "Retrying with a larger table." + } + } + } +} + +type planDemoState struct { + items []mujica.PlanItem + disabled bool +} + +func agentPlanDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "266", func() planDemoState { + return planDemoState{items: []mujica.PlanItem{ + {ID: "a", Text: "Read the guest list", State: mujica.AgentDone}, + {ID: "b", Text: "Assign the seats", State: mujica.AgentRunning}, + {ID: "c", Text: "Order the flowers"}, + }} + }) + overlayControls(c, func() { + overlayCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Agent finishes a step", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + for i := range s.items { + if s.items[i].State != mujica.AgentDone && s.items[i].State != mujica.AgentSkipped { + s.items[i].State = mujica.AgentDone + log("agent checked " + s.items[i].Text) + break + } + } + } + }) + if mujica.AgentPlan(c, &s.items, mujica.AgentPlanOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("plan changed (%d items)", len(s.items))) + } +} + +type statusDemoState struct { + state string + still bool +} + +func agentStatusDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "267", func() statusDemoState { return statusDemoState{state: "Running"} }) + var st mujica.AgentState + overlayControls(c, func() { + st = agentStatePick(c, log, &s.state) + overlayCheck(c, log, &s.still, "Still") + }) + ui.Row(c).Gap(8).Wrap().Children(func() { + mujica.AgentStatus(c, st, mujica.AgentStatusOptions{Still: s.still}) + for _, v := range []mujica.AgentState{mujica.AgentPlanning, mujica.AgentRunning, mujica.AgentWaiting, mujica.AgentDone, mujica.AgentFailed} { + mujica.AgentStatus(c, v, mujica.AgentStatusOptions{Still: s.still}) + } + }) +} + +type progressDemoState struct { + value float64 + indeterminate bool +} + +func agentProgressDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "268", func() progressDemoState { return progressDemoState{value: 0.4} }) + overlayControls(c, func() { + if ui.Slider(c, &s.value, 0, 1).Width(200).Changed() { + log(fmt.Sprintf("value = %.2f", s.value)) + } + overlayCheck(c, log, &s.indeterminate, "Indeterminate") + }) + action := "Reading ledger.md" + if s.value > 0.5 { + action = "Writing the seating chart" + } + mujica.AgentProgress(c, s.value, mujica.AgentProgressOptions{Label: "Preparing the ball", Action: action, Indeterminate: s.indeterminate}) +} + +func subAgentTreeDemo(c *ui.Context, log func(string)) { + disabled := ui.Local(c.Root(), "269", func() bool { return false }) + overlayControls(c, func() { overlayCheck(c, log, disabled, "Disabled") }) + agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", Task: "Prepare the ball", State: mujica.AgentRunning, Children: []mujica.SubAgent{ + {ID: "music", Name: "Musician", Task: "Hire the quartet", State: mujica.AgentDone}, + {ID: "kitchen", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting, Children: []mujica.SubAgent{ + {ID: "wine", Name: "Sommelier", Task: "Choose the wine", State: mujica.AgentFailed}, + }}, + }}} + if mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{Disabled: *disabled}).Changed() { + log("expanded or collapsed an agent") + } +} + +type fileChangeDemoState struct { + decision mujica.FileDecision + disabled bool + preview float64 +} + +func fileChangeCardDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "270", func() fileChangeDemoState { return fileChangeDemoState{preview: 8} }) + overlayControls(c, func() { + overlayCheck(c, log, &s.disabled, "Disabled") + overlaySlider(c, log, &s.preview, "Preview", "lines", 2, 12, 1) + }) + r := mujica.FileChangeCard(c, &s.decision, agentDemoChange, mujica.FileChangeCardOptions{Preview: int(s.preview), Disabled: s.disabled}) + if r.Decided { + log(fmt.Sprintf("decision = %d", s.decision)) + } + if r.OpenDiff { + log("open the full diff") + } +} + +var agentDemoChange = mujica.FileChange{Path: "court/ledger.md", + Old: "# Ledger\nGuests: 40\nWine: claret\nMusic: none\nHall: east wing\n", + New: "# Ledger\nGuests: 42\nWine: claret\nMusic: quartet\nFlowers: lilies\nHall: east wing\n"} diff --git a/examples/gallery/pages_agent_code.go b/examples/gallery/pages_agent_code.go new file mode 100644 index 0000000..1503640 --- /dev/null +++ b/examples/gallery/pages_agent_code.go @@ -0,0 +1,196 @@ +package main + +// Code snippets of the Agent pages, the same as their compiled Examples. + +const toolCallCardCode = `call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}", + Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true}) +} +ui.Render(view, 400, 300, 1)` + +const toolCallGroupCode = `open := false +calls := []mujica.ToolCall{ + {ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond}, + {ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const toolApprovalDialogCode = `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ToolApprovalDialog(c, &open, mujica.ToolApprovalOptions{ + Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.", + }) + if r.Allowed { + fmt.Println("run the tool") + } +} +ui.Render(view, 400, 300, 1)` + +const permissionPromptCode = `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{ + Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger", + }) + if r.Choice == mujica.PermissionAllowAlways { + fmt.Println("remember the grant") + } +} +ui.Render(view, 400, 300, 1)` + +const agentStepListCode = `steps := []mujica.AgentStep{ + {ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second}, + {ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."}, + {ID: "send", Title: "Send invitations", State: mujica.AgentPending}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" { + fmt.Println("retry", r.Retry) + } +} +ui.Render(view, 400, 300, 1)` + +const agentPlanCode = `items := []mujica.PlanItem{ + {ID: "a", Text: "Read the guest list", State: mujica.AgentDone}, + {ID: "b", Text: "Assign the seats", State: mujica.AgentRunning}, + {ID: "c", Text: "Order the flowers"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const agentStatusCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const agentProgressCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"}) +} +ui.Render(view, 400, 300, 1)` + +const subAgentTreeCode = `agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{ + {ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting}, +}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const fileChangeCardCode = `decision := mujica.FilePending +change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff { + fmt.Println("open the full diff") + } +} +ui.Render(view, 400, 300, 1)` + +const multiFileDiffReviewCode = `files := []mujica.FileChange{ + {Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}, + {Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"}, +} +decisions := make([]mujica.FileDecision, len(files)) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const commandExecutionCardCode = `run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() { + fmt.Println("abort the process") + } +} +ui.Render(view, 400, 300, 1)` + +const screenshotStreamCode = `var st mujica.ScreenshotStreamState +var frames []mujica.ScreenshotFrame // appended as the agent captures them +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const artifactPanelCode = `art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported { + fmt.Println("save the file") + } +} +ui.Render(view, 400, 300, 1)` + +const artifactVersionSwitcherCode = `current := 1 +versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactVersionSwitcher(c, ¤t, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare { + fmt.Println("diff v1 and v2") + } +} +ui.Render(view, 400, 300, 1)` + +const sandboxStatusCode = `info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning, + Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{}) +} +ui.Render(view, 400, 300, 1)` + +const mcpServerListCode = `servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop { + fmt.Println("stop", r.Server) + } +} +ui.Render(view, 400, 300, 1)` + +const memoryPanelCode = `entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" { + fmt.Println("forget", r.Deleted) + } +} +ui.Render(view, 400, 300, 1)` + +const checkpointListCode = `current := "c2" +points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CheckpointList(c, ¤t, points, mujica.CheckpointListOptions{}).Changed() { + fmt.Println("restore", current) + } +} +ui.Render(view, 400, 300, 1)` + +const humanInputRequestCode = `answer := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.HumanInputRequest(c, &answer, mujica.HumanInputRequestOptions{ + Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true, + }) + if r.Submitted { + fmt.Println("reply", answer) + } +} +ui.Render(view, 400, 300, 1)` diff --git a/examples/gallery/pages_agent_more.go b/examples/gallery/pages_agent_more.go new file mode 100644 index 0000000..dbc4b9b --- /dev/null +++ b/examples/gallery/pages_agent_more.go @@ -0,0 +1,285 @@ +package main + +import ( + "fmt" + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +type multiFileDemoState struct { + decisions []mujica.FileDecision + disabled bool +} + +var agentDemoFiles = []mujica.FileChange{ + agentDemoChange, + {Path: "court/menu.md", Old: "soup\nroast\n", New: "soup\nroast\ntart\n"}, + {Path: "court/old-plan.md", Old: "Hold the ball in the cellar.\n"}, +} + +func multiFileDiffDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "271", func() multiFileDemoState { + return multiFileDemoState{decisions: make([]mujica.FileDecision, len(agentDemoFiles))} + }) + overlayControls(c, func() { + overlayCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Reset", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + clear(s.decisions) + log("reset") + } + }) + if mujica.MultiFileDiffReview(c, &s.decisions, agentDemoFiles, mujica.MultiFileDiffReviewOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("decisions = %v", s.decisions)) + } +} + +type commandDemoState struct { + run mujica.CommandRun + disabled bool + lines int +} + +func commandExecutionDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "272", func() commandDemoState { + return commandDemoState{run: mujica.CommandRun{Command: "go test ./court/...", Dir: "~/projects/court", Running: true}} + }) + overlayControls(c, func() { + overlayCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Print a line", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.lines++ + color := []string{"32mok ", "33mwarn", "31mFAIL"}[s.lines%3] + s.run.Output += fmt.Sprintf("\x1b[%s\x1b[0m court/case%d 0.%02ds\n", color, s.lines, s.lines) + log("printed a line") + } + if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.run.Running, s.run.Output, s.lines = true, "", 0 + log("restarted") + } + }) + if mujica.CommandExecutionCard(c, s.run, mujica.CommandExecutionCardOptions{Disabled: s.disabled}).Changed() { + s.run.Running, s.run.ExitCode, s.run.Duration = false, 130, time.Duration(s.lines)*time.Second + s.run.Output += "\x1b[31maborted by user\x1b[0m\n" + log("abort requested") + } +} + +type screenshotDemoState struct { + st mujica.ScreenshotStreamState + frames []mujica.ScreenshotFrame + disabled bool +} + +// agentDemoFrame paints a frame with a moving block. +func agentDemoFrame(i int) mujica.ScreenshotFrame { + img := image.NewRGBA(image.Rect(0, 0, 320, 180)) + for y := range 180 { + for x := range 320 { + o := img.PixOffset(x, y) + v := uint8(235) + if x >= 20+i*24%260 && x < 60+i*24%260 && y > 60 && y < 120 { + v = 120 + } + img.Pix[o], img.Pix[o+1], img.Pix[o+2], img.Pix[o+3] = v, v-10, v-20, 255 + } + } + return mujica.ScreenshotFrame{Image: ui.NewBitmap(img), Caption: fmt.Sprintf("Clicked button %d", i+1), At: time.Duration(i) * 2 * time.Second} +} + +func screenshotStreamDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "273", func() screenshotDemoState { + return screenshotDemoState{frames: []mujica.ScreenshotFrame{agentDemoFrame(0), agentDemoFrame(1)}} + }) + overlayControls(c, func() { + overlayCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Capture a frame", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.frames = append(s.frames, agentDemoFrame(len(s.frames))) + log(fmt.Sprintf("frame %d arrived", len(s.frames))) + } + }) + if mujica.ScreenshotStream(c, &s.st, s.frames, mujica.ScreenshotStreamOptions{Height: 220, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("frame %d, paused = %v", s.st.Index+1, s.st.Paused)) + } +} + +type artifactDemoState struct { + kind string + disabled bool +} + +func artifactPanelDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "274", func() artifactDemoState { return artifactDemoState{kind: "Code"} }) + overlayControls(c, func() { + if ui.Select(c, &s.kind, []string{"Code", "Document"}).Changed() { + log("kind = " + s.kind) + } + overlayCheck(c, log, &s.disabled, "Disabled") + }) + art := mujica.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, \"Uika\": 2}\n}\n"} + opts := mujica.ArtifactPanelOptions{Height: 200, Disabled: s.disabled} + if s.kind == "Document" { + art = mujica.Artifact{Title: "Invitation", Kind: mujica.ArtifactDocument, Content: "# You are invited\n\nThe masquerade begins at nine.\n"} + opts.Preview = func() { + mujica.Heading(c, "You are invited", mujica.HeadingOptions{}) + ui.Text(c, "The masquerade begins at nine.") + } + } + r := mujica.ArtifactPanel(c, art, opts) + if r.Copied { + log("copied to the clipboard") + } + if r.Exported { + log("export requested") + } +} + +type versionDemoState struct { + current int + disabled bool +} + +func artifactVersionDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "275", func() versionDemoState { return versionDemoState{current: 2} }) + overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") }) + versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added the quartet"}, {Label: "v3", Note: "Moved the wine table"}} + r := mujica.ArtifactVersionSwitcher(c, &s.current, versions, mujica.ArtifactVersionSwitcherOptions{Disabled: s.disabled}) + if r.Changed { + log("version = " + versions[s.current].Label) + } + if r.Compare { + log("compare " + versions[s.current-1].Label + " and " + versions[s.current].Label) + } +} + +type sandboxDemoState struct { + state string + cpu float64 + expires time.Time +} + +func sandboxStatusDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "276", func() sandboxDemoState { + return sandboxDemoState{state: "Running", cpu: 1.2, expires: time.Now().Add(10 * time.Minute)} + }) + names := []string{"Starting", "Running", "Stopped", "Failed"} + state := mujica.SandboxStarting + overlayControls(c, func() { + if ui.Select(c, &s.state, names).Changed() { + log("state = " + s.state) + } + overlaySlider(c, log, &s.cpu, "CPU", "cores", 0, 2, 0.1) + if mujica.Button(c, "Extend 5 min", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.expires = s.expires.Add(5 * time.Minute) + log("extended") + } + }) + for i, n := range names { + if n == s.state { + state = mujica.SandboxState(i) + } + } + mujica.SandboxStatus(c, mujica.SandboxInfo{Name: "sandbox-7", State: state, Expires: s.expires, Resources: []mujica.SandboxResource{ + {Name: "CPU", Used: s.cpu, Limit: 2, Unit: "cores"}, {Name: "Memory", Used: 3.1, Limit: 4, Unit: "GB"}, + }}, mujica.SandboxStatusOptions{}) +} + +type mcpDemoState struct { + servers []mujica.MCPServer + disabled bool +} + +func mcpServerListDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "277", func() mcpDemoState { + return mcpDemoState{servers: []mujica.MCPServer{ + {ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}, + {ID: "gh", Name: "github", Transport: "http", State: mujica.MCPError, Error: "401 Unauthorized"}, + {ID: "db", Name: "postgres", Transport: "stdio", State: mujica.MCPStopped}, + }} + }) + overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") }) + r := mujica.MCPServerList(c, s.servers, mujica.MCPServerListOptions{Disabled: s.disabled}) + if r.Action == mujica.MCPNone { + return + } + log(fmt.Sprintf("%s: %s", r.Server, [...]string{"", "start", "stop", "reconnect"}[r.Action])) + for i := range s.servers { + if s.servers[i].ID != r.Server { + continue + } + switch r.Action { + case mujica.MCPStop: + s.servers[i].State = mujica.MCPStopped + default: + s.servers[i].State, s.servers[i].Tools, s.servers[i].Error = mujica.MCPConnected, 5, "" + } + } +} + +type memoryDemoState struct { + entries []mujica.MemoryEntry + disabled bool +} + +func memoryPanelDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "278", func() memoryDemoState { + return memoryDemoState{entries: []mujica.MemoryEntry{ + {ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat, 3 Oct"}, + {ID: "2", Text: "The quartet needs two days' notice.", Source: "Plan, 1 Oct"}, + {ID: "3", Text: "Claret is served after nine.", Source: "Ledger"}, + }} + }) + overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") }) + r := mujica.MemoryPanel(c, &s.entries, mujica.MemoryPanelOptions{Disabled: s.disabled}) + if r.Edited != "" { + log("edited " + r.Edited) + } + if r.Deleted != "" { + log("deleted " + r.Deleted) + } +} + +type checkpointDemoState struct { + current string + disabled bool +} + +func checkpointListDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "279", func() checkpointDemoState { return checkpointDemoState{current: "c3"} }) + overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") }) + points := []mujica.Checkpoint{{ID: "c3", Label: "Seats assigned", Note: "12 files changed"}, + {ID: "c2", Label: "Menu drafted", Note: "3 files changed"}, {ID: "c1", Label: "Start"}} + if mujica.CheckpointList(c, &s.current, points, mujica.CheckpointListOptions{Disabled: s.disabled}).Changed() { + log("rolled back to " + s.current) + } +} + +type humanDemoState struct { + answer string + free bool + deadline time.Time + disabled bool +} + +func humanInputDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "280", func() humanDemoState { return humanDemoState{free: true} }) + overlayControls(c, func() { + overlayCheck(c, log, &s.free, "AllowFree") + overlayCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Start a 30 s timer", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.deadline = time.Now().Add(30 * time.Second) + log("timer started") + } + }) + r := mujica.HumanInputRequest(c, &s.answer, mujica.HumanInputRequestOptions{Question: "Which hall should host the masquerade?", + Options: []string{"East wing", "Great hall"}, AllowFree: s.free, Deadline: s.deadline, Disabled: s.disabled}) + if r.Submitted { + log("answer sent: " + s.answer) + } + if r.TimedOut { + log("timed out") + } +} diff --git a/examples/gallery/pages_calendar.go b/examples/gallery/pages_calendar.go index 32f9be8..fef821f 100644 --- a/examples/gallery/pages_calendar.go +++ b/examples/gallery/pages_calendar.go @@ -1,3 +1,46 @@ package main -var calendarPages = []page{} +const calCategory = "Calendar" + +var calendarPages = []page{ + {ID: "421", Name: "CalendarMonthView", ZhName: "月视图", Category: calCategory, Demo: calMonthDemo, Code: calCalendarMonthViewCode, + Keys: "Tab reaches day numbers and event chips; arrows move between them; Enter opens; Alt+Left/Right moves an event a day, Alt+Up/Down a week; Escape closes the +N list."}, + {ID: "422", Name: "CalendarWeekView", ZhName: "周视图", Category: calCategory, Demo: calWeekDemo, Code: calCalendarWeekViewCode, + Keys: "Tab reaches a day column; Up/Down move the slot cursor, Enter creates a one-slot event; on a chip Alt+Up/Down moves by a slot, Alt+Left/Right by a day, Alt+Shift+Up/Down resizes."}, + {ID: "423", Name: "CalendarDayView", ZhName: "日视图", Category: calCategory, Demo: calDayDemo, Code: calCalendarDayViewCode, + Keys: "As the week view: Up/Down and Enter in the column, Alt with arrows on a chip."}, + {ID: "424", Name: "AgendaView", ZhName: "日程列表", Category: calCategory, Demo: calAgendaDemo, Code: calAgendaViewCode, + Keys: "Tab enters the list; Up/Down move between events; Enter opens."}, + {ID: "425", Name: "YearView", ZhName: "年视图", Category: calCategory, Demo: calYearDemo, Code: calYearViewCode, + Keys: "Tab enters the days; arrows move by day and week, PageUp/PageDown by month, across years; Enter chooses."}, + {ID: "426", Name: "EventChip", ZhName: "事件块", Category: calCategory, Demo: calChipDemo, Code: calEventChipCode, + Keys: "Tab focuses the chip; Enter or Space activates it."}, + {ID: "427", Name: "EventEditor", ZhName: "事件编辑器", Category: calCategory, Demo: calEditorDemo, Code: calEventEditorCode, + Keys: "Tab moves through the fields; each field keeps its own keys; Save and Cancel are buttons."}, + {ID: "428", Name: "EventPopover", ZhName: "事件浮层", Category: calCategory, Demo: calPopoverDemo, Code: calEventPopoverCode, + Keys: "Enter on the chip opens it; Tab reaches Close, Delete and Edit; Escape closes."}, + {ID: "429", Name: "CurrentTimeIndicator", ZhName: "当前时间线", Category: calCategory, Demo: calNowDemo, Code: calCurrentTimeIndicatorCode, + Keys: "Not focusable; screen readers read the time from its label."}, + {ID: "430", Name: "RecurrenceEditor", ZhName: "重复规则编辑器", Category: calCategory, Demo: calRecurrenceDemo, Code: calRecurrenceEditorCode, + Keys: "Tab moves between the repeat select, interval, weekdays and end choices; each control keeps its own keys."}, + {ID: "431", Name: "AvailabilityPicker", ZhName: "空闲时段选择", Category: calCategory, Demo: calAvailabilityDemo, Code: calAvailabilityPickerCode, + Keys: "Tab focuses the grid; arrows move the cursor; Space or Enter toggles the slot."}, + {ID: "432", Name: "TimezoneSelect", ZhName: "时区选择", Category: calCategory, Demo: calTimezoneDemo, Code: calTimezoneSelectCode, + Keys: "Enter, Space or Down opens; type to search; Up/Down move; Enter chooses; Escape closes."}, + {ID: "433", Name: "ReminderPicker", ZhName: "提醒选择", Category: calCategory, Demo: calReminderDemo, Code: calReminderPickerCode, + Keys: "Tab enters the list; Up/Down move; Space toggles."}, + {ID: "434", Name: "AttendeeList", ZhName: "参与者列表", Category: calCategory, Demo: calAttendeeDemo, Code: calAttendeeListCode, + Keys: "Tab reaches each remove button; Enter removes."}, + {ID: "435", Name: "WeekPicker", ZhName: "周选择器", Category: calCategory, Demo: calWeekPickerDemo, Code: calWeekPickerCode, + Keys: "Enter, Space or Down opens; arrows move; Enter picks the week; Escape closes."}, + {ID: "436", Name: "DurationPicker", ZhName: "时长选择器", Category: calCategory, Demo: calDurationDemo, Code: calDurationPickerCode, + Keys: "Tab moves between hours, minutes and seconds; Up/Down step; type digits and Enter."}, + {ID: "437", Name: "CronEditor", ZhName: "Cron 编辑器", Category: calCategory, Demo: calCronDemo, Code: calCronEditorCode, + Keys: "Type in the expression field; errors and the next runs update as you type."}, + {ID: "438", Name: "RelativeTime", ZhName: "相对时间", Category: calCategory, Demo: calRelativeDemo, Code: calRelativeTimeCode, + Keys: "Not focusable; the exact time is in its label and tooltip."}, + {ID: "439", Name: "Countdown", ZhName: "倒计时", Category: calCategory, Demo: calCountdownDemo, Code: calCountdownCode, + Keys: "Not focusable; reaching zero is announced."}, + {ID: "440", Name: "Stopwatch", ZhName: "秒表", Category: calCategory, Demo: calStopwatchDemo, Code: calStopwatchCode, + Keys: "Tab reaches Start/Pause, Lap and Reset; Enter or Space presses."}, +} diff --git a/examples/gallery/pages_calendar_code.go b/examples/gallery/pages_calendar_code.go new file mode 100644 index 0000000..949e14d --- /dev/null +++ b/examples/gallery/pages_calendar_code.go @@ -0,0 +1,253 @@ +package main + +// Code snippets mirror example_calendar_test.go. +const ( + calCalendarMonthViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +month := mujica.YearMonth{Year: 2026, Month: time.October} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarMonthView(c, &month, events, mujica.CalendarMonthViewOptions{Location: loc}) + if ch, ok := r.Changed(); ok { + fmt.Println("move", ch.ID, "to", ch.Start) + } +} +ui.Render(view, 400, 300, 1)` + calCalendarWeekViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +week := mujica.Date{Year: 2026, Month: time.October, Day: 14} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CalendarWeekView(c, &week, events, mujica.CalendarWeekViewOptions{Location: loc, SlotMinutes: 15}) + if ch, ok := r.Changed(); ok && ch.Kind == mujica.CalendarCreate { + fmt.Println("new event", ch.Start, ch.End) + } +} +ui.Render(view, 400, 300, 1)` + calCalendarDayViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +day := mujica.Date{Year: 2026, Month: time.October, Day: 14} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.CalendarDayView(c, &day, events, mujica.CalendarDayViewOptions{Location: loc}).Opened(); ok { + fmt.Println("open", id) + } +} +ui.Render(view, 400, 300, 1)` + calAgendaViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.AgendaViewOptions{Location: loc, From: mujica.Date{Year: 2026, Month: time.October, Day: 12}, Days: 14} + if id, ok := mujica.AgendaView(c, events, opts).Opened(); ok { + fmt.Println("open", id) + } +} +ui.Render(view, 400, 300, 1)` + calYearViewCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +events := exampleEvents(loc) +year := 2026 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if day, ok := mujica.YearView(c, &year, events, mujica.YearViewOptions{Location: loc}).DayChosen(); ok { + fmt.Println("go to", day) + } +} +ui.Render(view, 400, 300, 1)` + calEventChipCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +ev := exampleEvents(loc)[0] +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}).Clicked() { + fmt.Println("open", ev.ID) + } +} +ui.Render(view, 400, 300, 1)` + calEventEditorCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +ev := exampleEvents(loc)[0] +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EventEditor(c, &ev, mujica.EventEditorOptions{Location: loc}) + if r.Saved() { + fmt.Println("saved", ev.Title, ev.Start) + } +} +ui.Render(view, 400, 300, 1)` + calEventPopoverCode = `func exampleEvents(loc *time.Location) []mujica.CalendarEvent { + start := time.Date(2026, time.October, 14, 9, 0, 0, 0, loc) + return []mujica.CalendarEvent{ + {ID: "rehearsal", Title: "Rehearsal", Start: start, End: start.Add(2 * time.Hour), Location: "Hall"}, + {ID: "fitting", Title: "Fitting", Start: start.Add(time.Hour), End: start.Add(90 * time.Minute), Color: mujica.EventInfo}, + } +} + +loc := time.Local +ev := exampleEvents(loc)[0] +open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: loc}) + if chip.Clicked() { + open = true + } + r := mujica.EventPopover(c, chip, &open, ev, mujica.EventPopoverOptions{Location: loc}) + if r.Delete() { + fmt.Println("delete", ev.ID) + } +} +ui.Render(view, 400, 300, 1)` + calCurrentTimeIndicatorCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Box(c).Size(200, 240).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) +} +ui.Render(view, 400, 300, 1)` + calRecurrenceEditorCode = `rule := mujica.Recurrence{Unit: mujica.RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RecurrenceEditor(c, &rule, mujica.RecurrenceEditorOptions{Start: mujica.Date{Year: 2026, Month: time.October, Day: 5}}) + if r.Changed() && r.Problem() == "" { + fmt.Println(mujica.RecurrenceSummary(c, rule)) + } +} +ui.Render(view, 400, 300, 1)` + calAvailabilityPickerCode = `days := []mujica.Date{{Year: 2026, Month: time.October, Day: 12}, {Year: 2026, Month: time.October, Day: 13}} +mine := [][]bool{make([]bool, 18), make([]bool, 18)} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.AvailabilityPicker(c, &mine, mujica.AvailabilityPickerOptions{Days: days}).Changed() { + fmt.Println("availability changed") + } +} +ui.Render(view, 400, 300, 1)` + calTimezoneSelectCode = `zone := "Asia/Shanghai" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TimezoneSelect(c, &zone, mujica.TimezoneSelectOptions{}).Changed() { + fmt.Println("zone", zone) + } +} +ui.Render(view, 400, 300, 1)` + calReminderPickerCode = `reminders := []time.Duration{15 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ReminderPicker(c, &reminders, mujica.ReminderPickerOptions{Max: 3}).Changed() { + fmt.Println("reminders", reminders) + } +} +ui.Render(view, 400, 300, 1)` + calAttendeeListCode = `people := []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if i, ok := mujica.AttendeeList(c, people, mujica.AttendeeListOptions{Removable: true}).Removed(); ok { + people = append(people[:i], people[i+1:]...) + } +} +ui.Render(view, 400, 300, 1)` + calWeekPickerCode = `var week mujica.Date +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.WeekPicker(c, &week, mujica.WeekPickerOptions{WeekStart: mujica.WeekStartMonday, Clearable: true}).Changed() { + fmt.Println("week of", week) + } +} +ui.Render(view, 400, 300, 1)` + calDurationPickerCode = `length := 90 * time.Minute +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + opts := mujica.DurationPickerOptions{Min: 15 * time.Minute, Max: 8 * time.Hour} + if mujica.DurationPicker(c, &length, opts).Changed() { + fmt.Println("length", length) + } +} +ui.Render(view, 400, 300, 1)` + calCronEditorCode = `expr := "0 9 * * MON-FRI" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.CronEditor(c, &expr, mujica.CronEditorOptions{Location: time.Local}).Changed() { + if _, err := mujica.ParseCron(expr); err == nil { + fmt.Println("schedule", expr) + } + } +} +ui.Render(view, 400, 300, 1)` + calRelativeTimeCode = `posted := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.Local) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RelativeTime(c, posted, mujica.RelativeTimeOptions{Location: time.Local}) +} +ui.Render(view, 400, 300, 1)` + calCountdownCode = `deadline := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.Local) +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Countdown(c, deadline, mujica.CountdownOptions{}).Finished() { + fmt.Println("happy new year") + } +} +ui.Render(view, 400, 300, 1)` + calStopwatchCode = `var sw mujica.StopwatchState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Stopwatch(c, &sw, mujica.StopwatchOptions{}).Changed() { + fmt.Println("laps", len(sw.Laps)) + } +} +ui.Render(view, 400, 300, 1)` +) diff --git a/examples/gallery/pages_calendar_parts.go b/examples/gallery/pages_calendar_parts.go new file mode 100644 index 0000000..fa08df4 --- /dev/null +++ b/examples/gallery/pages_calendar_parts.go @@ -0,0 +1,217 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +var calColors = []string{"Accent", "Info", "Success", "Warning", "Danger", "Neutral"} + +func calChipDemo(c *ui.Context, log func(string)) { + type state struct { + color string + inline, selected, disabled bool + } + s := ui.Local(c.Root(), "426", func() state { return state{color: "Accent"} }) + dtSection(c, "EventChip", func() { + dtControls(c, func() { + mujica.Select(c, &s.color, calOptions(calColors), mujica.SelectOptions{Label: "Color"}) + mujica.Checkbox(c, &s.inline, "Inline", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.selected, "Selected", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + ev := calSample()[0] + for i, n := range calColors { + if n == s.color { + ev.Color = mujica.EventColor(i) + } + } + ui.Box(c).Width(240).Children(func() { + if mujica.EventChip(c, ev, mujica.EventChipOptions{Location: time.Local, Inline: s.inline, Selected: s.selected, Disabled: s.disabled}).Clicked() { + log("clicked " + ev.Title) + } + }) + }) +} + +func calEditorDemo(c *ui.Context, log func(string)) { + type state struct { + ev mujica.CalendarEvent + disabled bool + } + s := ui.Local(c.Root(), "427", func() state { return state{ev: calSample()[0]} }) + dtSection(c, "EventEditor", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + r := mujica.EventEditor(c, &s.ev, mujica.EventEditorOptions{Location: time.Local, Disabled: s.disabled}) + if r.Saved() { + log(fmt.Sprintf("saved %q %s, %d guests", s.ev.Title, s.ev.Start.Format("Jan 2 15:04"), len(s.ev.Attendees))) + } + if r.Canceled() { + log("canceled") + } + }) +} + +func calPopoverDemo(c *ui.Context, log func(string)) { + type state struct { + open, readOnly, disabled bool + } + s := ui.Local(c.Root(), "428", func() state { return state{} }) + dtSection(c, "EventPopover", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.readOnly, "Read-only", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + ev := calSample()[0] + ev.Attendees = []mujica.Attendee{{Name: "Sakiko", Status: mujica.AttendeeAccepted, Organizer: true}, {Name: "Mutsumi"}} + ev.Reminders = []time.Duration{15 * time.Minute} + ui.Box(c).Width(240).Children(func() { + chip := mujica.EventChip(c, ev, mujica.EventChipOptions{Location: time.Local, Disabled: s.disabled}) + if chip.Clicked() { + s.open = true + log("opened popover") + } + r := mujica.EventPopover(c, chip, &s.open, ev, mujica.EventPopoverOptions{Location: time.Local, ReadOnly: s.readOnly, Disabled: s.disabled}) + if r.Edit() { + log("edit " + ev.ID) + } + if r.Delete() { + log("delete " + ev.ID) + } + }) + }) +} + +func calNowDemo(c *ui.Context, log func(string)) { + dtSection(c, "CurrentTimeIndicator", func() { + k := mujica.Tokens(c) + ui.Box(c).Size(320, 240).Border(1, k.Border).Children(func() { + mujica.CurrentTimeIndicator(c, mujica.CurrentTimeIndicatorOptions{Location: time.Local}) + }) + }) +} + +func calRecurrenceDemo(c *ui.Context, log func(string)) { + type state struct { + rule mujica.Recurrence + disabled bool + } + s := ui.Local(c.Root(), "430", func() state { return state{} }) + dtSection(c, "RecurrenceEditor", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + r := mujica.RecurrenceEditor(c, &s.rule, mujica.RecurrenceEditorOptions{Start: mujica.DateOf(time.Now()), Disabled: s.disabled}) + if r.Changed() { + log(mujica.RecurrenceSummary(c, s.rule) + strings.TrimSpace(" "+r.Problem())) + } + }) +} + +func calAvailabilityDemo(c *ui.Context, log func(string)) { + type state struct { + mine, theirs [][]bool + days []mujica.Date + others, frozen bool + } + s := ui.Local(c.Root(), "431", func() state { + today := mujica.DateOf(time.Now()) + grid := func(seed int) [][]bool { + g := make([][]bool, 5) + for i := range g { + g[i] = make([]bool, 18) + for j := range g[i] { + g[i][j] = (i*7+j*3+seed)%4 == 0 + } + } + return g + } + days := []mujica.Date{today, today.AddDays(1), today.AddDays(2), today.AddDays(3), today.AddDays(4)} + return state{mine: grid(2), theirs: grid(1), days: days, others: true} + }) + dtSection(c, "AvailabilityPicker", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.others, "Show Mutsumi", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.frozen, "Disabled", mujica.CheckboxOptions{}) + }) + var others []mujica.AvailabilityPerson + if s.others { + others = []mujica.AvailabilityPerson{{Name: "Mutsumi", Free: s.theirs}} + } + if mujica.AvailabilityPicker(c, &s.mine, mujica.AvailabilityPickerOptions{Days: s.days, Others: others, Disabled: s.frozen}).Changed() { + n := 0 + for _, d := range s.mine { + for _, v := range d { + if v { + n++ + } + } + } + log(fmt.Sprintf("%d free slots", n)) + } + }) +} + +func calTimezoneDemo(c *ui.Context, log func(string)) { + type state struct { + zone string + disabled bool + } + s := ui.Local(c.Root(), "432", func() state { return state{zone: "Asia/Shanghai"} }) + dtSection(c, "TimezoneSelect", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + if mujica.TimezoneSelect(c, &s.zone, mujica.TimezoneSelectOptions{Disabled: s.disabled}).Changed() { + log("zone " + s.zone) + } + }) +} + +func calReminderDemo(c *ui.Context, log func(string)) { + type state struct { + v []time.Duration + max string + disabled bool + } + s := ui.Local(c.Root(), "433", func() state { return state{v: []time.Duration{15 * time.Minute}, max: "0"} }) + dtSection(c, "ReminderPicker", func() { + dtControls(c, func() { + mujica.Select(c, &s.max, calOptions([]string{"0", "2", "3"}), mujica.SelectOptions{Label: "Max (0 = any)"}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + if limit := calAtoi(s.max); limit > 0 && len(s.v) > limit { + s.v = s.v[:limit] + } + if mujica.ReminderPicker(c, &s.v, mujica.ReminderPickerOptions{Max: calAtoi(s.max), Disabled: s.disabled}).Changed() { + log(fmt.Sprint("reminders ", s.v)) + } + }) +} + +func calAttendeeDemo(c *ui.Context, log func(string)) { + type state struct { + list []mujica.Attendee + removable bool + } + people := func() []mujica.Attendee { + return []mujica.Attendee{{Name: "Sakiko Togawa", Status: mujica.AttendeeAccepted, Organizer: true}, + {Name: "Mutsumi Wakaba", Status: mujica.AttendeeTentative}, {Name: "Uika Misumi", Status: mujica.AttendeeDeclined}, + {Name: "Nyamu Yuutenji", Email: "nyamu@example.com"}} + } + s := ui.Local(c.Root(), "434", func() state { return state{list: people(), removable: true} }) + dtSection(c, "AttendeeList", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.removable, "Removable", mujica.CheckboxOptions{}) + if mujica.Button(c, "Reset", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.list = people() + log("reset") + } + }) + if i, ok := mujica.AttendeeList(c, s.list, mujica.AttendeeListOptions{Removable: s.removable}).Removed(); ok { + log("removed " + s.list[i].Name) + s.list = append(s.list[:i:i], s.list[i+1:]...) + } + }) +} diff --git a/examples/gallery/pages_calendar_time.go b/examples/gallery/pages_calendar_time.go new file mode 100644 index 0000000..78c432a --- /dev/null +++ b/examples/gallery/pages_calendar_time.go @@ -0,0 +1,128 @@ +package main + +import ( + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func calWeekPickerDemo(c *ui.Context, log func(string)) { + type state struct { + week mujica.Date + weekStart string + disabled bool + } + s := ui.Local(c.Root(), "435", func() state { return state{weekStart: "Locale"} }) + dtSection(c, "WeekPicker", func() { + dtControls(c, func() { + if mujica.Select(c, &s.weekStart, calOptions(calWeekStarts), mujica.SelectOptions{Label: "Week start"}).Changed() { + s.week = mujica.Date{} + log("week start " + s.weekStart + ", value cleared") + } + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + opts := mujica.WeekPickerOptions{WeekStart: calWeekStart(s.weekStart), Clearable: true, Disabled: s.disabled} + if mujica.WeekPicker(c, &s.week, opts).Changed() { + log("week of " + s.week.String()) + } + }) +} + +func calDurationDemo(c *ui.Context, log func(string)) { + type state struct { + v time.Duration + seconds, disabled bool + } + s := ui.Local(c.Root(), "436", func() state { return state{v: 90 * time.Minute} }) + dtSection(c, "DurationPicker", func() { + dtControls(c, func() { + mujica.Checkbox(c, &s.seconds, "Seconds", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + opts := mujica.DurationPickerOptions{Seconds: s.seconds, Min: 15 * time.Minute, Max: 8 * time.Hour, Disabled: s.disabled} + if mujica.DurationPicker(c, &s.v, opts).Changed() { + log("duration " + s.v.String()) + } + }) +} + +func calCronDemo(c *ui.Context, log func(string)) { + type state struct { + expr string + disabled bool + } + s := ui.Local(c.Root(), "437", func() state { return state{expr: "*/15 9-17 * * MON-FRI"} }) + dtSection(c, "CronEditor", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + if mujica.CronEditor(c, &s.expr, mujica.CronEditorOptions{Location: time.Local, Disabled: s.disabled}).Changed() { + if _, err := mujica.ParseCron(s.expr); err != nil { + log("invalid: " + s.expr) + } else { + log("schedule " + s.expr) + } + } + }) +} + +func calRelativeDemo(c *ui.Context, log func(string)) { + type state struct{ at time.Time } + s := ui.Local(c.Root(), "438", func() state { return state{at: time.Now()} }) + dtSection(c, "RelativeTime", func() { + dtControls(c, func() { + for _, b := range []struct { + label string + d time.Duration + }{{"Now", 0}, {"5 minutes ago", -5 * time.Minute}, {"In 3 hours", 3 * time.Hour}, {"40 days ago", -40 * 24 * time.Hour}} { + if mujica.Button(c, b.label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.at = time.Now().Add(b.d) + log("time set: " + b.label) + } + } + }) + mujica.RelativeTime(c, s.at, mujica.RelativeTimeOptions{Location: time.Local}) + }) +} + +func calCountdownDemo(c *ui.Context, log func(string)) { + type state struct { + deadline time.Time + words bool + } + s := ui.Local(c.Root(), "439", func() state { return state{deadline: time.Now().Add(10 * time.Second)} }) + dtSection(c, "Countdown", func() { + dtControls(c, func() { + if mujica.Button(c, "Restart 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.deadline = time.Now().Add(10 * time.Second) + log("restarted") + } + mujica.Checkbox(c, &s.words, "Words", mujica.CheckboxOptions{}) + }) + format := mujica.CountdownClock + if s.words { + format = mujica.CountdownWords + } + if mujica.Countdown(c, s.deadline, mujica.CountdownOptions{Format: format}).Finished() { + log("finished") + } + }) +} + +func calStopwatchDemo(c *ui.Context, log func(string)) { + type state struct { + sw mujica.StopwatchState + disabled bool + } + s := ui.Local(c.Root(), "440", func() state { return state{} }) + dtSection(c, "Stopwatch", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + if mujica.Stopwatch(c, &s.sw, mujica.StopwatchOptions{Disabled: s.disabled}).Changed() { + state := "paused" + if s.sw.Running() { + state = "running" + } + log(state + ", " + mujica.StopwatchText(s.sw.Elapsed(time.Now()))) + } + }) +} diff --git a/examples/gallery/pages_calendar_views.go b/examples/gallery/pages_calendar_views.go new file mode 100644 index 0000000..b7b627b --- /dev/null +++ b/examples/gallery/pages_calendar_views.go @@ -0,0 +1,194 @@ +package main + +import ( + "fmt" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// calSample is demo events around today in the local zone. +func calSample() []mujica.CalendarEvent { + now := time.Now() + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + at := func(d, h, m, mins int) (time.Time, time.Time) { + s := day.AddDate(0, 0, d).Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute) + return s, s.Add(time.Duration(mins) * time.Minute) + } + mk := func(id, title string, color mujica.EventColor, d, h, m, mins int) mujica.CalendarEvent { + s, e := at(d, h, m, mins) + return mujica.CalendarEvent{ID: id, Title: title, Color: color, Start: s, End: e, Location: "Rehearsal hall"} + } + evs := []mujica.CalendarEvent{ + mk("1", "Rehearsal", mujica.EventAccent, 0, 9, 0, 120), mk("2", "Costume fitting", mujica.EventInfo, 0, 10, 0, 60), + mk("3", "Lunch", mujica.EventSuccess, 0, 12, 30, 60), mk("4", "Lyrics review", mujica.EventWarning, 1, 14, 0, 90), + mk("5", "Press", mujica.EventDanger, 2, 16, 0, 45), mk("6", "Sound check", mujica.EventNeutral, 3, 18, 0, 60), + mk("7", "Encore notes", mujica.EventInfo, 0, 15, 0, 30), mk("8", "Stage walk", mujica.EventAccent, 0, 17, 0, 30), + } + holiday := mk("9", "Masquerade", mujica.EventAccent, 4, 0, 0, 24*60) + holiday.AllDay, holiday.Start = true, day.AddDate(0, 0, 4) + holiday.End = holiday.Start.AddDate(0, 0, 1) + return append(evs, holiday) +} + +// calApply applies and logs a view change. +func calApply(evs *[]mujica.CalendarEvent, ch mujica.CalendarChange, log func(string)) { + switch ch.Kind { + case mujica.CalendarCreate: + id := fmt.Sprint(len(*evs) + 100) + *evs = append(*evs, mujica.CalendarEvent{ID: id, Title: "New event", Start: ch.Start, End: ch.End}) + log("created " + ch.Start.Format("Jan 2 15:04") + " – " + ch.End.Format("15:04")) + return + } + for i := range *evs { + if (*evs)[i].ID == ch.ID { + (*evs)[i].Start, (*evs)[i].End = ch.Start, ch.End + } + } + verb := "moved" + if ch.Kind == mujica.CalendarResize { + verb = "resized" + } + log(fmt.Sprintf("%s %s to %s – %s", verb, ch.ID, ch.Start.Format("Jan 2 15:04"), ch.End.Format("15:04"))) +} + +// calViewResult logs what a view reported and applies its change. +func calViewResult(r mujica.CalendarViewResult, evs *[]mujica.CalendarEvent, version *int, log func(string)) { + if id, ok := r.Opened(); ok { + log("opened " + id) + } + if d, ok := r.DayChosen(); ok { + log("day " + d.String()) + } + if ch, ok := r.Changed(); ok { + calApply(evs, ch, log) + *version++ + } +} + +var calWeekStarts = []string{"Locale", "Sunday", "Monday", "Saturday"} + +func calWeekStart(s string) mujica.WeekStart { + for i, n := range calWeekStarts { + if n == s { + return mujica.WeekStart(i) + } + } + panic("gallery: unknown week start " + s) +} + +func calMonthDemo(c *ui.Context, log func(string)) { + type state struct { + month mujica.YearMonth + events []mujica.CalendarEvent + version, maxRows int + weekStart, maxStr string + disabled bool + } + s := ui.Local(c.Root(), "421", func() state { + now := time.Now() + return state{month: mujica.YearMonth{Year: now.Year(), Month: now.Month()}, events: calSample(), weekStart: "Locale", maxStr: "3"} + }) + dtSection(c, "CalendarMonthView", func() { + dtControls(c, func() { + mujica.Select(c, &s.weekStart, calOptions(calWeekStarts), mujica.SelectOptions{Label: "Week start"}) + mujica.Select(c, &s.maxStr, calOptions([]string{"1", "2", "3", "4"}), mujica.SelectOptions{Label: "Rows per day"}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + s.maxRows = calAtoi(s.maxStr) + r := mujica.CalendarMonthView(c, &s.month, s.events, mujica.CalendarMonthViewOptions{Location: time.Local, Version: s.version, + WeekStart: calWeekStart(s.weekStart), MaxPerDay: s.maxRows, Disabled: s.disabled}) + calViewResult(r, &s.events, &s.version, log) + }) +} + +func calWeekDemo(c *ui.Context, log func(string)) { + type state struct { + week mujica.Date + events []mujica.CalendarEvent + version int + slot string + hour12 bool + disabled bool + } + s := ui.Local(c.Root(), "422", func() state { + return state{week: mujica.DateOf(time.Now()), events: calSample(), slot: "30"} + }) + dtSection(c, "CalendarWeekView", func() { + dtControls(c, func() { + mujica.Select(c, &s.slot, calOptions([]string{"15", "30", "60"}), mujica.SelectOptions{Label: "Slot minutes"}) + mujica.Checkbox(c, &s.hour12, "12-hour", mujica.CheckboxOptions{}) + mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) + }) + slot := calAtoi(s.slot) + r := mujica.CalendarWeekView(c, &s.week, s.events, mujica.CalendarWeekViewOptions{Location: time.Local, Version: s.version, + SlotMinutes: slot, Hour12: s.hour12, Disabled: s.disabled}) + calViewResult(r, &s.events, &s.version, log) + }) +} + +func calDayDemo(c *ui.Context, log func(string)) { + type state struct { + day mujica.Date + events []mujica.CalendarEvent + version int + disabled bool + } + s := ui.Local(c.Root(), "423", func() state { return state{day: mujica.DateOf(time.Now()), events: calSample()} }) + dtSection(c, "CalendarDayView", func() { + dtControls(c, func() { mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}) }) + r := mujica.CalendarDayView(c, &s.day, s.events, mujica.CalendarDayViewOptions{Location: time.Local, Version: s.version, Disabled: s.disabled}) + calViewResult(r, &s.events, &s.version, log) + }) +} + +func calAgendaDemo(c *ui.Context, log func(string)) { + type state struct { + events []mujica.CalendarEvent + days string + } + s := ui.Local(c.Root(), "424", func() state { return state{events: calSample(), days: "7"} }) + dtSection(c, "AgendaView", func() { + dtControls(c, func() { + mujica.Select(c, &s.days, calOptions([]string{"1", "7", "30"}), mujica.SelectOptions{Label: "Days"}) + }) + days := calAtoi(s.days) + ui.Column(c).Height(420).Children(func() { + if id, ok := mujica.AgendaView(c, s.events, mujica.AgendaViewOptions{Location: time.Local, Days: days}).Opened(); ok { + log("opened " + id) + } + }) + }) +} + +func calYearDemo(c *ui.Context, log func(string)) { + type state struct { + year int + events []mujica.CalendarEvent + } + s := ui.Local(c.Root(), "425", func() state { return state{year: time.Now().Year(), events: calSample()} }) + dtSection(c, "YearView", func() { + if d, ok := mujica.YearView(c, &s.year, s.events, mujica.YearViewOptions{Location: time.Local}).DayChosen(); ok { + log("day " + d.String()) + } + }) +} + +func calAtoi(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return n +} + +func calOptions(values []string) []mujica.SelectOption[string] { + out := make([]mujica.SelectOption[string], len(values)) + for i, v := range values { + out[i] = mujica.SelectOption[string]{Value: v, Label: v} + } + return out +} diff --git a/examples/gallery/pages_charts1.go b/examples/gallery/pages_charts1.go index 77aacff..f7cb1dd 100644 --- a/examples/gallery/pages_charts1.go +++ b/examples/gallery/pages_charts1.go @@ -1,3 +1,77 @@ package main -var charts1Pages = []page{} +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +const c1Category = "Charts I" + +const c1Keys = "Tab focuses the chart (legend check boxes first); arrows move between points and the readout is announced; Home/End jump; Escape clears." + +var charts1Pages = []page{ + {ID: "101", Name: "LineChart", ZhName: "折线图", Category: c1Category, Demo: c1LineDemo, Code: c1LineCode, Keys: c1Keys + " Space on a legend entry hides or shows its series."}, + {ID: "102", Name: "AreaChart", ZhName: "面积图", Category: c1Category, Demo: c1AreaDemo, Code: c1AreaCode, Keys: c1Keys}, + {ID: "103", Name: "BarChart", ZhName: "柱状图", Category: c1Category, Demo: c1BarDemo, Code: c1BarCode, Keys: c1Keys + " Arrows step bar by bar."}, + {ID: "104", Name: "PieChart", ZhName: "饼图", Category: c1Category, Demo: c1PieDemo, Code: c1PieCode, Keys: c1Keys}, + {ID: "105", Name: "ScatterChart", ZhName: "散点图", Category: c1Category, Demo: c1ScatterDemo, Code: c1ScatterCode, Keys: c1Keys + " Enter or Space selects the point; drag a box to select."}, + {ID: "106", Name: "RadarChart", ZhName: "雷达图", Category: c1Category, Demo: c1RadarDemo, Code: c1RadarCode, Keys: c1Keys}, + {ID: "107", Name: "HeatmapChart", ZhName: "热力图", Category: c1Category, Demo: c1HeatDemo, Code: c1HeatCode, Keys: "Tab focuses the map; Left/Right move along a row, Up/Down along a column; Home/End jump; Escape clears."}, + {ID: "108", Name: "CalendarHeatmap", ZhName: "日历热力图", Category: c1Category, Demo: c1CalendarDemo, Code: c1CalendarCode, Keys: "Left/Right move a week, Up/Down a day; Home/End jump to the year's ends; the year buttons take Enter or Space."}, + {ID: "109", Name: "Histogram", ZhName: "直方图", Category: c1Category, Demo: c1HistogramDemo, Code: c1HistogramCode, Keys: c1Keys + " Arrows step bin by bin."}, + {ID: "110", Name: "BoxPlot", ZhName: "箱线图", Category: c1Category, Demo: c1BoxDemo, Code: c1BoxCode, Keys: c1Keys + " Arrows step group by group."}, + {ID: "111", Name: "WaterfallChart", ZhName: "瀑布图", Category: c1Category, Demo: c1WaterfallDemo, Code: c1WaterfallCode, Keys: c1Keys}, + {ID: "112", Name: "FunnelChart", ZhName: "漏斗图", Category: c1Category, Demo: c1FunnelDemo, Code: c1FunnelCode, Keys: c1Keys + " Arrows step stage by stage."}, + {ID: "113", Name: "BulletChart", ZhName: "子弹图", Category: c1Category, Demo: c1BulletDemo, Code: c1BulletCode, Keys: c1Keys + " Arrows read the value, the target, then each range."}, + {ID: "114", Name: "Sparkline", ZhName: "迷你图", Category: c1Category, Demo: c1SparkDemo, Code: c1SparkCode, Keys: "Tab focuses the sparkline; Left/Right read points; Home/End jump; Escape shows the last value again."}, + {ID: "115", Name: "Gauge", ZhName: "仪表盘", Category: c1Category, Demo: c1GaugeDemo, Code: c1GaugeCode, Keys: c1Keys + " Arrows read the value, then each segment."}, + {ID: "116", Name: "Treemap", ZhName: "矩形树图", Category: c1Category, Demo: c1TreemapDemo, Code: c1TreemapCode, Keys: c1Keys + " Enter or Space drills into a branch; Backspace goes back; Back and the breadcrumb take Tab."}, + {ID: "117", Name: "Sunburst", ZhName: "旭日图", Category: c1Category, Demo: c1SunburstDemo, Code: c1SunburstCode, Keys: c1Keys + " Enter or Space drills in; Backspace goes back."}, + {ID: "118", Name: "SankeyChart", ZhName: "桑基图", Category: c1Category, Demo: c1SankeyDemo, Code: c1SankeyCode, Keys: c1Keys + " Reading a node or link highlights every path through it."}, + {ID: "119", Name: "NetworkGraph", ZhName: "网络图", Category: c1Category, Demo: c1NetworkDemo, Code: c1NetworkCode, Keys: c1Keys + " Shift+arrows move the node; +/- zoom; 0 resets the view; drag nodes or the background."}, + {ID: "120", Name: "RealtimeChart", ZhName: "实时图表", Category: c1Category, Demo: c1RealtimeDemo, Code: c1RealtimeCode, Keys: "Tab reaches Pause, the legend and the chart; chart keys as in LineChart."}, +} + +var c1Statuses = []string{"Ready", "Loading", "Failed", "Empty"} + +// c1Frame shows the status picker and returns the frame. +func c1Frame(c *ui.Context, label string, status *string) (mujica.ChartFrame, bool) { + ui.Select(c, status, c1Statuses).Width(160).AlignSelf(ui.Start) + f := mujica.ChartFrame{Label: label} + switch *status { + case "Loading": + f.Status = mujica.DataLoading + case "Failed": + f.Status, f.Error = mujica.DataFailed, "The archive did not answer" + } + return f, *status != "Empty" +} + +// c1Section lays out a title, controls, then the chart. +func c1Section(c *ui.Context, title string, controls, body func()) { + ui.Column(c).Gap(12).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)) + ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(controls) + ui.Column(c).Width(560).Children(body) + }) +} + +// c1Log records a chart's events in the gallery log. +func c1Log(log func(string), name string, r mujica.ChartResult) { + if r.Changed() { + log(fmt.Sprintf("%s: point %d", name, r.Point)) + } + if s, ok := r.Toggled(); ok { + log(name + ": toggled " + s) + } + if r.ValueChanged() { + log(name + ": value changed") + } + if r.Retried() { + log(name + ": retry") + } +} diff --git a/examples/gallery/pages_charts1_code.go b/examples/gallery/pages_charts1_code.go new file mode 100644 index 0000000..8548a47 --- /dev/null +++ b/examples/gallery/pages_charts1_code.go @@ -0,0 +1,174 @@ +package main + +// Snippets are the bodies of the Examples in example_charts1_test.go. + +const c1LineCode = `series := []mujica.ChartSeries{ + {Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}}, + {Name: "Candles", Values: []float64{5, 2, 6, 7, 1}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineChart(c, series, mujica.LineChartOptions{Frame: mujica.ChartFrame{Label: "Chapel"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1AreaCode = `series := []mujica.ChartSeries{ + {Name: "Matins", Values: []float64{3, 4, 6, 5}}, + {Name: "Vespers", Values: []float64{2, 3, 2, 4}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: mujica.ChartFrame{Label: "Attendance"}, Stacked: true}) +} +ui.Render(view, 400, 300, 1)` + +const c1BarCode = `categories := []string{"Nave", "Choir", "Crypt"} +series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BarChart(c, categories, series, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Tithes"}, Labels: true}) +} +ui.Render(view, 400, 300, 1)` + +const c1PieCode = `slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: mujica.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20}) +} +ui.Render(view, 400, 300, 1)` + +const c1ScatterCode = `var selected []mujica.ScatterKey +groups := []mujica.ScatterGroup{{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScatterChart(c, &selected, groups, mujica.ScatterChartOptions{Frame: mujica.ChartFrame{Label: "Choir"}, Bubbles: true}) +} +ui.Render(view, 400, 300, 1)` + +const c1RadarCode = `axes := []string{"Voice", "Latin", "Chant", "Organ"} +series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RadarChart(c, axes, series, mujica.RadarChartOptions{Frame: mujica.ChartFrame{Label: "Skills"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1HeatCode = `rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"} +values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: mujica.ChartFrame{Label: "Hours"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1CalendarCode = `year := 2026 +values := map[mujica.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CalendarHeatmap(c, &year, values, mujica.CalendarHeatmapOptions{Frame: mujica.ChartFrame{Label: "Prayers"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1HistogramCode = `values := []float64{1, 2, 2, 3, 4, 5, 9, 10} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Histogram(c, values, mujica.HistogramOptions{Frame: mujica.ChartFrame{Label: "Ages"}, BinWidth: 5}) +} +ui.Render(view, 400, 300, 1)` + +const c1BoxCode = `groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1WaterfallCode = `steps := []mujica.WaterfallStep{ + {Label: "Tithes", Value: 12}, + {Label: "Repairs", Value: -5}, + {Label: "Year", Kind: mujica.WaterfallTotal}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: mujica.ChartFrame{Label: "Ledger"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1FunnelCode = `stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: mujica.ChartFrame{Label: "Order"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1BulletCode = `target := 250.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BulletChart(c, 270, mujica.BulletChartOptions{Frame: mujica.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}}) +} +ui.Render(view, 400, 300, 1)` + +const c1SparkCode = `values := []float64{3, 5, 4, 7, 6, 9} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: mujica.ChartFrame{Label: "Bells"}, Kind: mujica.SparklineArea}) +} +ui.Render(view, 400, 300, 1)` + +const c1GaugeCode = `segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Gauge(c, 72, mujica.GaugeOptions{Frame: mujica.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments}) +} +ui.Render(view, 400, 300, 1)` + +const c1TreemapCode = `var path []string +root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, +}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Treemap(c, &path, root, mujica.TreemapOptions{Frame: mujica.ChartFrame{Label: "Rooms"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1SunburstCode = `var path []string +root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, +}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Sunburst(c, &path, root, mujica.SunburstOptions{Frame: mujica.ChartFrame{Label: "Rooms"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1SankeyCode = `nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}} +links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: mujica.ChartFrame{Label: "Money"}}) +} +ui.Render(view, 400, 300, 1)` + +const c1NetworkCode = `var state mujica.NetworkGraphState +nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}} +edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.NetworkGraph(c, &state, nodes, edges, mujica.NetworkGraphOptions{Frame: mujica.ChartFrame{Label: "House"}, Force: true}) +} +ui.Render(view, 400, 300, 1)` + +const c1RealtimeCode = `buf := mujica.NewRealtimeBuffer(600, "Bell") +start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) +for i := range 60 { + buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7)) +} +var state mujica.RealtimeChartState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RealtimeChart(c, &state, buf, mujica.RealtimeChartOptions{Frame: mujica.ChartFrame{Label: "Sound"}, Window: 30 * time.Second}) +} +ui.Render(view, 400, 300, 1)` diff --git a/examples/gallery/pages_charts1_flow.go b/examples/gallery/pages_charts1_flow.go new file mode 100644 index 0000000..02161ff --- /dev/null +++ b/examples/gallery/pages_charts1_flow.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +var c1Abbey = mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{ + {Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}, {Name: "Letters", Value: 8}}}, + {Name: "Kitchen", Value: 25}, + {Name: "Garden", Children: []mujica.ChartNode{{Name: "Herbs", Value: 12}, {Name: "Vines", Value: 8}}}, + {Name: "Chapel", Children: []mujica.ChartNode{{Name: "Nave", Value: 18}, {Name: "Crypt", Value: 6}}}, +}} + +func c1TreemapDemo(c *ui.Context, log func(string)) { + type state struct { + status string + path []string + } + s := ui.Local(c.Root(), "116", func() state { return state{status: "Ready"} }) + c1Section(c, "Treemap", func() {}, func() { + frame, show := c1Frame(c, "Rooms", &s.status) + root := c1Abbey + if !show { + root = mujica.ChartNode{Name: "Abbey"} + s.path = nil + } + r := mujica.Treemap(c, &s.path, root, mujica.TreemapOptions{Frame: frame}) + c1Log(log, "Treemap", r) + if r.ValueChanged() { + log("Treemap: path /" + strings.Join(s.path, "/")) + } + }) +} + +func c1SunburstDemo(c *ui.Context, log func(string)) { + type state struct { + status string + path []string + depth float64 + } + s := ui.Local(c.Root(), "117", func() state { return state{status: "Ready", depth: 2} }) + c1Section(c, "Sunburst", func() { + ui.Text(c, fmt.Sprintf("%.0f rings", s.depth)) + ui.StepSlider(c, &s.depth, 1, 3, 1).Width(120) + }, func() { + frame, show := c1Frame(c, "Rooms", &s.status) + frame.Height = 300 + root := c1Abbey + if !show { + root = mujica.ChartNode{Name: "Abbey"} + s.path = nil + } + r := mujica.Sunburst(c, &s.path, root, mujica.SunburstOptions{Frame: frame, Depth: int(s.depth)}) + c1Log(log, "Sunburst", r) + if r.ValueChanged() { + log("Sunburst: path /" + strings.Join(s.path, "/")) + } + }) +} + +func c1SankeyDemo(c *ui.Context, log func(string)) { + type state struct{ status string } + s := ui.Local(c.Root(), "118", func() state { return state{status: "Ready"} }) + c1Section(c, "SankeyChart", func() {}, func() { + frame, show := c1Frame(c, "Money", &s.status) + nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "alms", Label: "Alms"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}, {ID: "bread", Label: "Bread"}, {ID: "bells", Label: "Bells"}} + links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "alms", To: "fund", Value: 30}, {From: "alms", To: "bread", Value: 10}, {From: "fund", To: "roof", Value: 50}, {From: "fund", To: "bells", Value: 40}} + if !show { + links = nil + } + c1Log(log, "SankeyChart", mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: frame})) + }) +} + +func c1NetworkDemo(c *ui.Context, log func(string)) { + type state struct { + status string + force bool + graph mujica.NetworkGraphState + } + s := ui.Local(c.Root(), "119", func() state { return state{status: "Ready", force: true} }) + c1Section(c, "NetworkGraph", func() { + ui.Checkbox(c, &s.force, "Force layout") + if mujica.Button(c, "Reset view", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.graph.ResetView() + log("NetworkGraph: reset view") + } + }, func() { + frame, show := c1Frame(c, "House", &s.status) + nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot", X: 0, Y: 0}, {ID: "b", Label: "Prior", X: 10, Y: 0}, {ID: "c", Label: "Cellarer", X: 10, Y: 10}, {ID: "d", Label: "Novice", X: 0, Y: 10}, {ID: "e", Label: "Cantor", X: 5, Y: 5}} + edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "a", To: "c"}, {From: "c", To: "d"}, {From: "b", To: "e"}, {From: "e", To: "d"}} + if !show { + nodes, edges = nil, nil + } + c1Log(log, "NetworkGraph", mujica.NetworkGraph(c, &s.graph, nodes, edges, mujica.NetworkGraphOptions{Frame: frame, Force: s.force})) + }) +} + +func c1RealtimeDemo(c *ui.Context, log func(string)) { + type state struct { + status string + feed bool + buf *mujica.RealtimeBuffer + next time.Time + tick int + view mujica.RealtimeChartState + } + s := ui.Local(c.Root(), "120", func() state { + return state{status: "Ready", feed: true, buf: mujica.NewRealtimeBuffer(600, "Bell", "Organ")} + }) + // The demo appends a sample every 500 ms while feeding. + if s.feed && !c.Now().Before(s.next) { + t := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC).Add(time.Duration(s.tick) * 500 * time.Millisecond) + x := float64(s.tick) + s.buf.Append(t, 5+3*math.Sin(x/6), 4+2*math.Sin(x/4+1)) + s.tick++ + s.next = c.Now().Add(500 * time.Millisecond) + } + if s.feed { + c.After(s.next.Sub(c.Now())) + } + c1Section(c, "RealtimeChart", func() { ui.Checkbox(c, &s.feed, "Feed data") }, func() { + frame, _ := c1Frame(c, "Sound", &s.status) + r := mujica.RealtimeChart(c, &s.view, s.buf, mujica.RealtimeChartOptions{Frame: frame, Window: 20 * time.Second}) + c1Log(log, "RealtimeChart", r) + if r.ValueChanged() { + log(fmt.Sprintf("RealtimeChart: paused=%v", s.view.Paused)) + } + }) +} diff --git a/examples/gallery/pages_charts1_series.go b/examples/gallery/pages_charts1_series.go new file mode 100644 index 0000000..6716f57 --- /dev/null +++ b/examples/gallery/pages_charts1_series.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func c1Series(gap bool) []mujica.ChartSeries { + a := []float64{3, 5, 4, 6, 8, 7, 9, 11} + if gap { + a = []float64{3, 5, math.NaN(), 6, 8, 7, 9, 11} + } + return []mujica.ChartSeries{ + {Name: "Matins", Values: a}, + {Name: "Vespers", Values: []float64{2, 3, 5, 4, 6, 9, 8, 7}}, + {Name: "Compline", Values: []float64{1, 2, 2, 3, 2, 4, 5, 4}}, + } +} + +func c1LineDemo(c *ui.Context, log func(string)) { + type state struct { + status string + gap, time bool + big bool + } + s := ui.Local(c.Root(), "101", func() state { return state{status: "Ready", gap: true} }) + c1Section(c, "LineChart", func() { + ui.Checkbox(c, &s.gap, "Missing value") + ui.Checkbox(c, &s.time, "Time axis") + ui.Checkbox(c, &s.big, "100,000 points") + }, func() { + frame, show := c1Frame(c, "Attendance", &s.status) + series, opts := c1Series(s.gap), mujica.LineChartOptions{Frame: frame} + if s.big { + series = []mujica.ChartSeries{{Name: "Wave", Values: c1Wave()}} + } + if s.time { + t0 := time.Date(2026, 10, 5, 6, 0, 0, 0, time.UTC) + opts.Times = make([]time.Time, len(series[0].Values)) + for i := range opts.Times { + opts.Times[i] = t0.Add(time.Duration(i) * time.Hour) + } + } + if !show { + series = nil + opts.Times = nil + } + c1Log(log, "LineChart", mujica.LineChart(c, series, opts)) + }) +} + +var c1WaveData []float64 + +// c1Wave is 100,000 points, made once. +func c1Wave() []float64 { + if c1WaveData == nil { + c1WaveData = make([]float64, 100_000) + for i := range c1WaveData { + c1WaveData[i] = math.Sin(float64(i)/900) + 0.3*math.Sin(float64(i)/37) + } + } + return c1WaveData +} + +func c1AreaDemo(c *ui.Context, log func(string)) { + type state struct { + status string + stacked bool + base float64 + } + s := ui.Local(c.Root(), "102", func() state { return state{status: "Ready", stacked: true} }) + c1Section(c, "AreaChart", func() { + ui.Checkbox(c, &s.stacked, "Stacked") + ui.Text(c, fmt.Sprintf("Baseline %.0f", s.base)) + ui.StepSlider(c, &s.base, -4, 4, 1).Width(140) + }, func() { + frame, show := c1Frame(c, "Attendance", &s.status) + series := c1Series(false) + if !show { + series = nil + } + c1Log(log, "AreaChart", mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: frame, Stacked: s.stacked, Baseline: s.base})) + }) +} + +func c1BarDemo(c *ui.Context, log func(string)) { + type state struct { + status string + horizontal, stacked, labels bool + } + s := ui.Local(c.Root(), "103", func() state { return state{status: "Ready", labels: true} }) + c1Section(c, "BarChart", func() { + ui.Checkbox(c, &s.horizontal, "Horizontal") + ui.Checkbox(c, &s.stacked, "Stacked") + ui.Checkbox(c, &s.labels, "Labels") + }, func() { + frame, show := c1Frame(c, "Tithes", &s.status) + cats := []string{"Nave", "Choir", "Crypt", "Bells"} + series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3, 5}}, {Name: "Silver", Values: []float64{2, 5, 1, -1}}} + if !show { + series = nil + } + c1Log(log, "BarChart", mujica.BarChart(c, cats, series, mujica.BarChartOptions{Frame: frame, Horizontal: s.horizontal, Stacked: s.stacked, Labels: s.labels})) + }) +} + +func c1PieDemo(c *ui.Context, log func(string)) { + type state struct { + status string + donut bool + min float64 + } + s := ui.Local(c.Root(), "104", func() state { return state{status: "Ready", donut: true, min: 20} }) + c1Section(c, "PieChart", func() { + ui.Checkbox(c, &s.donut, "Donut") + ui.Text(c, fmt.Sprintf("Merge under %.0f°", s.min)) + ui.StepSlider(c, &s.min, 0, 40, 5).Width(140) + }, func() { + frame, show := c1Frame(c, "Stores", &s.status) + slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Myrrh", Value: 12}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 4}} + if !show { + slices = nil + } + c1Log(log, "PieChart", mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: frame, Donut: s.donut, MinAngle: s.min})) + }) +} + +func c1ScatterDemo(c *ui.Context, log func(string)) { + type state struct { + status string + bubbles bool + selected []mujica.ScatterKey + } + s := ui.Local(c.Root(), "105", func() state { return state{status: "Ready", bubbles: true} }) + c1Section(c, "ScatterChart", func() { + ui.Checkbox(c, &s.bubbles, "Bubbles") + ui.Text(c, fmt.Sprintf("%d selected", len(s.selected))) + }, func() { + frame, show := c1Frame(c, "Choir", &s.status) + groups := []mujica.ScatterGroup{ + {Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}, {X: 6, Y: 4, Size: 1}, {X: 7, Y: 8, Size: 6}}}, + {Name: "Nuns", Points: []mujica.ScatterPoint{{X: 2, Y: 7, Size: 16}, {X: 8, Y: 9, Size: 2}, {X: 5, Y: 2, Size: 8}}}, + } + if !show { + groups = nil + } + r := mujica.ScatterChart(c, &s.selected, groups, mujica.ScatterChartOptions{Frame: frame, Bubbles: s.bubbles}) + c1Log(log, "ScatterChart", r) + if r.ValueChanged() { + log(fmt.Sprintf("ScatterChart: %d selected", len(s.selected))) + } + }) +} + +func c1RadarDemo(c *ui.Context, log func(string)) { + type state struct{ status string } + s := ui.Local(c.Root(), "106", func() state { return state{status: "Ready"} }) + c1Section(c, "RadarChart", func() {}, func() { + frame, show := c1Frame(c, "Skills", &s.status) + series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4, 7}}, {Name: "Clare", Values: []float64{5, 9, 6, 8, 3}}} + if !show { + series = nil + } + c1Log(log, "RadarChart", mujica.RadarChart(c, []string{"Voice", "Latin", "Chant", "Organ", "Bells"}, series, mujica.RadarChartOptions{Frame: frame})) + }) +} + +func c1HeatDemo(c *ui.Context, log func(string)) { + type state struct { + status string + gaps bool + } + s := ui.Local(c.Root(), "107", func() state { return state{status: "Ready", gaps: true} }) + c1Section(c, "HeatmapChart", func() { ui.Checkbox(c, &s.gaps, "Empty cells") }, func() { + frame, show := c1Frame(c, "Hours", &s.status) + rows, cols := []string{"Mon", "Tue", "Wed", "Thu"}, []string{"Matins", "Lauds", "Terce", "Vespers", "Compline"} + values := make([][]float64, len(rows)) + for r := range rows { + values[r] = make([]float64, len(cols)) + for col := range cols { + values[r][col] = float64((r*7 + col*5) % 11) + if s.gaps && (r+col)%6 == 1 { + values[r][col] = math.NaN() + } + } + } + if !show { + rows, values = nil, nil + } + c1Log(log, "HeatmapChart", mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: frame})) + }) +} diff --git a/examples/gallery/pages_charts1_stats.go b/examples/gallery/pages_charts1_stats.go new file mode 100644 index 0000000..3cb2c1e --- /dev/null +++ b/examples/gallery/pages_charts1_stats.go @@ -0,0 +1,171 @@ +package main + +import ( + "fmt" + "math" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func c1CalendarDemo(c *ui.Context, log func(string)) { + type state struct { + status, start string + year int + } + s := ui.Local(c.Root(), "108", func() state { return state{status: "Ready", start: "Monday", year: 2026} }) + c1Section(c, "CalendarHeatmap", func() { ui.Select(c, &s.start, []string{"Locale", "Sunday", "Monday", "Saturday"}) }, func() { + frame, show := c1Frame(c, "Prayers", &s.status) + frame.Height = 160 + values := map[mujica.Date]float64{} + if show { + d := mujica.Date{Year: s.year, Month: time.January, Day: 1} + for i := range 366 { + if (i*37)%5 != 0 { + values[d.AddDays(i)] = float64((i * 13) % 9) + } + } + } + start := map[string]mujica.WeekStart{"Locale": mujica.WeekStartLocale, "Sunday": mujica.WeekStartSunday, "Monday": mujica.WeekStartMonday, "Saturday": mujica.WeekStartSaturday}[s.start] + r := mujica.CalendarHeatmap(c, &s.year, values, mujica.CalendarHeatmapOptions{Frame: frame, WeekStart: start}) + c1Log(log, "CalendarHeatmap", r) + if r.ValueChanged() { + log("CalendarHeatmap: year " + strconv.Itoa(s.year)) + } + }) +} + +func c1HistogramDemo(c *ui.Context, log func(string)) { + type state struct { + status string + bins float64 + cumulative bool + values []float64 + } + s := ui.Local(c.Root(), "109", func() state { + v := make([]float64, 500) + for i := range v { + v[i] = 50 + 12*math.Sin(float64(i)*1.7)*math.Cos(float64(i)*0.31) + } + return state{status: "Ready", bins: 10, values: v} + }) + c1Section(c, "Histogram", func() { + ui.Text(c, fmt.Sprintf("%.0f bins", s.bins)) + ui.StepSlider(c, &s.bins, 3, 30, 1).Width(140) + ui.Checkbox(c, &s.cumulative, "Cumulative") + }, func() { + frame, show := c1Frame(c, "Ages", &s.status) + values := s.values + if !show { + values = nil + } + c1Log(log, "Histogram", mujica.Histogram(c, values, mujica.HistogramOptions{Frame: frame, Bins: int(s.bins), Cumulative: s.cumulative})) + }) +} + +func c1BoxDemo(c *ui.Context, log func(string)) { + type state struct{ status string } + s := ui.Local(c.Root(), "110", func() state { return state{status: "Ready"} }) + c1Section(c, "BoxPlot", func() {}, func() { + frame, show := c1Frame(c, "Hymns", &s.status) + groups := []mujica.BoxGroup{ + {Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 24}}, + {Name: "Nave", Values: []float64{4, 5, 5, 6, 7, 8}}, + {Name: "Crypt", Values: []float64{12, 14, 15, 18, 22, 25}}, + } + if !show { + groups = nil + } + c1Log(log, "BoxPlot", mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: frame})) + }) +} + +func c1WaterfallDemo(c *ui.Context, log func(string)) { + type state struct{ status string } + s := ui.Local(c.Root(), "111", func() state { return state{status: "Ready"} }) + c1Section(c, "WaterfallChart", func() {}, func() { + frame, show := c1Frame(c, "Ledger", &s.status) + steps := []mujica.WaterfallStep{ + {Label: "Tithes", Value: 12}, {Label: "Repairs", Value: -5}, {Label: "Q1", Kind: mujica.WaterfallSubtotal}, + {Label: "Alms", Value: 4}, {Label: "Bread", Value: -2}, {Label: "Year", Kind: mujica.WaterfallTotal}, + } + if !show { + steps = nil + } + c1Log(log, "WaterfallChart", mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: frame})) + }) +} + +func c1FunnelDemo(c *ui.Context, log func(string)) { + type state struct{ status string } + s := ui.Local(c.Root(), "112", func() state { return state{status: "Ready"} }) + c1Section(c, "FunnelChart", func() {}, func() { + frame, show := c1Frame(c, "Order", &s.status) + stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}, {Label: "Priors", Value: 12}} + if !show { + stages = nil + } + c1Log(log, "FunnelChart", mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: frame})) + }) +} + +func c1BulletDemo(c *ui.Context, log func(string)) { + type state struct { + status string + value, target float64 + vertical bool + } + s := ui.Local(c.Root(), "113", func() state { return state{status: "Ready", value: 270, target: 250} }) + c1Section(c, "BulletChart", func() { + ui.Text(c, fmt.Sprintf("Value %.0f", s.value)) + ui.StepSlider(c, &s.value, 0, 300, 10).Width(140) + ui.Checkbox(c, &s.vertical, "Vertical") + }, func() { + frame, _ := c1Frame(c, "Alms", &s.status) + if s.vertical { + frame.Height = 200 + } + target := s.target + c1Log(log, "BulletChart", mujica.BulletChart(c, s.value, mujica.BulletChartOptions{Frame: frame, Target: &target, Bands: []float64{150, 225, 300}, Vertical: s.vertical})) + }) +} + +func c1SparkDemo(c *ui.Context, log func(string)) { + type state struct{ status string } + s := ui.Local(c.Root(), "114", func() state { return state{status: "Ready"} }) + c1Section(c, "Sparkline", func() {}, func() { + frame, show := c1Frame(c, "Bells", &s.status) + values := []float64{3, 5, 4, math.NaN(), 7, 6, 9, 8, 11} + if !show { + values = nil + } + ui.Row(c).Gap(24).Wrap().Children(func() { + for _, k := range []mujica.SparklineKind{mujica.SparklineLine, mujica.SparklineBar, mujica.SparklineArea} { + f := frame + f.Label = fmt.Sprintf("Bells %d", k+1) + c1Log(log, f.Label, mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: f, Kind: k})) + } + }) + }) +} + +func c1GaugeDemo(c *ui.Context, log func(string)) { + type state struct { + status string + value float64 + full bool + } + s := ui.Local(c.Root(), "115", func() state { return state{status: "Ready", value: 72} }) + c1Section(c, "Gauge", func() { + ui.Text(c, fmt.Sprintf("Value %.0f", s.value)) + ui.StepSlider(c, &s.value, 0, 100, 1).Width(140) + ui.Checkbox(c, &s.full, "Full circle") + }, func() { + frame, _ := c1Frame(c, "Nave", &s.status) + segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 85, Label: "Busy", Tone: mujica.GaugeWarning}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}} + c1Log(log, "Gauge", mujica.Gauge(c, s.value, mujica.GaugeOptions{Frame: frame, Max: 100, Unit: "%", Segments: segments, Full: s.full})) + }) +} diff --git a/examples/gallery/pages_charts1_test.go b/examples/gallery/pages_charts1_test.go new file mode 100644 index 0000000..af673ca --- /dev/null +++ b/examples/gallery/pages_charts1_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// c1Demo runs page id's demo with an event log. +func c1Demo(t *testing.T, id string) (*ui.Tester, *[]string) { + t.Helper() + i := slices.IndexFunc(charts1Pages, func(p page) bool { return p.ID == id }) + if i < 0 { + t.Fatalf("no page %s", id) + } + events := &[]string{} + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Scroll(c).Fill().Padding(24).Children(func() { + charts1Pages[i].Demo(c, func(s string) { *events = append(*events, s) }) + }) + }, 900, 760) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + return tt, events +} + +// c1Labels names each demo's chart for the keys. +var c1Labels = map[string]string{ + "101": "Attendance", "102": "Attendance", "103": "Tithes", "104": "Stores", "105": "Choir", "106": "Skills", + "107": "Hours", "108": "Prayers", "109": "Ages", "110": "Hymns", "111": "Ledger", "112": "Order", "113": "Alms", + "114": "Bells 1", "115": "Nave", "116": "Rooms", "117": "Rooms", "118": "Money", "119": "House", "120": "Sound", +} + +func TestCharts1DemosLogReadouts(t *testing.T) { + for _, p := range charts1Pages { + if p.Category != "Charts I" || p.Code == "" || p.Keys == "" { + t.Fatalf("%s: incomplete page", p.ID) + } + tt, events := c1Demo(t, p.ID) + tabUntil(t, tt, c1Labels[p.ID]) + tt.Key(0, ui.KeyRight) + if len(*events) == 0 || !strings.HasSuffix((*events)[len(*events)-1], "point 0") { + t.Errorf("%s: keyboard readout not logged: %q", p.ID, *events) + } + } +} + +func TestCharts1TreemapDrillLogs(t *testing.T) { + tt, events := c1Demo(t, "116") + tabUntil(t, tt, "Rooms") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if !slices.Contains(*events, "Treemap: path /Library") { + t.Fatalf("drill not logged: %q", *events) + } +} + +func tabUntil(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + for range 40 { + if tt.Focused(label) { + return + } + tt.Key(0, ui.KeyTab) + } + t.Fatalf("Tab never reached %q", label) +} diff --git a/examples/gallery/pages_chatmsg.go b/examples/gallery/pages_chatmsg.go index 0172452..10d6b85 100644 --- a/examples/gallery/pages_chatmsg.go +++ b/examples/gallery/pages_chatmsg.go @@ -1,3 +1,261 @@ package main -var chatmsgPages = []page{} +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const chatmsgCategory = "Chat Messages" + +var chatmsgPages = append(append(chatmsgListPages, chatmsgBlockPages...), chatmsgActionPages...) + +// chatmsgReplies are the lines the demo assistant sends. +var chatmsgReplies = []string{ + "The ball begins at nine, in the Hall of Mirrors.", + "Guests arrive by the **east door**; carriages wait in the *lower court*.", + "Seating follows rank: dukes nearest the throne, then the envoys.", + "Shall I send the invitations tonight?", +} + +// chatmsgDemo is a chat whose messages the demo adds. +type chatmsgDemo struct { + ids []string + texts []string + list mujica.MessageListState + next int + sidebar bool + dated bool +} + +func newChatmsgDemo(n int) *chatmsgDemo { + d := &chatmsgDemo{sidebar: true, dated: true} + for range n { + d.add() + } + return d +} + +func (d *chatmsgDemo) add() { + d.ids = append(d.ids, fmt.Sprintf("msg-%d", d.next)) + d.texts = append(d.texts, chatmsgReplies[d.next%len(chatmsgReplies)]) + d.next++ +} + +// day puts three messages on each day, ending today. +func (d *chatmsgDemo) day(i int) time.Time { + last := (len(d.ids) - 1) / 3 + return time.Now().AddDate(0, 0, i/3-last) +} + +// row shows message i as a bubble, users and the assistant taking turns. +func (d *chatmsgDemo) row(c *ui.Context, i int) { + role := mujica.MessageAssistant + if i%2 == 0 { + role = mujica.MessageUser + } + mujica.MessageBubble(c, role, mujica.MessageBubbleOptions{}, func() { mujica.MarkdownView(c, d.texts[i]) }) +} + +// chatmsgControls adds messages to a demo chat and logs it. +func chatmsgControls(c *ui.Context, log func(string), d *chatmsgDemo) { + ui.Row(c).Gap(8).Children(func() { + if mujica.Button(c, "Add message", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + d.add() + log(fmt.Sprintf("message %s added", d.ids[len(d.ids)-1])) + } + if mujica.Button(c, "Add 10", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + for range 10 { + d.add() + } + log(fmt.Sprintf("10 messages added, %d in all", len(d.ids))) + } + if mujica.Button(c, "Jump to latest", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + d.list.ScrollToEnd() + log("scrolled to end") + } + }) +} + +var chatmsgListPages = []page{ + {ID: "201", Name: "ChatContainer", ZhName: "对话容器", Category: chatmsgCategory, + Keys: "Tab 在侧栏、消息与输入区之间移动;消息区滚轮与 PageUp/PageDown 滚动;上翻后出现回到最新按钮。", + Code: `mujica.ChatContainer(c, &chat, len(msgs), mujica.ChatContainerOptions{ + List: mujica.MessageListOptions{ID: func(i int) string { return msgs[i] }}, + Input: func() { ui.Text(c, "Composer goes here") }, +}, func(i int) { ui.Text(c, msgs[i]) })`, + Demo: func(c *ui.Context, log func(string)) { + d := *ui.Local(c.Root(), "201", func() *chatmsgDemo { return newChatmsgDemo(6) }) + draft := ui.Local(c.Root(), "201.draft", func() string { return "" }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Sidebar": &d.sidebar}, "Sidebar") + chatmsgControls(c, log, d) + }) + inputSection(c, "Live", func() { + ui.Box(c).Height(420).Border(1, mujica.Tokens(c).Border).Clip().Children(func() { + opts := mujica.ChatContainerOptions{ + List: mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }}, + Input: func() { + r := mujica.TextInput(c, draft, mujica.TextInputOptions{Label: "Message", Placeholder: "Write to the herald"}) + if r.Submitted() && strings.TrimSpace(*draft) != "" { + d.add() + d.texts[len(d.texts)-1] = *draft + log("sent a message") + *draft = "" + } + }, + } + if d.sidebar { + opts.Sidebar = func() { + ui.Column(c).Padding(12).Gap(8).Children(func() { + for _, s := range []string{"The ball", "Seating", "Envoys"} { + ui.Text(c, s) + } + }) + } + } + r := mujica.ChatContainer(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) }) + if r.Unread() > 0 { + ui.Text(c, fmt.Sprintf("%d unread", r.Unread())).Absolute().Top(8).Right(12) + } + }) + }) + }}, + {ID: "202", Name: "MessageList", ZhName: "消息列表", Category: chatmsgCategory, + Keys: "滚轮与方向键滚动;上翻后停止跟随;Tab 到回到最新按钮,Enter 回到底部。", + Code: `r := mujica.MessageList(c, &list, len(ids), mujica.MessageListOptions{ + ID: func(i int) string { return ids[i] }, +}, func(i int) { ui.Text(c, "Message "+ids[i]) }) +fmt.Println("unread", r.Unread())`, + Demo: func(c *ui.Context, log func(string)) { + d := *ui.Local(c.Root(), "202", func() *chatmsgDemo { return newChatmsgDemo(30) }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Date separators": &d.dated}, "Date separators") + chatmsgControls(c, log, d) + ui.Row(c).Children(func() { + if mujica.Button(c, "Load 100,000", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + for len(d.ids) < 100000 { + d.add() + } + log("100,000 messages loaded") + } + }) + }) + inputSection(c, "Live", func() { + opts := mujica.MessageListOptions{ID: func(i int) string { return d.ids[i] }, Label: func(i int) string { return d.texts[i] }} + if d.dated { + opts.Date = d.day + } + ui.Column(c).Height(360).Border(1, mujica.Tokens(c).Border).Children(func() { + r := mujica.MessageList(c, &d.list, len(d.ids), opts, func(i int) { d.row(c, i) }) + ui.Text(c, fmt.Sprintf("%d messages, %d unread, at end %v", len(d.ids), r.Unread(), d.list.AtEnd())).Padding(8) + }) + }) + }}, + {ID: "203", Name: "MessageBubble", ZhName: "消息气泡", Category: chatmsgCategory, + Keys: "气泡不获取焦点;内容中的链接与按钮按 Tab 顺序访问。", + Code: `mujica.MessageBubble(c, mujica.MessageAssistant, mujica.MessageBubbleOptions{Name: "Herald"}, func() { + ui.Text(c, "The ball begins at nine.") +})`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + role mujica.MessageRole + avatar, named bool + width float64 + } + s := ui.Local(c.Root(), "203", func() state { return state{role: mujica.MessageAssistant, avatar: true, named: true, width: 360} }) + roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"}, + {Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}} + inputSection(c, "Parameters", func() { + if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() { + log(fmt.Sprintf("role %d", s.role)) + } + inputFlags(c, log, map[string]*bool{"Avatar": &s.avatar, "Name": &s.named}, "Avatar", "Name") + if mujica.NumberInput(c, &s.width, mujica.NumberInputOptions{Min: 160, Max: 640, Step: 40, Label: "MaxWidth"}).Changed() { + log(fmt.Sprintf("MaxWidth %v", s.width)) + } + }) + inputSection(c, "Live", func() { + opts := mujica.MessageBubbleOptions{MaxWidth: float32(s.width)} + if s.named { + opts.Name = "Herald" + } + if s.avatar { + opts.Avatar = func() { mujica.Avatar(c, "Herald", mujica.AvatarOptions{}) } + } + mujica.MessageBubble(c, s.role, opts, func() { ui.Text(c, chatmsgReplies[0]+" "+chatmsgReplies[2]) }) + }) + inputSection(c, "States", func() { + for _, r := range roles { + mujica.MessageBubble(c, r.Value, mujica.MessageBubbleOptions{Name: r.Label}, func() { ui.Text(c, r.Label+" message") }) + } + }) + }}, + {ID: "204", Name: "MessageHeader", ZhName: "消息头", Category: chatmsgCategory, Keys: "不获取焦点。", + Code: `mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: mujica.MessageSent})`, + Demo: func(c *ui.Context, log func(string)) { + status := ui.Local(c.Root(), "204", func() mujica.MessageStatus { return mujica.MessageSent }) + items := []mujica.RadioItem[mujica.MessageStatus]{{Value: mujica.MessageNoStatus, Label: "None"}, {Value: mujica.MessageSending, Label: "Sending"}, + {Value: mujica.MessageStreaming, Label: "Streaming"}, {Value: mujica.MessageSent, Label: "Sent"}, {Value: mujica.MessageFailed, Label: "Failed"}} + inputSection(c, "Parameters", func() { + if mujica.RadioGroup(c, status, items, mujica.RadioGroupOptions{Horizontal: true, Label: "Status"}).Changed() { + log(fmt.Sprintf("status %d", *status)) + } + }) + inputSection(c, "Live", func() { + mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: *status}) + }) + inputSection(c, "States", func() { + for _, it := range items[1:] { + mujica.MessageHeader(c, mujica.MessageHeaderOptions{Model: "herald-2", Time: time.Now(), Status: it.Value}) + } + }) + }}, + {ID: "205", Name: "StreamingText", ZhName: "流式文本", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时直接显示全部文本。", + Code: `mujica.StreamingText(c, reply, mujica.StreamingTextOptions{Streaming: true, Smooth: true})`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + text string + smooth, streaming, auto bool + rate float64 + at time.Time + } + s := ui.Local(c.Root(), "205", func() state { return state{smooth: true, streaming: true, rate: 80} }) + const full = "By order of the Queen, the ball begins at nine. Guests arrive by the east door, and carriages wait in the lower court until midnight." + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Smooth": &s.smooth, "Streaming cursor": &s.streaming, "Stream": &s.auto}, "Smooth", "Streaming cursor", "Stream") + if mujica.NumberInput(c, &s.rate, mujica.NumberInputOptions{Min: 10, Max: 400, Step: 10, Label: "Rate"}).Changed() { + log(fmt.Sprintf("Rate %v", s.rate)) + } + ui.Row(c).Children(func() { + if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.text = "" + log("stream restarted") + } + }) + }) + // The demo's own source appends a word every 150 ms while streaming. + if s.auto && len(s.text) < len(full) && c.Now().Sub(s.at) > 150*time.Millisecond { + rest := full[len(s.text):] + n := strings.IndexByte(rest[1:], ' ') + 2 + if n <= 1 { + n = len(rest) + } + s.text += rest[:n] + s.at = c.Now() + if len(s.text) == len(full) { + log("stream finished") + } + } + if s.auto && len(s.text) < len(full) { + c.After(150 * time.Millisecond) + } + inputSection(c, "Live", func() { + mujica.StreamingText(c, s.text, mujica.StreamingTextOptions{Streaming: s.streaming && len(s.text) < len(full), Smooth: s.smooth, Rate: s.rate}) + }) + }}, +} diff --git a/examples/gallery/pages_chatmsg_actions.go b/examples/gallery/pages_chatmsg_actions.go new file mode 100644 index 0000000..4d84d48 --- /dev/null +++ b/examples/gallery/pages_chatmsg_actions.go @@ -0,0 +1,292 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +var chatmsgActionPages = []page{ + {ID: "211", Name: "AudioMessage", ZhName: "语音消息", Category: chatmsgCategory, Keys: "Tab 到播放按钮与波形;波形上 Left/Right 跳转,Home、End 到首尾。", + Code: `r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: time.Minute, Position: pos}) +if d, ok := r.Seek(); ok { + pos = d +}`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + playing bool + at time.Time + } + s := ui.Local(c.Root(), "211", func() state { return state{pos: 12 * time.Second} }) + const length = 47 * time.Second + // The demo plays: it moves the position while playing. + if s.playing { + s.pos += c.Now().Sub(s.at) + if s.pos >= length { + s.pos, s.playing = length, false + log("playback ended") + } + c.After(100 * time.Millisecond) + } + s.at = c.Now() + inputSection(c, "Live", func() { + r := mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Position: s.pos, Playing: s.playing, Waveform: chatmsgWave}) + if r.Toggled() { + s.playing = !s.playing + if s.pos >= length { + s.pos = 0 + } + log(fmt.Sprintf("playing %v", s.playing)) + } + if d, ok := r.Seek(); ok { + s.pos = d + log(fmt.Sprintf("seek to %v", d.Round(time.Second))) + } + }) + inputSection(c, "States", func() { + ui.Column(c).Disabled(true).Children(func() { + mujica.AudioMessage(c, mujica.AudioMessageOptions{Duration: length, Waveform: chatmsgWave}) + }) + }) + }}, + {ID: "212", Name: "LinkPreviewCard", ZhName: "链接预览卡片", Category: chatmsgCategory, Keys: "Tab 聚焦卡片,Enter 或 Space 打开。", + Code: `p := mujica.LinkPreview{URL: "https://example.com", Title: "The Palace", Site: "example.com"} +if mujica.LinkPreviewCard(c, p).Clicked() { + fmt.Println("open", p.URL) +}`, + Demo: func(c *ui.Context, log func(string)) { + thumb := ui.Local(c.Root(), "212", func() bool { return true }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Thumbnail": thumb}, "Thumbnail") + }) + p := mujica.LinkPreview{URL: "https://example.com/versailles", Title: "The Palace of Versailles", Site: "example.com", + Description: "A royal château in the Île-de-France region, seat of the French court from 1682 until the Revolution."} + if *thumb { + p.Thumbnail = chatmsgPicture("#AD9164") + } + inputSection(c, "Live", func() { + if mujica.LinkPreviewCard(c, p).Clicked() { + log("open " + p.URL) + } + if mujica.LinkPreviewCard(c, mujica.LinkPreview{URL: "https://example.com/hall", Title: "Hall of Mirrors"}).Clicked() { + log("open https://example.com/hall") + } + }) + }}, + {ID: "213", Name: "QuoteReply", ZhName: "引用回复", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 跳转。", + Code: `if mujica.QuoteReply(c, "Marie", "Let them eat cake.").Clicked() { + fmt.Println("jump to the quoted message") +}`, + Demo: func(c *ui.Context, log func(string)) { + inputSection(c, "Live", func() { + for _, q := range [][2]string{{"Marie", "Let them eat cake."}, {"Herald", chatmsgMarkdown}} { + if mujica.QuoteReply(c, q[0], q[1]).Clicked() { + log("jump to " + q[0] + ": " + chatmsgSnippet(q[1], 24)) + } + } + }) + }}, + {ID: "214", Name: "MessageActions", ZhName: "消息操作", Category: chatmsgCategory, Keys: "Tab 进入工具栏,Left/Right 移动,Enter 或 Space 触发。", + Code: `if a, ok := mujica.MessageActions(c, mujica.MessageAssistant, mujica.MessageActionsOptions{Feedback: &rating}).Action(); ok { + fmt.Println("action", a) +}`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + role mujica.MessageRole + rating mujica.MessageFeedback + rated bool + } + s := ui.Local(c.Root(), "214", func() state { return state{role: mujica.MessageAssistant, rated: true} }) + names := map[mujica.MessageAction]string{mujica.ActionCopy: "copy", mujica.ActionRegenerate: "regenerate", mujica.ActionEdit: "edit", + mujica.ActionLike: "like", mujica.ActionDislike: "dislike", mujica.ActionSpeak: "speak", mujica.ActionShare: "share"} + inputSection(c, "Parameters", func() { + roles := []mujica.RadioItem[mujica.MessageRole]{{Value: mujica.MessageUser, Label: "User"}, {Value: mujica.MessageAssistant, Label: "Assistant"}, + {Value: mujica.MessageSystem, Label: "System"}, {Value: mujica.MessageError, Label: "Error"}} + if mujica.RadioGroup(c, &s.role, roles, mujica.RadioGroupOptions{Horizontal: true, Label: "Role"}).Changed() { + log(fmt.Sprintf("role %d", s.role)) + } + inputFlags(c, log, map[string]*bool{"Rating": &s.rated}, "Rating") + }) + inputSection(c, "Live", func() { + opts := mujica.MessageActionsOptions{} + if s.rated { + opts.Feedback = &s.rating + } + if a, ok := mujica.MessageActions(c, s.role, opts).Action(); ok { + log(fmt.Sprintf("action %s, rating %d", names[a], s.rating)) + } + }) + }}, + {ID: "215", Name: "MessageEditor", ZhName: "消息编辑器", Category: chatmsgCategory, Keys: "Cmd/Ctrl+Enter 保存,Escape 取消。", + Code: `if mujica.MessageEditor(c, &draft, mujica.MessageEditorOptions{Original: "Hello"}).Saved() { + fmt.Println("new branch from", draft) +}`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + original, draft string + editing bool + branches []string + index int + } + s := ui.Local(c.Root(), "215", func() state { + return state{original: "Seat the ambassador.", branches: []string{"Seat the ambassador."}} + }) + inputSection(c, "Live", func() { + if !s.editing { + mujica.MessageBubble(c, mujica.MessageUser, mujica.MessageBubbleOptions{}, func() { ui.Text(c, s.branches[s.index]) }) + ui.Row(c).Gap(8).Justify(ui.End).Children(func() { + if mujica.BranchNavigator(c, &s.index, len(s.branches)).Changed() { + log(fmt.Sprintf("branch %d", s.index+1)) + } + if mujica.Button(c, "Edit", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() { + s.editing, s.draft = true, s.branches[s.index] + log("editing") + } + }) + return + } + r := mujica.MessageEditor(c, &s.draft, mujica.MessageEditorOptions{Original: s.branches[s.index]}) + if r.Saved() { + s.branches = append(s.branches, s.draft) + s.index, s.editing = len(s.branches)-1, false + log(fmt.Sprintf("saved as branch %d", len(s.branches))) + } + if r.Canceled() { + s.editing = false + log("edit canceled") + } + }) + }}, + {ID: "216", Name: "BranchNavigator", ZhName: "分支切换", Category: chatmsgCategory, Keys: "Tab 聚焦,Left/Right 切换,Home、End 到首末。", + Code: `if mujica.BranchNavigator(c, &branch, 3).Changed() { + fmt.Println("show branch", branch) +}`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + index int + count float64 + disabled bool + } + s := ui.Local(c.Root(), "216", func() state { return state{index: 1, count: 3} }) + inputSection(c, "Parameters", func() { + if mujica.NumberInput(c, &s.count, mujica.NumberInputOptions{Min: 1, Max: 9, StepperOnly: true, Label: "Branches"}).Changed() { + s.index = min(s.index, int(s.count)-1) + log(fmt.Sprintf("%v branches", s.count)) + } + inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled") + }) + inputSection(c, "Live", func() { + ui.Row(c).Disabled(s.disabled).Children(func() { + if mujica.BranchNavigator(c, &s.index, int(s.count)).Changed() { + log(fmt.Sprintf("branch %d of %v", s.index+1, s.count)) + } + }) + }) + }}, + {ID: "217", Name: "ErrorMessage", ZhName: "错误消息", Category: chatmsgCategory, Keys: "Tab 到错误文本(可选择复制)与重试按钮。", + Code: `opts := mujica.ErrorMessageOptions{RetryAt: time.Now().Add(30 * time.Second)} +if mujica.ErrorMessage(c, "429 Too Many Requests", opts).Retried() { + fmt.Println("send again") +}`, + Demo: func(c *ui.Context, log func(string)) { + until := ui.Local(c.Root(), "217", func() time.Time { return time.Time{} }) + inputSection(c, "Parameters", func() { + ui.Row(c).Children(func() { + if mujica.Button(c, "Rate limit for 10 s", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + *until = c.Now().Add(10 * time.Second) + log("rate limited for 10 s") + } + }) + }) + inputSection(c, "Live", func() { + opts := mujica.ErrorMessageOptions{RetryAt: *until} + if mujica.ErrorMessage(c, `429 Too Many Requests: {"type":"rate_limit_error","message":"Number of requests exceeded"}`, opts).Retried() { + log("retry") + } + if mujica.ErrorMessage(c, "dial tcp 10.0.0.7:443: connection refused", mujica.ErrorMessageOptions{Title: "Could not reach the server"}).Retried() { + log("retry connection") + } + }) + }}, + {ID: "218", Name: "ThinkingBlock", ZhName: "思考过程", Category: chatmsgCategory, Keys: "Tab 聚焦标题,Enter 或 Space 展开与折叠。", + Code: `mujica.ThinkingBlock(c, &open, mujica.ThinkingBlockOptions{Elapsed: 12 * time.Second}, func() { + ui.Text(c, "Weigh the guest list.") +})`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + open, thinking bool + started time.Time + elapsed time.Duration + } + s := ui.Local(c.Root(), "218", func() state { return state{elapsed: 14 * time.Second} }) + inputSection(c, "Parameters", func() { + label := "Start thinking" + if s.thinking { + label = "Finish thinking" + } + ui.Row(c).Children(func() { + if mujica.Button(c, label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + if s.thinking { + s.elapsed = c.Now().Sub(s.started) + } else { + s.started = c.Now() + } + s.thinking = !s.thinking + log(fmt.Sprintf("thinking %v", s.thinking)) + } + }) + }) + inputSection(c, "Live", func() { + opts := mujica.ThinkingBlockOptions{Thinking: s.thinking, Started: s.started, Elapsed: s.elapsed} + if mujica.ThinkingBlock(c, &s.open, opts, func() { + ui.Text(c, "Forty guests, thirty-six seats. The envoys outrank the counts; four counts dine in the antechamber.") + }).Changed() { + log(fmt.Sprintf("open %v", s.open)) + } + }) + }}, + {ID: "219", Name: "ScrollToBottomButton", ZhName: "回到底部按钮", Category: chatmsgCategory, Keys: "Tab 聚焦,Enter 或 Space 回到底部。", + Code: `ui.Box(c).Fill().Children(func() { + if mujica.ScrollToBottomButton(c, !list.AtEnd(), 3).Clicked() { + list.ScrollToEnd() + } +})`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + away bool + unread float64 + } + s := ui.Local(c.Root(), "219", func() state { return state{away: true, unread: 3} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Away from bottom": &s.away}, "Away from bottom") + if mujica.NumberInput(c, &s.unread, mujica.NumberInputOptions{Min: 0, Max: 99, Label: "Unread"}).Changed() { + log(fmt.Sprintf("unread %v", s.unread)) + } + }) + inputSection(c, "Live", func() { + ui.Box(c).Height(120).Background(mujica.Tokens(c).Surface).Border(1, mujica.Tokens(c).Border).Children(func() { + if mujica.ScrollToBottomButton(c, s.away, int(s.unread)).Clicked() { + s.away, s.unread = false, 0 + log("back to the bottom") + } + }) + }) + }}, + {ID: "220", Name: "TypingIndicator", ZhName: "正在输入", Category: chatmsgCategory, Keys: "不获取焦点;减少动画时显示静态文字。", + Code: `mujica.TypingIndicator(c, "Herald")`, + Demo: func(c *ui.Context, log func(string)) { + typing := ui.Local(c.Root(), "220", func() bool { return true }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Typing": typing}, "Typing") + }) + inputSection(c, "Live", func() { + if *typing { + mujica.TypingIndicator(c, "Herald") + } + }) + }}, +} diff --git a/examples/gallery/pages_chatmsg_blocks.go b/examples/gallery/pages_chatmsg_blocks.go new file mode 100644 index 0000000..3356e64 --- /dev/null +++ b/examples/gallery/pages_chatmsg_blocks.go @@ -0,0 +1,198 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const chatmsgMarkdown = "## The Decree\n\nBy order of **the Queen**, *all* guests use `the east door`. See [the protocol](https://example.com/protocol).\n\n" + + "1. Arrive at nine\n2. Bow to the throne\n - dukes first\n\n> Silence in the Hall of Mirrors.\n\n" + + "```python\ndef bow(guest):\n return f\"{guest} bows\" # deeply\n```\n\n| Guest | Seat |\n|---|--:|\n| Marie | 1 |\n| Axel | 14 |\n\n---\n\nVive la reine." + +// chatmsgPicture is a flat colored picture for image demos. +func chatmsgPicture(color string) ui.ImageSource { + return ui.MustParseSVG([]byte(fmt.Sprintf(``, color))) +} + +var chatmsgColors = []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"} + +var chatmsgBlockPages = []page{ + {ID: "206", Name: "MarkdownView", ZhName: "Markdown 视图", Category: chatmsgCategory, Keys: "Tab 到链接,Enter 激活;代码块与表格按钮按 Tab 顺序访问。", + Code: `if url, ok := mujica.MarkdownView(c, "# Decree\n\nSee **the guide** at [the court](https://example.com).").Link(); ok { + fmt.Println("open", url) +}`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + src string + stream bool + at time.Time + } + s := ui.Local(c.Root(), "206", func() state { return state{src: chatmsgMarkdown} }) + inputSection(c, "Parameters", func() { + ui.Row(c).Children(func() { + if mujica.Button(c, "Stream it again", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.src, s.stream = "", true + log("streaming started") + } + }) + mujica.TextArea(c, &s.src, mujica.TextAreaOptions{Label: "Markdown source", MaxHeight: 160}) + }) + if s.stream && len(s.src) < len(chatmsgMarkdown) && c.Now().Sub(s.at) > 40*time.Millisecond { + s.src = chatmsgMarkdown[:min(len(chatmsgMarkdown), len(s.src)+6)] + s.at = c.Now() + if len(s.src) == len(chatmsgMarkdown) { + s.stream = false + log("streaming finished; finished blocks were never parsed again") + } + } + if s.stream { + c.After(40 * time.Millisecond) + } + inputSection(c, "Live", func() { + if url, ok := mujica.MarkdownView(c, s.src).Link(); ok { + log("link " + url) + } + }) + }}, + {ID: "207", Name: "CodeBlock", ZhName: "代码块", Category: chatmsgCategory, Keys: "Tab 到换行、复制、折叠按钮;Space 或 Enter 触发。", + Code: `mujica.CodeBlock(c, "func bow() {}", mujica.CodeBlockOptions{Language: "go", LineNumbers: true})`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + lang string + numbers, wrap bool + } + s := ui.Local(c.Root(), "207", func() state { return state{lang: "go", numbers: true} }) + samples := map[string]string{ + "go": "// greet the court\nfunc greet(name string) int {\n\treturn len(\"Your Majesty, \" + name + \", the ball awaits you in the Hall of Mirrors\") + 42\n}", + "python": "def bow(guest):\n # deeply, always\n return f\"{guest} bows\" * 3", + "sql": "SELECT name, seat FROM guests WHERE rank > 2 ORDER BY seat LIMIT 10; -- the envoys", + } + inputSection(c, "Parameters", func() { + langs := []mujica.RadioItem[string]{{Value: "go", Label: "Go"}, {Value: "python", Label: "Python"}, {Value: "sql", Label: "SQL"}} + if mujica.RadioGroup(c, &s.lang, langs, mujica.RadioGroupOptions{Horizontal: true, Label: "Language"}).Changed() { + log("language " + s.lang) + } + inputFlags(c, log, map[string]*bool{"LineNumbers": &s.numbers, "Wrap at first": &s.wrap}, "LineNumbers", "Wrap at first") + }) + inputSection(c, "Live", func() { + ui.Column(c).Key(s.lang + fmt.Sprint(s.wrap)).Children(func() { + if mujica.CodeBlock(c, samples[s.lang], mujica.CodeBlockOptions{Language: s.lang, LineNumbers: s.numbers, Wrap: s.wrap}).Copied() { + log("code copied") + } + }) + }) + inputSection(c, "States", func() { + mujica.CodeBlock(c, samples["go"], mujica.CodeBlockOptions{Language: "go", Collapsed: true}) + }) + }}, + {ID: "208", Name: "TableBlock", ZhName: "表格块", Category: chatmsgCategory, Keys: "Tab 到复制按钮,Enter 或 Space 复制为 TSV。", + Code: `if mujica.TableBlock(c, []string{"Guest", "Seat"}, [][]string{{"Marie", "1"}}, mujica.TableBlockOptions{}).Copied() { + fmt.Println("copied as TSV") +}`, + Demo: func(c *ui.Context, log func(string)) { + wide := ui.Local(c.Root(), "208", func() bool { return false }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Wide table": wide}, "Wide table") + }) + header := []string{"Guest", "Title", "Seat"} + rows := [][]string{{"Marie", "Queen of France", "1"}, {"Axel", "Count of Fersen", "14"}} + if *wide { + header = append(header, "Arrival", "Carriage", "Retinue", "Notes") + for i := range rows { + rows[i] = append(rows[i], "21:00", "Gilded coach", "Twelve", "Seated near the orchestra, away from the draughts") + } + } + inputSection(c, "Live", func() { + ui.Column(c).MaxWidth(520).Children(func() { + if mujica.TableBlock(c, header, rows, mujica.TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}}).Copied() { + log("table copied as TSV") + } + }) + }) + }}, + {ID: "209", Name: "ImageGrid", ZhName: "图片网格", Category: chatmsgCategory, Keys: "Tab 聚焦图片,Enter 打开查看器;查看器内 Left/Right 切换,Escape 关闭。", + Code: `if i, ok := mujica.ImageGrid(c, []mujica.GridImage{{Source: pic, Alt: "Portrait"}}).Retried(); ok { + fmt.Println("reload", i) +}`, + Demo: func(c *ui.Context, log func(string)) { + type state struct { + n float64 + loading, fail bool + } + s := ui.Local(c.Root(), "209", func() state { return state{n: 5} }) + inputSection(c, "Parameters", func() { + if mujica.NumberInput(c, &s.n, mujica.NumberInputOptions{Min: 1, Max: 9, Label: "Images", StepperOnly: true}).Changed() { + log(fmt.Sprintf("%v images", s.n)) + } + inputFlags(c, log, map[string]*bool{"Last loading": &s.loading, "Second failed": &s.fail}, "Last loading", "Second failed") + }) + imgs := make([]mujica.GridImage, int(s.n)) + for i := range imgs { + imgs[i] = mujica.GridImage{Source: chatmsgPicture(chatmsgColors[i]), Alt: fmt.Sprintf("Portrait %d", i+1)} + } + if s.loading { + imgs[len(imgs)-1] = mujica.GridImage{Alt: "Loading portrait", Loading: true} + } + if s.fail && len(imgs) > 1 { + imgs[1] = mujica.GridImage{Alt: "Lost portrait", Err: errors.New("404")} + } + inputSection(c, "Live", func() { + if i, ok := mujica.ImageGrid(c, imgs).Retried(); ok { + s.fail = false + log(fmt.Sprintf("retry image %d", i)) + } + }) + }}, + {ID: "210", Name: "FileMessage", ZhName: "文件消息", Category: chatmsgCategory, Keys: "Tab 到文件与下载按钮,Enter 或 Space 触发。", + Code: `r := mujica.FileMessage(c, mujica.FileMessageOptions{Name: "treaty.pdf", Size: 2621440}) +if r.Downloaded() { + fmt.Println("save treaty.pdf") +}`, + Demo: func(c *ui.Context, log func(string)) { + disabled := ui.Local(c.Root(), "210", func() bool { return false }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Disabled": disabled}, "Disabled") + }) + inputSection(c, "Live", func() { + ui.Column(c).Gap(8).Disabled(*disabled).Children(func() { + for _, f := range []mujica.FileMessageOptions{{Name: "treaty.pdf", Size: 2621440}, {Name: "portrait.png", Size: 812000}, + {Name: "ledger.zip", Size: 73400320}, {Name: "decree.go", Size: 1840}, {Name: "notes", Size: 12}} { + r := mujica.FileMessage(c, f) + if r.Opened() { + log("open " + f.Name) + } + if r.Downloaded() { + log("download " + f.Name) + } + } + }) + }) + }}, +} + +// chatmsgWave is a fixed waveform for the voice demo. +var chatmsgWave = func() []float32 { + w := make([]float32, 48) + for i := range w { + w[i] = float32(0.25 + 0.7*float64((i*37)%11)/10) + if w[i] > 1 { + w[i] = 1 + } + } + return w +}() + +// chatmsgSnippet trims s to n runes for logs. +func chatmsgSnippet(s string, n int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) > n { + return string(r[:n]) + "…" + } + return string(r) +} diff --git a/examples/gallery/pages_chatmsg_test.go b/examples/gallery/pages_chatmsg_test.go new file mode 100644 index 0000000..7a2bf62 --- /dev/null +++ b/examples/gallery/pages_chatmsg_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "image/png" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// chatmsgDemoTester runs the demo of page id and returns its tester and event log. +func chatmsgDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) { + t.Helper() + i := slices.IndexFunc(chatmsgPages, func(p page) bool { return p.ID == id }) + if i < 0 { + t.Fatalf("no page %s", id) + } + events := &[]string{} + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{Mode: mode}) + ui.Scroll(c).Fill().Padding(24).Gap(16).Children(func() { + chatmsgPages[i].Demo(c, func(s string) { *events = append(*events, s) }) + }) + }, 900, 800) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Frame() + return tt, events +} + +func TestChatmsgDemosRender(t *testing.T) { + if len(chatmsgPages) != 20 { + t.Fatalf("%d chat message pages, want 20", len(chatmsgPages)) + } + dir := os.Getenv("CHATMSG_DEMO_DIR") + for _, p := range chatmsgPages { + for _, m := range []mujica.Mode{mujica.Light, mujica.Dark} { + tt, _ := chatmsgDemoTester(t, p.ID, m) + if len(tt.Texts()) == 0 { + t.Errorf("%s: demo shows nothing", p.ID) + } + if dir == "" { + continue + } + f, err := os.Create(filepath.Join(dir, p.ID+"-"+m.String()+".png")) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, tt.Image()); err != nil { + t.Fatal(err) + } + f.Close() + } + } +} + +func TestChatmsgDemosLogEvents(t *testing.T) { + for id, click := range map[string]string{ + "202": "Add message", "207": "Copy code", "214": "Regenerate", "216": "Next branch", "217": "Retry", + } { + tt, events := chatmsgDemoTester(t, id, mujica.Light) + if err := tt.Click(click); err != nil { + t.Fatalf("%s: %v", id, err) + } + if len(*events) == 0 { + t.Errorf("%s: clicking %q logged nothing", id, click) + } + } +} diff --git a/examples/gallery/pages_chatsession.go b/examples/gallery/pages_chatsession.go index e315674..7cd7b79 100644 --- a/examples/gallery/pages_chatsession.go +++ b/examples/gallery/pages_chatsession.go @@ -1,3 +1,186 @@ package main -var chatsessionPages = []page{} +const chatsessionCategory = "Chat Sessions" + +var chatsessionPages = []page{ + {ID: "241", Name: "ConversationList", ZhName: "会话列表", Category: chatsessionCategory, Demo: chatDemoConversationList, + Keys: "Up/Down choose a conversation, skipping group headers; typing jumps; Enter opens.", Code: `var state mujica.ConversationListState +convs := []mujica.ChatConversation{ + {ID: "1", Title: "Court etiquette", Updated: time.Now()}, + {ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ConversationList(c, &state, convs, mujica.ConversationListOptions{Label: "History"}, nil).Changed() { + log.Println("open", state.Selected) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "242", Name: "ConversationItem", ZhName: "会话条目", Category: chatsessionCategory, Demo: chatDemoConversationItem, + Keys: "Enter opens; F2 renames in place (Enter keeps, Escape cancels); Delete asks to delete; the ellipsis opens the menu.", Code: `conv := mujica.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{}) + if v.Deleted { + log.Println("delete", conv.ID) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "243", Name: "ConversationSearch", ZhName: "会话搜索", Category: chatsessionCategory, Demo: chatDemoConversationSearch, + Keys: "Type to search; Down moves into the results; Up/Down move; Enter opens; Escape clears.", Code: `query := "seat" +hits := []mujica.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ConversationSearch(c, &query, hits, mujica.ConversationSearchOptions{}) + if v.QueryChanged { + log.Println("search", query) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "244", Name: "ProjectList", ZhName: "项目列表", Category: chatsessionCategory, Demo: chatDemoProjectList, + Keys: "Up/Down move through folders, projects and conversations; Right/Left show or hide a project's conversations; the ellipsis menu moves a conversation.", Code: `var state mujica.ProjectListState +projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}} +convs := func(id string) []mujica.ChatConversation { + return []mujica.ChatConversation{{ID: "1", Title: "Menu"}} +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.ProjectList(c, &state, projects, convs, mujica.ProjectListOptions{}); v.Moved { + log.Println("move", v.MoveID, "to", v.MoveTo) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "245", Name: "ProjectKnowledgePanel", ZhName: "项目知识面板", Category: chatsessionCategory, Demo: chatDemoProjectKnowledgePanel, + Keys: "Tab reaches Add files and the file list; Up/Down move between remove buttons; Enter removes.", Code: `files := []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + v := mujica.ProjectKnowledgePanel(c, files, mujica.ProjectKnowledgePanelOptions{Capacity: 200000}) + if v.Add { + log.Println("pick files") + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "246", Name: "WelcomeScreen", ZhName: "欢迎页", Category: chatsessionCategory, Demo: chatDemoWelcomeScreen, + Keys: "Tab moves through the cards' examples and the prompt slot; Enter picks.", Code: `cards := []mujica.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" { + log.Println("ask", v.Example) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "247", Name: "SuggestionChips", ZhName: "建议标签", Category: chatsessionCategory, Demo: chatDemoSuggestionChips, + Keys: "Tab enters the chips; Left/Right move; Enter or Space picks.", Code: `input := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{}) +} +ui.Render(view, 400, 300, 1)`}, + {ID: "248", Name: "PromptLibrary", ZhName: "提示模板库", Category: chatsessionCategory, Demo: chatDemoPromptLibrary, + Keys: "Tab moves between categories, search, templates and fields; Up/Down move in a column; Enter picks.", Code: `var state mujica.PromptLibraryState +templates := []mujica.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.PromptLibrary(c, &state, templates, mujica.PromptLibraryOptions{}); v.Inserted != "" { + log.Println("insert", v.Inserted) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "249", Name: "CapabilityCards", ZhName: "能力卡片", Category: chatsessionCategory, Demo: chatDemoCapabilityCards, + Keys: "Tab moves between cards; Up/Down move among a card's examples; Enter picks.", Code: `cards := []mujica.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.CapabilityCards(c, cards, mujica.CapabilityCardsOptions{}); v.Example != "" { + log.Println("ask", v.Example) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "250", Name: "FeedbackForm", ZhName: "反馈表单", Category: chatsessionCategory, Demo: chatDemoFeedbackForm, + Keys: "Left/Right move among reasons, Space toggles; Escape cancels.", Code: `var value mujica.FeedbackValue +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FeedbackForm(c, &value, mujica.FeedbackFormOptions{Rating: mujica.FeedbackDown}).Submitted { + log.Println("reasons", value.Reasons) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "251", Name: "DateSeparator", ZhName: "日期分隔", Category: chatsessionCategory, Demo: chatDemoDateSeparator, + Keys: "Not focusable; read as the date.", Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DateSeparator(c, mujica.DateOf(time.Now()), mujica.DateSeparatorOptions{}) +} +ui.Render(view, 400, 300, 1)`}, + {ID: "252", Name: "ThinkingIndicator", ZhName: "思考指示", Category: chatsessionCategory, Demo: chatDemoThinkingIndicator, + Keys: "Not focusable; read as a status.", Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: "Searching the archives"}) +} +ui.Render(view, 400, 300, 1)`}, + {ID: "253", Name: "StopGeneratingButton", ZhName: "停止生成按钮", Category: chatsessionCategory, Demo: chatDemoStopGeneratingButton, + Keys: "Enter or Space stops; Cmd+. (Ctrl+. elsewhere) stops from anywhere.", Code: `generating := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: generating}).Changed() { + generating = false + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "254", Name: "SystemPromptEditor", ZhName: "系统提示编辑器", Category: chatsessionCategory, Demo: chatDemoSystemPromptEditor, + Keys: "Cmd+S (Ctrl+S elsewhere) saves.", Code: `prompt, saved := "You are helpful.", "You are helpful." +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SystemPromptEditor(c, &prompt, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save { + saved = prompt + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "255", Name: "CostEstimator", ZhName: "费用估算", Category: chatsessionCategory, Demo: chatDemoCostEstimator, + Keys: "Not focusable; read as the estimated cost.", Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15}) +} +ui.Render(view, 400, 300, 1)`}, + {ID: "256", Name: "SharedConversationView", ZhName: "分享会话视图", Category: chatsessionCategory, Demo: chatDemoSharedConversationView, + Keys: "Tab reaches Copy link and the messages; message text is selectable.", Code: `msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}, {Role: mujica.SessionAssistant, Text: "Good day."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SharedConversationView(c, msgs, mujica.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() { + c.WriteClipboard("https://example.com/share/1") + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "257", Name: "ConversationExport", ZhName: "会话导出", Category: chatsessionCategory, Demo: chatDemoConversationExport, + Keys: "Arrows choose a format and range; Tab reaches Export.", Code: `var state mujica.ConversationExportState +msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.ConversationExport(c, &state, msgs, mujica.ConversationExportOptions{Title: "Greeting"}); v.Exported { + log.Println(len(v.Content), "bytes to save") + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "258", Name: "RegenerateMenu", ZhName: "重新生成菜单", Category: chatsessionCategory, Demo: chatDemoRegenerateMenu, + Keys: "Enter, Space or Down open; Up/Down move; Right opens the model list; Escape closes.", Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen { + log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant) + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "259", Name: "ArtifactCard", ZhName: "产物卡片", Category: chatsessionCategory, Demo: chatDemoArtifactCard, + Keys: "Enter or Space opens the artifact panel.", Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ArtifactCard(c, mujica.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() { + log.Println("open artifact panel") + } +} +ui.Render(view, 400, 300, 1)`}, + {ID: "260", Name: "ChatEmptyState", ZhName: "会话空状态", Category: chatsessionCategory, Demo: chatDemoChatEmptyState, + Keys: "Tab reaches the action; Enter runs it.", Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyOffline}).Action { + log.Println("reconnect") + } +} +ui.Render(view, 400, 300, 1)`}, +} diff --git a/examples/gallery/pages_chatsession_demos.go b/examples/gallery/pages_chatsession_demos.go new file mode 100644 index 0000000..8594e36 --- /dev/null +++ b/examples/gallery/pages_chatsession_demos.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +// chatHistory is the gallery's sample conversations, dated from now. +func chatHistory(now time.Time) []mujica.ChatConversation { + day := 24 * time.Hour + return []mujica.ChatConversation{ + {ID: "a", Title: "Court etiquette notes", Updated: now.Add(-2 * time.Hour)}, + {ID: "b", Title: "Ballroom seating plan", Updated: now.Add(-day)}, + {ID: "c", Title: "Harpsichord repair", Updated: now.Add(-4 * day)}, + {ID: "d", Title: "Coronation menu", Updated: now.Add(-40 * day)}, + {ID: "e", Title: "Royal letters", Updated: now.Add(-90 * day), Pinned: true}, + {ID: "f", Title: "Garden maze", Updated: now.Add(-time.Hour)}, + } +} + +func chatSampleMessages(now time.Time) []mujica.SessionMessage { + return []mujica.SessionMessage{ + {Role: mujica.SessionUser, Text: "How should guests be seated at the banquet?", Time: now.Add(-10 * time.Minute)}, + {Role: mujica.SessionAssistant, Text: "Seat by rank: the host at the head, honored guests to the right.", Time: now.Add(-9 * time.Minute)}, + {Role: mujica.SessionUser, Author: "Lady Ann", Text: "And the musicians?", Time: now.Add(-5 * time.Minute)}, + {Role: mujica.SessionAssistant, Text: "Place the quartet behind a screen near the gallery.", Time: now.Add(-4 * time.Minute)}, + } +} + +func chatSampleCards() []mujica.CapabilityCard { + return []mujica.CapabilityCard{ + {Title: "Write", Description: "Letters, speeches and verse.", Icon: icons.Must("file-text"), Examples: []string{"Draft a royal decree", "Write a sonnet"}}, + {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"}}, + } +} + +func chatDemoConversationList(c *ui.Context, log func(string)) { + type state struct { + list mujica.ConversationListState + items []mujica.ChatConversation + many, rich bool + disabled bool + } + s := *ui.Local(c.Root(), "241", func() *state { return &state{items: chatHistory(time.Now())} }) + choiceDemo(c, func() { + if mujica.Switch(c, &s.many, "100,000 conversations", mujica.SwitchOptions{}).Changed() { + s.items = chatHistory(time.Now()) + if s.many { + now := time.Now() + for i := range 100000 { + s.items = append(s.items, mujica.ChatConversation{ID: fmt.Sprint("n", i), Title: fmt.Sprintf("Archive %d", i), Updated: now.Add(-time.Duration(i) * 7 * time.Minute)}) + } + } + log(fmt.Sprintf("conversations = %d", len(s.items))) + } + choiceFlag(c, &s.rich, "ConversationItem rows", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + var row func(conv mujica.ChatConversation) + if s.rich { + row = func(conv mujica.ChatConversation) { + v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{Selected: conv.ID == s.list.Selected}) + if v.Opened { + s.list.Selected = conv.ID + log("open " + conv.Title) + } + } + } + v := mujica.ConversationList(c, &s.list, s.items, mujica.ConversationListOptions{Label: "History", Disabled: s.disabled}, row) + v.Element.Width(380).Height(380) + if v.Changed() { + log("select " + s.list.Selected) + } + if v.Submitted() { + log("open " + s.list.Selected) + } + }) +} + +func chatDemoConversationItem(c *ui.Context, log func(string)) { + type state struct { + convs []mujica.ChatConversation + selected, disabled bool + } + s := *ui.Local(c.Root(), "242", func() *state { return &state{convs: chatHistory(time.Now())[:3]} }) + choiceDemo(c, func() { + choiceFlag(c, &s.selected, "Selected", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + ui.Column(c).Width(420).Gap(4).Children(func() { + for i := range s.convs { + conv := &s.convs[i] + ui.Row(c).Key(conv.ID).Children(func() { + v := mujica.ConversationItem(c, conv, mujica.ConversationItemOptions{Selected: s.selected && i == 0, Disabled: s.disabled}) + switch { + case v.Opened: + log("open " + conv.Title) + case v.Renamed: + log("rename to " + conv.Title) + case v.PinToggled: + conv.Pinned = !conv.Pinned + log(fmt.Sprintf("pinned %s = %v", conv.Title, conv.Pinned)) + case v.Exported: + log("export " + conv.Title) + case v.Deleted: + log("delete " + conv.Title) + } + }) + } + }) + }) +} + +func chatDemoConversationSearch(c *ui.Context, log func(string)) { + type state struct { + query, asked string + at time.Time + hits []mujica.ConversationSearchHit + fail, slow bool + disabled bool + } + s := *ui.Local(c.Root(), "243", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.slow, "Slow server", log) + choiceFlag(c, &s.fail, "Fail searches", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + loading := s.asked != s.query + if loading && (!s.slow || choiceDue(c, s.at)) { + s.asked, s.hits, loading = s.query, nil, false + for _, conv := range chatHistory(time.Now()) { + if q := strings.ToLower(s.query); q != "" && strings.Contains(strings.ToLower(conv.Title), q) { + s.hits = append(s.hits, mujica.ConversationSearchHit{ID: conv.ID, Title: conv.Title, Snippet: "…mentions " + strings.ToLower(conv.Title) + "…"}) + } + } + log(fmt.Sprintf("results for %q: %d", s.query, len(s.hits))) + } + errText := "" + if s.fail && s.query != "" { + errText = "The archive did not answer." + } + ui.Column(c).Width(420).Children(func() { + v := mujica.ConversationSearch(c, &s.query, s.hits, mujica.ConversationSearchOptions{Loading: loading, Error: errText, Disabled: s.disabled, Label: "Search conversations"}) + if v.QueryChanged { + s.at = c.Now() + log(fmt.Sprintf("query %q", s.query)) + } + if v.Chosen != "" { + log("open " + v.Chosen) + } + if v.Retry { + s.fail, s.asked = false, "" + log("retry") + } + }) + }) +} + +func chatDemoProjectList(c *ui.Context, log func(string)) { + type state struct { + list mujica.ProjectListState + owner map[string]string + disabled bool + } + s := *ui.Local(c.Root(), "244", func() *state { + return &state{owner: map[string]string{"a": "p1", "b": "p1", "c": "p3", "f": "p2"}} + }) + projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}, {ID: "p2", Name: "Gardens", Folder: "Court"}, {ID: "p3", Name: "Music"}} + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + ui.Row(c).Gap(24).AlignItems(ui.Start).Children(func() { + ui.Column(c).Width(380).Children(func() { + convs := func(id string) []mujica.ChatConversation { + var out []mujica.ChatConversation + for _, conv := range chatHistory(time.Now()) { + if s.owner[conv.ID] == id { + out = append(out, conv) + } + } + return out + } + v := mujica.ProjectList(c, &s.list, projects, convs, mujica.ProjectListOptions{Label: "Projects", Disabled: s.disabled}) + switch { + case v.Moved: + s.owner[v.MoveID] = v.MoveTo + log(fmt.Sprintf("move %s to %s", v.MoveID, v.MoveTo)) + case v.Changed: + log("project " + s.list.Selected) + case v.Opened != "": + log("open " + v.Opened) + } + }) + choiceLabeled(c, "Drag onto a project", func() { + conv := chatHistory(time.Now())[3] + v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{Draggable: true}) + v.Element.Width(300) + if v.Opened { + log("open " + conv.Title) + } + }) + }) + }) +} + +func chatDemoProjectKnowledgePanel(c *ui.Context, log func(string)) { + type state struct { + files []mujica.KnowledgeFile + next int + small bool + disabled bool + } + s := *ui.Local(c.Root(), "245", func() *state { + return &state{files: []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 120000}, {ID: "2", Name: "menu.md", Tokens: 30000}}} + }) + choiceDemo(c, func() { + choiceFlag(c, &s.small, "Capacity 150,000", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + capacity := 400000 + if s.small { + capacity = 150000 + } + ui.Column(c).Width(440).Children(func() { + v := mujica.ProjectKnowledgePanel(c, s.files, mujica.ProjectKnowledgePanelOptions{Capacity: capacity, Disabled: s.disabled}) + if v.Add { + s.next++ + s.files = append(s.files, mujica.KnowledgeFile{ID: fmt.Sprint("n", s.next), Name: fmt.Sprintf("notes-%d.txt", s.next), Tokens: 15000}) + log("add (the demo invents a file)") + } + if v.Removed != "" { + for i, f := range s.files { + if f.ID == v.Removed { + s.files = append(s.files[:i:i], s.files[i+1:]...) + log("remove " + f.Name) + break + } + } + } + }) + }) +} diff --git a/examples/gallery/pages_chatsession_more.go b/examples/gallery/pages_chatsession_more.go new file mode 100644 index 0000000..b6fd37d --- /dev/null +++ b/examples/gallery/pages_chatsession_more.go @@ -0,0 +1,341 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func chatDemoWelcomeScreen(c *ui.Context, log func(string)) { + type state struct { + input string + cards, disabled bool + } + s := *ui.Local(c.Root(), "246", func() *state { return &state{cards: true} }) + choiceDemo(c, func() { + choiceFlag(c, &s.cards, "Capability cards", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + var cards []mujica.CapabilityCard + if s.cards { + cards = chatSampleCards() + } + ui.Column(c).Width(860).Children(func() { + v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good afternoon", Subtitle: "How may I serve the court?", Cards: cards, Disabled: s.disabled, + Prompts: func() { + if mujica.SuggestionChips(c, &s.input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{Disabled: s.disabled}).Chosen != "" { + log(fmt.Sprintf("fill %q", s.input)) + } + }}) + if v.Example != "" { + log(fmt.Sprintf("ask %q", v.Example)) + } + }) + }) +} + +func chatDemoSuggestionChips(c *ui.Context, log func(string)) { + type state struct { + input string + send, disabled bool + } + s := *ui.Local(c.Root(), "247", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.send, "Send directly", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + v := mujica.SuggestionChips(c, &s.input, []string{"Plan a ball", "Write a sonnet", "Explain the minuet"}, + mujica.SuggestionChipsOptions{Send: s.send, Label: "Suggestions", Disabled: s.disabled}) + switch { + case v.Sent: + log(fmt.Sprintf("send %q", v.Chosen)) + case v.Chosen != "": + log(fmt.Sprintf("fill %q", v.Chosen)) + } + ui.Column(c).Width(420).Children(func() { + mujica.TextInput(c, &s.input, mujica.TextInputOptions{Label: "Message", Placeholder: "Message"}) + }) + }) +} + +func chatDemoPromptLibrary(c *ui.Context, log func(string)) { + type state struct { + lib mujica.PromptLibraryState + disabled bool + } + s := *ui.Local(c.Root(), "248", func() *state { return &state{} }) + templates := []mujica.PromptTemplate{ + {ID: "t1", Category: "Letters", Title: "Invitation", Body: "Write an invitation to {{guest}} for the {{event}}."}, + {ID: "t2", Category: "Letters", Title: "Thank-you note", Body: "Thank {{host}} for the evening."}, + {ID: "t3", Category: "Music", Title: "Recital program", Body: "List pieces for a {{instrument}} recital."}, + {ID: "t4", Category: "Planning", Title: "Seating chart", Body: "Seat {{count}} guests by rank."}, + } + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + before := s.lib.Selected + v := mujica.PromptLibrary(c, &s.lib, templates, mujica.PromptLibraryOptions{Label: "Prompt library", Disabled: s.disabled}) + if s.lib.Selected != before { + log("preview " + s.lib.Selected) + } + if v.Inserted != "" { + log(fmt.Sprintf("insert %q", v.Inserted)) + } + }) +} + +func chatDemoCapabilityCards(c *ui.Context, log func(string)) { + type state struct { + one, disabled bool + } + s := *ui.Local(c.Root(), "249", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.one, "One column", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + cols := 0 + if s.one { + cols = 1 + } + ui.Column(c).Width(860).Children(func() { + if v := mujica.CapabilityCards(c, chatSampleCards(), mujica.CapabilityCardsOptions{Columns: cols, Disabled: s.disabled}); v.Example != "" { + log(fmt.Sprintf("ask %q", v.Example)) + } + }) + }) +} + +func chatDemoFeedbackForm(c *ui.Context, log func(string)) { + type state struct { + value mujica.FeedbackValue + down, submitting bool + disabled bool + at time.Time + } + s := *ui.Local(c.Root(), "250", func() *state { return &state{down: true} }) + choiceDemo(c, func() { + choiceFlag(c, &s.down, "Thumbs down", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + rating := mujica.FeedbackUp + if s.down { + rating = mujica.FeedbackDown + } + if s.submitting && choiceDue(c, s.at) { + s.submitting, s.value = false, mujica.FeedbackValue{} + log("feedback sent") + } + ui.Column(c).Width(480).Children(func() { + v := mujica.FeedbackForm(c, &s.value, mujica.FeedbackFormOptions{Rating: rating, Submitting: s.submitting, Disabled: s.disabled}) + if v.Submitted { + s.submitting, s.at = true, c.Now() + log(fmt.Sprintf("submit reasons %q, %d characters", s.value.Reasons, len(s.value.Comment))) + } + if v.Cancelled { + s.value = mujica.FeedbackValue{} + log("cancel") + } + }) + }) +} + +func chatDemoDateSeparator(c *ui.Context, log func(string)) { + today := mujica.DateOf(time.Now()) + choiceDemo(c, func() { + ui.Text(c, "No parameters; the language follows the window.") + }, func() { + ui.Column(c).Width(520).Children(func() { + for _, d := range []int{0, -1, -12} { + mujica.DateSeparator(c, today.AddDays(d), mujica.DateSeparatorOptions{}) + } + }) + }) +} + +func chatDemoThinkingIndicator(c *ui.Context, log func(string)) { + type state struct{ phase int } + s := *ui.Local(c.Root(), "252", func() *state { return &state{} }) + phases := []string{"", "Searching the archives", "Reading 3 letters", "Composing a reply"} + choiceDemo(c, func() { + if mujica.Button(c, "Next phase", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.phase = (s.phase + 1) % len(phases) + log(fmt.Sprintf("phase %q", phases[s.phase])) + } + }, func() { + mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: phases[s.phase]}) + }) +} + +func chatDemoStopGeneratingButton(c *ui.Context, log func(string)) { + type state struct { + generating, disabled bool + at time.Time + } + s := *ui.Local(c.Root(), "253", func() *state { return &state{} }) + choiceDemo(c, func() { + if mujica.Button(c, "Generate", mujica.ButtonOptions{Disabled: s.generating}).Clicked() { + s.generating, s.at = true, c.Now() + log("generate") + } + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + if s.generating && c.Now().Sub(s.at) > 8*time.Second { + s.generating = false + log("finished") + } else if s.generating { + c.After(time.Second) + } + if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: s.generating, Disabled: s.disabled}).Changed() { + s.generating = false + log("stop") + } + if !s.generating { + ui.Text(c, "Idle: the button is hidden.").TextColor(mujica.Tokens(c).TextMuted) + } + }) +} + +func chatDemoSystemPromptEditor(c *ui.Context, log func(string)) { + type state struct { + value, saved string + limit, disabled bool + saving bool + at time.Time + } + s := *ui.Local(c.Root(), "254", func() *state { + return &state{value: "You are a courtly assistant.", saved: "You are a courtly assistant."} + }) + choiceDemo(c, func() { + choiceFlag(c, &s.limit, "Limit 60", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + if s.saving && choiceDue(c, s.at) { + s.saving, s.saved = false, s.value + log("saved") + } + limit := 0 + if s.limit { + limit = 60 + } + ui.Column(c).Width(520).Children(func() { + v := mujica.SystemPromptEditor(c, &s.value, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: s.saved, + MaxLength: limit, Saving: s.saving, Disabled: s.disabled}) + if v.Save { + s.saving, s.at = true, c.Now() + log(fmt.Sprintf("save %d characters", len(s.value))) + } + if v.Restored { + log("restore default") + } + }) + }) +} + +func chatDemoCostEstimator(c *ui.Context, log func(string)) { + type state struct{ in, out, inPrice, outPrice float64 } + s := *ui.Local(c.Root(), "255", func() *state { return &state{in: 12000, out: 3400, inPrice: 3, outPrice: 15} }) + choiceDemo(c, func() { + for _, f := range []struct { + v *float64 + label string + }{{&s.in, "Input tokens"}, {&s.out, "Output tokens"}, {&s.inPrice, "Input $/M"}, {&s.outPrice, "Output $/M"}} { + choiceLabeled(c, f.label, func() { + r := mujica.NumberInput(c, f.v, mujica.NumberInputOptions{Min: 0, Max: 1e7, Step: 100, Label: f.label}) + r.Element.Width(150) + if r.Changed() { + log(fmt.Sprintf("%s = %v", f.label, *f.v)) + } + }) + } + }, func() { + ui.Column(c).Width(420).Children(func() { + mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: int(s.in), OutputTokens: int(s.out), InputPrice: s.inPrice, OutputPrice: s.outPrice}) + }) + }) +} + +func chatDemoSharedConversationView(c *ui.Context, log func(string)) { + type state struct{ disabled bool } + s := *ui.Local(c.Root(), "256", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + ui.Column(c).Width(600).Height(420).Children(func() { + if mujica.SharedConversationView(c, chatSampleMessages(time.Now()), mujica.SharedConversationViewOptions{Title: "Banquet seating", + SharedBy: "Lady Ann", SharedAt: time.Now(), Disabled: s.disabled}).Changed() { + c.WriteClipboard("https://example.com/share/banquet") + log("copy link") + } + }) + }) +} + +func chatDemoConversationExport(c *ui.Context, log func(string)) { + type state struct { + export mujica.ConversationExportState + disabled bool + } + s := *ui.Local(c.Root(), "257", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + ui.Column(c).Width(580).Children(func() { + v := mujica.ConversationExport(c, &s.export, chatSampleMessages(time.Now()), mujica.ConversationExportOptions{Title: "Banquet seating", + Selected: []int{1, 3}, Disabled: s.disabled}) + if v.Exported { + log(fmt.Sprintf("export %d bytes as format %d (the caller writes the file)", len(v.Content), v.Format)) + } + }) + }) +} + +func chatDemoRegenerateMenu(c *ui.Context, log func(string)) { + type state struct{ disabled bool } + s := *ui.Local(c.Root(), "258", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet", "Haiku"}, Current: "Opus", Disabled: s.disabled}) + if v.Chosen { + log(fmt.Sprintf("retry kind %d model %q variant %q", v.Choice.Kind, v.Choice.Model, v.Choice.Variant)) + } + }) +} + +func chatDemoArtifactCard(c *ui.Context, log func(string)) { + type state struct{ streaming, disabled bool } + s := *ui.Local(c.Root(), "259", func() *state { return &state{} }) + choiceDemo(c, func() { + choiceFlag(c, &s.streaming, "Streaming", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + for _, a := range []mujica.ArtifactCardOptions{ + {Kind: mujica.ArtifactCode, Title: "seating.go", Detail: "Go · 120 lines"}, + {Kind: mujica.ArtifactDocument, Title: "Royal decree", Detail: "2 pages"}, + {Kind: mujica.ArtifactWeb, Title: "Ball invitation"}, + } { + a.Streaming, a.Disabled = s.streaming, s.disabled + if mujica.ArtifactCard(c, a).Changed() { + log("open " + a.Title) + } + } + }) +} + +func chatDemoChatEmptyState(c *ui.Context, log func(string)) { + type state struct{ kind int } + s := *ui.Local(c.Root(), "260", func() *state { return &state{} }) + choiceDemo(c, func() { + mujica.RadioGroup(c, &s.kind, []mujica.RadioItem[int]{{Value: 0, Label: "New"}, {Value: 1, Label: "No results"}, {Value: 2, Label: "Offline"}}, + mujica.RadioGroupOptions{Horizontal: true, Label: "Kind"}) + }, func() { + ui.Column(c).Width(420).Children(func() { + if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyKind(s.kind), Query: "minuet"}).Action { + log(fmt.Sprintf("action for kind %d", s.kind)) + } + }) + }) +} diff --git a/examples/gallery/pages_code.go b/examples/gallery/pages_code.go index 028688b..9f9be1e 100644 --- a/examples/gallery/pages_code.go +++ b/examples/gallery/pages_code.go @@ -1,3 +1,245 @@ package main -var codePages = []page{} +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const codeCategory = "Terminal & Code" + +var codePages = []page{ + {ID: "321", Name: "Terminal", ZhName: "终端", Category: codeCategory, Demo: codeTerminalDemo, Code: codeTerminalCode, + Keys: "Focused: keys and text go to the PTY; Ctrl+letters send control codes; Cmd+C/V (Ctrl+Shift+C/V elsewhere) copy and paste; Shift+PageUp/PageDown scroll back; drag selects."}, + {ID: "322", Name: "TerminalTabs", ZhName: "终端标签", Category: codeCategory, Demo: codeTerminalTabsDemo, Code: codeTerminalTabsCode, + Keys: "Left/Right, Home/End switch tabs; F2 or double-click renames (Enter saves, Escape cancels); Delete closes, asking first while a process runs."}, + {ID: "323", Name: "TerminalSearch", ZhName: "终端搜索", Category: codeCategory, Demo: codeTerminalSearchDemo, Code: codeTerminalSearchCode, + Keys: "Enter / Shift+Enter go to the next / previous match; Cmd+G (F3 elsewhere) too."}, + {ID: "324", Name: "CommandHistory", ZhName: "命令历史", Category: codeCategory, Demo: codeCommandHistoryDemo, Code: codeCommandHistoryCode, + Keys: "Up/Down choose; Enter or double-click replays; typing in the list jumps by command."}, + {ID: "325", Name: "LogViewer", ZhName: "日志查看器", Category: codeCategory, Demo: codeLogViewerDemo, Code: codeLogViewerCode, + Keys: "Space toggles a level or Follow; arrows, PageUp/PageDown, Home/End scroll the log."}, + {ID: "326", Name: "ProcessList", ZhName: "进程列表", Category: codeCategory, Demo: codeProcessListDemo, Code: codeProcessListCode, + Keys: "Up/Down choose; Delete ends the chosen process after confirming; click a header to sort, again to reverse."}, + {ID: "327", Name: "CodeViewer", ZhName: "代码查看器", Category: codeCategory, Demo: codeCodeViewerDemo, Code: codeCodeViewerCode, + Keys: "Up/Down move the current line; Shift extends; Cmd/Ctrl+C copies; Left folds, Right unfolds."}, + {ID: "328", Name: "Minimap", ZhName: "代码缩略图", Category: codeCategory, Demo: codeMinimapDemo, Code: codeMinimapCode, + Keys: "Up/Down move a line, PageUp/PageDown a screen, Home/End to the ends; click or drag jumps."}, + {ID: "329", Name: "FindWidget", ZhName: "查找替换", Category: codeCategory, Demo: codeFindWidgetDemo, Code: codeFindWidgetCode, + Keys: "Enter / Shift+Enter next / previous match; Enter in the replace field replaces the current match."}, + {ID: "330", Name: "SearchPanel", ZhName: "多文件搜索", Category: codeCategory, Demo: codeSearchPanelDemo, Code: codeSearchPanelCode, + Keys: "Up/Down choose; Enter opens a match or folds a file; Left folds, Right unfolds a file."}, + {ID: "331", Name: "ProblemsPanel", ZhName: "问题面板", Category: codeCategory, Demo: codeProblemsDemo, Code: codeProblemsPanelCode, + Keys: "Space toggles a severity; Up/Down choose; Enter opens a problem or folds a file; Left/Right fold and unfold."}, + {ID: "332", Name: "OutputPanel", ZhName: "输出面板", Category: codeCategory, Demo: codeOutputDemo, Code: codeOutputPanelCode, + Keys: "Tab reaches the channel menu, Follow and Clear; arrows choose a channel in the open menu."}, + {ID: "333", Name: "SymbolOutline", ZhName: "符号大纲", Category: codeCategory, Demo: codeSymbolDemo, Code: codeSymbolOutlineCode, + Keys: "Up/Down move; Right opens, Left closes; Enter or double-click jumps; typing jumps by name."}, + {ID: "334", Name: "CompletionMenu", ZhName: "补全菜单", Category: codeCategory, Demo: codeCompletionDemo, Code: codeCompletionMenuCode, + Keys: "While typing: Down/Up move, Tab or Enter accepts, Escape closes; the editor keeps the focus."}, + {ID: "335", Name: "DebugToolbar", ZhName: "调试工具栏", Category: codeCategory, Demo: codeDebugDemo, Code: codeDebugToolbarCode, + Keys: "F5 continue, F6 pause, F10 step over, F11 step into, Shift+F11 step out, Cmd/Ctrl+Shift+F5 restart, Shift+F5 stop; Left/Right inside the toolbar."}, + {ID: "336", Name: "BreakpointList", ZhName: "断点列表", Category: codeCategory, Demo: codeBreakpointDemo, Code: codeBreakpointListCode, + Keys: "Space toggles; Enter on a location opens it; the condition field saves with Enter, cancels with Escape."}, + {ID: "337", Name: "CallStack", ZhName: "调用栈", Category: codeCategory, Demo: codeCallStackDemo, Code: codeCallStackCode, + Keys: "Up/Down choose a frame; Enter folds a thread; Left/Right fold and unfold."}, + {ID: "338", Name: "VariablesPanel", ZhName: "变量面板", Category: codeCategory, Demo: codeVariablesDemo, Code: codeVariablesPanelCode, + Keys: "Up/Down move; Right opens, Left closes; Enter, F2 or double-click edits a leaf; Enter saves, Escape cancels."}, + {ID: "339", Name: "HexViewer", ZhName: "十六进制查看器", Category: codeCategory, Demo: codeHexDemo, Code: codeHexViewerCode, + Keys: "Arrows move by byte and row; Shift extends; Home/End go to the row ends; Cmd/Ctrl+C copies hex."}, + {ID: "340", Name: "Flamegraph", ZhName: "火焰图", Category: codeCategory, Demo: codeFlamegraphDemo, Code: codeFlamegraphCode, + Keys: "Up parent, Down first child, Left/Right siblings; Enter zooms in, Escape zooms out; the readout is announced."}, +} + +// codeDemoCheck is a switch that logs its changes. +func codeDemoCheck(c *ui.Context, v *bool, label string, log func(string)) { + if mujica.Switch(c, v, label, mujica.SwitchOptions{}).Changed() { + log(fmt.Sprintf("%s: %v", label, *v)) + } +} + +func codeDemoControls(c *ui.Context, fn func()) { + ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Margin(0, 0, 16, 0).Children(fn) +} + +func codeTerminalDemo(c *ui.Context, log func(string)) { + type state struct { + term mujica.TerminalState + line string + disabled bool + } + s := ui.Local(c.Root(), "321", func() *state { + s := &state{} + s.term.Write("Welcome to the \x1b[1;35mcourt\x1b[0m shell. Type and press Enter.\r\n$ ") + return s + }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + if mujica.Button(c, "Clear screen", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + st.term.Write("\x1b[2J\x1b[H$ ") + log("clear screen") + } + }) + // A tiny echo shell stands in for the PTY. + mujica.Terminal(c, &st.term, mujica.TerminalOptions{Disabled: st.disabled, + Input: func(data string) { + log(fmt.Sprintf("input %q", data)) + for _, r := range data { + switch r { + case '\r': + st.term.Write("\r\n" + codeShell(st.line) + "$ ") + st.line = "" + case 0x7f: + if st.line != "" { + st.line = st.line[:len(st.line)-1] + st.term.Write("\b \b") + } + default: + if r >= ' ' { + st.line += string(r) + st.term.Write(string(r)) + } + } + } + }, + Resized: func(cols, rows int) { log(fmt.Sprintf("resized %d×%d", cols, rows)) }, + }).Size(640, 260) +} + +// codeShell answers a few commands for the demo terminal. +func codeShell(line string) string { + switch f := strings.Fields(line); { + case len(f) == 0: + return "" + case f[0] == "ls": + return "\x1b[34mchronicles\x1b[0m decrees.txt \x1b[32mseal.sh\x1b[0m\r\n" + case f[0] == "echo": + return strings.Join(f[1:], " ") + "\r\n" + case f[0] == "date": + return time.Now().Format(time.RFC1123) + "\r\n" + } + return "\x1b[31m" + line + ": command not found\x1b[0m\r\n" +} + +func codeTerminalTabsDemo(c *ui.Context, log func(string)) { + type state struct { + tabs []mujica.TerminalTab + active string + next int + disabled bool + } + s := ui.Local(c.Root(), "322", func() *state { + return &state{tabs: []mujica.TerminalTab{{ID: "1", Title: "zsh"}, {ID: "2", Title: "build", Busy: true}, {ID: "3", Title: "logs"}}, active: "1", next: 4} + }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + if mujica.Button(c, "New terminal", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + id := fmt.Sprint(st.next) + st.next++ + st.tabs = append(st.tabs, mujica.TerminalTab{ID: id, Title: "shell " + id}) + st.active = id + log("new " + id) + } + }) + r := mujica.TerminalTabs(c, &st.tabs, &st.active, mujica.TerminalTabsOptions{Disabled: st.disabled}, func(id string) { + ui.Text(c, "Terminal "+id+" content").TextColor(mujica.Tokens(c).TextMuted) + }) + r.Element.Width(560) + if r.Changed { + log("active " + st.active) + } + if r.Renamed != "" { + log("renamed " + r.Renamed) + } + if r.Closed != "" { + log("closed " + r.Closed) + } +} + +func codeTerminalSearchDemo(c *ui.Context, log func(string)) { + type state struct { + term mujica.TerminalState + search mujica.TerminalSearchState + disabled bool + } + s := ui.Local(c.Root(), "323", func() *state { + s := &state{} + for i := range 60 { + if i%9 == 4 { + s.term.Write(fmt.Sprintf("[%02d] \x1b[31merror\x1b[0m: seal %d missing\r\n", i, i)) + } else { + s.term.Write(fmt.Sprintf("[%02d] ledger page %d copied\r\n", i, i)) + } + } + return s + }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + ui.Column(c).Width(600).Gap(8).Children(func() { + if mujica.TerminalSearch(c, &st.term, &st.search, mujica.TerminalSearchOptions{Disabled: st.disabled}).Changed() { + log(fmt.Sprintf("query %q, match %d", st.search.Query, st.search.Current+1)) + } + mujica.Terminal(c, &st.term, mujica.TerminalOptions{}).Height(220) + }) +} + +func codeCommandHistoryDemo(c *ui.Context, log func(string)) { + type state struct { + entries []mujica.HistoryEntry + s mujica.CommandHistoryState + disabled bool + } + s := ui.Local(c.Root(), "324", func() *state { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + var e []mujica.HistoryEntry + for i, cmd := range []string{"git pull", "go build ./...", "go test ./...", "make seal", "ls -la", "git status"} { + e = append(e, mujica.HistoryEntry{Command: cmd, Time: at.Add(time.Duration(i) * time.Minute)}) + } + e[3].ExitCode = 2 + return &state{entries: e} + }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + r := mujica.CommandHistory(c, st.entries, &st.s, mujica.CommandHistoryOptions{Disabled: st.disabled}) + r.Element.Size(480, 300) + if r.Replayed != nil { + log("replay " + r.Replayed.Command) + st.entries = append(st.entries, mujica.HistoryEntry{Command: r.Replayed.Command, Time: time.Now()}) + } +} + +func codeLogViewerDemo(c *ui.Context, log func(string)) { + type state struct { + lines []mujica.LogLine + s mujica.LogViewerState + disabled bool + } + s := ui.Local(c.Root(), "325", func() *state { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + lines := make([]mujica.LogLine, 100_000) + words := []string{"ledger synced", "seal applied", "herald dispatched", "archive rotated"} + for i := range lines { + lines[i] = mujica.LogLine{Time: at.Add(time.Duration(i) * time.Second), Level: mujica.LogLevel(i % 7 % 4), Text: fmt.Sprintf("%s #%d", words[i%4], i)} + } + return &state{lines: lines} + }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + if mujica.Button(c, "Append line", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + st.lines = append(st.lines, mujica.LogLine{Time: time.Now(), Level: mujica.LogWarn, Text: fmt.Sprintf("late seal #%d", len(st.lines))}) + log("append") + } + }) + r := mujica.LogViewer(c, st.lines, &st.s, mujica.LogViewerOptions{Disabled: st.disabled}) + r.Element.Size(720, 360) + if r.Changed() { + log(fmt.Sprintf("hidden %v, follow %v", st.s.Hidden, st.s.Follow)) + } +} diff --git a/examples/gallery/pages_code_debug.go b/examples/gallery/pages_code_debug.go new file mode 100644 index 0000000..a6e91a2 --- /dev/null +++ b/examples/gallery/pages_code_debug.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func codeSymbolDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.SymbolOutlineState + cursor float64 + disabled bool + } + s := ui.Local(c.Root(), "333", func() *state { return &state{cursor: 15} }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + ui.Text(c, fmt.Sprintf("Cursor line %.0f", st.cursor)) + if mujica.Slider(c, &st.cursor, mujica.SliderOptions{Min: 1, Max: 34, Step: 1}).Changed() { + log(fmt.Sprintf("cursor %.0f", st.cursor)) + } + }) + symbols := []mujica.Symbol{ + {Name: "Decree", Kind: mujica.SymbolType, Line: 6, EndLine: 9, Detail: "struct", Children: []mujica.Symbol{ + {Name: "ID", Kind: mujica.SymbolField, Line: 7, EndLine: 7, Detail: "int"}, + {Name: "Title", Kind: mujica.SymbolField, Line: 8, EndLine: 8, Detail: "string"}, + }}, + {Name: "Seal", Kind: mujica.SymbolFunction, Line: 13, EndLine: 21, Detail: "(d Decree) (string, error)"}, + {Name: "Herald", Kind: mujica.SymbolFunction, Line: 23, EndLine: 31, Detail: "(ds []Decree) int"}, + } + r := mujica.SymbolOutline(c, symbols, &st.s, mujica.SymbolOutlineOptions{CursorLine: int(st.cursor), Disabled: st.disabled}) + r.Element.Size(420, 320) + if r.Jumped != nil { + log(fmt.Sprintf("jump to %s, line %d", r.Jumped.Name, r.Jumped.Line)) + } +} + +func codeCompletionDemo(c *ui.Context, log func(string)) { + type state struct { + text string + open bool + s mujica.CompletionMenuState + disabled bool + } + s := ui.Local(c.Root(), "334", func() *state { return &state{} }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + all := []mujica.Completion{ + {Label: "Seal", Kind: mujica.SymbolFunction, Detail: "func(d Decree) (string, error)", Doc: "Seal stamps a decree with the royal wax."}, + {Label: "Search", Kind: mujica.SymbolFunction, Detail: "func(q string) []Decree"}, + {Label: "Season", Kind: mujica.SymbolConstant, Detail: "= 4"}, + {Label: "Herald", Kind: mujica.SymbolFunction, Detail: "func(ds []Decree) int"}, + {Label: "Decree", Kind: mujica.SymbolType, Detail: "struct", Doc: "Decree is an order of the court."}, + } + word := st.text[strings.LastIndexAny(st.text, " .(")+1:] + var items []mujica.Completion + for _, it := range all { + if word != "" && strings.HasPrefix(strings.ToLower(it.Label), strings.ToLower(word)) { + items = append(items, it) + } + } + ui.Column(c).Width(420).Gap(8).Children(func() { + ui.Text(c, "Type “se” or “de” to see suggestions.").TextColor(mujica.Tokens(c).TextMuted) + in := mujica.TextInput(c, &st.text, mujica.TextInputOptions{Label: "Code", Placeholder: "court.", Disabled: st.disabled}) + if in.Changed() { + st.open = len(items) > 0 + } + r := mujica.CompletionMenu(c, in.Input, &st.open, items, &st.s, mujica.CompletionMenuOptions{Disabled: st.disabled}) + if r.Accepted != nil { + st.text = st.text[:len(st.text)-len(word)] + r.Accepted.Label + log("accept " + r.Accepted.Label) + } + }) +} + +func codeDebugDemo(c *ui.Context, log func(string)) { + type state struct { + dbg mujica.DebugState + disabled bool + } + s := ui.Local(c.Root(), "335", func() *state { return &state{dbg: mujica.DebugPaused} }) + st := *s + names := []string{"Stopped", "Running", "Paused"} + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + ui.Text(c, "State: "+names[st.dbg]) + }) + a := mujica.DebugToolbar(c, st.dbg, mujica.DebugToolbarOptions{Disabled: st.disabled}).Action + switch a { + case mujica.DebugNone: + return + case mujica.DebugContinue, mujica.DebugRestart: + st.dbg = mujica.DebugRunning + case mujica.DebugPause, mujica.DebugStepOver, mujica.DebugStepInto, mujica.DebugStepOut: + st.dbg = mujica.DebugPaused + case mujica.DebugStop: + st.dbg = mujica.DebugStopped + } + log(fmt.Sprintf("action %d, now %s", a, names[st.dbg])) +} + +func codeBreakpointDemo(c *ui.Context, log func(string)) { + type state struct { + bps []mujica.Breakpoint + disabled bool + } + s := ui.Local(c.Root(), "336", func() *state { + return &state{bps: []mujica.Breakpoint{ + {ID: "1", File: "court/seal.go", Line: 14, Enabled: true}, + {ID: "2", File: "court/ledger.go", Line: 40, Condition: "n > 3"}, + {ID: "3", File: "main.go", Line: 9, Enabled: true}, + }} + }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + r := mujica.BreakpointList(c, &st.bps, mujica.BreakpointListOptions{Disabled: st.disabled}) + r.Element.Size(480, 240) + for _, e := range []struct{ what, id string }{{"toggled", r.Toggled}, {"condition", r.Edited}, {"open", r.Opened}} { + if e.id != "" { + log(e.what + " " + e.id) + } + } +} + +func codeCallStackDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.CallStackState + disabled bool + } + s := ui.Local(c.Root(), "337", func() *state { return &state{s: mujica.CallStackState{Current: "f1"}} }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + threads := []mujica.DebugThread{ + {ID: "t1", Name: "main", Frames: []mujica.StackFrame{ + {ID: "f1", Name: "court.Seal", File: "court/seal.go", Line: 14}, + {ID: "f2", Name: "court.Herald", File: "court/seal.go", Line: 27}, + {ID: "f3", Name: "main.main", File: "main.go", Line: 9}, + }}, + {ID: "t2", Name: "ledger writer", Frames: []mujica.StackFrame{{ID: "f4", Name: "ledger.Flush", File: "ledger/write.go", Line: 51}}}, + } + r := mujica.CallStack(c, threads, &st.s, mujica.CallStackOptions{Disabled: st.disabled}) + r.Element.Size(440, 300) + if r.Changed() { + log("frame " + st.s.Current) + } +} + +func codeVariablesDemo(c *ui.Context, log func(string)) { + type state struct { + vars []mujica.Variable + s mujica.VariablesPanelState + disabled bool + } + s := ui.Local(c.Root(), "338", func() *state { + return &state{vars: []mujica.Variable{ + {Name: "d", Type: "court.Decree", Value: "{…}", Children: []mujica.Variable{ + {Name: "ID", Value: "42", Type: "int", Changed: true}, + {Name: "Title", Value: `"Of seals"`, Type: "string"}, + }}, + {Name: "i", Value: "2", Type: "int", Changed: true}, + {Name: "err", Value: "nil", Type: "error"}, + }} + }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + r := mujica.VariablesPanel(c, st.vars, &st.s, mujica.VariablesPanelOptions{Disabled: st.disabled}) + r.Element.Size(440, 260) + if r.Edited != nil { + log(fmt.Sprintf("set %s = %s", r.Edited.Path, r.Edited.Value)) + codeDemoSet(st.vars, strings.Split(r.Edited.Path, "."), r.Edited.Value) + } +} + +// codeDemoSet writes value at the dotted path. +func codeDemoSet(vars []mujica.Variable, path []string, value string) { + for i := range vars { + if vars[i].Name != path[0] { + continue + } + if len(path) == 1 { + vars[i].Value, vars[i].Changed = value, true + return + } + codeDemoSet(vars[i].Children, path[1:], value) + } +} + +func codeHexDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.HexViewerState + disabled bool + } + s := ui.Local(c.Root(), "339", func() *state { return &state{} }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + data := []byte(strings.Join(codeDemoSource, "\n")) + r := mujica.HexViewer(c, data, &st.s, mujica.HexViewerOptions{Disabled: st.disabled}) + r.Element.Size(720, 300) + if r.Changed() { + a, b := st.s.Selection() + log(fmt.Sprintf("bytes %d..%d", a, b)) + } +} + +func codeFlamegraphDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.FlamegraphState + status string + disabled bool + } + s := ui.Local(c.Root(), "340", func() *state { return &state{status: "Ready"} }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + opts := []mujica.SelectOption[string]{{Value: "Ready", Label: "Ready"}, {Value: "Loading", Label: "Loading"}, {Value: "Failed", Label: "Failed"}} + if mujica.Select(c, &st.status, opts, mujica.SelectOptions{Label: "Status"}).Changed() { + log("status " + st.status) + } + }) + root := &mujica.FlameNode{Name: "main", Value: 240, Children: []mujica.FlameNode{ + {Name: "court.Herald", Value: 150, Children: []mujica.FlameNode{ + {Name: "court.Seal", Value: 110, Children: []mujica.FlameNode{{Name: "wax.Melt", Value: 70}, {Name: "ink.Dry", Value: 25}}}, + {Name: "ledger.Write", Value: 30}, + }}, + {Name: "archive.Load", Value: 60, Children: []mujica.FlameNode{{Name: "json.Decode", Value: 45}}}, + {Name: "gc", Value: 20}, + }} + status := map[string]mujica.DataStatus{"Ready": mujica.DataReady, "Loading": mujica.DataLoading, "Failed": mujica.DataFailed}[st.status] + ui.Column(c).Width(680).Children(func() { + if mujica.Flamegraph(c, root, &st.s, mujica.FlamegraphOptions{Unit: "ms", Status: status, Error: "Profile could not be read", Disabled: st.disabled}).Changed() { + log(fmt.Sprintf("zoom %v focus %v", st.s.Zoom, st.s.Focus)) + } + }) +} diff --git a/examples/gallery/pages_code_snippets.go b/examples/gallery/pages_code_snippets.go new file mode 100644 index 0000000..3cff0a4 --- /dev/null +++ b/examples/gallery/pages_code_snippets.go @@ -0,0 +1,230 @@ +package main + +// Snippets are the bodies of the Example functions in example_code_test.go. + +const codeTerminalCode = `var term mujica.TerminalState +term.Write("\x1b[1m~/court\x1b[0m $ ls\r\n\x1b[34mdecrees\x1b[0m ledger.txt\r\n$ ") +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Terminal(c, &term, mujica.TerminalOptions{ + Input: func(data string) { fmt.Printf("to pty: %q\n", data) }, + Resized: func(cols, rows int) { fmt.Println("pty size", cols, rows) }, + }).Size(380, 200) +} +ui.Render(view, 400, 300, 1)` + +const codeTerminalTabsCode = `tabs := []mujica.TerminalTab{{ID: "a", Title: "zsh"}, {ID: "b", Title: "build", Busy: true}} +active := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TerminalTabs(c, &tabs, &active, mujica.TerminalTabsOptions{}, func(id string) { + ui.Text(c, "terminal "+id) + }) + if r.Closed != "" { + fmt.Println("close pty", r.Closed) + } +} +ui.Render(view, 400, 300, 1)` + +const codeTerminalSearchCode = `var term mujica.TerminalState +term.Write("build ok\r\nerror: missing seal\r\n") +var search mujica.TerminalSearchState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Gap(8).Children(func() { + mujica.TerminalSearch(c, &term, &search, mujica.TerminalSearchOptions{}) + mujica.Terminal(c, &term, mujica.TerminalOptions{}).Height(160) + }) +} +ui.Render(view, 400, 300, 1)` + +const codeCommandHistoryCode = `history := []mujica.HistoryEntry{{Command: "git status"}, {Command: "go test ./...", ExitCode: 1}} +var state mujica.CommandHistoryState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommandHistory(c, history, &state, mujica.CommandHistoryOptions{}) + r.Element.Height(240) + if r.Replayed != nil { + fmt.Println("run", r.Replayed.Command) + } +} +ui.Render(view, 400, 300, 1)` + +const codeLogViewerCode = `lines := []mujica.LogLine{{Level: mujica.LogInfo, Text: "court opened"}, {Level: mujica.LogError, Text: "seal missing"}} +var state mujica.LogViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LogViewer(c, lines, &state, mujica.LogViewerOptions{}).Element.Height(240) +} +ui.Render(view, 400, 300, 1)` + +const codeProcessListCode = `procs := []mujica.Process{{PID: 7, Name: "archivist", CPU: 42, Memory: 50 << 20}, {PID: 9, Name: "herald", CPU: 3, Memory: 8 << 20}} +var state mujica.ProcessListState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ProcessList(c, procs, &state, mujica.ProcessListOptions{}) + r.Element.Height(240) + if r.End != nil { + fmt.Println("kill", r.End.PID) + } +} +ui.Render(view, 400, 300, 1)` + +const codeCodeViewerCode = `lines := []string{"func Seal(d string) int {", "\treturn len(d)", "}"} +var state mujica.CodeViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CodeViewer(c, lines, &state, mujica.CodeViewerOptions{Language: "go"}).Element.Height(240) +} +ui.Render(view, 400, 300, 1)` + +const codeMinimapCode = `lines := []string{"package court", "", "func Seal() {}", "func Herald() {}"} +view := mujica.MinimapView{Top: 0, Visible: 2} +draw := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Minimap(c, lines, &view, mujica.MinimapOptions{Language: "go"}).Changed() { + fmt.Println("scroll editor to", view.Top) + } +} +ui.Render(draw, 400, 300, 1)` + +const codeFindWidgetCode = `text := "the seal and the seal" +state := mujica.FindState{Query: "seal", Replacement: "signet", ShowReplace: true} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.FindWidget(c, &text, &state, mujica.FindWidgetOptions{}).Replaced > 0 { + fmt.Println(text) + } +} +ui.Render(view, 400, 300, 1)` + +const codeSearchPanelCode = `results := []mujica.SearchFileResult{{Path: "court/seal.go", Matches: []mujica.SearchMatch{{Line: 12, Text: "func Seal() {", Start: 5, End: 9}}}} +state := mujica.SearchPanelState{Query: "seal"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SearchPanel(c, results, &state, mujica.SearchPanelOptions{}) + r.Element.Height(280) + if r.Opened != nil { + fmt.Println("open", r.Opened.Path, r.Opened.Line) + } +} +ui.Render(view, 400, 300, 1)` + +const codeProblemsPanelCode = `problems := []mujica.Problem{{Severity: mujica.ProblemError, File: "court/seal.go", Line: 12, Col: 5, Message: "undefined: Decree"}} +var state mujica.ProblemsPanelState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ProblemsPanel(c, problems, &state, mujica.ProblemsPanelOptions{}) + r.Element.Height(240) + if r.Opened != nil { + fmt.Println("go to", r.Opened.File, r.Opened.Line) + } +} +ui.Render(view, 400, 300, 1)` + +const codeOutputPanelCode = `channels := []mujica.OutputChannel{{Name: "Build", Lines: []string{"compiling court", "\x1b[32mok\x1b[0m"}}} +var state mujica.OutputPanelState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OutputPanel(c, channels, &state, mujica.OutputPanelOptions{}) + r.Element.Height(240) + if r.Cleared != "" { + channels[state.Channel].Lines = nil + } +} +ui.Render(view, 400, 300, 1)` + +const codeSymbolOutlineCode = `symbols := []mujica.Symbol{{Name: "Court", Kind: mujica.SymbolType, Line: 3, EndLine: 40, Children: []mujica.Symbol{ + {Name: "Seal", Kind: mujica.SymbolMethod, Line: 10, EndLine: 20}, +}}} +var state mujica.SymbolOutlineState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.SymbolOutline(c, symbols, &state, mujica.SymbolOutlineOptions{CursorLine: 12}) + r.Element.Height(240) + if r.Jumped != nil { + fmt.Println("go to line", r.Jumped.Line) + } +} +ui.Render(view, 400, 300, 1)` + +const codeCompletionMenuCode = `text := "se" +open := true +items := []mujica.Completion{{Label: "Seal", Kind: mujica.SymbolMethod, Detail: "func()"}, {Label: "Search", Kind: mujica.SymbolFunction}} +var state mujica.CompletionMenuState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + in := mujica.TextInput(c, &text, mujica.TextInputOptions{Label: "Code"}) + if r := mujica.CompletionMenu(c, in.Input, &open, items, &state, mujica.CompletionMenuOptions{}); r.Accepted != nil { + text = r.Accepted.Label + } +} +ui.Render(view, 400, 300, 1)` + +const codeDebugToolbarCode = `state := mujica.DebugPaused +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + switch mujica.DebugToolbar(c, state, mujica.DebugToolbarOptions{}).Action { + case mujica.DebugContinue: + state = mujica.DebugRunning + case mujica.DebugStop: + state = mujica.DebugStopped + } +} +ui.Render(view, 400, 300, 1)` + +const codeBreakpointListCode = `bps := []mujica.Breakpoint{{ID: "a", File: "court/seal.go", Line: 12, Enabled: true}, {ID: "b", File: "main.go", Line: 9, Condition: "n > 3"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BreakpointList(c, &bps, mujica.BreakpointListOptions{}) + r.Element.Height(200) + if r.Toggled != "" || r.Edited != "" { + fmt.Println("sync breakpoints with the debugger") + } +} +ui.Render(view, 400, 300, 1)` + +const codeCallStackCode = `threads := []mujica.DebugThread{{ID: "t1", Name: "main", Frames: []mujica.StackFrame{ + {ID: "f1", Name: "court.Seal", File: "court/seal.go", Line: 14}, + {ID: "f2", Name: "main.main", File: "main.go", Line: 9}, +}}} +state := mujica.CallStackState{Current: "f1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CallStack(c, threads, &state, mujica.CallStackOptions{}) + r.Element.Height(200) + if r.Changed() { + fmt.Println("show frame", state.Current) + } +} +ui.Render(view, 400, 300, 1)` + +const codeVariablesPanelCode = `vars := []mujica.Variable{{Name: "decree", Value: "{…}", Children: []mujica.Variable{{Name: "id", Value: "42", Changed: true}}}, {Name: "count", Value: "7"}} +var state mujica.VariablesPanelState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.VariablesPanel(c, vars, &state, mujica.VariablesPanelOptions{}) + r.Element.Height(200) + if r.Edited != nil { + fmt.Println("set", r.Edited.Path, "=", r.Edited.Value) + } +} +ui.Render(view, 400, 300, 1)` + +const codeHexViewerCode = `data := []byte("MUJICA court ledger\x00\x01") +var state mujica.HexViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HexViewer(c, data, &state, mujica.HexViewerOptions{}).Element.Height(160) +} +ui.Render(view, 400, 300, 1)` + +const codeFlamegraphCode = `root := &mujica.FlameNode{Name: "main", Value: 100, Children: []mujica.FlameNode{ + {Name: "court.Seal", Value: 60}, {Name: "ledger.Write", Value: 30}, +}} +var state mujica.FlamegraphState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Flamegraph(c, root, &state, mujica.FlamegraphOptions{Unit: "ms"}) +} +ui.Render(view, 400, 300, 1)` diff --git a/examples/gallery/pages_code_tools.go b/examples/gallery/pages_code_tools.go new file mode 100644 index 0000000..16357dc --- /dev/null +++ b/examples/gallery/pages_code_tools.go @@ -0,0 +1,264 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func codeProcessListDemo(c *ui.Context, log func(string)) { + type state struct { + procs []mujica.Process + s mujica.ProcessListState + disabled bool + } + s := ui.Local(c.Root(), "326", func() *state { + return &state{procs: []mujica.Process{ + {PID: 101, Name: "chancellor", CPU: 3.5, Memory: 200 << 20}, {PID: 7, Name: "archivist", CPU: 42.1, Memory: 50 << 20}, + {PID: 330, Name: "herald", CPU: 0.2, Memory: 2 << 30}, {PID: 412, Name: "scribe", CPU: 12.8, Memory: 96 << 20}, + }} + }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + r := mujica.ProcessList(c, st.procs, &st.s, mujica.ProcessListOptions{Disabled: st.disabled}) + r.Element.Size(560, 260) + if r.End != nil { + log(fmt.Sprintf("end %s (PID %d)", r.End.Name, r.End.PID)) + var kept []mujica.Process + for _, p := range st.procs { + if p.PID != r.End.PID { + kept = append(kept, p) + } + } + st.procs = kept + } +} + +var codeDemoSource = strings.Split(`package court + +import "errors" + +// Decree is an order of the court. +type Decree struct { + ID int + Title string +} + +/* Seal stamps a decree + with the royal wax. */ +func Seal(d Decree) (string, error) { + if d.Title == "" { + return "", errors.New("untitled decree") + } + for i := 0; i < 3; i++ { + d.ID += i + } + return "sealed: " + d.Title, nil +} + +func Herald(ds []Decree) int { + n := 0 + for _, d := range ds { + if _, err := Seal(d); err == nil { + n++ + } + } + return n +}`, "\n") + +func codeCodeViewerDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.CodeViewerState + lang string + disabled bool + } + s := ui.Local(c.Root(), "327", func() *state { return &state{lang: "go"} }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + langs := []mujica.SelectOption[string]{{Value: "go", Label: "Go"}, {Value: "", Label: "Plain"}} + if mujica.Select(c, &st.lang, langs, mujica.SelectOptions{Label: "Language"}).Changed() { + log("language " + st.lang) + } + if mujica.Button(c, "Go to line 20", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + st.s.GoTo(19) + log("go to 20") + } + }) + r := mujica.CodeViewer(c, codeDemoSource, &st.s, mujica.CodeViewerOptions{Language: st.lang, Label: "Source", Disabled: st.disabled}) + r.Element.Size(600, 360) + if r.Changed() { + log(fmt.Sprintf("line %d", st.s.Line()+1)) + } +} + +func codeMinimapDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.CodeViewerState + view mujica.MinimapView + lines []string + disabled bool + } + s := ui.Local(c.Root(), "328", func() *state { + var lines []string + for range 12 { + lines = append(lines, codeDemoSource...) + } + return &state{lines: lines} + }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + first, last := st.s.Visible() + st.view = mujica.MinimapView{Top: first, Visible: max(last-first+1, 1)} + ui.Row(c).Gap(8).Height(360).AlignItems(ui.Stretch).Children(func() { + mujica.CodeViewer(c, st.lines, &st.s, mujica.CodeViewerOptions{Language: "go"}).Element.Width(520) + if mujica.Minimap(c, st.lines, &st.view, mujica.MinimapOptions{Language: "go", Disabled: st.disabled}).Changed() { + st.s.ScrollTo(st.view.Top) + log(fmt.Sprintf("jump to line %d", st.view.Top+1)) + } + }) +} + +func codeFindWidgetDemo(c *ui.Context, log func(string)) { + type state struct { + text string + s mujica.FindState + readOnly, disabled bool + } + s := ui.Local(c.Root(), "329", func() *state { + return &state{text: "The seal of the court; seals are kept sealed. Seal 42 and seal 7.", s: mujica.FindState{Query: "seal"}} + }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.readOnly, "Read-only", log) + codeDemoCheck(c, &st.disabled, "Disabled", log) + }) + ui.Column(c).Width(620).Gap(12).Children(func() { + r := mujica.FindWidget(c, &st.text, &st.s, mujica.FindWidgetOptions{ReadOnly: st.readOnly, Disabled: st.disabled}) + if r.Moved { + log(fmt.Sprintf("%d matches, at %d", len(st.s.Matches()), st.s.Current+1)) + } + if r.Replaced > 0 { + log(fmt.Sprintf("replaced %d", r.Replaced)) + } + var spans []ui.Span + at := 0 + for i, m := range st.s.Matches() { + spans = append(spans, ui.Span{Text: st.text[at:m[0]]}) + sp := ui.Span{Text: st.text[m[0]:m[1]], Background: mujica.Tokens(c).Selection, Underline: true} + if i == st.s.Current { + sp.Weight = 700 + } + spans = append(spans, sp) + at = m[1] + } + spans = append(spans, ui.Span{Text: st.text[at:]}) + ui.RichText(c, spans...) + }) +} + +func codeSearchPanelDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.SearchPanelState + results []mujica.SearchFileResult + disabled bool + } + s := ui.Local(c.Root(), "330", func() *state { return &state{s: mujica.SearchPanelState{Query: "seal"}} }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + // The demo searches its own two files. + files := map[string][]string{"court/seal.go": codeDemoSource, "docs/ledger.md": {"# Ledger", "The seal is kept by the chancellor.", "Seals expire after a year."}} + if st.results == nil || st.s.Query != "" && len(st.results) == 0 { + st.results = codeDemoSearch(files, st.s.Query, st.s.Include, st.s.Exclude) + } + r := mujica.SearchPanel(c, st.results, &st.s, mujica.SearchPanelOptions{Disabled: st.disabled}) + r.Element.Size(560, 380) + if r.Changed { + st.results = codeDemoSearch(files, st.s.Query, st.s.Include, st.s.Exclude) + log(fmt.Sprintf("search %q include %q exclude %q", st.s.Query, st.s.Include, st.s.Exclude)) + } + if r.Opened != nil { + log(fmt.Sprintf("open %s:%d", r.Opened.Path, r.Opened.Line)) + } +} + +// codeDemoSearch finds q in the demo files. +func codeDemoSearch(files map[string][]string, q, include, exclude string) []mujica.SearchFileResult { + out := []mujica.SearchFileResult{} + if q == "" { + return out + } + for _, path := range []string{"court/seal.go", "docs/ledger.md"} { + if include != "" && !strings.Contains(path, strings.Trim(include, "*")) || exclude != "" && strings.Contains(path, strings.Trim(exclude, "*")) { + continue + } + f := mujica.SearchFileResult{Path: path} + for i, line := range files[path] { + if at := strings.Index(strings.ToLower(line), strings.ToLower(q)); at >= 0 { + f.Matches = append(f.Matches, mujica.SearchMatch{Line: i + 1, Text: line, Start: at, End: at + len(q)}) + } + } + if len(f.Matches) > 0 { + out = append(out, f) + } + } + return out +} + +func codeProblemsDemo(c *ui.Context, log func(string)) { + type state struct { + s mujica.ProblemsPanelState + disabled bool + } + s := ui.Local(c.Root(), "331", func() *state { return &state{} }) + st := *s + codeDemoControls(c, func() { codeDemoCheck(c, &st.disabled, "Disabled", log) }) + problems := []mujica.Problem{ + {Severity: mujica.ProblemError, File: "court/seal.go", Line: 12, Col: 5, Message: "undefined: Decree", Source: "compiler"}, + {Severity: mujica.ProblemWarning, File: "court/seal.go", Line: 30, Col: 2, Message: "unused variable wax", Source: "vet"}, + {Severity: mujica.ProblemInfo, File: "docs/ledger.md", Line: 4, Col: 1, Message: "line is longer than 80", Source: "lint"}, + {Severity: mujica.ProblemError, File: "main.go", Line: 9, Col: 14, Message: "missing return", Source: "compiler"}, + } + r := mujica.ProblemsPanel(c, problems, &st.s, mujica.ProblemsPanelOptions{Disabled: st.disabled}) + r.Element.Size(620, 320) + if r.Opened != nil { + log(fmt.Sprintf("go to %s:%d:%d", r.Opened.File, r.Opened.Line, r.Opened.Col)) + } +} + +func codeOutputDemo(c *ui.Context, log func(string)) { + type state struct { + channels []mujica.OutputChannel + s mujica.OutputPanelState + disabled bool + } + s := ui.Local(c.Root(), "332", func() *state { + var build []string + for i := range 300 { + build = append(build, fmt.Sprintf("compiling unit %d of 300", i+1)) + } + return &state{channels: []mujica.OutputChannel{{Name: "Build", Lines: build}, + {Name: "Tests", Lines: []string{"\x1b[32mPASS\x1b[0m court/seal", "\x1b[31mFAIL\x1b[0m court/ledger: expected 3 seals", "\x1b[1mok\x1b[0m 2 of 3 packages"}}}} + }) + st := *s + codeDemoControls(c, func() { + codeDemoCheck(c, &st.disabled, "Disabled", log) + if mujica.Button(c, "Write line", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + ch := &st.channels[st.s.Channel] + ch.Lines = append(ch.Lines, fmt.Sprintf("line %d", len(ch.Lines)+1)) + log("write to " + ch.Name) + } + }) + r := mujica.OutputPanel(c, st.channels, &st.s, mujica.OutputPanelOptions{Disabled: st.disabled}) + r.Element.Size(560, 300) + if r.Changed { + log("channel " + st.channels[st.s.Channel].Name) + } + if r.Cleared != "" { + st.channels[st.s.Channel].Lines = nil + log("cleared " + r.Cleared) + } +} diff --git a/examples/gallery/pages_git.go b/examples/gallery/pages_git.go index 103b0e7..4ac34b8 100644 --- a/examples/gallery/pages_git.go +++ b/examples/gallery/pages_git.go @@ -1,3 +1,298 @@ package main -var gitPages = []page{} +var gitPages = []page{ + { + ID: "301", Name: "DiffViewer", ZhName: "差异查看器 比较 diff", Category: "Git", + Demo: gitDiffViewerDemo, + Code: `var state mujica.DiffViewerState +from := "func main() {\n\tprintln(\"hi\")\n}\n" +to := "func main() {\n\tprintln(\"hello\")\n}\n" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DiffViewer(c, &state, from, to, mujica.DiffViewerOptions{Language: "go", SideBySide: true}).Height(240) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab focuses the list; arrows and Page Up/Down scroll. Fold buttons take Tab; Enter or Space shows the hidden lines.", + }, + { + ID: "302", Name: "InlineDiff", ZhName: "行内差异 词级差异", Category: "Git", + Demo: gitInlineDiffDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.InlineDiff(c, "The court meets at noon", "The court meets at dusk", mujica.InlineDiffOptions{}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; each line is read with its full text.", + }, + { + ID: "303", Name: "ThreeWayMerge", ZhName: "三方合并 合并冲突", Category: "Git", + Demo: gitThreeWayMergeDemo, + Code: `var state mujica.ThreeWayMergeState +base, local, remote := "a\nb\nc", "a\nB1\nc", "a\nB2\nc" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ThreeWayMerge(c, &state, base, local, remote, mujica.ThreeWayMergeOptions{}) + r.Element.Height(280) + if done, total := state.Resolved(); r.Changed() && done == total { + _ = state.Result() // write the merged file + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab reaches the take buttons of each conflict; Enter or Space chooses. The list scrolls with the arrows.", + }, + { + ID: "304", Name: "ConflictResolver", ZhName: "冲突解决 冲突标记", Category: "Git", + Demo: gitConflictResolverDemo, + Code: `var state mujica.ConflictResolverState +text := "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\nb" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ConflictResolver(c, &state, text, mujica.ConflictResolverOptions{}).Changed() { + _ = state.Result() // save the file once every block is resolved + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab moves through each block's buttons; Enter or Space accepts a side; Undo clears the choice.", + }, + { + ID: "305", Name: "ChangesList", ZhName: "更改列表 暂存 未暂存", Category: "Git", + Demo: gitChangesListDemo, + Code: `var state mujica.ChangesListState +files := []mujica.ChangedFile{ + {Path: "main.go", Status: mujica.GitModified, Staged: true}, + {Path: "notes.txt", Status: mujica.GitUntracked}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ChangesList(c, &state, files, mujica.ChangesListOptions{}) + r.Element.Height(240) + if action, path, ok := r.Action(); ok { + _, _ = action, path // run git add or git restore --staged + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a file, skipping group titles; typing jumps by name; stage buttons take Tab, Enter or Space.", + }, + { + ID: "306", Name: "CommitInput", ZhName: "提交输入 提交信息", Category: "Git", + Demo: gitCommitInputDemo, + Code: `msg := mujica.CommitMessage{} +busy := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommitInput(c, &msg, mujica.CommitInputOptions{Busy: busy}) + if r.Committed() || r.Amended() { + busy = true // run git commit, then clear busy + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Enter in the summary or Cmd/Ctrl+Enter in the description commits; Tab reaches Amend and Commit.", + }, + { + ID: "307", Name: "CommitGraph", ZhName: "提交图谱 泳道 分支图", Category: "Git", + Demo: gitCommitGraphDemo, + Code: `var state mujica.CommitGraphState +commits := []mujica.Commit{ + {Hash: "c3", Parents: []string{"c2", "b1"}, Subject: "Merge branch", Refs: []string{"main"}}, + {Hash: "b1", Parents: []string{"c1"}, Subject: "Feature"}, + {Hash: "c2", Parents: []string{"c1"}, Subject: "Fix"}, + {Hash: "c1", Subject: "Initial commit"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CommitGraph(c, &state, commits, mujica.CommitGraphOptions{}).Element.Height(240) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down/Home/End choose a commit; typing jumps by subject; Enter opens it.", + }, + { + ID: "308", Name: "CommitList", ZhName: "提交列表 提交历史", Category: "Git", + Demo: gitCommitListDemo, + Code: `var state mujica.ListState[string] +commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Add the commit list"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommitList(c, &state, commits, mujica.CommitListOptions{}) + r.Element.Height(240) + if hash, ok := state.Selected(); ok && r.Submitted() { + _ = hash // show the commit + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down/Home/End choose a commit; typing jumps by subject; Enter opens it.", + }, + { + ID: "309", Name: "BranchSelector", ZhName: "分支选择器 切换分支", Category: "Git", + Demo: gitBranchSelectorDemo, + Code: `branch := "main" +branches := []mujica.Branch{{Name: "main", Current: true}, {Name: "origin/main", Remote: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BranchSelector(c, &branch, branches, mujica.BranchSelectorOptions{AllowCreate: true}) + if name, ok := r.Created(); ok { + _ = name // run git switch -c name + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Enter or Space opens; type to filter; Up/Down move; Enter chooses or creates; Escape closes.", + }, + { + ID: "310", Name: "BranchList", ZhName: "分支列表 上游 领先落后", Category: "Git", + Demo: gitBranchListDemo, + Code: `var state mujica.ListState[string] +branches := []mujica.Branch{{Name: "main", Current: true, Upstream: "origin/main", Ahead: 2}} +actions := []mujica.GitAction{{ID: "delete", Label: "Delete", Icon: "trash-2"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.BranchList(c, &state, branches, mujica.BranchListOptions{Actions: actions}) + r.Element.Height(200) + if action, name, ok := r.Action(); ok { + _, _ = action, name // run the action on the branch + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a branch; row actions take Tab, Enter or Space.", + }, + { + ID: "311", Name: "TagList", ZhName: "标签列表 tag", Category: "Git", + Demo: gitTagListDemo, + Code: `var state mujica.ListState[string] +tags := []mujica.GitTag{{Name: "v1.1.0", Target: "a1b2c3d4", Annotation: "Release"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TagList(c, &state, tags, mujica.TagListOptions{}) + r.Element.Height(200) + if _, hash, ok := r.Action(); ok { + _ = hash // show the tagged commit + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a tag; the jump button takes Tab, Enter or Space.", + }, + { + ID: "312", Name: "StashList", ZhName: "贮藏列表 stash", Category: "Git", + Demo: gitStashListDemo, + Code: `var state mujica.ListState[string] +stashes := []mujica.Stash{{Ref: "stash@{0}", Message: "WIP on main", Branch: "main"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.StashList(c, &state, stashes, mujica.StashListOptions{}) + r.Element.Height(200) + if action, ref, ok := r.Action(); ok { + _, _ = action, ref // run git stash apply, pop or drop + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a stash; Apply, Pop and Drop take Tab, Enter or Space.", + }, + { + ID: "313", Name: "BlameView", ZhName: "逐行追溯 blame 作者", Category: "Git", + Demo: gitBlameViewDemo, + Code: `var state mujica.BlameViewState +lines := []mujica.BlameLine{ + {Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "package main"}, + {Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Summary: "Add main", Text: "func main() {}"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BlameView(c, &state, lines, mujica.BlameViewOptions{Language: "go"}).Height(240) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab focuses the code; arrows scroll; typing jumps to a line starting with the text. Hover the left column for details.", + }, + { + ID: "314", Name: "FileHistory", ZhName: "文件历史 单文件提交", Category: "Git", + Demo: gitFileHistoryDemo, + Code: `var state mujica.ListState[string] +commits := []mujica.Commit{{Hash: "a1b2c3d4", Author: "Sakiko", When: time.Now(), Subject: "Edit main.go"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.FileHistory(c, &state, "main.go", commits, mujica.FileHistoryOptions{}) + r.Element.Height(240) + if _, hash, ok := r.Action(); ok { + _ = hash // open the diff of main.go at hash + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a commit; View changes takes Tab, Enter or Space.", + }, + { + ID: "315", Name: "PullRequestCard", ZhName: "拉取请求卡片 合并请求 PR", Category: "Git", + Demo: gitPullRequestCardDemo, + Code: `pr := mujica.PullRequest{Number: 42, Title: "Add git components", Author: "Sakiko", Head: "feature", Base: "main", + ChecksPassed: 3, Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}}, Added: 120, Deleted: 8, Files: 6} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{}).Changed() { + // open the pull request + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab reaches the title link; Enter or Space opens the pull request.", + }, + { + ID: "316", Name: "ReviewComment", ZhName: "评审评论 代码评审", Category: "Git", + Demo: gitReviewCommentDemo, + Code: `thread := mujica.ReviewThread{File: "main.go", Line: 12, Messages: []mujica.ReviewMessage{ + {Author: "Uika", When: time.Now(), Body: "Can this return early?"}, +}} +draft := "" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ReviewComment(c, thread, &draft, mujica.ReviewCommentOptions{}) + if text, ok := r.Replied(); ok { + thread.Messages = append(thread.Messages, mujica.ReviewMessage{Author: "Sakiko", When: time.Now(), Body: text}) + } + if r.Resolved() || r.Reopened() { + thread.Resolved = !thread.Resolved + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab moves through the reply field and buttons; Cmd/Ctrl+Enter sends the reply.", + }, + { + ID: "317", Name: "DiffStat", ZhName: "差异统计 增删行数", Category: "Git", + Demo: gitDiffStatDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DiffStat(c, 120, 8, mujica.DiffStatOptions{}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as additions and deletions.", + }, + { + ID: "318", Name: "GitStatusBadge", ZhName: "Git 状态标记 文件状态", Category: "Git", + Demo: gitStatusBadgeDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.GitStatusBadge(c, mujica.GitModified, mujica.GitStatusBadgeOptions{ShowLabel: true}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; each badge is read by its status name.", + }, + { + ID: "319", Name: "CommandBlock", ZhName: "命令块 终端输出 退出码", Category: "Git", + Demo: gitCommandBlockDemo, + Code: `open := true +run := mujica.CommandRun{Command: "go test ./...", Output: "\x1b[32mok\x1b[0m example 0.4s", Duration: 400 * time.Millisecond} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.CommandBlock(c, run, &open, mujica.CommandBlockOptions{}) + if what, ok := r.Copied(); ok { + _ = what // "command" or "output" is on the clipboard + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab reaches the header; Enter or Space folds the output; the copy buttons take Tab.", + }, + { + ID: "320", Name: "AnsiText", ZhName: "ANSI 文本 终端颜色", Category: "Git", + Demo: gitAnsiTextDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AnsiText(c, "\x1b[1;32mPASS\x1b[0m 12 tests\n\x1b[31mFAIL\x1b[0m 1 test", mujica.AnsiTextOptions{}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; shows the text with its styles.", + }, +} diff --git a/examples/gallery/pages_git_cards.go b/examples/gallery/pages_git_cards.go new file mode 100644 index 0000000..3f6dc03 --- /dev/null +++ b/examples/gallery/pages_git_cards.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func gitPullRequestCardDemo(c *ui.Context, log func(string)) { + type state struct { + st int + failed, disabled bool + } + s := ui.Local(c.Root(), "315", func() state { return state{} }) + navigationDemo(c, func() { + mujica.SegmentedControl(c, &s.st, []mujica.Segment{{Label: "Open"}, {Label: "Draft"}, {Label: "Merged"}, {Label: "Closed"}}, mujica.SegmentedControlOptions{Label: "State"}) + navigationCheck(c, &s.failed, "Failing check", log) + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + pr := mujica.PullRequest{Number: 318, Title: "Git components 301–320", Author: "Sakiko", State: mujica.PullRequestState(s.st), + Head: "feature/git", Base: "main", ChecksPassed: 12, ChecksPending: 1, + Reviewers: []mujica.Reviewer{{Name: "Uika", State: mujica.ReviewApproved}, {Name: "Mutsumi", State: mujica.ReviewChangesRequested}, {Name: "Umiri"}}, + Added: 2412, Deleted: 310, Files: 46} + if s.failed { + pr.ChecksFailed = 1 + } + if mujica.PullRequestCard(c, pr, mujica.PullRequestCardOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("Open #%d", pr.Number)) + } + }) +} + +func gitReviewCommentDemo(c *ui.Context, log func(string)) { + type state struct { + thread mujica.ReviewThread + draft string + disabled bool + } + s := ui.Local(c.Root(), "316", func() state { + return state{thread: mujica.ReviewThread{File: "commit_graph.go", Line: 88, Messages: []mujica.ReviewMessage{ + {Author: "Uika", When: gitDemoTime, Body: "Should merge lanes reuse the parent's color?"}, + {Author: "Sakiko", When: gitDemoTime.Add(time.Hour), Body: "Yes, the first parent keeps it."}, + }}} + }) + navigationDemo(c, func() { + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + r := mujica.ReviewComment(c, s.thread, &s.draft, mujica.ReviewCommentOptions{Disabled: s.disabled}) + if text, ok := r.Replied(); ok { + log("Reply: " + text) + s.thread.Messages = append(s.thread.Messages, mujica.ReviewMessage{Author: "You", When: c.Now(), Body: text}) + } + if r.Resolved() { + log("Resolved") + s.thread.Resolved = true + } + if r.Reopened() { + log("Reopened") + s.thread.Resolved = false + } + }) +} + +func gitDiffStatDemo(c *ui.Context, log func(string)) { + type state struct{ added, deleted, blocks float64 } + s := ui.Local(c.Root(), "317", func() state { return state{added: 120, deleted: 30, blocks: 5} }) + navigationDemo(c, func() { + navigationSlider(c, &s.added, "Added", 0, 500, log) + navigationSlider(c, &s.deleted, "Deleted", 0, 500, log) + navigationSlider(c, &s.blocks, "Blocks", 1, 10, log) + }, func() { + mujica.DiffStat(c, int(s.added), int(s.deleted), mujica.DiffStatOptions{Blocks: int(s.blocks)}) + }) +} + +func gitStatusBadgeDemo(c *ui.Context, log func(string)) { + type state struct{ labels bool } + s := ui.Local(c.Root(), "318", func() state { return state{} }) + navigationDemo(c, func() { + navigationCheck(c, &s.labels, "Show labels", log) + }, func() { + ui.Row(c).Gap(16).Wrap().Children(func() { + for st := mujica.GitModified; st <= mujica.GitCopied; st++ { + mujica.GitStatusBadge(c, st, mujica.GitStatusBadgeOptions{ShowLabel: s.labels}) + } + }) + }) +} + +func gitCommandBlockDemo(c *ui.Context, log func(string)) { + type state struct { + open, failed, running bool + } + s := ui.Local(c.Root(), "319", func() state { return state{open: true} }) + navigationDemo(c, func() { + navigationCheck(c, &s.failed, "Failed", log) + navigationCheck(c, &s.running, "Running", log) + }, func() { + run := mujica.CommandRun{Command: "go test -run Git ./...", Duration: 3214 * time.Millisecond, + Output: "\x1b[32mok\x1b[0m \tmujicaui\t3.214s\n\x1b[33mwarn\x1b[0m slow test: TestCommitGraphVirtualized"} + if s.failed { + run.ExitCode = 1 + run.Output = "\x1b[31m--- FAIL\x1b[0m: TestDiffViewer (0.02s)\n\x1b[1mFAIL\x1b[0m\tmujicaui\t0.401s" + } + run.Running = s.running + r := mujica.CommandBlock(c, run, &s.open, mujica.CommandBlockOptions{}) + if r.Toggled() { + log(fmt.Sprintf("Toggled: open=%v", s.open)) + } + if what, ok := r.Copied(); ok { + log("Copied " + what) + } + }) +} + +func gitAnsiTextDemo(c *ui.Context, log func(string)) { + type state struct{ wrap bool } + s := ui.Local(c.Root(), "320", func() state { return state{} }) + navigationDemo(c, func() { + navigationCheck(c, &s.wrap, "Wrap", log) + }, func() { + ui.Column(c).Padding(12).Background(mujica.Tokens(c).Surface).Radius(4).Children(func() { + mujica.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[9mstruck\x1b[0m \x1b[7m inverse \x1b[0m\n"+ + "A long line that only breaks when wrapping is on, so the terminal layout of columns stays intact as it was printed.\n"+ + "Unknown escapes stay as text: \x1b[2K(cleared line)", mujica.AnsiTextOptions{Wrap: s.wrap}) + }) + }) +} diff --git a/examples/gallery/pages_git_demos.go b/examples/gallery/pages_git_demos.go new file mode 100644 index 0000000..8ad4b9b --- /dev/null +++ b/examples/gallery/pages_git_demos.go @@ -0,0 +1,367 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +var gitDemoTime = time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC) + +const gitDemoOld = "package court\n\nimport \"fmt\"\n\n// Greet welcomes a guest.\nfunc Greet(name string) {\n\tfmt.Println(\"Welcome,\", name)\n}\n\nfunc Leave() {}\n\nfunc Dance() {}\n\nfunc Bow() {}\n" +const gitDemoNew = "package court\n\nimport \"fmt\"\n\n// Greet welcomes a guest by title.\nfunc Greet(title, name string) {\n\tfmt.Println(\"Welcome,\", title, name)\n}\n\nfunc Leave() {}\n\nfunc Dance() {}\n\nfunc Bow() {}\n\nfunc Curtsy() {}\n" + +func gitDemoCommits(n int) []mujica.Commit { + out := make([]mujica.Commit, n) + authors := []string{"Sakiko", "Mutsumi", "Uika", "Umiri", "Nyamu"} + for i := range out { + out[i] = mujica.Commit{Hash: fmt.Sprintf("%016x%024x", uint64(n-i)*0x9E3779B97F4A7C15, n-i), Author: authors[i%5], + When: gitDemoTime.Add(-time.Duration(i) * 37 * time.Minute), Subject: fmt.Sprintf("Chapter %d of the chronicle", n-i)} + if i+1 < n { + out[i].Parents = []string{fmt.Sprintf("%016x%024x", uint64(n-i-1)*0x9E3779B97F4A7C15, n-i-1)} + } + } + if n > 0 { + out[0].Refs = []string{"main", "origin/main"} + } + return out +} + +func gitDemoGraph() []mujica.Commit { + cs := []mujica.Commit{ + {Hash: "e7", Parents: []string{"e5", "f3"}, Subject: "Merge branch 'feature/lanes'", Refs: []string{"main"}}, + {Hash: "f3", Parents: []string{"f2"}, Subject: "Color lanes by branch"}, + {Hash: "e5", Parents: []string{"e4", "h1"}, Subject: "Merge hotfix"}, + {Hash: "h1", Parents: []string{"e3"}, Subject: "Fix the overture timing", Refs: []string{"hotfix"}}, + {Hash: "f2", Parents: []string{"f1"}, Subject: "Draw merge lines"}, + {Hash: "e4", Parents: []string{"e3"}, Subject: "Tune the ballroom palette"}, + {Hash: "f1", Parents: []string{"e3"}, Subject: "Start lane layout"}, + {Hash: "e3", Parents: []string{"e2"}, Subject: "Add the commit list"}, + {Hash: "e2", Parents: []string{"e1"}, Subject: "Add the blame view"}, + {Hash: "e1", Subject: "Initial commit", Refs: []string{"v1.0.0"}}, + } + for i := range cs { + cs[i].Author = []string{"Sakiko", "Mutsumi", "Uika"}[i%3] + cs[i].When = gitDemoTime.Add(-time.Duration(i) * 2 * time.Hour) + } + return cs +} + +func gitDemoBranches() []mujica.Branch { + return []mujica.Branch{ + {Name: "main", Current: true, Upstream: "origin/main", Ahead: 2, Behind: 1}, + {Name: "feature/lanes", Upstream: "origin/feature/lanes", Ahead: 5}, + {Name: "fix/blame"}, + {Name: "origin/main", Remote: true}, + {Name: "origin/feature/lanes", Remote: true}, + {Name: "origin/release/1.1", Remote: true}, + } +} + +func gitDiffViewerDemo(c *ui.Context, log func(string)) { + type state struct { + viewer mujica.DiffViewerState + split, plain bool + context float64 + } + s := ui.Local(c.Root(), "301", func() state { return state{context: 2} }) + navigationDemo(c, func() { + navigationCheck(c, &s.split, "Side by side", log) + navigationCheck(c, &s.plain, "Plain text", log) + navigationSlider(c, &s.context, "Context", 1, 6, log) + }, func() { + lang := "go" + if s.plain { + lang = "" + } + mujica.DiffViewer(c, &s.viewer, gitDemoOld, gitDemoNew, mujica.DiffViewerOptions{SideBySide: s.split, Context: int(s.context), Language: lang}).Height(380) + }) +} + +func gitInlineDiffDemo(c *ui.Context, log func(string)) { + type state struct { + from, to string + mono bool + } + s := ui.Local(c.Root(), "302", func() state { + return state{from: "The court meets at noon in the east hall.", to: "The council meets at dusk in the east hall."} + }) + navigationDemo(c, func() { + navigationCheck(c, &s.mono, "Monospace", log) + }, func() { + if mujica.TextInput(c, &s.from, mujica.TextInputOptions{Label: "Old line"}).Changed() { + log("Old: " + s.from) + } + if mujica.TextInput(c, &s.to, mujica.TextInputOptions{Label: "New line"}).Changed() { + log("New: " + s.to) + } + mujica.InlineDiff(c, s.from, s.to, mujica.InlineDiffOptions{Mono: s.mono}) + }) +} + +func gitThreeWayMergeDemo(c *ui.Context, log func(string)) { + type state struct { + merge mujica.ThreeWayMergeState + disabled bool + } + s := ui.Local(c.Root(), "303", func() state { return state{} }) + base := "title: Overture\ntempo: 96\nkey: D minor\nlength: 4:10\nhall: east" + ours := "title: Overture\ntempo: 104\nkey: D minor\nlength: 4:10\nhall: east\nencore: yes" + theirs := "title: Overture\ntempo: 92\nkey: D minor\nlength: 4:30\nhall: east" + navigationDemo(c, func() { + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + r := mujica.ThreeWayMerge(c, &s.merge, base, ours, theirs, mujica.ThreeWayMergeOptions{Disabled: s.disabled}) + r.Element.Height(360) + if r.Changed() { + done, total := s.merge.Resolved() + log(fmt.Sprintf("Changed: %d of %d resolved", done, total)) + } + ui.Text(c, s.merge.Result()).Font("monospace").TextColor(mujica.Tokens(c).TextMuted) + }) +} + +func gitConflictResolverDemo(c *ui.Context, log func(string)) { + type state struct { + res mujica.ConflictResolverState + disabled bool + } + s := ui.Local(c.Root(), "304", func() state { return state{} }) + text := "# Program\n<<<<<<< HEAD\nOpening: Overture in D minor\n=======\nOpening: Prelude in G major\n>>>>>>> feature/program\nInterval: 15 minutes\n<<<<<<< HEAD\nFinale: Masquerade\n=======\nFinale: Ave Mujica\n>>>>>>> feature/program" + navigationDemo(c, func() { + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + if mujica.ConflictResolver(c, &s.res, text, mujica.ConflictResolverOptions{Disabled: s.disabled}).Changed() { + done, total := s.res.Resolved() + log(fmt.Sprintf("Changed: %d of %d resolved", done, total)) + } + }) +} + +func gitChangesListDemo(c *ui.Context, log func(string)) { + type state struct { + list mujica.ChangesListState + files []mujica.ChangedFile + disabled bool + } + s := ui.Local(c.Root(), "305", func() state { + return state{files: []mujica.ChangedFile{ + {Path: "commit_graph.go", Status: mujica.GitAdded, Staged: true}, + {Path: "internal/git.go", Status: mujica.GitModified}, + {Path: "docs/old.md", Status: mujica.GitDeleted}, + {Path: "notes.txt", Status: mujica.GitUntracked}, + {Path: "icons/svg/tag.svg", Status: mujica.GitRenamed}, + }} + }) + navigationDemo(c, func() { + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + r := mujica.ChangesList(c, &s.list, s.files, mujica.ChangesListOptions{Disabled: s.disabled}) + r.Element.Height(320) + if a, path, ok := r.Action(); ok { + log("Action: " + a + " " + path) + s.files = gitStage(s.files, a, path) + } + if r.Changed() && s.list.Selected() >= 0 { + log("Selected: " + s.files[s.list.Selected()].Path) + } + }) +} + +// gitStage applies a stage action to a copy of files. +func gitStage(files []mujica.ChangedFile, action, path string) []mujica.ChangedFile { + out := append([]mujica.ChangedFile(nil), files...) + for i := range out { + switch { + case action == "stage_all": + out[i].Staged = true + case action == "unstage_all": + out[i].Staged = false + case out[i].Path == path: + out[i].Staged = action == "stage" + } + } + return out +} + +func gitCommitInputDemo(c *ui.Context, log func(string)) { + type state struct { + msg mujica.CommitMessage + busy, disabled bool + until time.Time + } + s := ui.Local(c.Root(), "306", func() state { return state{} }) + if s.busy && c.Now().After(s.until) { + s.busy = false + s.msg = mujica.CommitMessage{} + log("Committed") + } + if s.busy { + c.After(s.until.Sub(c.Now())) + } + navigationDemo(c, func() { + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + r := mujica.CommitInput(c, &s.msg, mujica.CommitInputOptions{Busy: s.busy, Disabled: s.disabled}) + if r.Committed() || r.Amended() { + log(fmt.Sprintf("Submit: amend=%v title=%q", r.Amended(), s.msg.Title)) + s.busy, s.until = true, c.Now().Add(1200*time.Millisecond) + } + }) +} + +func gitCommitGraphDemo(c *ui.Context, log func(string)) { + type state struct { + graph mujica.CommitGraphState + big bool + } + s := ui.Local(c.Root(), "307", func() state { return state{} }) + small, big := gitDemoGraph(), gitDemoCommits(100000) + navigationDemo(c, func() { + navigationCheck(c, &s.big, "100,000 commits", log) + }, func() { + commits := small + if s.big { + commits = big + } + r := mujica.CommitGraph(c, &s.graph, commits, mujica.CommitGraphOptions{}) + r.Element.Height(380) + if hash, ok := s.graph.List.Selected(); ok && (r.Changed() || r.Submitted()) { + log(fmt.Sprintf("Selected %s, open=%v", hash[:min(7, len(hash))], r.Submitted())) + } + }) +} + +func gitCommitListDemo(c *ui.Context, log func(string)) { + type state struct { + list mujica.ListState[string] + loading bool + } + s := ui.Local(c.Root(), "308", func() state { return state{} }) + commits := gitDemoCommits(40) + navigationDemo(c, func() { + navigationCheck(c, &s.loading, "Loading more", log) + }, func() { + st := mujica.DataReady + if s.loading { + st = mujica.DataLoadingMore + } + r := mujica.CommitList(c, &s.list, commits, mujica.CommitListOptions{Status: st}) + r.Element.Height(360) + if hash, ok := s.list.Selected(); ok && (r.Changed() || r.Submitted()) { + log(fmt.Sprintf("Selected %s, open=%v", hash[:7], r.Submitted())) + } + }) +} + +func gitBranchSelectorDemo(c *ui.Context, log func(string)) { + type state struct { + branch string + create, disabled bool + branches []mujica.Branch + } + s := ui.Local(c.Root(), "309", func() state { return state{branch: "main", create: true, branches: gitDemoBranches()} }) + navigationDemo(c, func() { + navigationCheck(c, &s.create, "Allow create", log) + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + r := mujica.BranchSelector(c, &s.branch, s.branches, mujica.BranchSelectorOptions{AllowCreate: s.create, Disabled: s.disabled}) + if r.Changed() { + log("Changed: " + s.branch) + } + if name, ok := r.Created(); ok { + log("Create: " + name) + s.branches = append(append([]mujica.Branch(nil), s.branches...), mujica.Branch{Name: name}) + s.branch = name + } + }) +} + +func gitBranchListDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "310", func() mujica.ListState[string] { return mujica.ListState[string]{} }) + actions := []mujica.GitAction{{ID: "checkout", Label: "Check out", Icon: "git-branch"}, {ID: "delete", Label: "Delete", Icon: "trash-2"}} + r := mujica.BranchList(c, s, gitDemoBranches(), mujica.BranchListOptions{Actions: actions}) + r.Element.Height(300) + if a, name, ok := r.Action(); ok { + log("Action: " + a + " " + name) + } + if key, ok := s.Selected(); ok && r.Changed() { + log("Selected: " + key) + } +} + +func gitTagListDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "311", func() mujica.ListState[string] { return mujica.ListState[string]{} }) + tags := []mujica.GitTag{ + {Name: "v1.1.0", Target: "9e3779b97f4a7c15", Annotation: "Five hundred components"}, + {Name: "v1.0.0", Target: "3c6ef372fe94f82a", Annotation: "The first hundred"}, + {Name: "nightly", Target: "daa66d2c7ddf743f"}, + } + r := mujica.TagList(c, s, tags, mujica.TagListOptions{}) + r.Element.Height(220) + if _, hash, ok := r.Action(); ok { + log("Jump to " + hash[:7]) + } + if key, ok := s.Selected(); ok && r.Changed() { + log("Selected: " + key) + } +} + +func gitStashListDemo(c *ui.Context, log func(string)) { + type state struct { + list mujica.ListState[string] + stashes []mujica.Stash + } + s := ui.Local(c.Root(), "312", func() state { + return state{stashes: []mujica.Stash{ + {Ref: "stash@{0}", Message: "WIP on main: lane colors", Branch: "main"}, + {Ref: "stash@{1}", Message: "Try serif headings", Branch: "feature/type"}, + {Ref: "stash@{2}", Message: "Blame hover experiment", Branch: "fix/blame"}, + }} + }) + r := mujica.StashList(c, &s.list, s.stashes, mujica.StashListOptions{}) + r.Element.Height(240) + if a, ref, ok := r.Action(); ok { + log("Action: " + a + " " + ref) + if a != "apply" { + var keep []mujica.Stash + for _, st := range s.stashes { + if st.Ref != ref { + keep = append(keep, st) + } + } + s.stashes = keep + } + } +} + +func gitBlameViewDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "313", func() mujica.BlameViewState { return mujica.BlameViewState{} }) + var lines []mujica.BlameLine + for i, l := range strings.Split(strings.TrimSuffix(gitDemoNew, "\n"), "\n") { + who := []mujica.BlameLine{ + {Hash: "9e3779b97f4a7c15", Author: "Sakiko", When: gitDemoTime, Summary: "Create the court package"}, + {Hash: "3c6ef372fe94f82a", Author: "Uika", When: gitDemoTime.Add(-48 * time.Hour), Summary: "Greet guests by title"}, + }[min(i/4%2, 1)] + who.Text = l + lines = append(lines, who) + } + mujica.BlameView(c, s, lines, mujica.BlameViewOptions{Language: "go"}).Height(380) + ui.Text(c, "Hover the left column for commit details.").TextColor(mujica.Tokens(c).TextMuted) +} + +func gitFileHistoryDemo(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "314", func() mujica.ListState[string] { return mujica.ListState[string]{} }) + r := mujica.FileHistory(c, s, "court/greet.go", gitDemoCommits(12), mujica.FileHistoryOptions{}) + r.Element.Height(360) + if _, hash, ok := r.Action(); ok { + log("Diff at " + hash[:7]) + } + if key, ok := s.Selected(); ok && r.Changed() { + log("Selected: " + key[:7]) + } +} diff --git a/examples/gallery/pages_media.go b/examples/gallery/pages_media.go index 92da6e4..dd1d945 100644 --- a/examples/gallery/pages_media.go +++ b/examples/gallery/pages_media.go @@ -1,3 +1,118 @@ package main -var mediaPages = []page{} +import ( + "fmt" + "image" + "image/color" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/theme" +) + +const mediaCategory = "Media" + +var mediaPages = []page{ + {ID: "341", Name: "ImageCropper", ZhName: "图片裁剪", Category: mediaCategory, Demo: imageCropperDemo, Code: imageCropperCode, + Keys: "方向键移动裁剪框;Shift+方向键缩放;R / Shift+R 向右 / 向左旋转。"}, + {ID: "342", Name: "ImageAnnotator", ZhName: "图片标注", Category: mediaCategory, Demo: imageAnnotatorDemo, Code: imageAnnotatorCode, + Keys: "[ / ] 切换选中标注;方向键移动;Delete 删除;Escape 取消选择。"}, + {ID: "343", Name: "ImageThumbnail", ZhName: "缩略图", Category: mediaCategory, Demo: imageThumbnailDemo, Code: imageThumbnailCode, + Keys: "Tab 聚焦;Enter / Space 激活。"}, + {ID: "344", Name: "Lightbox", ZhName: "灯箱", Category: mediaCategory, Demo: lightboxDemo, Code: lightboxCode, + Keys: "← / → 切换;Home / End 首尾;Escape 关闭并恢复焦点。"}, + {ID: "345", Name: "VideoPlayer", ZhName: "视频播放器", Category: mediaCategory, Demo: videoPlayerDemo, Code: videoPlayerCode, + Keys: "画面聚焦时 Space / K 播放暂停,← / → 跳转 5 秒,F 全屏,C 字幕。"}, + {ID: "346", Name: "VideoScrubber", ZhName: "视频时间轴", Category: mediaCategory, Demo: videoScrubberDemo, Code: videoScrubberCode, + Keys: "← / → 跳转一个步长;Home / End 开头结尾。"}, + {ID: "347", Name: "VideoThumbnailStrip", ZhName: "缩略帧条", Category: mediaCategory, Demo: videoStripDemo, Code: videoStripCode, + Keys: "Tab 聚焦帧条;← / → 上下帧;Home / End 首尾帧。"}, + {ID: "348", Name: "AudioPlayer", ZhName: "音频播放器", Category: mediaCategory, Demo: audioPlayerDemo, Code: audioPlayerCode, + Keys: "Space 播放或暂停;进度条聚焦时 ← / → 跳转 5 秒。"}, + {ID: "349", Name: "AudioWaveform", ZhName: "音频波形", Category: mediaCategory, Demo: audioWaveformDemo, Code: audioWaveformCode, + Keys: "← / → 跳转 5 秒;Shift+← / → 调整选区;Escape 清除选区。"}, + {ID: "350", Name: "AudioSpectrum", ZhName: "音频频谱", Category: mediaCategory, Demo: audioSpectrumDemo, Code: audioSpectrumCode, + Keys: "Tab 聚焦;← / → 逐个读出频带;Home / End 首尾。"}, + {ID: "351", Name: "VolumeControl", ZhName: "音量控制", Category: mediaCategory, Demo: volumeDemo, Code: volumeCode, + Keys: "← / → 音量 ±5%;Home / End;M 静音。"}, + {ID: "352", Name: "PlaybackSpeedControl", ZhName: "倍速选择", Category: mediaCategory, Demo: speedDemo, Code: speedCode, + Keys: "Enter / Space 打开;↑ / ↓ 移动;Enter 确认;Escape 关闭。"}, + {ID: "353", Name: "MediaControls", ZhName: "媒体控制", Category: mediaCategory, Demo: mediaControlsDemo, Code: mediaControlsCode, + Keys: "Tab 进入;← / → 在按钮间移动;Enter / Space 按下。"}, + {ID: "354", Name: "Playlist", ZhName: "播放列表", Category: mediaCategory, Demo: playlistDemo, Code: playlistCode, + Keys: "↑ / ↓ 选择;Enter 播放;Alt+↑ / ↓ 移动;Delete 移除;可拖动排序。"}, + {ID: "355", Name: "SubtitleEditor", ZhName: "字幕编辑器", Category: mediaCategory, Demo: subtitleDemo, Code: subtitleCode, + Keys: "Tab 在一行的开始、结束、文本、删除间移动;时间格式 m:ss.mmm。"}, + {ID: "356", Name: "ScreenRecorderControls", ZhName: "录屏控制", Category: mediaCategory, Demo: recorderDemo, Code: recorderCode, + Keys: "Tab 在按钮间移动;Enter / Space 按下。"}, + {ID: "357", Name: "CameraPreview", ZhName: "摄像头预览", Category: mediaCategory, Demo: cameraDemo, Code: cameraCode, + Keys: "Tab 聚焦预览;M 切换镜像。"}, + {ID: "358", Name: "MicLevelMeter", ZhName: "麦克风电平", Category: mediaCategory, Demo: micDemo, Code: micCode, + Keys: "不获取焦点;读数随描述朗读。"}, + {ID: "359", Name: "DeviceSelector", ZhName: "设备选择", Category: mediaCategory, Demo: deviceDemo, Code: deviceCode, + Keys: "Tab 在选择框间移动;Enter 打开;输入字母跳转。"}, + {ID: "360", Name: "ImageCompare", ZhName: "前后对比", Category: mediaCategory, Demo: compareDemo, Code: compareCode, + Keys: "← / → 移动 1%;Home / End;可拖动。"}, +} + +// mediaSection titles a part of a demo. +func mediaSection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(8).FillWidth().Children(func() { + ui.Text(c, title).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted) + fn() + }) +} + +// mediaCheck is a parameter checkbox that logs its changes. +func mediaCheck(c *ui.Context, log func(string), v *bool, label string) { + before := *v + mujica.Checkbox(c, v, label, mujica.CheckboxOptions{}) + if *v != before { + log(fmt.Sprintf("%s = %v", label, *v)) + } +} + +// mediaPicture draws a w×h court scene in variant hues. +func mediaPicture(w, h, variant int) *image.RGBA { + walls := []color.RGBA{{36, 28, 32, 255}, {242, 235, 221, 255}, {73, 42, 54, 255}} + arch := []color.RGBA{{125, 32, 52, 255}, {173, 145, 100, 255}, {53, 94, 128, 255}} + img := image.NewRGBA(image.Rect(0, 0, w, h)) + cx := float64(w) / 2 + for y := range h { + for x := range w { + c := walls[variant%3] + dx, dy := (float64(x)-cx)/(float64(w)*0.28), (float64(y)-float64(h)*0.55)/(float64(h)*0.5) + if dy > 0 && math.Abs(dx) < 1 || dx*dx+dy*dy < 1 { + c = arch[variant%3] + } + if x%(w/6) < w/40 || y > h*7/8 { + c = color.RGBA{189, 161, 114, 255} + } + if math.Abs(float64(x)-cx)/float64(w)+math.Abs(float64(y)-float64(h)*0.45)/float64(h) < 0.06 { + c = color.RGBA{251, 247, 239, 255} + } + img.Set(x, y, c) + } + } + return img +} + +var ( + mediaPhotos = []*image.RGBA{mediaPicture(480, 270, 0), mediaPicture(480, 270, 1), mediaPicture(360, 360, 2)} + mediaBitmaps = func() []*ui.Bitmap { + out := make([]*ui.Bitmap, len(mediaPhotos)) + for i, p := range mediaPhotos { + out[i] = ui.NewBitmap(p) + } + return out + }() +) + +// mediaClockLabel writes d as m:ss for event logs. +func mediaClockLabel(d time.Duration) string { + s := int(d / time.Second) + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/examples/gallery/pages_media_av.go b/examples/gallery/pages_media_av.go new file mode 100644 index 0000000..394b94a --- /dev/null +++ b/examples/gallery/pages_media_av.go @@ -0,0 +1,191 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// mediaClockState simulates the caller's playback: position advances while playing. +type mediaClockState struct { + pos, dur time.Duration + playing bool + rate float64 + last time.Time +} + +// tick advances the position; frames are asked only while playing. +func (m *mediaClockState) tick(c *ui.Context) { + now := c.Now() + if m.playing { + if !m.last.IsZero() { + m.pos += time.Duration(float64(now.Sub(m.last)) * m.rate) + } + if m.pos >= m.dur { + m.pos, m.playing = m.dur, false + } else { + c.AnimationFrame() + } + } + m.last = now +} + +func videoPlayerDemo(c *ui.Context, log func(string)) { + type state struct { + clock mediaClockState + subs, full, pip, noFrame bool + disabled bool + } + s := ui.Local(c.Root(), "345", func() state { + return state{clock: mediaClockState{dur: 3 * time.Minute, rate: 1}, subs: true} + }) + s.clock.tick(c) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.noFrame, "No frame") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + cues := []string{"The curtain rises.", "Welcome to the masquerade.", "Ave Mujica."} + var frame ui.ImageSource = mediaBitmaps[int(s.clock.pos/(20*time.Second))%3] + if s.noFrame { + frame = nil + } + st := mujica.VideoState{Frame: frame, Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing, + Rate: s.clock.rate, Buffered: []mujica.MediaRange{{Start: 0, End: s.clock.pos + 40*time.Second}}, + Subtitle: cues[int(s.clock.pos/(10*time.Second))%3], SubtitlesOn: s.subs, Fullscreen: s.full, PiP: s.pip} + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(640).Children(func() { + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { s.clock.playing = true; log("play") }, + OnPause: func() { s.clock.playing = false; log("pause") }, + OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) }, + OnRate: func(r float64) { s.clock.rate = r; log(fmt.Sprintf("rate %v×", r)) }, + OnSubtitles: func(on bool) { s.subs = on; log(fmt.Sprintf("subtitles %v", on)) }, + OnFullscreen: func(on bool) { s.full = on; log(fmt.Sprintf("full screen requested %v", on)) }, + OnPiP: func(on bool) { s.pip = on; log(fmt.Sprintf("picture in picture requested %v", on)) }, + Disabled: s.disabled, + }) + }) + }) +} + +func videoScrubberDemo(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + preview, disabled bool + } + s := ui.Local(c.Root(), "346", func() state { return state{pos: 40 * time.Second, preview: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.preview, "Preview slot") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + opts := mujica.VideoScrubberOptions{Duration: 3 * time.Minute, Disabled: s.disabled, + Buffered: []mujica.MediaRange{{Start: 0, End: 90 * time.Second}, {Start: 120 * time.Second, End: 140 * time.Second}}} + if s.preview { + opts.Preview = func(at time.Duration) { + ui.Image(c, mediaBitmaps[int(at/(time.Minute))%3]).Fit(ui.Cover).Fill() + } + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Padding(110, 0, 0, 0).Children(func() { + if mujica.VideoScrubber(c, &s.pos, opts).Changed() { + log("seek " + mediaClockLabel(s.pos)) + } + }) + }) +} + +func audioPlayerDemo(c *ui.Context, log func(string)) { + type state struct { + clock mediaClockState + disabled bool + } + s := ui.Local(c.Root(), "348", func() state { return state{clock: mediaClockState{dur: 4*time.Minute + 5*time.Second, rate: 1}} }) + s.clock.tick(c) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Children(func() { + mujica.AudioPlayer(c, mujica.AudioState{Title: "Ave Mujica — Overture", Position: s.clock.pos, Duration: s.clock.dur, Playing: s.clock.playing}, + mujica.AudioPlayerOptions{ + OnPlay: func() { s.clock.playing = true; log("play") }, + OnPause: func() { s.clock.playing = false; log("pause") }, + OnSeek: func(d time.Duration) { s.clock.pos = d; log("seek " + mediaClockLabel(d)) }, + Disabled: s.disabled, + }) + }) + }) +} + +// mediaSamples is a synthetic voice-like recording, made once. +var mediaSamples = func() []float32 { + s := make([]float32, 120000) + for i := range s { + env := 0.25 + 0.7*math.Abs(math.Sin(float64(i)/9000))*math.Abs(math.Sin(float64(i)/2100)) + s[i] = float32(env * math.Sin(float64(i)/7)) + } + return s +}() + +func audioWaveformDemo(c *ui.Context, log func(string)) { + type state struct { + pos time.Duration + sel mujica.MediaRange + status int + selecting bool + } + s := ui.Local(c.Root(), "349", func() state { return state{pos: 20 * time.Second, selecting: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + before := s.status + mujica.SegmentedControl(c, &s.status, []mujica.Segment{{Label: "Ready"}, {Label: "Loading"}, {Label: "Failed"}, {Label: "Empty"}}, mujica.SegmentedControlOptions{Label: "Status"}) + if s.status != before { + log(fmt.Sprintf("status %d", s.status)) + } + mediaCheck(c, log, &s.selecting, "Selectable") + }) + }) + opts := mujica.AudioWaveformOptions{Duration: time.Minute, Position: s.pos, OnSeek: func(d time.Duration) { s.pos = d; log("seek " + mediaClockLabel(d)) }} + if s.selecting { + opts.Selection = &s.sel + } + data := mediaSamples + switch s.status { + case 1: + opts.Status, data = mujica.DataLoading, nil + case 2: + opts.Status, opts.Error, data = mujica.DataFailed, "The recording could not be decoded.", nil + case 3: + data = nil + } + mediaSection(c, "Live", func() { + if mujica.AudioWaveform(c, data, opts).Changed() { + log(fmt.Sprintf("selection %s – %s", mediaClockLabel(s.sel.Start), mediaClockLabel(s.sel.End))) + } + }) +} + +func audioSpectrumDemo(c *ui.Context, log func(string)) { + type state struct{ live bool } + s := ui.Local(c.Root(), "350", func() state { return state{live: true} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") }) + freqs := []string{"32 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"} + bands := make([]float32, len(freqs)) + t := float64(c.Now().UnixMilli()) / 1000 + for i := range bands { + bands[i] = float32(0.2 + 0.6*math.Abs(math.Sin(float64(i)*0.9+t*(1+float64(i)*0.2)))) + } + if s.live { + // The demo is its own analyser; the component only draws. + c.AnimationFrame() + } + mediaSection(c, "Live", func() { + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{BandLabel: func(i int) string { return freqs[i] }}) + }) +} diff --git a/examples/gallery/pages_media_code.go b/examples/gallery/pages_media_code.go new file mode 100644 index 0000000..7a2528d --- /dev/null +++ b/examples/gallery/pages_media_code.go @@ -0,0 +1,178 @@ +package main + +// Media page snippets, each its compiled Example's body. +const ( + imageCropperCode = `crop := mujica.ImageCrop{} // empty: starts fitted to the image +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageCropper(c, examplePhoto, &crop, mujica.ImageCropperOptions{Aspect: 1}).Changed() { + _ = crop.Rect // in pixels of the photo turned by crop.Rotation + } +} +ui.Render(view, 400, 300, 1)` + imageAnnotatorCode = `photo := ui.NewBitmap(examplePhoto) +var notes []mujica.Annotation +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageAnnotator(c, photo, ¬es, mujica.ImageAnnotatorOptions{}).Changed() { + data, _ := mujica.ExportAnnotations(notes) + _ = data // save the JSON + } +} +ui.Render(view, 400, 300, 1)` + imageThumbnailCode = `photo := ui.NewBitmap(examplePhoto) +selected := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ImageThumbnail(c, photo, mujica.ImageThumbnailOptions{Alt: "Grand hall", Aspect: 4.0 / 3, Selected: selected}).Changed() { + selected = !selected + } +} +ui.Render(view, 400, 300, 1)` + lightboxCode = `images := []mujica.ViewerImage{{Source: ui.NewBitmap(examplePhoto), Alt: "The grand hall"}} +open, index := false, 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "View photos", mujica.ButtonOptions{}).Clicked() { + open = true + } + mujica.Lightbox(c, &open, &index, images) +} +ui.Render(view, 400, 300, 1)` + videoPlayerCode = `st := mujica.VideoState{Duration: 4 * time.Minute, Rate: 1} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoPlayer(c, st, mujica.VideoPlayerOptions{ + OnPlay: func() { st.Playing = true }, // start your decoder + OnPause: func() { st.Playing = false }, // pause it + OnSeek: func(d time.Duration) { st.Position = d }, + OnRate: func(r float64) { st.Rate = r }, + }) +} +ui.Render(view, 400, 300, 1)` + videoScrubberCode = `pos := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VideoScrubber(c, &pos, mujica.VideoScrubberOptions{ + Duration: 2 * time.Minute, + Buffered: []mujica.MediaRange{{Start: 0, End: time.Minute}}, + }) +} +ui.Render(view, 400, 300, 1)` + videoStripCode = `frames := []mujica.StripFrame{{At: 0}, {At: 10 * time.Second}, {At: 20 * time.Second}} +current := 0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.VideoThumbnailStrip(c, ¤t, frames, mujica.VideoThumbnailStripOptions{}).Changed() { + _ = frames[current].At // seek the player there + } +} +ui.Render(view, 400, 300, 1)` + audioPlayerCode = `st := mujica.AudioState{Title: "Overture", Duration: 3 * time.Minute} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioPlayer(c, st, mujica.AudioPlayerOptions{ + OnPlay: func() { st.Playing = true }, + OnPause: func() { st.Playing = false }, + OnSeek: func(d time.Duration) { st.Position = d }, + }) +} +ui.Render(view, 400, 300, 1)` + audioWaveformCode = `samples := make([]float32, 48000) // from your decoder, -1 to 1 +pos, sel := time.Duration(0), mujica.MediaRange{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioWaveform(c, samples, mujica.AudioWaveformOptions{ + Duration: time.Second, Position: pos, Selection: &sel, + OnSeek: func(d time.Duration) { pos = d }, + }) +} +ui.Render(view, 400, 300, 1)` + audioSpectrumCode = `bands := []float32{0.2, 0.6, 0.9, 0.4, 0.1} // set by your analyser, then c.Invalidate() +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.AudioSpectrum(c, bands, mujica.AudioSpectrumOptions{}) +} +ui.Render(view, 400, 300, 1)` + volumeCode = `volume, muted := 0.8, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeControl(c, &volume, &muted, mujica.VolumeControlOptions{}) +} +ui.Render(view, 400, 300, 1)` + speedCode = `rate := 1.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PlaybackSpeedControl(c, &rate, mujica.PlaybackSpeedControlOptions{}) +} +ui.Render(view, 400, 300, 1)` + mediaControlsCode = `st := mujica.MediaControlsState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MediaControls(c, st, mujica.MediaControlsOptions{ + OnPrevious: func() {}, OnNext: func() {}, + OnPlay: func() { st.Playing = true }, OnPause: func() { st.Playing = false }, + OnLoop: func(m mujica.LoopMode) { st.Loop = m }, OnShuffle: func(on bool) { st.Shuffle = on }, + }) +} +ui.Render(view, 400, 300, 1)` + playlistCode = `items := []mujica.PlaylistItem{{ID: "a", Title: "Overture"}, {ID: "b", Title: "Masquerade"}} +current := "a" +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if r := mujica.Playlist(c, &items, mujica.PlaylistOptions{Current: current}); r.Played != "" { + current = r.Played + } +} +ui.Render(view, 400, 300, 1)` + subtitleCode = `cues := []mujica.SubtitleCue{{Start: 0, End: 2 * time.Second, Text: "The curtain rises."}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SubtitleEditor(c, &cues, mujica.SubtitleEditorOptions{}) + if len(mujica.CheckSubtitles(cues)) > 0 { + _ = cues // block saving until the issues are fixed + } +} +ui.Render(view, 400, 300, 1)` + recorderCode = `st := mujica.RecorderState{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ScreenRecorderControls(c, st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { st.Status = mujica.RecorderRecording }, + OnPause: func() { st.Status = mujica.RecorderPaused }, + OnResume: func() { st.Status = mujica.RecorderRecording }, + OnStop: func() { st = mujica.RecorderState{} }, + }) +} +ui.Render(view, 400, 300, 1)` + cameraCode = `frame := mujica.CameraFrame{Image: examplePhoto, Seq: 1} // a new Seq for each captured frame +mirror := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CameraPreview(c, frame, &mirror, mujica.CameraPreviewOptions{}) +} +ui.Render(view, 400, 300, 1)` + micCode = `level := float32(0.4) // the input's peak, 0 to 1 of full scale +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) +} +ui.Render(view, 400, 300, 1)` + deviceCode = `devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "mic-1", Name: "Built-in Microphone"}}, + Speakers: []mujica.MediaDevice{{ID: "out-1", Name: "Headphones"}}, +} +sel := mujica.DeviceSelection{Microphone: "mic-1"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DeviceSelector(c, &sel, devices, mujica.DeviceSelectorOptions{}) +} +ui.Render(view, 400, 300, 1)` + compareCode = `before, after := ui.NewBitmap(examplePhoto), ui.NewBitmap(examplePhoto) +split := 0.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ImageCompare(c, before, after, &split, mujica.ImageCompareOptions{}) +} +ui.Render(view, 400, 300, 1)` +) diff --git a/examples/gallery/pages_media_devices.go b/examples/gallery/pages_media_devices.go new file mode 100644 index 0000000..19527d7 --- /dev/null +++ b/examples/gallery/pages_media_devices.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func volumeDemo(c *ui.Context, log func(string)) { + type state struct { + volume float64 + muted, disabled bool + } + s := ui.Local(c.Root(), "351", func() state { return state{volume: 0.7} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.VolumeControl(c, &s.volume, &s.muted, mujica.VolumeControlOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("volume %.0f%%, muted %v", s.volume*100, s.muted)) + } + }) +} + +func speedDemo(c *ui.Context, log func(string)) { + type state struct { + rate float64 + disabled bool + } + s := ui.Local(c.Root(), "352", func() state { return state{rate: 1} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.PlaybackSpeedControl(c, &s.rate, mujica.PlaybackSpeedControlOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("rate %v×", s.rate)) + } + }) +} + +func mediaControlsDemo(c *ui.Context, log func(string)) { + type state struct { + st mujica.MediaControlsState + track int + disabled bool + } + s := ui.Local(c.Root(), "353", func() state { return state{} }) + tracks := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △"} + s.st.NoPrevious, s.st.NoNext = s.track == 0, s.track == len(tracks)-1 + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Text(c, fmt.Sprintf("Now: %s (%d of %d)", tracks[s.track], s.track+1, len(tracks))) + mujica.MediaControls(c, s.st, mujica.MediaControlsOptions{ + OnPrevious: func() { s.track--; log("previous") }, + OnNext: func() { s.track++; log("next") }, + OnPlay: func() { s.st.Playing = true; log("play") }, + OnPause: func() { s.st.Playing = false; log("pause") }, + OnLoop: func(m mujica.LoopMode) { s.st.Loop = m; log(fmt.Sprintf("loop mode %d", m)) }, + OnShuffle: func(on bool) { s.st.Shuffle = on; log(fmt.Sprintf("shuffle %v", on)) }, + Disabled: s.disabled, + }) + }) +} + +func playlistDemo(c *ui.Context, log func(string)) { + type state struct { + items []mujica.PlaylistItem + current string + disabled bool + } + s := ui.Local(c.Root(), "354", func() state { + titles := []string{"Ave Mujica", "Imprisoned XII", "KiLLKiSS", "Symbol I: △", "Choir 'S' Choir", "Georgette Me, Georgette You"} + items := make([]mujica.PlaylistItem, len(titles)) + for i, t := range titles { + items[i] = mujica.PlaylistItem{ID: fmt.Sprint("t", i), Title: t, Subtitle: fmt.Sprintf("Act %d", i+1), Duration: time.Duration(200+i*17) * time.Second} + } + return state{items: items, current: "t1"} + }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Add track", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + id := fmt.Sprint("t", len(s.items)+int(time.Now().UnixNano()%1000000)) + s.items = append(s.items, mujica.PlaylistItem{ID: id, Title: "Encore " + id, Duration: 3 * time.Minute}) + log("added " + id) + } + }) + }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(520).Children(func() { + r := mujica.Playlist(c, &s.items, mujica.PlaylistOptions{Current: s.current, Disabled: s.disabled}) + switch { + case r.Played != "": + s.current = r.Played + log("play " + r.Played) + case r.Removed != "": + log("removed " + r.Removed) + case r.Moved: + log("reordered") + } + }) + }) +} + +func subtitleDemo(c *ui.Context, log func(string)) { + type state struct { + cues []mujica.SubtitleCue + disabled bool + } + s := ui.Local(c.Root(), "355", func() state { + return state{cues: []mujica.SubtitleCue{ + {Start: 0, End: 2500 * time.Millisecond, Text: "The curtain rises."}, + {Start: 2 * time.Second, End: 5 * time.Second, Text: "Welcome to the masquerade."}, + {Start: 6 * time.Second, End: 9 * time.Second, Text: "Ave Mujica."}, + }} + }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(720).Children(func() { + if mujica.SubtitleEditor(c, &s.cues, mujica.SubtitleEditorOptions{Height: 260, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("%d cues, %d issues", len(s.cues), len(mujica.CheckSubtitles(s.cues)))) + } + }) + }) +} + +func recorderDemo(c *ui.Context, log func(string)) { + type state struct { + st mujica.RecorderState + since time.Time + disabled bool + accumulated time.Duration + } + s := ui.Local(c.Root(), "356", func() state { return state{} }) + if s.st.Status == mujica.RecorderRecording { + s.st.Elapsed = s.accumulated + c.Now().Sub(s.since) + // The clock changes each second; ask for that frame only. + c.After(time.Second - s.st.Elapsed%time.Second) + } + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + mujica.ScreenRecorderControls(c, s.st, mujica.ScreenRecorderControlsOptions{ + OnStart: func() { + s.st, s.since, s.accumulated = mujica.RecorderState{Status: mujica.RecorderRecording}, c.Now(), 0 + log("start") + }, + OnPause: func() { s.st.Status, s.accumulated = mujica.RecorderPaused, s.st.Elapsed; log("pause") }, + OnResume: func() { s.st.Status, s.since = mujica.RecorderRecording, c.Now(); log("resume") }, + OnStop: func() { log("stop at " + mediaClockLabel(s.st.Elapsed)); s.st = mujica.RecorderState{} }, + Disabled: s.disabled, + }) + }) +} + +func cameraDemo(c *ui.Context, log func(string)) { + type state struct { + mirror, signal, disabled bool + } + s := ui.Local(c.Root(), "357", func() state { return state{mirror: true, signal: true} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.signal, "Signal") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + frame := mujica.CameraFrame{} + if s.signal { + frame = mujica.CameraFrame{Image: mediaPhotos[2], Seq: 1} + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(480).Children(func() { + if mujica.CameraPreview(c, frame, &s.mirror, mujica.CameraPreviewOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("mirror %v", s.mirror)) + } + }) + }) +} + +func micDemo(c *ui.Context, log func(string)) { + type state struct{ live bool } + s := ui.Local(c.Root(), "358", func() state { return state{live: true} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.live, "Live input") }) + level := float32(0.3) + if s.live { + t := float64(c.Now().UnixMilli()) / 1000 + level = float32(math.Min(1, 0.15+0.8*math.Abs(math.Sin(t*1.3)*math.Sin(t*0.37)))) + c.AnimationFrame() + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(420).Children(func() { mujica.MicLevelMeter(c, level, mujica.MicLevelMeterOptions{}) }) + }) +} + +func deviceDemo(c *ui.Context, log func(string)) { + type state struct { + sel mujica.DeviceSelection + noCamera, disabled bool + } + s := ui.Local(c.Root(), "359", func() state { return state{sel: mujica.DeviceSelection{Microphone: "m1", Speaker: "s2"}} }) + devices := mujica.MediaDevices{ + Microphones: []mujica.MediaDevice{{ID: "m1", Name: "Built-in Microphone"}, {ID: "m2", Name: "Studio Condenser"}}, + Cameras: []mujica.MediaDevice{{ID: "c1", Name: "FaceTime HD Camera"}}, + Speakers: []mujica.MediaDevice{{ID: "s1", Name: "Built-in Speakers"}, {ID: "s2", Name: "Headphones"}}, + } + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + before := s.noCamera + mediaCheck(c, log, &s.noCamera, "No cameras") + if s.noCamera != before { + s.sel.Camera = "" + } + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + if s.noCamera { + devices.Cameras = []mujica.MediaDevice{} + } + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(480).Children(func() { + if mujica.DeviceSelector(c, &s.sel, devices, mujica.DeviceSelectorOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("microphone %q, camera %q, speaker %q", s.sel.Microphone, s.sel.Camera, s.sel.Speaker)) + } + }) + }) +} diff --git a/examples/gallery/pages_media_image.go b/examples/gallery/pages_media_image.go new file mode 100644 index 0000000..f62ff53 --- /dev/null +++ b/examples/gallery/pages_media_image.go @@ -0,0 +1,162 @@ +package main + +import ( + "errors" + "fmt" + "image" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func imageCropperDemo(c *ui.Context, log func(string)) { + type state struct { + crop mujica.ImageCrop + aspect int + disabled bool + } + s := ui.Local(c.Root(), "341", func() state { return state{} }) + aspects := []float64{0, 1, 16.0 / 9, 4.0 / 3} + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + before := s.aspect + mujica.SegmentedControl(c, &s.aspect, []mujica.Segment{{Label: "Free"}, {Label: "1:1"}, {Label: "16:9"}, {Label: "4:3"}}, mujica.SegmentedControlOptions{Label: "Aspect"}) + if s.aspect != before { + s.crop.Rect = image.Rectangle{} // refit to the new ratio + log(fmt.Sprintf("aspect = %v", aspects[s.aspect])) + } + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + mediaSection(c, "Live", func() { + if mujica.ImageCropper(c, mediaPhotos[0], &s.crop, mujica.ImageCropperOptions{Aspect: aspects[s.aspect], Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("crop %v rotated %d°", s.crop.Rect, s.crop.Rotation)) + } + }) +} + +func imageAnnotatorDemo(c *ui.Context, log func(string)) { + type state struct { + notes []mujica.Annotation + disabled bool + } + s := ui.Local(c.Root(), "342", func() state { + return state{notes: []mujica.Annotation{{Kind: mujica.AnnotateRect, From: image.Pt(150, 60), To: image.Pt(330, 230)}}} + }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).AlignItems(ui.Center).Children(func() { + mediaCheck(c, log, &s.disabled, "Disabled") + if mujica.Button(c, "Export JSON", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + data, err := mujica.ExportAnnotations(s.notes) + if err != nil { + log("export failed: " + err.Error()) + } else { + c.WriteClipboard(string(data)) + log(fmt.Sprintf("exported %d bytes to the clipboard", len(data))) + } + } + }) + }) + mediaSection(c, "Live", func() { + if mujica.ImageAnnotator(c, mediaBitmaps[1], &s.notes, mujica.ImageAnnotatorOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("annotations: %d", len(s.notes))) + } + }) +} + +func imageThumbnailDemo(c *ui.Context, log func(string)) { + type state struct { + selected int + loading, failed, disabled bool + } + s := ui.Local(c.Root(), "343", func() state { return state{} }) + mediaSection(c, "Parameters", func() { + ui.Row(c).Gap(16).Children(func() { + mediaCheck(c, log, &s.loading, "Loading") + mediaCheck(c, log, &s.failed, "Failed") + mediaCheck(c, log, &s.disabled, "Disabled") + }) + }) + mediaSection(c, "Live", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + for i, b := range mediaBitmaps { + var err error + if s.failed && i == 2 { + err = errors.New("the file could not be read") + } + o := mujica.ImageThumbnailOptions{Alt: fmt.Sprintf("Scene %d", i+1), Aspect: 4.0 / 3, Width: 120, + Selected: s.selected == i, Loading: s.loading && i == 1, Err: err, Disabled: s.disabled} + if mujica.ImageThumbnail(c, b, o).Changed() { + s.selected = i + log(fmt.Sprintf("selected scene %d", i+1)) + } + } + }) + }) +} + +func lightboxDemo(c *ui.Context, log func(string)) { + type state struct { + open bool + index int + } + s := ui.Local(c.Root(), "344", func() state { return state{} }) + images := []mujica.ViewerImage{ + {Source: mediaBitmaps[0], Alt: "The grand hall at dusk"}, + {Source: mediaBitmaps[1], Alt: "The ivory gallery"}, + {Source: mediaBitmaps[2], Alt: "The blue chapel"}, + } + mediaSection(c, "Live", func() { + ui.Row(c).Gap(12).Children(func() { + for i, im := range images { + if mujica.ImageThumbnail(c, im.Source, mujica.ImageThumbnailOptions{Alt: im.Alt, Aspect: 4.0 / 3}).Changed() { + s.open, s.index = true, i + log(fmt.Sprintf("open at %d", i+1)) + } + } + }) + was := s.open + if mujica.Lightbox(c, &s.open, &s.index, images).Changed() { + log(fmt.Sprintf("image %d", s.index+1)) + } + if was && !s.open { + log("closed") + } + }) +} + +func videoStripDemo(c *ui.Context, log func(string)) { + type state struct { + current int + disabled bool + } + s := ui.Local(c.Root(), "347", func() state { return state{} }) + frames := make([]mujica.StripFrame, 240) + for i := range frames { + frames[i] = mujica.StripFrame{Image: mediaBitmaps[i%3], At: time.Duration(i) * 5 * time.Second} + } + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + if mujica.VideoThumbnailStrip(c, &s.current, frames, mujica.VideoThumbnailStripOptions{Disabled: s.disabled}).Changed() { + log("jump to " + mediaClockLabel(frames[s.current].At)) + } + }) +} + +func compareDemo(c *ui.Context, log func(string)) { + type state struct { + split float64 + disabled bool + } + s := ui.Local(c.Root(), "360", func() state { return state{split: 0.5} }) + mediaSection(c, "Parameters", func() { mediaCheck(c, log, &s.disabled, "Disabled") }) + mediaSection(c, "Live", func() { + ui.Column(c).MaxWidth(560).Children(func() { + if mujica.ImageCompare(c, mediaBitmaps[0], mediaBitmaps[1], &s.split, mujica.ImageCompareOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("split %.0f%%", s.split*100)) + } + }) + }) +} diff --git a/examples/gallery/pages_trading.go b/examples/gallery/pages_trading.go index e79ef71..5a65056 100644 --- a/examples/gallery/pages_trading.go +++ b/examples/gallery/pages_trading.go @@ -1,3 +1,276 @@ package main -var tradingPages = []page{} +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const tradingCategory = "Trading" + +// tradingTrend shows and applies the rise-color switch. +func tradingTrend(c *ui.Context, redUp *bool, log func(string)) { + if mujica.Checkbox(c, redUp, "Red means up (China)", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("red up %v", *redUp)) + } + t := mujica.GreenUp + if *redUp { + t = mujica.RedUp + } + mujica.UseTrend(c, t) +} + +// tradingAction is a demo button at its natural width. +func tradingAction(c *ui.Context, label string) bool { + clicked := false + ui.Row(c).Children(func() { + clicked = mujica.Button(c, label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() + }) + return clicked +} + +// tradingBook is a deterministic book around mid, moved by step. +func tradingBook(mid float64, step int) mujica.OrderBookData { + var b mujica.OrderBookData + for i := range 12 { + w := float64((i*7+step*3)%11 + 1) + b.Bids = append(b.Bids, mujica.BookLevel{Price: math.Round((mid-0.01*float64(i+1))*100) / 100, Size: w * 10}) + b.Asks = append(b.Asks, mujica.BookLevel{Price: math.Round((mid+0.01*float64(i))*100) / 100, Size: float64((i*5+step)%9+1) * 10}) + } + b.Version = step + return b +} + +var tradingPages = []page{ + {ID: "161", Name: "TickerTape", ZhName: "行情滚动条", Category: tradingCategory, Code: tradingCode["TickerTape"], Demo: tradingTickerDemo, + Keys: "Tab focuses the tape, which pauses it. With reduced motion, Left and Right turn pages."}, + {ID: "162", Name: "QuoteCard", ZhName: "报价卡片", Category: tradingCategory, Code: tradingCode["QuoteCard"], Demo: tradingQuoteDemo, + Keys: "The card takes no focus; controls in its chart slot do."}, + {ID: "163", Name: "PriceText", ZhName: "价格文本", Category: tradingCategory, Code: tradingCode["PriceText"], Demo: tradingPriceDemo, + Keys: "Display only."}, + {ID: "164", Name: "PriceChangeBadge", ZhName: "涨跌徽标", Category: tradingCategory, Code: tradingCode["PriceChangeBadge"], Demo: tradingBadgeDemo, + Keys: "Display only; the name reads the direction and values."}, + {ID: "165", Name: "OrderBook", ZhName: "盘口", Category: tradingCategory, Code: tradingCode["OrderBook"], Demo: tradingBookDemo, + Keys: "Tab into the levels, Up and Down move, Enter picks a price."}, + {ID: "166", Name: "Level2Quotes", ZhName: "多档行情", Category: tradingCategory, Code: tradingCode["Level2Quotes"], Demo: tradingLevel2Demo, + Keys: "Display only; each row reads its level, price, size and orders."}, + {ID: "167", Name: "TimeAndSales", ZhName: "逐笔成交", Category: tradingCategory, Code: tradingCode["TimeAndSales"], Demo: tradingTapeDemo, + Keys: "Tab to Pause and the list; Up and Down move in the list."}, + {ID: "168", Name: "DepthLadder", ZhName: "价格阶梯", Category: tradingCategory, Code: tradingCode["DepthLadder"], Demo: tradingLadderDemo, + Keys: "Arrows move between cells, Enter orders, Page Up and Page Down scroll, the wheel steps a tick."}, + {ID: "169", Name: "SpreadIndicator", ZhName: "价差指示", Category: tradingCategory, Code: tradingCode["SpreadIndicator"], Demo: tradingSpreadDemo, + Keys: "Display only."}, + {ID: "170", Name: "BidAskBar", ZhName: "买卖力量条", Category: tradingCategory, Code: tradingCode["BidAskBar"], Demo: tradingBarDemo, + Keys: "Display only; read as a meter."}, + {ID: "171", Name: "OrderEntry", ZhName: "下单面板", Category: tradingCategory, Code: tradingCode["OrderEntry"], Demo: tradingEntryDemo, + Keys: "Tab through side, type and fields; Up and Down step numbers; a failed submit focuses the first error."}, + {ID: "172", Name: "QuickTradeButtons", ZhName: "快速交易按钮", Category: tradingCategory, Code: tradingCode["QuickTradeButtons"], Demo: tradingQuickDemo, + Keys: "Enter or Space trades; with Confirm, Enter in the popup cancels and Escape closes it."}, + {ID: "173", Name: "PositionTable", ZhName: "持仓表", Category: tradingCategory, Code: tradingCode["PositionTable"], Demo: tradingPositionsDemo, + Keys: "DataTable keys; Tab reaches each Close button."}, + {ID: "174", Name: "OrderTable", ZhName: "委托表", Category: tradingCategory, Code: tradingCode["OrderTable"], Demo: tradingOrdersDemo, + Keys: "Arrows switch the status filter; DataTable keys; Tab reaches Cancel."}, + {ID: "175", Name: "TradeHistoryTable", ZhName: "成交记录表", Category: tradingCategory, Code: tradingCode["TradeHistoryTable"], Demo: tradingHistoryDemo, + Keys: "Tab to the range picker, Export and the table."}, + {ID: "176", Name: "PnLDisplay", ZhName: "盈亏显示", Category: tradingCategory, Code: tradingCode["PnLDisplay"], Demo: tradingPnLDemo, + Keys: "Tab to the eye button; Enter or Space masks the amounts."}, + {ID: "177", Name: "LeverageSlider", ZhName: "杠杆滑块", Category: tradingCategory, Code: tradingCode["LeverageSlider"], Demo: tradingLeverageDemo, + Keys: "Arrows step levels, Home and End jump; Apply sets the value."}, + {ID: "178", Name: "MarginIndicator", ZhName: "保证金率指示", Category: tradingCategory, Code: tradingCode["MarginIndicator"], Demo: tradingMarginDemo, + Keys: "Display only; read as a meter with its lines."}, + {ID: "179", Name: "RiskMeter", ZhName: "风险等级", Category: tradingCategory, Code: tradingCode["RiskMeter"], Demo: tradingRiskDemo, + Keys: "Display only."}, + {ID: "180", Name: "OrderConfirmDialog", ZhName: "下单确认对话框", Category: tradingCategory, Code: tradingCode["OrderConfirmDialog"], Demo: tradingConfirmDemo, + Keys: "Cancel has the focus; Escape cancels; the backdrop does not close it."}, +} + +type tradingTickerState struct { + redUp bool + items []mujica.TickerItem +} + +func tradingTickerDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "161", func() tradingTickerState { + return tradingTickerState{items: []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}, {Symbol: "OBLV", Price: 1204.5, ChangePercent: 0.4}, + {Symbol: "DOLO", Price: 7.25, ChangePercent: -0.8}, {Symbol: "MORT", Price: 33.1, ChangePercent: 1.2}, {Symbol: "TIMR", Price: 61.9, ChangePercent: -2.3}}} + }) + tradingTrend(c, &d.redUp, log) + if tradingAction(c, "Move prices") { + for i := range d.items { + d.items[i].ChangePercent = -d.items[i].ChangePercent + } + log("prices moved") + } + ui.Box(c).Width(560).Children(func() { mujica.TickerTape(c, d.items, mujica.TickerTapeOptions{}) }) + formStates(c, func() { + ui.Box(c).Width(280).Children(func() { mujica.TickerTape(c, d.items[:2], mujica.TickerTapeOptions{Speed: 15}) }) + }) +} + +type tradingQuoteState struct { + redUp bool + price float64 +} + +func tradingQuoteDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "162", func() tradingQuoteState { return tradingQuoteState{price: 412.08} }) + tradingTrend(c, &d.redUp, log) + if mujica.Slider(c, &d.price, mujica.SliderOptions{Min: 380, Max: 440, Step: 0.01, Label: "Price", ShowValue: true}).Changed() { + log(fmt.Sprintf("price %.2f", d.price)) + } + change := d.price - 417.7 + ui.Row(c).Gap(16).Wrap().Children(func() { + mujica.QuoteCard(c, mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: d.price, Change: change, ChangePercent: change / 417.7 * 100}, + mujica.QuoteCardOptions{Chart: func() { + k := mujica.Tokens(c) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + p.Line(r.X, r.Y+r.H*0.7, r.X+r.W/2, r.Y+r.H*0.3, 1.5, k.Info) + p.Line(r.X+r.W/2, r.Y+r.H*0.3, r.X+r.W, r.Y+r.H*(0.5-float32(change)/40), 1.5, k.Info) + }) + }}) + mujica.QuoteCard(c, mujica.Quote{Symbol: "AVE", Price: 88.2}, mujica.QuoteCardOptions{}) + }) +} + +type tradingPriceState struct { + redUp, noFlash, grouping bool + price float64 +} + +func tradingPriceDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "163", func() tradingPriceState { return tradingPriceState{price: 18234.5, grouping: true} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Thousands separators", "No flash"}, &d.grouping, &d.noFlash) + ui.Row(c).Gap(8).Children(func() { + if mujica.Button(c, "Tick up", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + d.price += 12.5 + log(fmt.Sprintf("price %.2f", d.price)) + } + if mujica.Button(c, "Tick down", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + d.price -= 12.5 + log(fmt.Sprintf("price %.2f", d.price)) + } + }) + mujica.PriceText(c, d.price, mujica.PriceTextOptions{Size: 28, Compare: true, Reference: 18234.5, NoFlash: d.noFlash, NoGrouping: !d.grouping}) + formStates(c, func() { + mujica.PriceText(c, 0.004521, mujica.PriceTextOptions{Decimals: 6}) + mujica.PriceText(c, 100, mujica.PriceTextOptions{Compare: true, Reference: 100}) + }) +} + +type tradingBadgeState struct { + redUp, hidePercent bool + change float64 +} + +func tradingBadgeDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "164", func() tradingBadgeState { return tradingBadgeState{change: 1.25} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Hide percent"}, &d.hidePercent) + if mujica.Slider(c, &d.change, mujica.SliderOptions{Min: -5, Max: 5, Step: 0.05, Label: "Change", ShowValue: true}).Changed() { + log(fmt.Sprintf("change %.2f", d.change)) + } + mujica.PriceChangeBadge(c, d.change, d.change/1.47, mujica.PriceChangeBadgeOptions{HidePercent: d.hidePercent}) + formStates(c, func() { + mujica.PriceChangeBadge(c, 2.4, 1.2, mujica.PriceChangeBadgeOptions{}) + mujica.PriceChangeBadge(c, -2.4, -1.2, mujica.PriceChangeBadgeOptions{}) + mujica.PriceChangeBadge(c, 0, 0, mujica.PriceChangeBadgeOptions{}) + }) +} + +type tradingBookState struct { + redUp, disabled bool + step int + book mujica.OrderBookState +} + +func tradingBookDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "165", func() tradingBookState { return tradingBookState{} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled"}, &d.disabled) + if tradingAction(c, "Next update") { + d.step++ + log(fmt.Sprintf("book update %d", d.step)) + } + ui.Box(c).Width(320).Children(func() { + r := mujica.OrderBook(c, &d.book, tradingBook(100, d.step), mujica.OrderBookOptions{Depth: 6, Groups: []float64{0.01, 0.05, 0.1}, Disabled: d.disabled}) + if price, side, ok := r.Clicked(); ok { + log(fmt.Sprintf("level %v at %.2f", side, price)) + } + }) +} + +type tradingLevel2State struct { + redUp bool + step int +} + +func tradingLevel2Demo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "166", func() tradingLevel2State { return tradingLevel2State{} }) + tradingTrend(c, &d.redUp, log) + if tradingAction(c, "Next update") { + d.step++ + log(fmt.Sprintf("level 2 update %d", d.step)) + } + book := tradingBook(100, d.step) + var data mujica.Level2Data + for i := range 5 { + data.Bids = append(data.Bids, mujica.L2Level{Price: book.Bids[i].Price, Size: book.Bids[i].Size, Orders: int(book.Bids[i].Size / 10)}) + data.Asks = append(data.Asks, mujica.L2Level{Price: book.Asks[i].Price, Size: book.Asks[i].Size, Orders: int(book.Asks[i].Size / 10)}) + } + ui.Box(c).Width(620).Children(func() { mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) }) +} + +type tradingTapeState struct { + redUp bool + trades []mujica.TradePrint + tape mujica.TimeAndSalesState +} + +func tradingTapeDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "167", func() tradingTapeState { return tradingTapeState{} }) + tradingTrend(c, &d.redUp, log) + if tradingAction(c, "Add 20 trades") { + at := time.Date(2026, 10, 5, 9, 30, 0, 0, time.Local) + for range 20 { + n := len(d.trades) + side := mujica.SideBuy + if n%3 == 1 { + side = mujica.SideSell + } + d.trades = append(d.trades, mujica.TradePrint{ID: fmt.Sprint(n), Time: at.Add(time.Duration(n) * time.Second), + Price: 100 + float64(n%17)/100, Size: float64((n*37)%1500 + 1), Side: side}) + } + log(fmt.Sprintf("%d trades", len(d.trades))) + } + ui.Box(c).Width(460).Children(func() { + if mujica.TimeAndSales(c, &d.tape, d.trades, mujica.TimeAndSalesOptions{LargeSize: 1000}).Changed() { + log(fmt.Sprintf("paused %v", d.tape.Paused)) + } + }) +} + +type tradingLadderState struct { + redUp, disabled bool + last float64 + ladder mujica.DepthLadderState +} + +func tradingLadderDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "168", func() tradingLadderState { return tradingLadderState{last: 100} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled"}, &d.disabled) + if mujica.Slider(c, &d.last, mujica.SliderOptions{Min: 99.9, Max: 100.1, Step: 0.01, Label: "Last price", ShowValue: true}).Changed() { + log(fmt.Sprintf("last %.2f", d.last)) + } + ui.Box(c).Width(360).Children(func() { + r := mujica.DepthLadder(c, &d.ladder, d.last, tradingBook(100, 0), mujica.DepthLadderOptions{TickSize: 0.01, Rows: 11, Disabled: d.disabled}) + if price, side, ok := r.Clicked(); ok { + log(fmt.Sprintf("order %v at %.2f", side, price)) + } + }) +} diff --git a/examples/gallery/pages_trading_code.go b/examples/gallery/pages_trading_code.go new file mode 100644 index 0000000..b5fdbc7 --- /dev/null +++ b/examples/gallery/pages_trading_code.go @@ -0,0 +1,156 @@ +package main + +// tradingCode holds each page's copyable snippet, the same as its doc and Example. +var tradingCode = map[string]string{ + "TickerTape": `items := []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TickerTape(c, items, mujica.TickerTapeOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "QuoteCard": `quote := mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QuoteCard(c, quote, mujica.QuoteCardOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "PriceText": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UseTrend(c, mujica.RedUp) + mujica.PriceText(c, 18234.5, mujica.PriceTextOptions{Compare: true, Reference: 18000}) +} +ui.Render(view, 400, 300, 1)`, + "PriceChangeBadge": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriceChangeBadge(c, -5.62, -1.35, mujica.PriceChangeBadgeOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "OrderBook": `var state mujica.OrderBookState +book := mujica.OrderBookData{ + Bids: []mujica.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}}, + Asks: []mujica.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.OrderBook(c, &state, book, mujica.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok { + fmt.Println("order at", price, side) + } +} +ui.Render(view, 400, 300, 1)`, + "Level2Quotes": `data := mujica.Level2Data{ + Bids: []mujica.L2Level{{Price: 100.00, Size: 500, Orders: 12}}, + Asks: []mujica.L2Level{{Price: 100.01, Size: 200, Orders: 3}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "TimeAndSales": `var state mujica.TimeAndSalesState +trades := []mujica.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: mujica.SideBuy}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeAndSales(c, &state, trades, mujica.TimeAndSalesOptions{LargeSize: 1000}) +} +ui.Render(view, 400, 300, 1)`, + "DepthLadder": `var state mujica.DepthLadderState +book := mujica.OrderBookData{Bids: []mujica.BookLevel{{Price: 100, Size: 5}}, Asks: []mujica.BookLevel{{Price: 100.01, Size: 2}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.DepthLadder(c, &state, 100, book, mujica.DepthLadderOptions{TickSize: 0.01}).Clicked(); ok { + fmt.Println("place", side, "at", price) + } +} +ui.Render(view, 400, 300, 1)`, + "SpreadIndicator": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) +} +ui.Render(view, 400, 300, 1)`, + "BidAskBar": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BidAskBar(c, 6234, 3820, mujica.BidAskBarOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "OrderEntry": `var state mujica.OrderEntryState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if order, ok := mujica.OrderEntry(c, &state, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2}).Submitted(); ok { + fmt.Println("place", order.Quantity) + state.EndSubmit("") + } +} +ui.Render(view, 400, 300, 1)`, + "QuickTradeButtons": `quantity := 10.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if side, q, ok := mujica.QuickTradeButtons(c, &quantity, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok { + fmt.Println(side, q) + } +} +ui.Render(view, 400, 300, 1)`, + "PositionTable": `var state mujica.DataTableState[string] +rows := []mujica.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.PositionTable(c, &state, rows, mujica.PositionTableOptions{}).Acted(); ok { + fmt.Println("close", id) + } +} +ui.Render(view, 400, 300, 1)`, + "OrderTable": `var state mujica.OrderTableState +rows := []mujica.OrderRow{{ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.OrderTable(c, &state, rows, mujica.OrderTableOptions{}).Acted(); ok { + fmt.Println("cancel", id) + } +} +ui.Render(view, 400, 300, 1)`, + "TradeHistoryTable": `var state mujica.TradeHistoryTableState +rows := []mujica.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: mujica.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if fills, ok := mujica.TradeHistoryTable(c, &state, rows, mujica.TradeHistoryTableOptions{}).Exported(); ok { + fmt.Println("export", len(fills)) + } +} +ui.Render(view, 400, 300, 1)`, + "PnLDisplay": `hidden := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, mujica.PnLDisplayOptions{Currency: "$"}) +} +ui.Render(view, 400, 300, 1)`, + "LeverageSlider": `leverage := 5.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LeverageSlider(c, &leverage, mujica.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() { + fmt.Println("leverage", leverage) + } +} +ui.Render(view, 400, 300, 1)`, + "MarginIndicator": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MarginIndicator(c, 0.86, mujica.MarginIndicatorOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "RiskMeter": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RiskMeter(c, 4, mujica.RiskMeterOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "OrderConfirmDialog": `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OrderConfirmDialog(c, &open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Quantity", Value: "100"}}, + Risk: "Leveraged products can lose more than the deposit.", + Acknowledge: "I understand the risk", + }) + if r.Confirmed { + fmt.Println("send order") + } +} +ui.Render(view, 400, 300, 1)`, +} diff --git a/examples/gallery/pages_trading_orders.go b/examples/gallery/pages_trading_orders.go new file mode 100644 index 0000000..cecdc50 --- /dev/null +++ b/examples/gallery/pages_trading_orders.go @@ -0,0 +1,286 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +type tradingSpreadState struct { + ask, warn float64 +} + +func tradingSpreadDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "169", func() tradingSpreadState { return tradingSpreadState{ask: 100.05, warn: 0.5} }) + if mujica.Slider(c, &d.ask, mujica.SliderOptions{Min: 99.5, Max: 101, Step: 0.01, Label: "Ask (bid 99.95)", ShowValue: true}).Changed() { + log(fmt.Sprintf("ask %.2f", d.ask)) + } + if mujica.Slider(c, &d.warn, mujica.SliderOptions{Min: 0, Max: 2, Step: 0.1, Label: "Warn at percent", ShowValue: true}).Changed() { + log(fmt.Sprintf("warn %.1f%%", d.warn)) + } + mujica.SpreadIndicator(c, 99.95, d.ask, mujica.SpreadIndicatorOptions{WarnPercent: d.warn}) + formStates(c, func() { + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{}) + mujica.SpreadIndicator(c, 99, 101, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) + mujica.SpreadIndicator(c, 100.1, 100, mujica.SpreadIndicatorOptions{}) + }) +} + +type tradingBarState struct { + redUp bool + bid, ask float64 +} + +func tradingBarDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "170", func() tradingBarState { return tradingBarState{bid: 6234, ask: 3820} }) + tradingTrend(c, &d.redUp, log) + for _, v := range []struct { + name string + p *float64 + }{{"Bid volume", &d.bid}, {"Ask volume", &d.ask}} { + if mujica.Slider(c, v.p, mujica.SliderOptions{Min: 0, Max: 10000, Step: 1, Label: v.name, ShowValue: true}).Changed() { + log(fmt.Sprintf("%s %.0f", v.name, *v.p)) + } + } + ui.Box(c).Width(360).Children(func() { mujica.BidAskBar(c, d.bid, d.ask, mujica.BidAskBarOptions{}) }) +} + +type tradingEntryState struct { + redUp, disabled, reject bool + entry mujica.OrderEntryState +} + +func tradingEntryDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "171", func() tradingEntryState { return tradingEntryState{} }) + checks := *ui.Local(c.Root(), "171.checks", func() chan [2]any { return make(chan [2]any, 8) }) + placed := *ui.Local(c.Root(), "171.placed", func() chan string { return make(chan string, 1) }) + for drained := false; !drained; { + select { + case r := <-checks: + v, msg := r[0].(uint64), r[1].(string) + log(fmt.Sprintf("buying power check v%d: %q, kept %v", v, msg, d.entry.Resolve(v, msg))) + case e := <-placed: + d.entry.EndSubmit(e) + log(fmt.Sprintf("order placed, error %q", e)) + default: + drained = true + } + } + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled", "Broker rejects"}, &d.disabled, &d.reject) + r := mujica.OrderEntry(c, &d.entry, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2, MaxQuantity: 5000, Disabled: d.disabled, + Check: func(o mujica.OrderDraft, v uint64) { + log(fmt.Sprintf("check started v%d", v)) + go func() { + time.Sleep(600 * time.Millisecond) + msg := "" + if o.Quantity*100 > 250000 { + msg = "Not enough buying power." + } + checks <- [2]any{v, msg} + c.Invalidate() + }() + }}) + if o, ok := r.Submitted(); ok { + log(fmt.Sprintf("submitted %+v", o)) + fail := "" + if d.reject { + fail = "The broker rejected the order. Your entry is kept." + } + go func() { + time.Sleep(800 * time.Millisecond) + placed <- fail + c.Invalidate() + }() + } +} + +type tradingQuickState struct { + redUp, confirm, disabled bool + qty float64 +} + +func tradingQuickDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "172", func() tradingQuickState { return tradingQuickState{confirm: true, qty: 10} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Confirm first", "Disabled"}, &d.confirm, &d.disabled) + ui.Box(c).Width(400).Children(func() { + if side, q, ok := mujica.QuickTradeButtons(c, &d.qty, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: d.confirm, Disabled: d.disabled}).Traded(); ok { + log(fmt.Sprintf("trade %v %v", side, q)) + } + }) +} + +type tradingPositionsState struct { + redUp, disabled bool + rows []mujica.PositionRow + table mujica.DataTableState[string] +} + +func tradingPositionsDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "173", func() tradingPositionsState { + return tradingPositionsState{rows: []mujica.PositionRow{ + {ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}, + {ID: "p2", Symbol: "AVE", Quantity: 50, AvgCost: 90, Price: 88.2}, + {ID: "p3", Symbol: "OBLV", Quantity: 8, AvgCost: 1180, Price: 1204.5}, + }} + }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(1000).Height(260).Children(func() { + r := mujica.PositionTable(c, &d.table, d.rows, mujica.PositionTableOptions{Disabled: d.disabled}) + if id, ok := r.Acted(); ok { + for i, p := range d.rows { + if p.ID == id { + d.rows = append(d.rows[:i:i], d.rows[i+1:]...) + break + } + } + log("close " + id) + } + if r.Table.Changed() { + id, _ := d.table.Selected() + log("select " + id) + } + }) +} + +type tradingOrdersState struct { + disabled bool + rows []mujica.OrderRow + table mujica.OrderTableState + version int +} + +func tradingOrdersDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "174", func() tradingOrdersState { + return tradingOrdersState{rows: []mujica.OrderRow{ + {ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Type: mujica.OrderLimit, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}, + {ID: "o2", Symbol: "AVE", Side: mujica.SideSell, Type: mujica.OrderMarket, Quantity: 10, Filled: 10, Status: mujica.OrderFilled}, + {ID: "o3", Symbol: "OBLV", Side: mujica.SideBuy, Type: mujica.OrderStop, Price: 12, Quantity: 5, Status: mujica.OrderCanceled}, + {ID: "o4", Symbol: "DOLO", Side: mujica.SideSell, Type: mujica.OrderLimit, Price: 7.25, Quantity: 30, Status: mujica.OrderOpen}, + }} + }) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(1000).Height(300).Children(func() { + if id, ok := mujica.OrderTable(c, &d.table, d.rows, mujica.OrderTableOptions{Disabled: d.disabled, Version: d.version}).Acted(); ok { + for i := range d.rows { + if d.rows[i].ID == id { + d.rows[i].Status = mujica.OrderCanceled + } + } + d.version++ + log("cancel " + id) + } + }) +} + +type tradingHistoryState struct { + disabled bool + rows []mujica.FillRow + table mujica.TradeHistoryTableState +} + +func tradingHistoryDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "175", func() tradingHistoryState { + var rows []mujica.FillRow + for i := range 30 { + side := mujica.SideBuy + if i%3 == 0 { + side = mujica.SideSell + } + rows = append(rows, mujica.FillRow{ID: fmt.Sprint(i), Time: time.Date(2026, 9, 10+i/2, 9+i%6, 0, 0, 0, time.Local), + Symbol: []string{"MUJC", "AVE", "OBLV"}[i%3], Side: side, Price: 100 + float64(i), Quantity: float64(i%5+1) * 10, Fee: 0.5}) + } + return tradingHistoryState{rows: rows} + }) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(1000).Height(360).Children(func() { + if fills, ok := mujica.TradeHistoryTable(c, &d.table, d.rows, mujica.TradeHistoryTableOptions{Disabled: d.disabled}).Exported(); ok { + log(fmt.Sprintf("export %d fills", len(fills))) + } + }) +} + +type tradingPnLState struct { + redUp, hidden bool +} + +func tradingPnLDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "176", func() tradingPnLState { return tradingPnLState{} }) + tradingTrend(c, &d.redUp, log) + if mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &d.hidden, mujica.PnLDisplayOptions{Currency: "$"}).Changed() { + log(fmt.Sprintf("hidden %v", d.hidden)) + } +} + +type tradingLeverageState struct { + disabled bool + value float64 +} + +func tradingLeverageDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "177", func() tradingLeverageState { return tradingLeverageState{value: 5} }) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(380).Children(func() { + if mujica.LeverageSlider(c, &d.value, mujica.LeverageSliderOptions{Warning: 10, Danger: 50, Disabled: d.disabled}).Changed() { + log(fmt.Sprintf("leverage applied %v×", d.value)) + } + }) +} + +type tradingMarginState struct{ ratio float64 } + +func tradingMarginDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "178", func() tradingMarginState { return tradingMarginState{ratio: 0.55} }) + if mujica.Slider(c, &d.ratio, mujica.SliderOptions{Min: 0, Max: 1.4, Step: 0.01, Label: "Margin ratio", ShowValue: true}).Changed() { + log(fmt.Sprintf("ratio %.2f", d.ratio)) + } + ui.Box(c).Width(360).Children(func() { mujica.MarginIndicator(c, d.ratio, mujica.MarginIndicatorOptions{}) }) + formStates(c, func() { + for _, r := range []float64{0.3, 0.85, 1.1} { + ui.Box(c).Width(260).Children(func() { mujica.MarginIndicator(c, r, mujica.MarginIndicatorOptions{}) }) + } + }) +} + +type tradingRiskState struct{ level float64 } + +func tradingRiskDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "179", func() tradingRiskState { return tradingRiskState{level: 3} }) + if mujica.Slider(c, &d.level, mujica.SliderOptions{Min: 1, Max: 5, Step: 1, Label: "Level", ShowValue: true}).Changed() { + log(fmt.Sprintf("level %.0f", d.level)) + } + ui.Box(c).Width(360).Children(func() { mujica.RiskMeter(c, int(d.level), mujica.RiskMeterOptions{}) }) +} + +type tradingConfirmState struct { + open, acknowledge bool +} + +func tradingConfirmDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "180", func() tradingConfirmState { return tradingConfirmState{acknowledge: true} }) + formToggles(c, []string{"Require acknowledgement"}, &d.acknowledge) + if tradingAction(c, "Review order") { + d.open = true + log("open") + } + ack := "" + if d.acknowledge { + ack = "I understand leveraged products can lose more than the deposit" + } + r := mujica.OrderConfirmDialog(c, &d.open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Side", Value: "Buy"}, {Label: "Type", Value: "Limit 100.20"}, + {Label: "Quantity", Value: "100"}, {Label: "Estimated amount", Value: "10,020.00"}}, + Risk: "5× leverage: a 20% move against you wipes out the margin.", Acknowledge: ack, + }) + if r.Confirmed { + log("confirmed") + } + if r.Canceled { + log("canceled") + } +} diff --git a/feedback_form.go b/feedback_form.go new file mode 100644 index 0000000..0b7e0b5 --- /dev/null +++ b/feedback_form.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FeedbackRating is the thumb given; zero is FeedbackUp. +type FeedbackRating int + +const ( + FeedbackUp FeedbackRating = iota + FeedbackDown +) + +// FeedbackValue is the chosen reasons and a comment. +type FeedbackValue struct { + Reasons []string + Comment string +} + +// FeedbackFormOptions configure a FeedbackForm. +type FeedbackFormOptions struct { + Rating FeedbackRating + // Reasons replaces the built-in reasons for the rating. + Reasons []string + // Submitting shows the caller sending; it blocks the form. + Submitting bool + Disabled bool +} + +// FeedbackFormView is a built FeedbackForm and this frame's outcome. +type FeedbackFormView struct { + Element *ui.Element + Submitted, Cancelled bool +} + +var feedbackReasons = [2][]string{ + {"feedback_form.up.accurate", "feedback_form.up.helpful", "feedback_form.up.clear"}, + {"feedback_form.down.wrong", "feedback_form.down.unhelpful", "feedback_form.down.unsafe", "feedback_form.down.style"}, +} + +// FeedbackForm asks for reasons and a comment after a rating. +func FeedbackForm(c *ui.Context, value *FeedbackValue, opts FeedbackFormOptions) FeedbackFormView { + if value == nil { + panic("mujica: FeedbackForm needs a value") + } + if opts.Rating != FeedbackUp && opts.Rating != FeedbackDown { + panic("mujica: FeedbackForm Rating out of range") + } + reasons := opts.Reasons + if reasons == nil { + for _, key := range feedbackReasons[opts.Rating] { + reasons = append(reasons, msg(c, key)) + } + } + k := Tokens(c) + d := Density(c) + blocked := opts.Disabled || opts.Submitting + col := ui.Column(c).Gap(d.Unit()*3).Padding(d.Unit()*4).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Role(ui.RoleGroup).Disabled(blocked) + v := FeedbackFormView{Element: col} + title := map[FeedbackRating]string{FeedbackUp: "feedback_form.up.title", FeedbackDown: "feedback_form.down.title"}[opts.Rating] + col.Label(msg(c, title)) + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, title)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) + }) + ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Children(func() { + for _, r := range reasons { + on := slices.Contains(value.Reasons, r) + was := on + Toggle(c, &on, r, ToggleOptions{Disabled: blocked}) + if on != was && !col.IsDisabled() { + if on { + value.Reasons = append(slices.Clone(value.Reasons), r) + } else { + value.Reasons = slices.DeleteFunc(slices.Clone(value.Reasons), func(x string) bool { return x == r }) + } + } + } + }) + TextArea(c, &value.Comment, TextAreaOptions{Placeholder: msg(c, "feedback_form.comment"), Label: msg(c, "feedback_form.comment"), + MinHeight: 72, Disabled: blocked}) + ui.Row(c).Gap(d.Unit() * 2).Justify(ui.End).Children(func() { + v.Cancelled = Button(c, msg(c, "feedback_form.cancel"), ButtonOptions{Variant: Ghost, Disabled: blocked}).Clicked() + v.Submitted = Button(c, msg(c, "feedback_form.submit"), ButtonOptions{Icon: icons.Must("check"), Loading: opts.Submitting, Disabled: opts.Disabled}).Clicked() + }) + }) + if !col.IsDisabled() && col.Shortcut(0, ui.KeyEscape) { + v.Cancelled = true + } + if col.IsDisabled() { + v.Submitted, v.Cancelled = false, false + } + switch { + case v.Submitted: + slog.Debug("mujica: feedback_form submit", "rating", opts.Rating, "reasons", len(value.Reasons)) + case v.Cancelled: + slog.Debug("mujica: feedback_form cancel") + } + return v +} diff --git a/feedback_form_test.go b/feedback_form_test.go new file mode 100644 index 0000000..8cb86e0 --- /dev/null +++ b/feedback_form_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFeedbackFormReasonsAndSubmit(t *testing.T) { + var v FeedbackValue + var last FeedbackFormView + tt := chatTester(480, 420, func(c *ui.Context) { + if r := FeedbackForm(c, &v, FeedbackFormOptions{Rating: FeedbackDown}); r.Submitted || r.Cancelled { + last = r + } + }) + if !tt.HasText("What went wrong?") || !tt.HasText("Harmful or unsafe") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Incorrect") + _ = tt.Click("Not helpful") + _ = tt.Click("Incorrect") + if !slices.Equal(v.Reasons, []string{"Not helpful"}) { + t.Fatalf("reasons %q", v.Reasons) + } + _ = tt.Click("Tell us more (optional)") + tt.Type("Wrong date") + _ = tt.Click("Submit") + if !last.Submitted || v.Comment != "Wrong date" { + t.Fatalf("submit %+v comment %q", last, v.Comment) + } +} + +func TestFeedbackFormCancelAndUpReasons(t *testing.T) { + var v FeedbackValue + var last FeedbackFormView + tt := chatTester(480, 420, func(c *ui.Context) { + if r := FeedbackForm(c, &v, FeedbackFormOptions{Reasons: []string{"Elegant"}}); r.Submitted || r.Cancelled { + last = r + } + }) + if !tt.HasText("What did you like?") || !tt.HasText("Elegant") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEscape) + if !last.Cancelled { + t.Fatal("Escape did not cancel") + } +} + +func TestFeedbackFormBlocked(t *testing.T) { + for _, opts := range []FeedbackFormOptions{{Disabled: true}, {Submitting: true}} { + var v FeedbackValue + submitted := false + tt := chatTester(480, 420, func(c *ui.Context) { + if FeedbackForm(c, &v, opts).Submitted { + submitted = true + } + }) + _ = tt.Click("Accurate") + _ = tt.Click("Submit") + if submitted || len(v.Reasons) != 0 { + t.Fatalf("%+v: blocked form acted", opts) + } + } +} + +func TestFeedbackFormSnapshot(t *testing.T) { + snapshot(t, "feedback_form", 480, 300, func(c *ui.Context) { + v := FeedbackValue{Reasons: []string{"Incorrect"}} + ui.Column(c).Padding(16).Children(func() { FeedbackForm(c, &v, FeedbackFormOptions{Rating: FeedbackDown}) }) + }) +} diff --git a/file_change_card.go b/file_change_card.go new file mode 100644 index 0000000..76785dc --- /dev/null +++ b/file_change_card.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FileChangeCardOptions configure a FileChangeCard. +type FileChangeCardOptions struct { + // Preview is the diff lines shown; zero means 8. + Preview int + Disabled bool +} + +// FileChangeResult reports this frame's events. +type FileChangeResult struct { + Element *ui.Element + // Decided reports that *decision changed. + Decided bool + // OpenDiff asks the caller to show the full diff. + OpenDiff bool +} + +// FileChangeCard previews a file change to accept or reject. +func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, opts FileChangeCardOptions) FileChangeResult { + if decision == nil || change.Path == "" { + panic("mujica: FileChangeCard needs a decision and a Path") + } + if opts.Preview < 0 { + panic("mujica: FileChangeCard Preview is negative") + } + preview := opts.Preview + if preview == 0 { + preview = 8 + } + u := Density(c).Unit() + var r FileChangeResult + r.Element = agentCard(c).Disabled(opts.Disabled).Label(change.Path) + dc := agentDiffOf(r.Element, change) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() { + ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(Tokens(c).TextMuted).Shrink(0) + ui.Text(c, change.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1) + switch { + case change.Old == "" && change.New != "": + agentCaption(c, msg(c, "file_change_card.new")) + case change.New == "" && change.Old != "": + agentCaption(c, msg(c, "file_change_card.deleted")) + } + agentDiffStats(c, dc.add, dc.del) + agentDecisionMark(c, *decision) + }) + from := max(0, dc.firstEdit-2) + shown := dc.lines[from:min(len(dc.lines), from+preview)] + if len(shown) > 0 { + agentDiffView(c, shown, 320).Font(theme.MonoFont) + } + ui.Row(c).Gap(u * 2).Justify(ui.End).Wrap().Children(func() { + if Button(c, msg(c, "file_change_card.open"), ButtonOptions{Variant: Ghost, Disabled: locked}).Clicked() && !locked { + r.OpenDiff = true + } + if *decision != FilePending { + if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked { + *decision, r.Decided = FilePending, true + } + return + } + if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked { + *decision, r.Decided = FileRejected, true + } + if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked { + *decision, r.Decided = FileAccepted, true + } + }) + }) + if r.Decided || r.OpenDiff { + slog.Debug("mujica: file_change_card event", "path", change.Path, "decision", int(*decision), "open", r.OpenDiff) + } + return r +} diff --git a/file_change_card_test.go b/file_change_card_test.go new file mode 100644 index 0000000..0c93ff4 --- /dev/null +++ b/file_change_card_test.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var fileChangeSample = FileChange{Path: "court/ledger.md", + Old: "# Ledger\nGuests: 40\nWine: claret\nMusic: none\n", + New: "# Ledger\nGuests: 42\nWine: claret\nMusic: quartet\nFlowers: lilies\n"} + +func fileChangeTester(d *FileDecision, ch FileChange, opts FileChangeCardOptions, got *[]FileChangeResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := FileChangeCard(c, d, ch, opts) + if r.Decided || r.OpenDiff { + *got = append(*got, r) + } + }, 560, 400) +} + +func TestFileChangeCardStatsPreviewAndDecide(t *testing.T) { + d := FilePending + var got []FileChangeResult + tt := fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{}, &got) + for _, s := range []string{"court/ledger.md", "+3", "−2", "Pending", "Guests: 40", "Guests: 42", "Flowers: lilies"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + _ = tt.Click("Open diff") + if len(got) != 1 || !got[0].OpenDiff || d != FilePending { + t.Fatalf("open diff: %+v", got) + } + _ = tt.Click("Accept") + if d != FileAccepted || !got[1].Decided || !tt.HasText("Accepted") { + t.Fatalf("accept: %v", d) + } + _ = tt.Click("Undo") + if d != FilePending { + t.Fatalf("undo: %v", d) + } + tt = fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{}, &got) + for range 3 { + tt.Key(0, ui.KeyTab) + if tt.Focused("Reject") { + break + } + } + tt.Key(0, ui.KeyEnter) + if d != FileRejected || !tt.HasText("Rejected") { + t.Fatalf("keyboard reject: %v", d) + } +} + +func TestFileChangeCardPreviewLimitAndNewFile(t *testing.T) { + d := FilePending + var got []FileChangeResult + tt := fileChangeTester(&d, FileChange{Path: "a.txt", New: "1\n2\n3\n4\n5\n"}, FileChangeCardOptions{Preview: 2}, &got) + if !tt.HasText("New file") || !tt.HasText("+5") || !tt.HasText("2") || tt.HasText("3") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestFileChangeCardDisabled(t *testing.T) { + d := FilePending + var got []FileChangeResult + tt := fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{Disabled: true}, &got) + _ = tt.Click("Accept") + _ = tt.Click("Open diff") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(got) != 0 || d != FilePending { + t.Fatalf("disabled card acted: %+v %v", got, d) + } +} + +func TestFileChangeCardSnapshot(t *testing.T) { + snapshot(t, "file_change_card", 520, 330, func(c *ui.Context) { + d := FilePending + ui.Column(c).Padding(16).Children(func() { FileChangeCard(c, &d, fileChangeSample, FileChangeCardOptions{}) }) + }) +} diff --git a/file_history.go b/file_history.go new file mode 100644 index 0000000..d13a1fb --- /dev/null +++ b/file_history.go @@ -0,0 +1,48 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FileHistoryOptions configure a FileHistory. +type FileHistoryOptions struct { + // TimeFormat lays out commit times; empty means "2006-01-02 15:04". + TimeFormat string + Status DataStatus + Error string +} + +// FileHistory lists commits that changed path; Action "diff" opens one. +func FileHistory(c *ui.Context, s *ListState[string], path string, commits []Commit, opts FileHistoryOptions) GitListResult { + if path == "" { + panic("mujica: FileHistory needs a path") + } + k := Tokens(c) + u := Density(c).Unit() + layout := gitTimeLayout(opts.TimeFormat) + var r GitListResult + col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2) + col.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + ui.Icon(c, icons.Must("history")).Size(16, 16).TextColor(k.Ornament.Mix(k.Text, 0.35)) + ui.Text(c, path).Font(theme.MonoFont).FontWeight(600).SingleLine().MinWidth(0) + ui.Text(c, msg(c, "file_history.title")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0) + }) + actions := []GitAction{{ID: "diff", Label: msg(c, "file_history.diff"), Icon: "arrow-left-right"}} + 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, "file_history.empty"), + }, func(i int) { + commitListRow(c, commits[i], layout) + gitRowActions(c, actions, commits[i].Hash, &r) + }) + v.Element.Grow(1) + r.changed, r.submitted = v.Changed(), v.Submitted() + }) + r.Element = col + return r +} diff --git a/file_history_test.go b/file_history_test.go new file mode 100644 index 0000000..21fcbac --- /dev/null +++ b/file_history_test.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFileHistoryDiffAction(t *testing.T) { + var s ListState[string] + commits := gitHistory(3) + diff := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := FileHistory(c, &s, "commit_graph.go", commits, FileHistoryOptions{}) + r.Element.Height(240) + if a, item, ok := r.Action(); ok && a == "diff" { + diff = item + } + }, 700, 280) + if !tt.HasText("commit_graph.go") || !tt.HasText("History") { + t.Fatalf("header: %q", tt.Texts()) + } + r, _ := tt.Find("Commit number 2") + tt.ClickAt(700-30, r.Y+r.H/2) + if diff != commits[1].Hash { + t.Fatalf("diff %q", diff) + } + _ = tt.Click("Commit number 1") + tt.Key(0, ui.KeyUp) + if key, _ := s.Selected(); key != commits[1].Hash { + t.Fatalf("selected %q", key) + } +} + +func TestFileHistoryEmptyAndInvalid(t *testing.T) { + var s ListState[string] + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileHistory(c, &s, "a.go", nil, FileHistoryOptions{}).Element.Height(200) + }, 400, 240) + if !tt.HasText("No history for this file") { + t.Fatalf("empty: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + var s ListState[string] + FileHistory(c, &s, "", nil, FileHistoryOptions{}) + }, 300, 120, 1) +} + +func TestFileHistorySnapshot(t *testing.T) { + commits := gitHistory(3) + snapshot(t, "file_history", 640, 200, func(c *ui.Context) { + s := ui.Local(c.Root(), "list", func() *ListState[string] { return &ListState[string]{} }) + ui.Column(c).Padding(16).Children(func() { + FileHistory(c, *s, "internal/git.go", commits, FileHistoryOptions{}).Element.Height(170) + }) + }) +} diff --git a/file_message.go b/file_message.go new file mode 100644 index 0000000..451d76c --- /dev/null +++ b/file_message.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "log/slog" + "path" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// FileMessageOptions describe a file sent in a chat. +type FileMessageOptions struct { + // Name is the file name; required. + Name string + // Size is the length in bytes. + Size int64 + // Type is the MIME type; empty guesses from the name's extension. + Type string +} + +// FileMessageResult is a FileMessage's element and this frame's actions. +type FileMessageResult struct { + Element *ui.Element + opened, downloaded bool +} + +// Opened reports a click or Enter on the file. +func (r FileMessageResult) Opened() bool { return r.opened } + +// Downloaded reports a click on the download button. +func (r FileMessageResult) Downloaded() bool { return r.downloaded } + +// FileMessage shows a file's type icon, name and size, with open and download actions. +func FileMessage(c *ui.Context, opts FileMessageOptions) FileMessageResult { + if opts.Name == "" { + panic("mujica: FileMessage needs a Name") + } + if opts.Size < 0 { + panic("mujica: FileMessage Size must not be negative") + } + k := Tokens(c) + u := Density(c).Unit() + size := fileMessageSize(c, opts.Size) + var r FileMessageResult + r.Element = ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u, u, u, u*2).MaxWidth(360). + Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + open := ui.ButtonBase(c).Grow(1).MinWidth(0).Gap(u * 3).Padding(u).Justify(ui.Start).Radius(theme.ControlRadius). + Label(fmt.Sprintf(msg(c, "file_message.open"), opts.Name, size)) + internal.Ring(open, k.Focus, theme.ControlRadius) + if open.Hovered() { + open.Background(k.SurfaceHover) + } + open.Children(func() { + ui.Icon(c, icons.Must(fileMessageIcon(opts.Name, opts.Type))).Size(24, 24).Shrink(0).TextColor(k.AccentText) + ui.Column(c).MinWidth(0).Grow(1).Children(func() { + ui.Text(c, opts.Name).SingleLine().FontWeight(600) + ui.Text(c, size).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + }) + }) + if open.Clicked() && !disabled { + r.opened = true + slog.Debug("mujica: file_message open") + } + if iconAction(c, icons.Must("download"), fmt.Sprintf(msg(c, "file_message.download"), opts.Name)).Clicked() && !disabled { + r.downloaded = true + slog.Debug("mujica: file_message download") + } + }) + return r +} + +// fileMessageSize formats n bytes in binary units. +func fileMessageSize(c *ui.Context, n int64) string { + units := []string{"B", "KB", "MB", "GB", "TB"} + v, i := float64(n), 0 + for v >= 1024 && i < len(units)-1 { + v /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf(msg(c, "file_message.bytes"), n) + } + return fmt.Sprintf("%.1f %s", v, units[i]) +} + +// fileMessageKinds maps extensions to type icons. +var fileMessageKinds = func() map[string]string { + m := map[string]string{} + for icon, exts := range map[string]string{ + "image": ".png .jpg .jpeg .gif .webp .svg", + "file-archive": ".zip .tar .gz .tgz .7z .rar", + "file-code": ".go .js .ts .py .json .sh .sql .html .css .rs .java .c .h", + "file-text": ".txt .md .pdf .doc .docx .rtf", + } { + for _, e := range strings.Fields(exts) { + m[e] = icon + } + } + return m +}() + +// fileMessageIcon picks a type icon from the MIME type, else the extension. +func fileMessageIcon(name, mime string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.Contains(mime, "zip"): + return "file-archive" + case strings.HasPrefix(mime, "text/"): + return "file-text" + } + if icon, ok := fileMessageKinds[strings.ToLower(path.Ext(name))]; ok { + return icon + } + return "file" +} diff --git a/file_message_test.go b/file_message_test.go new file mode 100644 index 0000000..3ec84cf --- /dev/null +++ b/file_message_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFileMessageOpenDownloadAndSize(t *testing.T) { + opened, downloaded, off := 0, 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(off).Children(func() { + r := FileMessage(c, FileMessageOptions{Name: "treaty.pdf", Size: 2_621_440}) + if r.Opened() { + opened++ + } + if r.Downloaded() { + downloaded++ + } + }) + }, 400, 200) + if !tt.HasText("2.5 MB") || !tt.HasText("treaty.pdf") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Open treaty.pdf, 2.5 MB") + _ = tt.Click("Download treaty.pdf") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if opened != 2 || downloaded != 1 { + t.Fatalf("opened %d downloaded %d", opened, downloaded) + } + off = true + _ = tt.Click("Open treaty.pdf, 2.5 MB") + _ = tt.Click("Download treaty.pdf") + if opened != 2 || downloaded != 1 { + t.Fatalf("disabled file acted: opened %d downloaded %d", opened, downloaded) + } +} + +func TestFileMessageIconsAndSizes(t *testing.T) { + for name, want := range map[string]string{"a.png": "image", "b.zip": "file-archive", "c.go": "file-code", "d.md": "file-text", "e.bin": "file", "f.c": "file-code", "g.s": "file"} { + if got := fileMessageIcon(name, ""); got != want { + t.Errorf("%s: icon %s, want %s", name, got, want) + } + } + if fileMessageIcon("x", "image/heic") != "image" { + t.Error("MIME type ignored") + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + for _, n := range []int64{0, 1023, 1024, 5 << 30} { + ui.Text(c, fileMessageSize(c, n)) + } + }, 200, 200) + for _, s := range []string{"0 B", "1023 B", "1.0 KB", "5.0 GB"} { + if !tt.HasText(s) { + t.Errorf("missing size %q in %q", s, tt.Texts()) + } + } +} + +func TestFileMessageNeedsName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without a name") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FileMessage(c, FileMessageOptions{}) + }, 100, 100) +} + +func TestFileMessageSnapshot(t *testing.T) { + snapshot(t, "file_message", 400, 160, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + FileMessage(c, FileMessageOptions{Name: "seating-plan.xlsx", Size: 48_213}) + FileMessage(c, FileMessageOptions{Name: "portrait.png", Size: 3_400_000}) + }) + }) +} diff --git a/find_widget.go b/find_widget.go new file mode 100644 index 0000000..97f8629 --- /dev/null +++ b/find_widget.go @@ -0,0 +1,181 @@ +package mujica + +import ( + "fmt" + "log/slog" + "regexp" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// FindState is a FindWidget's query, options and current match. +type FindState struct { + Query, Replacement string + // MatchCase, WholeWord and Regex refine the query. + MatchCase, WholeWord, Regex bool + // ShowReplace opens the replace row. + ShowReplace bool + // Current is the chosen match, from 0. + Current int + matches [][]int + err string + key findKey +} + +type findKey struct { + text, query string + matchCase, word, regex bool + ran bool +} + +// Matches returns the byte ranges of matches. +func (s *FindState) Matches() [][2]int { + out := make([][2]int, len(s.matches)) + for i, m := range s.matches { + out[i] = [2]int{m[0], m[1]} + } + return out +} + +// FindWidgetOptions configure a FindWidget. +type FindWidgetOptions struct { + // ReadOnly hides replacing. + ReadOnly bool + Disabled bool +} + +// FindWidgetResult is a FindWidget and what happened in this frame. +type FindWidgetResult struct { + Element *ui.Element + // Moved reports navigation; Replaced counts replacements. + Moved bool + Replaced int +} + +// FindWidget finds and replaces in *text. +func FindWidget(c *ui.Context, text *string, s *FindState, opts FindWidgetOptions) FindWidgetResult { + if text == nil || s == nil { + panic("mujica: FindWidget needs text and a state") + } + k := Tokens(c) + u := Density(c).Unit() + var res FindWidgetResult + res.Element = codePanel(c).Padding(u * 2).Gap(u * 2).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + findRun(*text, s) + res.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + if !opts.ReadOnly { + chev := "chevron-right" + if s.ShowReplace { + chev = "chevron-down" + } + if iconAction(c, icons.Must(chev), msg(c, "find_widget.toggleReplace")).Clicked() && !disabled { + s.ShowReplace = !s.ShowReplace + } + } + r := SearchInput(c, &s.Query, SearchInputOptions{Find: true, Matches: len(s.matches), Current: &s.Current, Disabled: opts.Disabled}) + r.Element.Grow(1).MinWidth(0) + changed := r.Changed() || r.Moved() + for _, o := range []struct { + on *bool + short string + key string + }{{&s.MatchCase, "Aa", "find_widget.case"}, {&s.WholeWord, "ab", "find_widget.word"}, {&s.Regex, ".*", "find_widget.regex"}} { + if Toggle(c, o.on, o.short, ToggleOptions{Label: msg(c, o.key), Disabled: opts.Disabled}).Tooltip(msg(c, o.key)).Changed() { + changed = true + } + } + if changed { + findRun(*text, s) + res.Moved = true + slog.Debug("mujica: find_widget move", "matches", len(s.matches), "current", s.Current) + } + }) + if s.err != "" { + ui.Text(c, fmt.Sprintf(msg(c, "find_widget.invalid"), s.err)).TextColor(k.Danger).Role(ui.RoleStatus). + FontSize(FontSize(c, 12)) + } + if !s.ShowReplace || opts.ReadOnly { + return + } + ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(0, 0, 0, 28+u*2).Children(func() { + in := TextInput(c, &s.Replacement, TextInputOptions{Placeholder: msg(c, "find_widget.replace"), Label: msg(c, "find_widget.replace"), Disabled: opts.Disabled}) + in.Element.Grow(1).MinWidth(0) + none := len(s.matches) == 0 + if (Button(c, msg(c, "find_widget.replaceOne"), ButtonOptions{Variant: Secondary, Disabled: none || opts.Disabled}).Clicked() || in.Submitted()) && !none && !disabled { + res.Replaced = findReplace(text, s, false) + } + if Button(c, msg(c, "find_widget.replaceAll"), ButtonOptions{Variant: Secondary, Disabled: none || opts.Disabled}).Clicked() && !disabled { + res.Replaced = findReplace(text, s, true) + } + }) + }) + return res +} + +// findPattern compiles the query with its options. +func findPattern(s *FindState) (*regexp.Regexp, error) { + q := s.Query + if !s.Regex { + q = regexp.QuoteMeta(q) + } + if s.WholeWord { + q = `\b(?:` + q + `)\b` + } + if !s.MatchCase { + q = "(?i)" + q + } + return regexp.Compile(q) +} + +// findRun refinds matches when inputs change. +func findRun(text string, s *FindState) { + key := findKey{text, s.Query, s.MatchCase, s.WholeWord, s.Regex, true} + if key == s.key { + return + } + s.key, s.err, s.matches = key, "", nil + if s.Query != "" { + re, err := findPattern(s) + if err != nil { + s.err = err.Error() + } else { + for _, m := range re.FindAllStringSubmatchIndex(text, -1) { + if m[1] > m[0] { + s.matches = append(s.matches, m) + } + } + } + } + s.Current = max(0, min(s.Current, len(s.matches)-1)) +} + +// findReplace replaces one or all matches. +func findReplace(text *string, s *FindState, all bool) int { + re, err := findPattern(s) + if err != nil { + panic("mujica: FindWidget replaced with an invalid pattern") + } + targets := s.matches + if !all { + targets = s.matches[s.Current : s.Current+1] + } + out := []byte{} + last := 0 + for _, m := range targets { + out = append(out, (*text)[last:m[0]]...) + if s.Regex { + out = re.ExpandString(out, s.Replacement, *text, m) + } else { + out = append(out, s.Replacement...) + } + last = m[1] + } + *text = string(append(out, (*text)[last:]...)) + findRun(*text, s) + slog.Debug("mujica: find_widget replace", "count", len(targets)) + return len(targets) +} diff --git a/find_widget_test.go b/find_widget_test.go new file mode 100644 index 0000000..5a94aef --- /dev/null +++ b/find_widget_test.go @@ -0,0 +1,117 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func findTester(text *string, s *FindState, opts FindWidgetOptions, replaced *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + *replaced += FindWidget(c, text, s, opts).Replaced + }, 640, 200) +} + +func TestFindWidgetOptions(t *testing.T) { + text := "Seal the seal; sealing wax. SEAL" + s := &FindState{Query: "seal"} + n := 0 + tt := findTester(&text, s, FindWidgetOptions{}, &n) + if len(s.Matches()) != 4 || !tt.HasText("1 of 4") { + t.Fatalf("plain matches %d", len(s.Matches())) + } + if err := tt.Click("Match case"); err != nil { + t.Fatal(err) + } + if len(s.Matches()) != 2 { + t.Fatalf("case-sensitive matches %d, want 2", len(s.Matches())) + } + _ = tt.Click("Whole word") + if len(s.Matches()) != 1 || s.Matches()[0][0] != 9 { + t.Fatalf("whole-word matches %v", s.Matches()) + } + _ = tt.Click("Match case") + _ = tt.Click("Whole word") + _ = tt.Click("Regular expression") + s.Query = `seal(ing)?` + tt.Frame() + if len(s.Matches()) != 4 || s.Matches()[2] != [2]int{15, 22} { + t.Fatalf("regex matches %v", s.Matches()) + } +} + +func TestFindWidgetInvalidRegex(t *testing.T) { + text := "abc" + s := &FindState{Query: "a(", Regex: true} + n := 0 + tt := findTester(&text, s, FindWidgetOptions{}, &n) + if len(s.Matches()) != 0 || !tt.HasText("Invalid pattern: error parsing regexp: missing closing ): `(?i)a(`") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestFindWidgetReplace(t *testing.T) { + text := "one cat, two cat, red cat" + s := &FindState{Query: "cat", Replacement: "owl", ShowReplace: true, Current: 1} + n := 0 + tt := findTester(&text, s, FindWidgetOptions{}, &n) + if err := tt.Click("Replace"); err != nil { + t.Fatal(err) + } + if text != "one cat, two owl, red cat" || n != 1 { + t.Fatalf("replace one gave %q (%d)", text, n) + } + if err := tt.Click("Replace all"); err != nil { + t.Fatal(err) + } + if text != "one owl, two owl, red owl" || n != 3 || len(s.Matches()) != 0 { + t.Fatalf("replace all gave %q (%d)", text, n) + } + text, s.Query, s.Regex, s.Replacement = "a1 b2", `([a-z])(\d)`, true, "$2$1" + tt.Frame() + _ = tt.Click("Replace all") + if text != "1a 2b" { + t.Fatalf("regex replace gave %q", text) + } +} + +func TestFindWidgetKeysAndReadOnly(t *testing.T) { + text := "x y x y x" + s := &FindState{} + n := 0 + tt := findTester(&text, s, FindWidgetOptions{ReadOnly: true}, &n) + if tt.HasText("Replace all") { + t.Fatal("read-only widget offers replace") + } + tt.Key(0, ui.KeyTab) + tt.Type("x") + tt.Key(0, ui.KeyEnter) + if s.Current != 1 || !tt.HasText("2 of 3") { + t.Fatalf("Enter went to %d", s.Current) + } +} + +func TestFindWidgetDisabled(t *testing.T) { + text := "cat" + s := &FindState{Query: "cat", Replacement: "dog", ShowReplace: true} + n := 0 + tt := findTester(&text, s, FindWidgetOptions{Disabled: true}, &n) + _ = tt.Click("Replace all") + _ = tt.Click("Match case") + if text != "cat" || s.MatchCase { + t.Fatalf("disabled widget changed %q case=%v", text, s.MatchCase) + } +} + +func TestFindWidgetSnapshot(t *testing.T) { + snapshot(t, "find_widget", 640, 130, func(c *ui.Context) { + st := ui.Local(c.Root(), "snap", func() *FindState { + return &FindState{Query: "seal", Replacement: "signet", ShowReplace: true, WholeWord: true} + }) + text := "the seal and the seal" + ui.Column(c).Padding(12).Children(func() { + FindWidget(c, &text, *st, FindWidgetOptions{}) + }) + }) +} diff --git a/flamegraph.go b/flamegraph.go new file mode 100644 index 0000000..e3e3673 --- /dev/null +++ b/flamegraph.go @@ -0,0 +1,276 @@ +package mujica + +import ( + "fmt" + "hash/fnv" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// FlameNode is a profile frame; Value includes children. +type FlameNode struct { + Name string + Value float64 + Children []FlameNode +} + +// FlamegraphState holds zoom and focus paths and the search. +type FlamegraphState struct { + Zoom, Focus []int + Query string + said string +} + +// FlamegraphOptions configure a Flamegraph. +type FlamegraphOptions struct { + // Unit follows values in the readout, as "ms". + Unit string + // Status shows loading or failure in place of the graph. + Status DataStatus + Error string + Disabled bool +} + +// flameRow is a frame row's height, a full hit target. +const flameRow = theme.MinHitSize + +// flamegraphPalette colors frames at 3:1 on Surface. +func flamegraphPalette(k theme.Tokens) []ui.Color { + // Ornament alone is too faint on ivory. + return []ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Ornament.Mix(k.Text, 0.2), k.TextMuted} +} + +// Flamegraph draws a zoomable, searchable profile. +func Flamegraph(c *ui.Context, root *FlameNode, s *FlamegraphState, opts FlamegraphOptions) ChoiceResult { + if s == nil { + panic("mujica: Flamegraph needs a state") + } + k := Tokens(c) + d := Density(c) + var res ChoiceResult + res.Element = codePanel(c).Disabled(opts.Disabled) + res.Element.Children(func() { + codeHeader(c, func() { + SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "flamegraph.search"), Disabled: opts.Disabled}).Element.Grow(1) + }) + if root == nil || opts.Status != DataReady { + ui.Column(c).Height(flameRow * 6).Children(func() { + res.changed = dataPlaceholder(c, opts.Status, opts.Error, msg(c, "flamegraph.empty"), msg(c, "flamegraph.empty")) + }) + return + } + flameCheck(root) + if flameAt(root, s.Zoom) == nil { + s.Zoom = nil + } + if flameAt(root, s.Focus) == nil { + s.Focus = nil + } + q := strings.ToLower(strings.TrimSpace(s.Query)) + depth := flameDepth(root) + g := ui.Box(c).Height(float32(depth)*flameRow).Shrink(0).Margin(d.Unit()*2, d.Unit()*3, 0, d.Unit()*3).Focusable().Label(msg(c, "flamegraph.label")) + internal.Ring(g, k.Focus, theme.ControlRadius) + hover := ui.Local(g, "flamegraph.hover", func() []int { return nil }) + zoom, focus := s.Zoom, s.Focus + if !g.IsDisabled() { + if flameInput(c, g, root, s, hover) { + res.changed = true + } + } + b := g.Bounds() + g.Draw(func(p *ui.Painter, r ui.Rect) { + if r.W != b.W { + c.Invalidate() + } + flamePaint(p, r, k, root, zoom, focus, q, g.Focused(), FontSize(c, theme.CaptionSize)) + }) + readout := s.Focus + if *hover != nil { + readout = *hover + } + text := flameReadout(c, root, readout, opts.Unit) + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit(), d.Unit()*3). + Role(ui.RoleStatus).Shrink(0).SingleLine() + if g.Focused() && text != s.said { + s.said = text + c.Announce(text) + } + }) + return res +} + +// flameInput zooms, moves focus and tracks hover. +func flameInput(c *ui.Context, g *ui.Element, root *FlameNode, s *FlamegraphState, hover *[]int) bool { + b := g.Bounds() + x, y, over := g.PointerPosition() + *hover = nil + if over && b.W > 0 { + *hover = flameHit(root, s.Zoom, x/b.W, int(y/flameRow)) + } + changed := false + zoomTo := func(path []int) { + s.Zoom, s.Focus, changed = slices.Clone(path), slices.Clone(path), true + slog.Debug("mujica: flamegraph zoom", "depth", len(path)) + } + if g.Clicked() && *hover != nil { + zoomTo(*hover) + } + f := s.Focus + switch { + case g.Shortcut(0, ui.KeyEnter): + zoomTo(f) + case g.Shortcut(0, ui.KeyEscape) && len(s.Zoom) > 0: + zoomTo(s.Zoom[:len(s.Zoom)-1]) + case g.Shortcut(0, ui.KeyUp) && len(f) > 0: + s.Focus, changed = f[:len(f)-1], true + case g.Shortcut(0, ui.KeyDown): + if n := flameAt(root, f); len(n.Children) > 0 { + s.Focus, changed = append(slices.Clone(f), 0), true + } + case g.Shortcut(0, ui.KeyLeft) && len(f) > 0 && f[len(f)-1] > 0: + s.Focus, changed = append(slices.Clone(f[:len(f)-1]), f[len(f)-1]-1), true + case g.Shortcut(0, ui.KeyRight) && len(f) > 0 && f[len(f)-1] < len(flameAt(root, f[:len(f)-1]).Children)-1: + s.Focus, changed = append(slices.Clone(f[:len(f)-1]), f[len(f)-1]+1), true + } + return changed +} + +// flameCheck panics on impossible values. +func flameCheck(n *FlameNode) { + sum := 0.0 + for i := range n.Children { + flameCheck(&n.Children[i]) + sum += n.Children[i].Value + } + if n.Value < 0 || sum > n.Value*(1+1e-9) { + panic("mujica: FlameNode " + n.Name + " has a negative value or children larger than itself") + } +} + +func flameAt(root *FlameNode, path []int) *FlameNode { + n := root + for _, i := range path { + if i < 0 || i >= len(n.Children) { + return nil + } + n = &n.Children[i] + } + return n +} + +func flameDepth(n *FlameNode) int { + d := 0 + for i := range n.Children { + d = max(d, flameDepth(&n.Children[i])) + } + return d + 1 +} + +// flameSpan returns a frame's fractional left and width. +func flameSpan(root *FlameNode, zoom, path []int) (x, w float64) { + if len(path) <= len(zoom) { + return 0, 1 + } + z := flameAt(root, zoom) + x, w = 0, 1 + n := z + for _, i := range path[len(zoom):] { + for j := range i { + x += w * n.Children[j].Value / max(n.Value, 1e-12) + } + w *= n.Children[i].Value / max(n.Value, 1e-12) + n = &n.Children[i] + } + return x, w +} + +// flameHit finds the frame at fx in row. +func flameHit(root *FlameNode, zoom []int, fx float32, row int) []int { + if row < len(zoom) { + return slices.Clone(zoom[:row]) + } + path := slices.Clone(zoom) + for len(path) < row { + n := flameAt(root, path) + next := -1 + for i := range n.Children { + x, w := flameSpan(root, zoom, append(path, i)) + if float64(fx) >= x && float64(fx) < x+w { + next = i + } + } + if next < 0 { + return nil + } + path = append(path, next) + } + if x, w := flameSpan(root, zoom, path); float64(fx) < x || float64(fx) >= x+w { + return nil + } + return path +} + +func flameColor(k theme.Tokens, name string) ui.Color { + h := fnv.New32a() + h.Write([]byte(name)) + pal := flamegraphPalette(k) + return pal[h.Sum32()%uint32(len(pal))] +} + +// flamePaint draws visible frames with search and focus marks. +func flamePaint(p *ui.Painter, r ui.Rect, k theme.Tokens, root *FlameNode, zoom, focus []int, q string, focused bool, size float32) { + var walk func(n *FlameNode, path []int) + walk = func(n *FlameNode, path []int) { + fx, fw := flameSpan(root, zoom, path) + w := float32(fw) * r.W + if w < 1 { + return + } + box := ui.Rect{X: internal.Snap(p, r.X+float32(fx)*r.W), Y: r.Y + float32(len(path))*flameRow, W: internal.Snap(p, w) - 1, H: flameRow - 2} + col := flameColor(k, n.Name) + face := col.Mix(k.Surface, 0.78) + match := q != "" && strings.Contains(strings.ToLower(n.Name), q) + above := len(path) < len(zoom) + if q != "" && !match || above { + face, col = k.Surface, k.Border + } + p.Fill(box, face, 2) + p.Stroke(box, col, 2, 1) + if match { + p.Stroke(box, k.AccentText, 2, 2) + } + if focused && slices.Equal(path, focus) { + internal.FocusRing(p, box, 2, k.Focus) + } + if box.W > 24 { + fg := k.Text + if above || q != "" && !match { + fg = k.TextMuted + } + p.Clip(box, 2, func() { p.Text(box.X+6, box.Y+(box.H-size*1.3)/2, n.Name, size, fg) }) + } + for i := range n.Children { + if len(path) < len(zoom) && i != zoom[len(path)] { + continue + } + walk(&n.Children[i], append(slices.Clone(path), i)) + } + } + walk(root, nil) +} + +// flameReadout names a frame's time and share. +func flameReadout(c *ui.Context, root *FlameNode, path []int, unit string) string { + n := flameAt(root, path) + if n == nil { + return msg(c, "flamegraph.hint") + } + share := 100 * n.Value / max(root.Value, 1e-12) + return fmt.Sprintf(msg(c, "flamegraph.readout"), n.Name, n.Value, unit, share) +} diff --git a/flamegraph_test.go b/flamegraph_test.go new file mode 100644 index 0000000..ff6cda6 --- /dev/null +++ b/flamegraph_test.go @@ -0,0 +1,143 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func flameSample() *FlameNode { + return &FlameNode{Name: "main", Value: 100, Children: []FlameNode{ + {Name: "court.Seal", Value: 60, Children: []FlameNode{ + {Name: "wax.Melt", Value: 40}, + {Name: "ink.Dry", Value: 15}, + }}, + {Name: "ledger.Write", Value: 30}, + }} +} + +type flameProbe struct { + root *FlameNode + s FlamegraphState + opts FlamegraphOptions + changed int +} + +func (p *flameProbe) view(c *ui.Context) { + Use(c, Settings{}) + if Flamegraph(c, p.root, &p.s, p.opts).Changed() { + p.changed++ + } +} + +func TestFlamegraphPaletteContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + for i, col := range flamegraphPalette(k) { + if r := theme.Contrast(col, k.Surface); r < 3 { + t.Errorf("dark=%v color %d contrast %.2f < 3", k.Dark, i, r) + } + } + } +} + +func TestFlamegraphClickZoomAndEscape(t *testing.T) { + p := &flameProbe{root: flameSample(), opts: FlamegraphOptions{Unit: "ms"}} + tt := ui.NewTester(p.view, 600, 300) + g, _ := tt.Find("Flame graph") + // Row 1, at 30% of the width, is court.Seal. + tt.ClickAt(g.X+g.W*0.3, g.Y+flameRow*1.5) + if len(p.s.Zoom) != 1 || p.s.Zoom[0] != 0 || p.changed != 1 { + t.Fatalf("zoom %v changed %d", p.s.Zoom, p.changed) + } + if x, w := flameSpan(p.root, p.s.Zoom, []int{0, 0}); x != 0 || w < 0.66 || w > 0.67 { + t.Fatalf("wax.Melt spans %v+%v of the zoomed width", x, w) + } + if !tt.HasText("court.Seal: 60.0 ms, 60.0% of total") { + t.Fatalf("readout %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEscape) + if len(p.s.Zoom) != 0 { + t.Fatalf("Escape left zoom %v", p.s.Zoom) + } +} + +func TestFlamegraphKeyboardReadout(t *testing.T) { + p := &flameProbe{root: flameSample(), opts: FlamegraphOptions{Unit: "ms"}} + tt := ui.NewTester(p.view, 600, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Flame graph") { + t.Fatal("Tab did not reach the graph") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + if len(p.s.Focus) != 2 || p.s.Focus[1] != 1 { + t.Fatalf("focus %v", p.s.Focus) + } + want := "ink.Dry: 15.0 ms, 15.0% of total" + if !tt.HasText(want) { + t.Fatalf("readout %q", tt.Texts()) + } + found := false + for _, a := range tt.Announcements() { + found = found || a == want + } + if !found { + t.Fatalf("readout not announced: %q", tt.Announcements()) + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if len(p.s.Zoom) != 1 { + t.Fatalf("Enter zoomed to %v", p.s.Zoom) + } +} + +func TestFlamegraphSearchAndStates(t *testing.T) { + p := &flameProbe{root: flameSample(), s: FlamegraphState{Query: "wax"}} + tt := ui.NewTester(p.view, 600, 300) + if hit := flameHit(p.root, nil, 0.2, 2); len(hit) != 2 || flameAt(p.root, hit).Name != "wax.Melt" { + t.Fatalf("hit %v", hit) + } + _ = tt + p = &flameProbe{opts: FlamegraphOptions{Status: DataFailed, Error: "profile unreadable"}} + tt = ui.NewTester(p.view, 600, 300) + if !tt.HasText("profile unreadable") { + t.Fatalf("texts %q", tt.Texts()) + } + p = &flameProbe{} + tt = ui.NewTester(p.view, 600, 300) + if !tt.HasText("No profile") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestFlamegraphInvalidAndDisabled(t *testing.T) { + p := &flameProbe{root: flameSample(), opts: FlamegraphOptions{Disabled: true}} + tt := ui.NewTester(p.view, 600, 300) + g, _ := tt.Find("Flame graph") + tt.ClickAt(g.X+g.W*0.3, g.Y+flameRow*1.5) + if len(p.s.Zoom) != 0 { + t.Fatal("disabled graph zoomed") + } + defer func() { + if recover() == nil { + t.Fatal("children above their parent did not panic") + } + }() + bad := &FlameNode{Name: "x", Value: 1, Children: []FlameNode{{Name: "y", Value: 2}}} + ui.NewTester((&flameProbe{root: bad}).view, 600, 300) +} + +func TestFlamegraphSnapshot(t *testing.T) { + snapshot(t, "flamegraph", 560, 200, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *flameProbe { + return &flameProbe{root: flameSample(), s: FlamegraphState{Focus: []int{0, 1}}, opts: FlamegraphOptions{Unit: "ms"}} + }) + ui.Column(c).Padding(12).Children(func() { (*p).view(c) }) + }) +} diff --git a/funnel_chart.go b/funnel_chart.go new file mode 100644 index 0000000..afac694 --- /dev/null +++ b/funnel_chart.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// FunnelChartOptions configure a FunnelChart. +type FunnelChartOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// FunnelChart narrows by stage, labeled with conversion rates. +func FunnelChart(c *ui.Context, stages []ChartSlice, opts FunnelChartOptions) ChartResult { + top := 0.0 + for _, s := range stages { + if s.Label == "" || s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) { + panic("mujica: FunnelChart stages need a label and a finite value ≥ 0") + } + top = max(top, s.Value) + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + col := internal.Palette(k)[0] + size := FontSize(c, theme.CaptionSize) + n := len(stages) + if top == 0 { + n = 0 + } + pct := func(a, b float64) string { + if b == 0 { + return "—" + } + return fmt.Sprintf("%.1f%%", 100*a/b) + } + rate := func(i int) string { + if i == 0 { + return pct(stages[0].Value, stages[0].Value) + } + return pct(stages[i].Value, stages[i-1].Value) + } + geom := func(r ui.Rect) (ui.Rect, internal.Band) { + plot := ui.Rect{X: r.X + 8, Y: r.Y + 4, W: r.W * 0.58, H: r.H - 8} + return plot, internal.Band{N: len(stages), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.08} + } + spec := chartSpec{n: n} + spec.readout = func(i int) string { + s := stages[i] + return fmt.Sprintf(msg(c, "funnel_chart.readout"), s.Label, chartValue(c, s.Value, opts.Format), rate(i), pct(s.Value, stages[0].Value)) + } + spec.pick = func(r ui.Rect, x, y float32) int { + plot, band := geom(r) + if y < plot.Y || y >= plot.Y+plot.H || x < r.X || x > r.X+r.W { + return -1 + } + return min(len(stages)-1, int((y-plot.Y)/band.Step())) + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, band := geom(r) + cx := plot.X + plot.W/2 + width := func(i int) float32 { return max(plot.W*float32(stages[min(i, len(stages)-1)].Value/top), 2) } + for i, s := range stages { + y0, y1 := band.Start(i), band.Start(i)+band.Width() + w0, w1 := width(i), width(i+1) + var shape ui.Path + shape.MoveTo(cx-w0/2, y0).LineTo(cx+w0/2, y0).LineTo(cx+w1/2, y1).LineTo(cx-w1/2, y1).Close() + p.FillPath(&shape, col) + if i == active { + p.StrokePath(&shape, 2, k.Text) + } + ly := (y0 + y1) / 2 + lx := plot.X + plot.W + 16 + chartText(p, s.Label+" · "+chartValue(c, s.Value, opts.Format), lx, ly, size, k.Text, 0, 1) + chartText(p, rate(i), lx, ly+2, size, k.TextMuted, 0, 0) + } + } + res, _ := ch.build(spec) + return res +} diff --git a/funnel_chart_test.go b/funnel_chart_test.go new file mode 100644 index 0000000..d3dbfa6 --- /dev/null +++ b/funnel_chart_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var funnelStages = []ChartSlice{{"Visitors", 1000}, {"Novices", 400}, {"Vows", 100}, {"Abbots", 0}} + +func TestFunnelChartReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return FunnelChart(c, funnelStages, FunnelChartOptions{Frame: ChartFrame{Label: "Order"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Order") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Novices · 400 · 40.0% of the stage before · 40.0% of the first" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyEscape) + b := h.res.Element.Bounds() + tt.Move(b.X+b.W/3, b.Y+4+232*0.6) // third stage band + if got := chartReadout(tt); got != "Vows · 100 · 25.0% of the stage before · 10.0% of the first" { + t.Fatalf("hover %q", got) + } +} + +func TestFunnelChartBadAndEmpty(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return FunnelChart(c, []ChartSlice{{"a", 0}}, FunnelChartOptions{Frame: ChartFrame{Label: "Order"}}) + }} + if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") { + t.Fatal("all-zero funnel is not empty") + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a negative stage") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + FunnelChart(c, []ChartSlice{{"a", -1}}, FunnelChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestFunnelChartSnapshot(t *testing.T) { + snapshot(t, "funnel_chart", 520, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + FunnelChart(c, funnelStages[:3], FunnelChartOptions{Frame: ChartFrame{Label: "Order", Height: 220}}) + }) + }) +} diff --git a/gauge.go b/gauge.go new file mode 100644 index 0000000..21915df --- /dev/null +++ b/gauge.go @@ -0,0 +1,154 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// GaugeTone colors a Gauge segment; its label still names it. +type GaugeTone int + +const ( + GaugeNeutral GaugeTone = iota + GaugeGood + GaugeWarning + GaugeDanger +) + +// GaugeSegment is a named dial range up to To. +type GaugeSegment struct { + To float64 + Label string + Tone GaugeTone +} + +// GaugeOptions configure a Gauge; Frame.Height 0 means 200 DIPs. +type GaugeOptions struct { + Frame ChartFrame + Min, Max float64 + // Segments split Min..Max ascending; the last ends at Max. + Segments []GaugeSegment + Unit string + // Full draws a whole circle instead of a half. + Full bool + Format func(v float64) string +} + +// Gauge points a needle at value on named segments. +func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult { + if !(opts.Max > opts.Min) || math.IsNaN(value) || value < opts.Min || value > opts.Max { + panic("mujica: Gauge needs Min < Max and a value between them") + } + prev := opts.Min + for i, s := range opts.Segments { + if s.To <= prev || s.Label == "" || i == len(opts.Segments)-1 && s.To != opts.Max { + panic("mujica: Gauge segments need labels and ascending ends finishing at Max") + } + prev = s.To + } + if opts.Frame.Height == 0 { + opts.Frame.Height = 200 + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + tone := func(t GaugeTone) ui.Color { + return map[GaugeTone]ui.Color{GaugeNeutral: internal.Palette(k)[5], GaugeGood: k.Success, GaugeWarning: k.Warning, GaugeDanger: k.Danger}[t] + } + val := func(v float64) string { return chartValue(c, v, opts.Format) } + seg := -1 + for i, s := range opts.Segments { + if value <= s.To && seg < 0 { + seg = i + } + } + a0, a1 := -math.Pi/2, math.Pi/2 + if opts.Full { + a0, a1 = -math.Pi, math.Pi + } + angle := func(v float64) float64 { return a0 + (a1-a0)*(v-opts.Min)/(opts.Max-opts.Min) } + geom := func(r ui.Rect) (cx, cy, rad float32) { + if opts.Full { + rad = min(r.W, r.H)/2 - 12 + return r.X + r.W/2, r.Y + r.H/2, rad + } + rad = min(r.W/2-12, r.H-36) + return r.X + r.W/2, r.Y + 8 + rad, rad + } + reading := val(value) + " " + opts.Unit + if seg >= 0 { + reading += " · " + opts.Segments[seg].Label + } + spec := chartSpec{n: 1 + len(opts.Segments)} + spec.readout = func(i int) string { + if i == 0 { + return reading + } + s, from := opts.Segments[i-1], opts.Min + if i > 1 { + from = opts.Segments[i-2].To + } + return fmt.Sprintf("%s: %s–%s", s.Label, val(from), val(s.To)) + } + spec.pick = func(r ui.Rect, x, y float32) int { + cx, cy, rad := geom(r) + d := float32(math.Hypot(float64(x-cx), float64(y-cy))) + if d < rad*0.45 { + return 0 + } + if d > rad+6 { + return -1 + } + a := math.Atan2(float64(x-cx), float64(cy-y)) + from := a0 + for i, s := range opts.Segments { + if a >= from && a <= angle(s.To) { + return i + 1 + } + from = angle(s.To) + } + return -1 + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + cx, cy, rad := geom(r) + from := a0 + if len(opts.Segments) == 0 { + p.FillPath(internal.Sector(cx, cy, rad*0.78, rad, a0, a1), k.SurfaceHover) + } + for i, s := range opts.Segments { + to := angle(s.To) + grow := float32(0) + if active == i+1 { + grow = 4 + } + p.FillPath(internal.Sector(cx, cy, rad*0.78-grow, rad+grow, from, to), tone(s.Tone)) + p.StrokePath(internal.Sector(cx, cy, rad*0.78-grow, rad+grow, from, to), 1.5, k.Surface) + lx, ly := internal.Polar(cx, cy, rad+10, to) + if i < len(opts.Segments)-1 { + chartText(p, val(s.To), lx, ly, size, k.TextMuted, 0.5, 1) + } + from = to + } + nx, ny := internal.Polar(cx, cy, rad*0.9, angle(value)) + var needle ui.Path + needle.MoveTo(cx, cy).LineTo(internal.Snap(p, nx), internal.Snap(p, ny)) + w := float32(3) + if active == 0 { + w = 4.5 + } + p.StrokePath(&needle, w, k.Text) + p.FillPath(internal.Diamond(cx, cy, 12), k.Text) + ty := cy + 34 + chartText(p, val(value)+" "+opts.Unit, cx, ty, FontSize(c, theme.H3Size), k.Text, 0.5, 1) + if seg >= 0 { + chartText(p, opts.Segments[seg].Label, cx, ty+2, size, k.TextMuted, 0.5, 0) + } + } + res, _ := ch.build(spec) + return res +} diff --git a/gauge_test.go b/gauge_test.go new file mode 100644 index 0000000..5f87b6a --- /dev/null +++ b/gauge_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var gaugeSegments = []GaugeSegment{{60, "Calm", GaugeGood}, {85, "Busy", GaugeWarning}, {100, "Crowded", GaugeDanger}} + +func TestGaugeReadoutNamesSegment(t *testing.T) { + full := false + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return Gauge(c, 72, GaugeOptions{Frame: ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: gaugeSegments, Full: full}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Nave") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "72 % · Busy" { + t.Fatalf("value %q", got) + } + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Crowded: 85–100" { + t.Fatalf("last segment %q", got) + } + tt.Key(0, ui.KeyEscape) + for _, full = range []bool{false, true} { + tt.Frame() + b := h.res.Element.Bounds() + cx := b.X + b.W/2 + var cy, rad float32 + if full { + rad = 200/2 - 12 + cy = b.Y + 100 + } else { + rad = min(b.W/2-12, 200-36) + cy = b.Y + 8 + rad + } + tt.Move(cx-rad*0.7, cy-rad*0.6) // the left of the dial: the first segment + if got := chartReadout(tt); got != "Calm: 0–60" { + t.Fatalf("full=%v hover %q", full, got) + } + } +} + +func TestGaugeBad(t *testing.T) { + for name, o := range map[string]GaugeOptions{ + "range": {Max: 0}, + "segments": {Max: 100, Segments: []GaugeSegment{{50, "a", 0}, {40, "b", 0}}}, + "end": {Max: 100, Segments: []GaugeSegment{{50, "a", 0}}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + o.Frame.Label = "x" + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Gauge(c, 0, o) }, 300, 300) + }() + } +} + +func TestGaugeSnapshot(t *testing.T) { + snapshot(t, "gauge", 560, 300, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ui.Column(c).Width(256).Children(func() { + Gauge(c, 72, GaugeOptions{Frame: ChartFrame{Label: "Half"}, Max: 100, Unit: "%", Segments: gaugeSegments}) + }) + ui.Column(c).Width(256).Children(func() { + Gauge(c, 30, GaugeOptions{Frame: ChartFrame{Label: "Full"}, Max: 100, Unit: "%", Segments: gaugeSegments, Full: true}) + }) + }) + }) +} diff --git a/git_status_badge.go b/git_status_badge.go new file mode 100644 index 0000000..b9b44a4 --- /dev/null +++ b/git_status_badge.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// GitStatus is a file's change state in version control. +type GitStatus int + +const ( + GitModified GitStatus = iota + GitAdded + GitDeleted + GitUntracked + GitRenamed + GitCopied +) + +var gitStatusKeys = [...]string{"modified", "added", "deleted", "untracked", "renamed", "copied"} + +// Letter is the git status code, as M. +func (s GitStatus) Letter() string { return string("MADURC"[gitStatusIndex(s)]) } + +func gitStatusIndex(s GitStatus) int { + if s < GitModified || s > GitCopied { + panic("mujica: unknown GitStatus") + } + return int(s) +} + +// GitStatusBadgeOptions configure a GitStatusBadge. +type GitStatusBadgeOptions struct { + // ShowLabel writes the status name beside the letter. + ShowLabel bool +} + +// GitStatusBadge marks a status by letter, outline and color. +func GitStatusBadge(c *ui.Context, status GitStatus, opts GitStatusBadgeOptions) *ui.Element { + k := Tokens(c) + name := msg(c, "git_status_badge."+gitStatusKeys[gitStatusIndex(status)]) + col := gitStatusColor(k, status) + row := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 1.5).Shrink(0).Label(name).Role(ui.RoleImage) + if !opts.ShowLabel { + row.Tooltip(name) + } + row.Children(func() { + ui.Box(c).Size(18, 18).Shrink(0).Center().Radius(theme.ControlRadius).Border(theme.BorderWidth, col). + Children(func() { + ui.Text(c, status.Letter()).Font(theme.MonoFont).FontWeight(700).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col) + }) + if opts.ShowLabel { + ui.Text(c, name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + return row +} + +func gitStatusColor(k theme.Tokens, s GitStatus) ui.Color { + switch s { + case GitAdded: + return k.Success + case GitDeleted: + return k.Danger + case GitUntracked, GitRenamed: + return k.Info + case GitCopied: + return k.AccentText + } + return k.Warning +} diff --git a/git_status_badge_test.go b/git_status_badge_test.go new file mode 100644 index 0000000..2888fdc --- /dev/null +++ b/git_status_badge_test.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestGitStatusBadgeLettersAndNames(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Gap(8).Children(func() { + for s := GitModified; s <= GitCopied; s++ { + GitStatusBadge(c, s, GitStatusBadgeOptions{}) + } + }) + }, 400, 80) + for i, name := range []string{"Modified", "Added", "Deleted", "Untracked", "Renamed", "Copied"} { + if _, ok := tt.Find(name); !ok { + t.Errorf("no badge named %q", name) + } + if got := GitStatus(i).Letter(); got != string("MADURC"[i]) { + t.Errorf("letter %d = %q", i, got) + } + } + if !tt.HasText("R") || !tt.HasText("U") { + t.Fatalf("letters not shown: %q", tt.Texts()) + } +} + +func TestGitStatusBadgeLabelAndInvalid(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + GitStatusBadge(c, GitDeleted, GitStatusBadgeOptions{ShowLabel: true}) + }, 300, 80) + if !tt.HasText("已删除") { + t.Fatalf("label missing: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("unknown status did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + GitStatusBadge(c, GitStatus(9), GitStatusBadgeOptions{}) + }, 100, 50, 1) +} + +func TestGitStatusBadgeSnapshot(t *testing.T) { + snapshot(t, "git_status_badge", 520, 90, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + ui.Row(c).Gap(10).Children(func() { + for s := GitModified; s <= GitCopied; s++ { + GitStatusBadge(c, s, GitStatusBadgeOptions{}) + } + }) + ui.Row(c).Gap(14).Children(func() { + for _, s := range []GitStatus{GitModified, GitAdded, GitDeleted} { + GitStatusBadge(c, s, GitStatusBadgeOptions{ShowLabel: true}) + } + }) + }) + }) +} diff --git a/heatmap_chart.go b/heatmap_chart.go new file mode 100644 index 0000000..321dbf0 --- /dev/null +++ b/heatmap_chart.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// HeatmapChartOptions configure a HeatmapChart. +type HeatmapChartOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// heatSteps is the number of color steps. +const heatSteps = 5 + +// HeatmapChart colors a matrix by value; NaN cells are dashed. +func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts HeatmapChartOptions) ChartResult { + if len(values) != len(rows) { + panic("mujica: HeatmapChart needs one value row per row label") + } + lo, hi := math.Inf(1), math.Inf(-1) + for _, r := range values { + if len(r) != len(cols) { + panic("mujica: HeatmapChart needs one value per column label") + } + for _, v := range r { + if !math.IsNaN(v) { + lo, hi = min(lo, v), max(hi, v) + } + } + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + n := len(rows) * len(cols) + if math.IsInf(lo, 1) { + n = 0 + } + // level is v's color step, 0 to heatSteps-1. + level := func(v float64) int { + if hi == lo { + return heatSteps - 1 + } + return min(heatSteps-1, int((v-lo)/(hi-lo)*heatSteps)) + } + geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Band) { + plot := ui.Rect{X: r.X + 72, Y: r.Y + 4, W: max(r.W-72-64, 1), H: max(r.H-28, 1)} + return plot, internal.Band{N: len(cols), R0: plot.X, R1: plot.X + plot.W, Pad: 0.06}, internal.Band{N: len(rows), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.06} + } + cell := func(xb, yb internal.Band, i int) ui.Rect { + return ui.Rect{X: xb.Start(i % len(cols)), Y: yb.Start(i / len(cols)), W: xb.Width(), H: yb.Width()} + } + spec := chartSpec{n: n} + spec.readout = func(i int) string { + return rows[i/len(cols)] + " · " + cols[i%len(cols)] + " · " + chartValue(c, values[i/len(cols)][i%len(cols)], opts.Format) + } + spec.move = func(i int, key ui.Key) int { + r, col := i/len(cols), i%len(cols) + switch key { + case ui.KeyLeft: + col = max(col-1, 0) + case ui.KeyRight: + col = min(col+1, len(cols)-1) + case ui.KeyUp: + r = max(r-1, 0) + default: + r = min(r+1, len(rows)-1) + } + return r*len(cols) + col + } + spec.pick = func(r ui.Rect, x, y float32) int { + plot, xb, yb := geom(r) + if !plot.Contains(x, y) { + return -1 + } + col := min(len(cols)-1, int((x-plot.X)/xb.Step())) + row := min(len(rows)-1, int((y-plot.Y)/yb.Step())) + return row*len(cols) + col + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, xb, yb := geom(r) + for i := range len(rows) * len(cols) { + b := cell(xb, yb, i) + v := values[i/len(cols)][i%len(cols)] + if math.IsNaN(v) { + p.StrokeDashed(b, k.ControlBorder, 2, 1) + } else { + bg := internal.Ramp(k, (float64(level(v))+0.5)/heatSteps) + p.Fill(b, bg, 2) + if b.W >= 30 && b.H >= 18 { + chartText(p, chartValue(c, v, opts.Format), b.X+b.W/2, b.Y+b.H/2, size, chartInk(k, bg), 0.5, 0.5) + } + } + 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 i, name := range rows { + chartText(p, name, plot.X-8, yb.Center(i), size, k.TextMuted, 1, 0.5) + } + for i, name := range cols { + chartText(p, name, xb.Center(i), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0) + } + heatLegend(p, ui.Rect{X: plot.X + plot.W + 16, Y: plot.Y, W: 14, H: plot.H}, lo, hi, size, k, opts.Format, c) + } + res, _ := ch.build(spec) + return res +} + +// heatLegend stacks the color steps with the range at ends. +func heatLegend(p *ui.Painter, r ui.Rect, lo, hi float64, size float32, k theme.Tokens, format func(float64) string, c *ui.Context) { + h := min(r.H, 120) / heatSteps + for i := range heatSteps { + p.Fill(ui.Rect{X: r.X, Y: r.Y + float32(heatSteps-1-i)*h, W: r.W, H: h - 1}, internal.Ramp(k, (float64(i)+0.5)/heatSteps), 1) + } + chartText(p, chartValue(c, hi, format), r.X+r.W+4, r.Y, size, k.TextMuted, 0, 0) + chartText(p, chartValue(c, lo, format), r.X+r.W+4, r.Y+h*heatSteps, size, k.TextMuted, 0, 1) +} diff --git a/heatmap_chart_test.go b/heatmap_chart_test.go new file mode 100644 index 0000000..4b6b10d --- /dev/null +++ b/heatmap_chart_test.go @@ -0,0 +1,68 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var heatRows = []string{"Mon", "Tue", "Wed"} +var heatCols = []string{"Matins", "Lauds", "Vespers", "Compline"} + +func heatValues() [][]float64 { + return [][]float64{{1, 4, 9, 2}, {3, math.NaN(), 7, 5}, {8, 6, 0, 3}} +} + +func TestHeatmapChartKeyboard2D(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Hours") + tt.Key(0, ui.KeyRight) // enters at the first cell + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + if got := chartReadout(tt); got != "Tue · Lauds · no value" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Wed · Compline · 3" { + t.Fatalf("End %q", got) + } +} + +func TestHeatmapChartHover(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + plotW := b.W - 72 - 64 + tt.Move(b.X+72+plotW*0.6, b.Y+4+212*0.2) // third column, first row + if got := chartReadout(tt); got != "Mon · Vespers · 9" { + t.Fatalf("hover %q", got) + } +} + +func TestHeatmapChartBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a short row") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + HeatmapChart(c, heatRows[:1], heatCols, [][]float64{{1}}, HeatmapChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestHeatmapChartSnapshot(t *testing.T) { + snapshot(t, "heatmap_chart", 520, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours", Height: 220}}) + }) + }) +} diff --git a/hex_viewer.go b/hex_viewer.go new file mode 100644 index 0000000..7bce36c --- /dev/null +++ b/hex_viewer.go @@ -0,0 +1,157 @@ +package mujica + +import ( + "fmt" + "log/slog" + "runtime" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// hexPerRow is how many bytes a row shows. +const hexPerRow = 16 + +// HexViewerState is the cursor and selection anchor. +type HexViewerState struct { + // Anchor is where a Shift selection started. + Cursor, Anchor int + rows ui.ListState +} + +// Selection returns the selected byte range, start inclusive, end exclusive. +func (s *HexViewerState) Selection() (start, end int) { + return min(s.Cursor, s.Anchor), max(s.Cursor, s.Anchor) + 1 +} + +// HexViewerOptions configure a HexViewer. +type HexViewerOptions struct { + Disabled bool +} + +// HexViewer shows bytes as synced hex and ASCII columns. +func HexViewer(c *ui.Context, data []byte, s *HexViewerState, opts HexViewerOptions) ChoiceResult { + if s == nil { + panic("mujica: HexViewer needs a state") + } + n := len(data) + if n > 0 && (s.Cursor < 0 || s.Cursor >= n || s.Anchor < 0 || s.Anchor >= n) { + panic("mujica: HexViewer cursor outside the data") + } + k := Tokens(c) + d := Density(c) + cw := codeTextWidth(c, strings.Repeat("0", hexPerRow*3)) / (hexPerRow * 3) + var res ChoiceResult + frame := codePanel(c).Focusable().Disabled(opts.Disabled).Label(msg(c, "hex_viewer.label")) + res.Element = frame + internal.Ring(frame, k.Focus, theme.ControlRadius) + move := func(to int, extend bool) { + to = max(0, min(to, n-1)) + if to == s.Cursor && (extend || s.Anchor == to) { + return + } + s.Cursor = to + if !extend { + s.Anchor = to + } + s.rows.ScrollIntoView(to / hexPerRow) + res.changed = true + slog.Debug("mujica: hex_viewer select", "cursor", s.Cursor, "anchor", s.Anchor) + } + if n > 0 && !frame.IsDisabled() { + hexKeys(c, frame, data, s, move) + } + a, b := s.Selection() + frame.Description(fmt.Sprintf(msg(c, "hex_viewer.at"), s.Cursor, b-a)) + frame.Children(func() { + if n == 0 { + codeCaption(c, msg(c, "hex_viewer.empty")).Padding(d.Unit() * 4) + return + } + rows := (n + hexPerRow - 1) / hexPerRow + ui.List(c, &s.rows, rows, func(r int) { + start := r * hexPerRow + end := min(start+hexPerRow, n) + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(cw * 2).AlignItems(ui.Center).Children(func() { + codeMono(c, fmt.Sprintf("%08x", start)).TextColor(k.TextMuted).Shrink(0) + hexColumn(c, s, data, start, end, cw, true, move) + hexColumn(c, s, data, start, end, cw, false, move) + }) + }).Grow(1).MinHeight(d.ControlHeight() * 3) + }) + return res +} + +// hexColumn draws a row's hex or ASCII with marks. +func hexColumn(c *ui.Context, s *HexViewerState, data []byte, start, end int, cw float32, hex bool, move func(int, bool)) { + k := Tokens(c) + var b strings.Builder + step := cw + if hex { + step = 3 * cw + } + for i := start; i < end; i++ { + if hex { + fmt.Fprintf(&b, "%02x ", data[i]) + } else if data[i] >= 0x20 && data[i] < 0x7f { + b.WriteByte(data[i]) + } else { + b.WriteByte('.') + } + } + box := ui.Box(c).Shrink(0).Width(step * hexPerRow).Cursor(ui.CursorText) + lo, hi := s.Selection() + // A hex mark covers the two digits. + mark := step + if hex { + mark = 2 * cw + } + box.Draw(func(p *ui.Painter, r ui.Rect) { + for i := max(lo, start); i < min(hi, end); i++ { + x := r.X + float32(i-start)*step + p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: mark + 2, H: r.H}, k.Selection, 2) + } + if s.Cursor >= start && s.Cursor < end { + x := r.X + float32(s.Cursor-start)*step + p.Stroke(ui.Rect{X: x - 1, Y: r.Y, W: mark + 2, H: r.H}, k.AccentText, 2, 1.5) + } + }) + if box.Clicked() && !box.IsDisabled() { + x, _, _ := box.PointerPosition() + move(start+min(int(x/step), end-start-1), box.ClickModifiers()&ui.Shift != 0) + } + box.Children(func() { codeMono(c, strings.TrimRight(b.String(), " ")) }) +} + +// hexKeys moves, extends and copies the selection. +func hexKeys(c *ui.Context, frame *ui.Element, data []byte, s *HexViewerState, move func(int, bool)) { + copyMods := ui.Cmd + if runtime.GOOS != "darwin" { + copyMods = ui.Ctrl + } + for _, mods := range []ui.Modifiers{0, ui.Shift} { + ext := mods == ui.Shift + switch { + case frame.Shortcut(mods, ui.KeyRight): + move(s.Cursor+1, ext) + case frame.Shortcut(mods, ui.KeyLeft): + move(s.Cursor-1, ext) + case frame.Shortcut(mods, ui.KeyDown): + move(s.Cursor+hexPerRow, ext) + case frame.Shortcut(mods, ui.KeyUp): + move(s.Cursor-hexPerRow, ext) + case frame.Shortcut(mods, ui.KeyHome): + move(s.Cursor/hexPerRow*hexPerRow, ext) + case frame.Shortcut(mods, ui.KeyEnd): + move(s.Cursor/hexPerRow*hexPerRow+hexPerRow-1, ext) + } + } + if frame.Shortcut(copyMods, ui.KeyC) { + a, b := s.Selection() + c.WriteClipboard(fmt.Sprintf("% x", data[a:b])) + slog.Debug("mujica: hex_viewer copy", "bytes", b-a) + } +} diff --git a/hex_viewer_test.go b/hex_viewer_test.go new file mode 100644 index 0000000..609c656 --- /dev/null +++ b/hex_viewer_test.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +type hexProbe struct { + data []byte + s HexViewerState + disabled bool + changed int +} + +func (p *hexProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := HexViewer(c, p.data, &p.s, HexViewerOptions{Disabled: p.disabled}) + r.Element.Height(300) + if r.Changed() { + p.changed++ + } +} + +func TestHexViewerColumns(t *testing.T) { + p := &hexProbe{data: []byte("Court seal\x00\x01 ledger of the realm")} + tt := ui.NewTester(p.view, 760, 320) + for _, s := range []string{"00000000", "00000010", "43 6f 75 72 74 20 73 65 61 6c 00 01 20 6c 65 64", "Court seal.. led"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } +} + +func TestHexViewerKeysSelectAndCopy(t *testing.T) { + p := &hexProbe{data: []byte("0123456789abcdefGHIJ")} + tt := ui.NewTester(p.view, 760, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(ui.Shift, ui.KeyRight) + tt.Key(ui.Shift, ui.KeyRight) + if a, b := p.s.Selection(); a != 1 || b != 4 { + t.Fatalf("selection %d..%d", a, b) + } + tt.Key(ui.Cmd, ui.KeyC) + if tt.Clipboard() != "31 32 33" { + t.Fatalf("copied %q", tt.Clipboard()) + } + tt.Key(0, ui.KeyDown) + if p.s.Cursor != 19 || p.s.Anchor != 19 { + t.Fatalf("Down clamped to %d", p.s.Cursor) + } + tt.Key(0, ui.KeyHome) + if p.s.Cursor != 16 { + t.Fatalf("Home gave %d", p.s.Cursor) + } +} + +func TestHexViewerClickSyncsColumns(t *testing.T) { + p := &hexProbe{data: []byte("0123456789abcdef")} + tt := ui.NewTester(p.view, 760, 320) + r, _ := tt.Find("0123456789abcdef") + cw := r.W / 16 + tt.ClickAt(r.X+cw*5.5, r.Y+r.H/2) + if p.s.Cursor != 5 || p.changed != 1 { + t.Fatalf("ASCII click chose %d", p.s.Cursor) + } + h, _ := tt.Find("30 31 32 33 34 35 36 37 38 39 61 62 63 64 65 66") + tt.ClickAt(h.X+cw*3*9+cw, h.Y+h.H/2) + if p.s.Cursor != 9 { + t.Fatalf("hex click chose %d", p.s.Cursor) + } +} + +func TestHexViewerLargeIsVirtual(t *testing.T) { + p := &hexProbe{data: make([]byte, 1_600_000)} + tt := ui.NewTester(p.view, 760, 320) + rows := 0 + for _, s := range tt.Texts() { + if len(s) == 8 && strings.Trim(s, "0123456789abcdef") == "" { + rows++ + } + } + if rows == 0 || rows > 40 { + t.Fatalf("built %d of 100000 rows", rows) + } +} + +func TestHexViewerDisabledAndBadCursor(t *testing.T) { + p := &hexProbe{data: []byte("abc"), disabled: true} + tt := ui.NewTester(p.view, 760, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if p.s.Cursor != 0 { + t.Fatal("disabled viewer moved") + } + defer func() { + if recover() == nil { + t.Fatal("cursor outside data did not panic") + } + }() + ui.NewTester((&hexProbe{data: []byte("a"), s: HexViewerState{Cursor: 4}}).view, 760, 320) +} + +func TestHexViewerSnapshot(t *testing.T) { + snapshot(t, "hex_viewer", 720, 170, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *hexProbe { + return &hexProbe{data: []byte("MUJICA\x00\x01court ledger\nseal: 42\xff\xfe"), s: HexViewerState{Cursor: 9, Anchor: 6}} + }) + ui.Column(c).Padding(12).Children(func() { + HexViewer(c, (*p).data, &(*p).s, HexViewerOptions{}).Element.Height(146) + }) + }) +} diff --git a/histogram.go b/histogram.go new file mode 100644 index 0000000..b36e96d --- /dev/null +++ b/histogram.go @@ -0,0 +1,157 @@ +package mujica + +import ( + "fmt" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// HistogramOptions configure a Histogram. +type HistogramOptions struct { + Frame ChartFrame + // Bins is the bin count; 0 uses Sturges' rule. + Bins int + BinWidth float64 + // Cumulative counts every value up to each bin's upper edge. + Cumulative bool + Format func(v float64) string +} + +// histogramMaxBins bounds the bins a width may make. +const histogramMaxBins = 10_000 + +type histBins struct { + start, width float64 + counts []int +} + +// histogramBin counts finite values into equal bins. +func histogramBin(values []float64, bins int, width float64, cumulative bool) histBins { + lo, hi := math.Inf(1), math.Inf(-1) + n := 0 + for _, v := range values { + if !math.IsNaN(v) && !math.IsInf(v, 0) { + lo, hi, n = min(lo, v), max(hi, v), n+1 + } + } + if n == 0 { + return histBins{} + } + var b histBins + switch { + case width > 0: + b.start, b.width = math.Floor(lo/width)*width, width + bins = max(1, int(math.Ceil((hi-b.start)/width-1e-9))) + if hi == b.start+float64(bins)*width { + bins++ + } + default: + if bins == 0 { + bins = int(math.Ceil(math.Log2(float64(n)))) + 1 + } + b.start, b.width = lo, (hi-lo)/float64(bins) + if b.width == 0 { + b.width = 1 + } + } + if bins > histogramMaxBins { + panic("mujica: Histogram would make more than 10000 bins") + } + b.counts = make([]int, bins) + for _, v := range values { + if math.IsNaN(v) || math.IsInf(v, 0) { + continue + } + b.counts[min(bins-1, int((v-b.start)/b.width))]++ + } + if cumulative { + for i := 1; i < bins; i++ { + b.counts[i] += b.counts[i-1] + } + } + return b +} + +// Histogram counts values into equal bins drawn as bars. +func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResult { + if opts.Bins < 0 || opts.BinWidth < 0 || math.IsNaN(opts.BinWidth) { + panic("mujica: Histogram Bins and BinWidth must be ≥ 0") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + col := internal.Palette(k)[0] + size := FontSize(c, theme.CaptionSize) + b := chartMemo(ch, "bins", [4]any{chartID(values), opts.Bins, opts.BinWidth, opts.Cumulative}, func() histBins { + return histogramBin(values, opts.Bins, opts.BinWidth, opts.Cumulative) + }) + top := 0 + for _, n := range b.counts { + top = max(top, n) + } + var ys internal.Linear + var hi, step float64 + if top > 0 { + _, hi, step = internal.Nice(0, float64(top), 4) + step = max(step, 1) + } + // edge writes bin edge i at a given precision. + edge := func(i int, fine float64) string { + v := b.start + float64(i)*b.width + if opts.Format != nil { + return opts.Format(v) + } + s := internal.FormatTick(v, b.width/fine) + if strings.Contains(s, ".") { + s = strings.TrimRight(strings.TrimRight(s, "0"), ".") + } + return s + } + geom := func(r ui.Rect) (ui.Rect, internal.Band) { + plot := chartInset(r, 52, 26) + ys = internal.Linear{D0: 0, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + return plot, internal.Band{N: len(b.counts), R0: plot.X, R1: plot.X + plot.W, Pad: 0.06} + } + spec := chartSpec{n: len(b.counts)} + spec.readout = func(i int) string { + if opts.Cumulative { + return fmt.Sprintf("≤ %s · %d", edge(i+1, 100), b.counts[i]) + } + closer := ")" + if i == len(b.counts)-1 && opts.BinWidth == 0 { + closer = "]" + } + return fmt.Sprintf("[%s, %s%s · %d", edge(i, 100), edge(i+1, 100), closer, b.counts[i]) + } + spec.pick = func(r ui.Rect, x, y float32) int { + plot, band := geom(r) + if !plot.Contains(x, y) { + return -1 + } + return min(len(b.counts)-1, int((x-plot.X)/band.Step())) + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, band := geom(r) + internal.AxisY(p, plot, ys, internal.Ticks(0, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k) + var xs []float32 + var labels []string + for i := 0; i <= len(b.counts); i++ { + xs, labels = append(xs, plot.X+band.Step()*float32(i)), append(labels, edge(i, 1)) + } + internal.AxisX(p, plot, xs, labels, size, k) + for i, n := range b.counts { + y := ys.Map(float64(n)) + bar := ui.Rect{X: band.Start(i), Y: y, W: band.Width(), H: plot.Y + plot.H - y} + p.Fill(bar, col, 1) + if i == active { + p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 2, 1.5) + } + } + } + res, _ := ch.build(spec) + return res +} diff --git a/histogram_test.go b/histogram_test.go new file mode 100644 index 0000000..514bbdd --- /dev/null +++ b/histogram_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestHistogramBin(t *testing.T) { + vals := []float64{1, 2, 2, 3, 4, 5, 9, 10, math.NaN()} + if got := histogramBin(vals, 3, 0, false); fmt.Sprint(got.counts) != "[4 2 2]" || got.start != 1 || got.width != 3 { + t.Fatalf("3 bins %+v", got) + } + if got := histogramBin(vals, 0, 5, false); fmt.Sprint(got.counts) != "[5 2 1]" || got.start != 0 { + t.Fatalf("width 5 %+v", got) + } + if got := histogramBin(vals, 0, 5, true); fmt.Sprint(got.counts) != "[5 7 8]" { + t.Fatalf("cumulative %+v", got) + } + if got := histogramBin(vals, 0, 0, false); len(got.counts) != 4 { + t.Fatalf("Sturges for 8 values: %d bins", len(got.counts)) + } + if got := histogramBin([]float64{math.NaN()}, 3, 0, false); got.counts != nil { + t.Fatal("no finite values made bins") + } +} + +func TestHistogramReadoutAndModes(t *testing.T) { + opts := HistogramOptions{Frame: ChartFrame{Label: "Ages"}, BinWidth: 5} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return Histogram(c, []float64{1, 2, 2, 3, 4, 5, 9, 10}, opts) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Ages") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "[0, 5) · 5" { + t.Fatalf("first bin %q", got) + } + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "[10, 15) · 1" { + t.Fatalf("last bin %q", got) + } + opts.Cumulative = true + tt.Frame() + if got := chartReadout(tt); got != "≤ 15 · 8" { + t.Fatalf("cumulative %q", got) + } +} + +func TestHistogramBad(t *testing.T) { + for _, o := range []HistogramOptions{{Bins: -1}, {BinWidth: 1e-9}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", o) + } + }() + o.Frame.Label = "x" + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Histogram(c, []float64{0, 1}, o) }, 300, 300) + }() + } +} + +func TestHistogramSnapshot(t *testing.T) { + vals := make([]float64, 400) + for i := range vals { + vals[i] = 50 + 15*math.Sin(float64(i)*1.7)*math.Cos(float64(i)*0.3) + } + snapshot(t, "histogram", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Histogram(c, vals, HistogramOptions{Frame: ChartFrame{Label: "Ages"}, Bins: 12}) + }) + }) +} diff --git a/human_input_request.go b/human_input_request.go new file mode 100644 index 0000000..1d4a6cf --- /dev/null +++ b/human_input_request.go @@ -0,0 +1,126 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// HumanInputRequestOptions configure a HumanInputRequest. Question is required. +type HumanInputRequestOptions struct { + Question string + // Options are suggested answers; AllowFree adds a free one. + Options []string + AllowFree bool + // Deadline is when the agent stops waiting; zero waits forever. + Deadline time.Time + Disabled bool +} + +// HumanInputResult reports this frame's events. +type HumanInputResult struct { + Element *ui.Element + Submitted bool + // TimedOut reports the frame the deadline passed. + TimedOut bool +} + +type humanInputState struct { + draft string + expired bool +} + +// HumanInputRequest asks the user a question before a deadline. +func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult { + if answer == nil || opts.Question == "" { + panic("mujica: HumanInputRequest needs an answer and a Question") + } + if len(opts.Options) == 0 && !opts.AllowFree { + panic("mujica: HumanInputRequest needs Options or AllowFree") + } + k := Tokens(c) + u := Density(c).Unit() + var r HumanInputResult + r.Element = agentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question) + st := ui.Local(r.Element, "mujica.humaninput", func() humanInputState { return humanInputState{} }) + left := time.Duration(0) + if !opts.Deadline.IsZero() { + left = opts.Deadline.Sub(c.Now()) + if left <= 0 && !st.expired { + st.expired, r.TimedOut = true, true + } + } + r.Element.Disabled(opts.Disabled || st.expired) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + ui.Icon(c, icons.Must("user")).Size(18, 18).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0) + ui.Text(c, opts.Question).FontWeight(600).Grow(1).Shrink(1) + }) + if len(opts.Options) > 0 { + items := make([]RadioItem[string], len(opts.Options)) + for i, o := range opts.Options { + items[i] = RadioItem[string]{Value: o, Label: o} + } + chosen := *answer + if RadioGroup(c, &chosen, items, RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked { + *answer, st.draft = chosen, "" + } + } + submit := false + if opts.AllowFree { + in := TextInput(c, &st.draft, TextInputOptions{Placeholder: msg(c, "human_input_request.free"), + Label: msg(c, "human_input_request.free"), Disabled: locked}) + if in.Changed() && !locked { + *answer = st.draft + } + submit = in.Submitted() + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + humanInputClock(c, opts.Deadline, left, st.expired) + blank := strings.TrimSpace(*answer) == "" + if Button(c, msg(c, "human_input_request.send"), ButtonOptions{Disabled: locked || blank}).Clicked() { + submit = true + } + r.Submitted = submit && !locked && !blank + }) + }) + if len(opts.Options) > 0 && !opts.AllowFree && *answer != "" && !slices.Contains(opts.Options, *answer) { + panic(fmt.Sprintf("mujica: HumanInputRequest answer %q is not an option", *answer)) + } + if r.Submitted || r.TimedOut { + slog.Debug("mujica: human_input_request event", "submitted", r.Submitted, "timed_out", r.TimedOut) + } + return r +} + +// humanInputClock shows the time left, or that it ran out. +func humanInputClock(c *ui.Context, deadline time.Time, left time.Duration, expired bool) { + k := Tokens(c) + text, tone := "", k.TextMuted + switch { + case expired: + text, tone = msg(c, "human_input_request.timeout"), k.Warning + case !deadline.IsZero(): + text = fmt.Sprintf(msg(c, "human_input_request.left"), agentDuration(left.Truncate(time.Second))) + wait := left % time.Second + if wait == 0 { + wait = time.Second + } + c.After(wait) + } + ui.Row(c).Grow(1).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() { + if text == "" { + return + } + ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(tone) + ui.Text(c, text).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1)) + }) +} diff --git a/human_input_request_test.go b/human_input_request_test.go new file mode 100644 index 0000000..cb82671 --- /dev/null +++ b/human_input_request_test.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func humanTester(answer *string, opts *HumanInputRequestOptions, got *[]HumanInputResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := HumanInputRequest(c, answer, *opts) + if r.Submitted || r.TimedOut { + *got = append(*got, r) + } + }, 560, 400) +} + +func TestHumanInputRequestOptionAndFree(t *testing.T) { + answer := "" + opts := HumanInputRequestOptions{Question: "Which hall for the ball?", Options: []string{"East wing", "Great hall"}, AllowFree: true} + var got []HumanInputResult + tt := humanTester(&answer, &opts, &got) + _ = tt.Click("Send answer") // disabled while blank + if len(got) != 0 { + t.Fatal("blank answer sent") + } + _ = tt.Click("Great hall") + if answer != "Great hall" { + t.Fatalf("option: %q", answer) + } + _ = tt.Click("Or write your own answer") + tt.Type("The garden") + if answer != "The garden" { + t.Fatalf("free: %q", answer) + } + tt.Key(0, ui.KeyEnter) + if len(got) != 1 || !got[0].Submitted { + t.Fatalf("Enter: %+v", got) + } +} + +func TestHumanInputRequestDeadline(t *testing.T) { + answer := "East wing" + opts := HumanInputRequestOptions{Question: "Which hall?", Options: []string{"East wing"}, Deadline: time.Now().Add(75 * time.Second)} + var got []HumanInputResult + tt := humanTester(&answer, &opts, &got) + found := false + for _, s := range tt.Texts() { + found = found || strings.HasPrefix(s, "Answer within 1 m 1") + } + if !found { + t.Fatalf("countdown missing: %q", tt.Texts()) + } + opts.Deadline = time.Now().Add(-time.Second) + tt.Frame() + tt.Frame() + if len(got) != 1 || !got[0].TimedOut || !tt.HasText("The agent stopped waiting") { + t.Fatalf("timeout: %+v", got) + } + _ = tt.Click("Send answer") + if len(got) != 1 { + t.Fatal("answer sent after the deadline") + } +} + +func TestHumanInputRequestDisabledAndRules(t *testing.T) { + answer := "" + opts := HumanInputRequestOptions{Question: "Which hall?", Options: []string{"East wing"}, Disabled: true} + var got []HumanInputResult + tt := humanTester(&answer, &opts, &got) + _ = tt.Click("East wing") + if answer != "" { + t.Fatal("disabled request changed the answer") + } + defer func() { + if recover() == nil { + t.Fatal("no options and no free answer did not panic") + } + }() + humanTester(&answer, &HumanInputRequestOptions{Question: "x"}, &got) +} + +func TestHumanInputRequestSnapshot(t *testing.T) { + snapshot(t, "human_input_request", 520, 300, func(c *ui.Context) { + answer := "Great hall" + ui.Column(c).Padding(16).Children(func() { + HumanInputRequest(c, &answer, HumanInputRequestOptions{Question: "Which hall should host the masquerade?", + Options: []string{"East wing", "Great hall"}, AllowFree: true}) + }) + }) +} diff --git a/icons/svg/archive.svg b/icons/svg/archive.svg new file mode 100644 index 0000000..47823fa --- /dev/null +++ b/icons/svg/archive.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/arrow-left-right.svg b/icons/svg/arrow-left-right.svg new file mode 100644 index 0000000..1b0e8bd --- /dev/null +++ b/icons/svg/arrow-left-right.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/svg/brain.svg b/icons/svg/brain.svg new file mode 100644 index 0000000..5a030d0 --- /dev/null +++ b/icons/svg/brain.svg @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/icons/svg/chevrons-up-down.svg b/icons/svg/chevrons-up-down.svg new file mode 100644 index 0000000..629ee86 --- /dev/null +++ b/icons/svg/chevrons-up-down.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/download.svg b/icons/svg/download.svg new file mode 100644 index 0000000..4da2f2a --- /dev/null +++ b/icons/svg/download.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/file-archive.svg b/icons/svg/file-archive.svg new file mode 100644 index 0000000..c9ea9ef --- /dev/null +++ b/icons/svg/file-archive.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/icons/svg/file-code.svg b/icons/svg/file-code.svg new file mode 100644 index 0000000..34c065e --- /dev/null +++ b/icons/svg/file-code.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/svg/git-branch.svg b/icons/svg/git-branch.svg new file mode 100644 index 0000000..85b3f96 --- /dev/null +++ b/icons/svg/git-branch.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/git-commit-horizontal.svg b/icons/svg/git-commit-horizontal.svg new file mode 100644 index 0000000..5ee4eb5 --- /dev/null +++ b/icons/svg/git-commit-horizontal.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/git-merge.svg b/icons/svg/git-merge.svg new file mode 100644 index 0000000..dc901a4 --- /dev/null +++ b/icons/svg/git-merge.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/git-pull-request.svg b/icons/svg/git-pull-request.svg new file mode 100644 index 0000000..d2540a1 --- /dev/null +++ b/icons/svg/git-pull-request.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/icons/svg/history.svg b/icons/svg/history.svg new file mode 100644 index 0000000..02ac266 --- /dev/null +++ b/icons/svg/history.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/message-square.svg b/icons/svg/message-square.svg new file mode 100644 index 0000000..4908056 --- /dev/null +++ b/icons/svg/message-square.svg @@ -0,0 +1,14 @@ + + + diff --git a/icons/svg/pause.svg b/icons/svg/pause.svg new file mode 100644 index 0000000..12b6f06 --- /dev/null +++ b/icons/svg/pause.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/pencil.svg b/icons/svg/pencil.svg new file mode 100644 index 0000000..f0e3f38 --- /dev/null +++ b/icons/svg/pencil.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/play.svg b/icons/svg/play.svg new file mode 100644 index 0000000..6eb03bb --- /dev/null +++ b/icons/svg/play.svg @@ -0,0 +1,14 @@ + + + diff --git a/icons/svg/reply.svg b/icons/svg/reply.svg new file mode 100644 index 0000000..eb1eb3f --- /dev/null +++ b/icons/svg/reply.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/rotate-ccw.svg b/icons/svg/rotate-ccw.svg new file mode 100644 index 0000000..1d22d4a --- /dev/null +++ b/icons/svg/rotate-ccw.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/share-2.svg b/icons/svg/share-2.svg new file mode 100644 index 0000000..7825a02 --- /dev/null +++ b/icons/svg/share-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/icons/svg/tag.svg b/icons/svg/tag.svg new file mode 100644 index 0000000..954f3f2 --- /dev/null +++ b/icons/svg/tag.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/thumbs-down.svg b/icons/svg/thumbs-down.svg new file mode 100644 index 0000000..6e74b23 --- /dev/null +++ b/icons/svg/thumbs-down.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/thumbs-up.svg b/icons/svg/thumbs-up.svg new file mode 100644 index 0000000..30dca62 --- /dev/null +++ b/icons/svg/thumbs-up.svg @@ -0,0 +1,15 @@ + + + + diff --git a/icons/svg/volume-2.svg b/icons/svg/volume-2.svg new file mode 100644 index 0000000..d403d95 --- /dev/null +++ b/icons/svg/volume-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/icons/svg/wrap-text.svg b/icons/svg/wrap-text.svg new file mode 100644 index 0000000..d5ce615 --- /dev/null +++ b/icons/svg/wrap-text.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/image_annotator.go b/image_annotator.go new file mode 100644 index 0000000..90461d2 --- /dev/null +++ b/image_annotator.go @@ -0,0 +1,292 @@ +package mujica + +import ( + "encoding/json" + "fmt" + "image" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// AnnotationKind is the shape of an Annotation. +type AnnotationKind int + +const ( + AnnotateRect AnnotationKind = iota + AnnotateArrow + AnnotateText +) + +// Annotation is a mark in image pixels; text at From. +type Annotation struct { + Kind AnnotationKind + From, To image.Point + Text string +} + +// annotationJSON is the exported form of an Annotation. +type annotationJSON struct { + Kind string `json:"kind"` + X0 int `json:"x0"` + Y0 int `json:"y0"` + X1 int `json:"x1"` + Y1 int `json:"y1"` + Text string `json:"text,omitempty"` +} + +// ExportAnnotations writes notes as JSON: kind, corners and text. +func ExportAnnotations(notes []Annotation) ([]byte, error) { + out := make([]annotationJSON, len(notes)) + for i, n := range notes { + out[i] = annotationJSON{[]string{"rect", "arrow", "text"}[n.Kind], n.From.X, n.From.Y, n.To.X, n.To.Y, n.Text} + } + return json.Marshal(out) +} + +// ImageAnnotatorOptions configure an ImageAnnotator. +type ImageAnnotatorOptions struct { + // Height is the stage's height; zero means 320. + Height float32 + Disabled bool + Label string +} + +type annotatorState struct { + tool int // 0 select, 1 rect, 2 arrow, 3 text + selected int + draft string + pressing bool + start, now image.Point + moved image.Point +} + +type annotatorKey struct{} + +// ImageAnnotator draws, selects, moves and deletes marks over src. +func ImageAnnotator(c *ui.Context, src ui.ImageSource, notes *[]Annotation, opts ImageAnnotatorOptions) ChoiceResult { + iw, ih := viewerSize(src) + size := image.Pt(int(iw), int(ih)) + for _, n := range *notes { + if n.Kind < AnnotateRect || n.Kind > AnnotateText || (n.Kind == AnnotateText && n.Text == "") { + panic(fmt.Sprintf("mujica: ImageAnnotator bad annotation %+v", n)) + } + } + if opts.Height < 0 { + panic("mujica: ImageAnnotator Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + before := slices.Clone(*notes) + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + st := ui.Local(col, annotatorKey{}, func() *annotatorState { return &annotatorState{selected: -1, draft: msg(c, "image_annotator.note")} }) + s := *st + if s.selected >= len(*notes) { + s.selected = -1 + } + col.Children(func() { + annotatorToolbar(c, k, s, notes) + stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius). + Background(videoBlack).Label(annotatorLabel(c, opts.Label)).Cursor(ui.CursorCrosshair) + internal.Ring(stage, k.Focus, theme.ControlRadius) + r := stage.Bounds() + scale, ox, oy := cropperPlace(r, size) + if !stage.IsDisabled() && scale > 0 { + annotatorPointer(stage, s, notes, r, size, scale, ox, oy) + annotatorKeys(stage, s, notes, size) + } + if s.selected >= 0 { + n := (*notes)[s.selected] + stage.Description(fmt.Sprintf(msg(c, "image_annotator.selected"), s.selected+1, len(*notes), annotatorKind(c, n.Kind))) + } + list, sel, preview := slices.Clone(*notes), s.selected, annotatorPreview(s) + size := FontSize(c, theme.BodySize) + stage.Draw(func(p *ui.Painter, r ui.Rect) { + scale, ox, oy := cropperPlace(r, image.Pt(int(iw), int(ih))) + p.Image(src, ui.Rect{X: ox, Y: oy, W: iw * scale, H: ih * scale}, ui.FillBox) + at := func(pt image.Point) (float32, float32) { + return internal.Snap(p, ox+float32(pt.X)*scale), internal.Snap(p, oy+float32(pt.Y)*scale) + } + for i, n := range append(list, preview...) { + annotatorPaint(p, k, n, at, size, i == sel) + } + }) + }) + changed := !slices.Equal(before, *notes) + if changed { + slog.Debug("mujica: image_annotator changed", "count", len(*notes)) + } + return ChoiceResult{Element: col, changed: changed} +} + +func annotatorLabel(c *ui.Context, l string) string { + if l != "" { + return l + } + return msg(c, "image_annotator.label") +} + +func annotatorKind(c *ui.Context, k AnnotationKind) string { + return msg(c, []string{"image_annotator.rect", "image_annotator.arrow", "image_annotator.text"}[k]) +} + +// annotatorToolbar picks tools, edits text and deletes the selection. +func annotatorToolbar(c *ui.Context, k theme.Tokens, s *annotatorState, notes *[]Annotation) { + u := Density(c).Unit() + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() { + SegmentedControl(c, &s.tool, []Segment{ + {Label: msg(c, "image_annotator.select"), Icon: mediaPointer, HideLabel: true}, + {Label: msg(c, "image_annotator.rect"), Icon: mediaRect, HideLabel: true}, + {Label: msg(c, "image_annotator.arrow"), Icon: mediaArrow, HideLabel: true}, + {Label: msg(c, "image_annotator.text"), Icon: mediaText, HideLabel: true}, + }, SegmentedControlOptions{Label: msg(c, "image_annotator.tool")}) + editing := s.selected >= 0 && (*notes)[s.selected].Kind == AnnotateText + if editing { + s.draft = (*notes)[s.selected].Text + } + if s.tool == 3 || editing { + in := TextInput(c, &s.draft, TextInputOptions{Label: msg(c, "image_annotator.textLabel")}) + in.Element.Width(180) + if editing && in.Changed() && s.draft != "" { + (*notes)[s.selected].Text = s.draft + } + } + del := iconAction(c, icons.Must("trash-2"), msg(c, "image_annotator.delete")).Disabled(s.selected < 0) + if del.Clicked() && !del.IsDisabled() { + *notes = slices.Delete(*notes, s.selected, s.selected+1) + s.selected = -1 + } + ui.Textf(c, msg(c, "image_annotator.count"), len(*notes)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) +} + +// annotatorPreview is the shape being drawn, if any. +func annotatorPreview(s *annotatorState) []Annotation { + if !s.pressing || s.tool == 0 || s.tool == 3 { + return nil + } + return []Annotation{{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}} +} + +// annotatorPointer draws shapes, selects by click, drags the selection. +func annotatorPointer(stage *ui.Element, s *annotatorState, notes *[]Annotation, r ui.Rect, size image.Point, scale, ox, oy float32) { + _, _, _ = stage.Dragged() + px, py, _ := stage.PointerPosition() + pt := image.Pt(int(math.Round(float64((px+r.X-ox)/scale))), int(math.Round(float64((py+r.Y-oy)/scale)))) + pt = image.Pt(max(0, min(size.X, pt.X)), max(0, min(size.Y, pt.Y))) + switch { + case stage.Pressed() && !s.pressing: + s.pressing, s.start, s.now, s.moved = true, pt, pt, image.Point{} + if s.tool == 0 { + s.selected = annotatorHit(*notes, pt, int(8/scale)+1) + } + case stage.Pressed(): + if s.tool == 0 && s.selected >= 0 { + d := pt.Sub(s.now) + n := &(*notes)[s.selected] + n.From, n.To = n.From.Add(d), n.To.Add(d) + } + s.now = pt + case s.pressing: + s.pressing = false + big := absInt(s.now.X-s.start.X) >= 4 || absInt(s.now.Y-s.start.Y) >= 4 + switch { + case s.tool == 3 && s.draft != "": + *notes = append(*notes, Annotation{Kind: AnnotateText, From: s.start, To: s.start, Text: s.draft}) + s.selected = len(*notes) - 1 + case (s.tool == 1 || s.tool == 2) && big: + *notes = append(*notes, Annotation{Kind: AnnotationKind(s.tool - 1), From: s.start, To: s.now}) + s.selected = len(*notes) - 1 + } + } +} + +// annotatorKeys: [ ] select, arrows move, Delete removes, Escape clears. +func annotatorKeys(stage *ui.Element, s *annotatorState, notes *[]Annotation, size image.Point) { + n := len(*notes) + step := max(1, size.X/100) + move := func(dx, dy int) { + if s.selected >= 0 { + a := &(*notes)[s.selected] + a.From, a.To = a.From.Add(image.Pt(dx, dy)), a.To.Add(image.Pt(dx, dy)) + } + } + switch { + case n > 0 && stage.Shortcut(0, ui.KeyBracketRight): + s.selected = (s.selected + 1) % n + case n > 0 && stage.Shortcut(0, ui.KeyBracketLeft): + s.selected = (s.selected - 1 + n) % n + case s.selected >= 0 && (stage.Shortcut(0, ui.KeyDelete) || stage.Shortcut(0, ui.KeyBackspace)): + *notes = slices.Delete(*notes, s.selected, s.selected+1) + s.selected = -1 + case stage.Shortcut(0, ui.KeyEscape): + s.selected = -1 + case stage.Shortcut(0, ui.KeyLeft): + move(-step, 0) + case stage.Shortcut(0, ui.KeyRight): + move(step, 0) + case stage.Shortcut(0, ui.KeyUp): + move(0, -step) + case stage.Shortcut(0, ui.KeyDown): + move(0, step) + } +} + +// annotatorHit is the topmost mark near pt, or -1. +func annotatorHit(notes []Annotation, pt image.Point, slop int) int { + for i := len(notes) - 1; i >= 0; i-- { + n := notes[i] + b := image.Rectangle{Min: n.From, Max: n.To}.Canon() + if n.Kind == AnnotateText { + b = image.Rectangle{Min: n.From, Max: n.From.Add(image.Pt(len(n.Text)*8, 18))} + } + if pt.In(b.Inset(-slop)) { + return i + } + } + return -1 +} + +func absInt(v int) int { return max(v, -v) } + +// annotatorPaint draws a mark with a halo for any picture. +func annotatorPaint(p *ui.Painter, k theme.Tokens, n Annotation, at func(image.Point) (float32, float32), size float32, selected bool) { + x0, y0 := at(n.From) + x1, y1 := at(n.To) + halo, ink := ui.Hex("#FBF7EF"), ui.Hex("#A12635") + switch n.Kind { + case AnnotateRect: + b := ui.Rect{X: min(x0, x1), Y: min(y0, y1), W: abs32(x1 - x0), H: abs32(y1 - y0)} + p.Stroke(b, halo, 0, 4) + p.Stroke(b, ink, 0, 2) + case AnnotateArrow: + var path ui.Path + path.MoveTo(x0, y0).LineTo(x1, y1) + a := math.Atan2(float64(y1-y0), float64(x1-x0)) + for _, d := range []float64{2.6, -2.6} { + path.MoveTo(x1, y1).LineTo(x1+12*float32(math.Cos(a+d)), y1+12*float32(math.Sin(a+d))) + } + p.StrokePath(&path, 4, halo) + p.StrokePath(&path, 2, ink) + case AnnotateText: + w, h := p.MeasureText(1000, ui.Span{Text: n.Text, Size: size}) + p.Fill(ui.Rect{X: x0, Y: y0, W: w + 8, H: h + 4}, halo, theme.ControlRadius) + p.Text(x0+4, y0+2, n.Text, size, ink) + x1, y1 = x0+w+8, y0+h+4 + } + if selected { + b := ui.Rect{X: min(x0, x1) - 4, Y: min(y0, y1) - 4, W: abs32(x1-x0) + 8, H: abs32(y1-y0) + 8} + p.StrokeDashed(b, k.Focus, 0, 1) + p.FillPath(internal.Diamond(internal.Snap(p, b.X), internal.Snap(p, b.Y), 8), k.Focus) + } +} diff --git a/image_annotator_test.go b/image_annotator_test.go new file mode 100644 index 0000000..2229d6a --- /dev/null +++ b/image_annotator_test.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func annotatorTester(notes *[]Annotation, disabled bool, n *int) *ui.Tester { + src := mediaTestBitmap(400, 200) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(440).Children(func() { + if ImageAnnotator(c, src, notes, ImageAnnotatorOptions{Height: 200, Disabled: disabled}).Changed() { + *n++ + } + }) + }, 520, 360) +} + +// annotatorAt maps image pixel (x, y) to window DIPs on the stage. +func annotatorAt(tt *ui.Tester, x, y float32) (float32, float32) { + r, _ := tt.Find("Annotations") + scale := min(r.W/400, r.H/200) + return r.X + (r.W-400*scale)/2 + x*scale, r.Y + (r.H-200*scale)/2 + y*scale +} + +func annotatorDrag(tt *ui.Tester, x0, y0, x1, y1 float32) { + ax, ay := annotatorAt(tt, x0, y0) + bx, by := annotatorAt(tt, x1, y1) + tt.Press(ax, ay) + tt.Move((ax+bx)/2, (ay+by)/2) + tt.Move(bx, by) + tt.Release(bx, by) +} + +func TestImageAnnotatorDrawShapes(t *testing.T) { + var notes []Annotation + n := 0 + tt := annotatorTester(¬es, false, &n) + if err := tt.Click("Rectangle"); err != nil { + t.Fatal(err) + } + annotatorDrag(tt, 40, 40, 140, 100) + if err := tt.Click("Arrow"); err != nil { + t.Fatal(err) + } + annotatorDrag(tt, 200, 150, 300, 60) + if err := tt.Click("Text"); err != nil { + t.Fatal(err) + } + x, y := annotatorAt(tt, 250, 20) + tt.ClickAt(x, y) + if len(notes) != 3 || n != 3 { + t.Fatalf("notes %+v, changes %d", notes, n) + } + near := func(p, q image.Point) bool { return absInt(p.X-q.X) <= 2 && absInt(p.Y-q.Y) <= 2 } + if notes[0].Kind != AnnotateRect || !near(notes[0].From, image.Pt(40, 40)) || !near(notes[0].To, image.Pt(140, 100)) { + t.Fatalf("rect %+v", notes[0]) + } + if notes[1].Kind != AnnotateArrow || !near(notes[1].To, image.Pt(300, 60)) { + t.Fatalf("arrow %+v", notes[1]) + } + if notes[2].Kind != AnnotateText || notes[2].Text != "Note" { + t.Fatalf("text %+v", notes[2]) + } +} + +func TestImageAnnotatorSelectMoveDelete(t *testing.T) { + notes := []Annotation{{Kind: AnnotateRect, From: image.Pt(40, 40), To: image.Pt(140, 100)}, {Kind: AnnotateArrow, From: image.Pt(200, 150), To: image.Pt(300, 60)}} + n := 0 + tt := annotatorTester(¬es, false, &n) + x, y := annotatorAt(tt, 90, 70) + tt.ClickAt(x, y) + if !tt.Focused("Annotations") { + t.Fatal("stage not focused") + } + tt.Key(0, ui.KeyRight) + if notes[0].From != image.Pt(44, 40) { + t.Fatalf("Right moved %+v", notes[0]) + } + tt.Key(0, ui.KeyBracketRight) + tt.Key(0, ui.KeyDelete) + if len(notes) != 1 || notes[0].Kind != AnnotateRect { + t.Fatalf("delete left %+v", notes) + } + data, err := ExportAnnotations(notes) + if err != nil || string(data) != `[{"kind":"rect","x0":44,"y0":40,"x1":144,"y1":100}]` { + t.Fatalf("export %s %v", data, err) + } +} + +func TestImageAnnotatorDisabledAndBad(t *testing.T) { + var notes []Annotation + n := 0 + tt := annotatorTester(¬es, true, &n) + _ = tt.Click("Rectangle") + annotatorDrag(tt, 40, 40, 140, 100) + if len(notes) != 0 || n != 0 { + t.Fatalf("disabled annotator drew %+v", notes) + } + bad := []Annotation{{Kind: AnnotateText}} + mediaPanics(t, "empty text", func(c *ui.Context) { + ImageAnnotator(c, mediaTestBitmap(10, 10), &bad, ImageAnnotatorOptions{}) + }) +} + +func TestImageAnnotatorSnapshot(t *testing.T) { + src := mediaTestBitmap(400, 200) + notes := []Annotation{ + {Kind: AnnotateRect, From: image.Pt(30, 40), To: image.Pt(150, 120)}, + {Kind: AnnotateArrow, From: image.Pt(330, 160), To: image.Pt(220, 80)}, + {Kind: AnnotateText, From: image.Pt(230, 30), To: image.Pt(230, 30), Text: "Chandelier"}, + } + snapshot(t, "image_annotator", 460, 320, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(420).Children(func() { + ImageAnnotator(c, src, ¬es, ImageAnnotatorOptions{Height: 220}) + }) + }) +} diff --git a/image_compare.go b/image_compare.go new file mode 100644 index 0000000..d621e6f --- /dev/null +++ b/image_compare.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageCompareOptions configure an ImageCompare. +type ImageCompareOptions struct { + // BeforeLabel and AfterLabel caption the sides; empty uses defaults. + BeforeLabel, AfterLabel string + Disabled bool +} + +// ImageCompare shows before left of *split, after right. +func ImageCompare(c *ui.Context, before, after ui.ImageSource, split *float64, opts ImageCompareOptions) ChoiceResult { + if *split < 0 || *split > 1 || math.IsNaN(*split) { + panic("mujica: ImageCompare split outside 0..1") + } + bw, bh := viewerSize(before) + viewerSize(after) + k := Tokens(c) + bl, al := opts.BeforeLabel, opts.AfterLabel + if bl == "" { + bl = msg(c, "image_compare.before") + } + if al == "" { + al = msg(c, "image_compare.after") + } + g, _ := sliderSetup(0, 1, 0.01, nil, false, c) + old := *split + var s *ui.Element + var next float64 + ui.Box(c).Disabled(opts.Disabled).Children(func() { + s, next, _ = sliderControl(c, old, g, 0, 1) + }) + s.FillWidth().AspectRatio(bw / bh).FocusRing(false).Cursor(ui.CursorResizeEW).Clip().Radius(theme.ControlRadius). + Label(bl + " / " + al) + internal.Ring(s, k.Focus, theme.ControlRadius) + if !s.IsDisabled() { + *split = next + } + changed := *split != old + if changed { + slog.Debug("mujica: image_compare split", "split", *split) + } + v, size := float32(*split), FontSize(c, theme.CaptionSize) + s.Draw(func(p *ui.Painter, r ui.Rect) { + x := internal.Snap(p, r.X+r.W*v) + p.Image(after, r, ui.Cover) + p.Clip(ui.Rect{X: r.X, Y: r.Y, W: x - r.X, H: r.H}, 0, func() { p.Image(before, r, ui.Cover) }) + compareTag(p, k, r.X+8, r.Y+8, bl, size) + w, _ := p.MeasureText(1000, ui.Span{Text: al, Size: size}) + compareTag(p, k, r.X+r.W-w-20, r.Y+8, al, size) + p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Surface, 0) + cy := internal.Snap(p, r.Y+r.H/2) + p.FillPath(internal.Diamond(x, cy, 22), k.Surface) + p.FillPath(internal.Diamond(x, cy, 16), k.AccentText) + }) + return ChoiceResult{Element: s, changed: changed} +} + +// compareTag draws a caption chip at (x, y). +func compareTag(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) { + w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size}) + b := ui.Rect{X: x, Y: y, W: w + 12, H: h + 4} + p.Fill(b, k.Surface.Alpha(0.88), theme.ControlRadius) + p.Text(b.X+6, b.Y+2, s, size, k.Text) +} diff --git a/image_compare_test.go b/image_compare_test.go new file mode 100644 index 0000000..89c476b --- /dev/null +++ b/image_compare_test.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func compareTester(split *float64, disabled bool, n *int) *ui.Tester { + before, after := mediaTestBitmap(160, 90), mediaTestBitmap(160, 90) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(320).Children(func() { + if ImageCompare(c, before, after, split, ImageCompareOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 300) +} + +func TestImageCompareKeysAndDrag(t *testing.T) { + split, n := 0.5, 0 + tt := compareTester(&split, false, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if split != 0.51 { + t.Fatalf("Right: %v", split) + } + tt.Key(0, ui.KeyHome) + if split != 0 { + t.Fatalf("Home: %v", split) + } + r, ok := tt.Find("Before / After") + if !ok { + t.Fatal("not named") + } + tt.Press(r.X+r.W/2, r.Y+r.H/2) + tt.Move(r.X+r.W*0.75, r.Y+r.H/2) + tt.Release(r.X+r.W*0.75, r.Y+r.H/2) + if split < 0.7 || split > 0.8 { + t.Fatalf("drag: %v", split) + } + if n < 3 { + t.Fatalf("changes %d", n) + } +} + +func TestImageCompareDisabled(t *testing.T) { + split, n := 0.5, 0 + tt := compareTester(&split, true, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + r, _ := tt.Find("Before / After") + tt.ClickAt(r.X+10, r.Y+10) + if split != 0.5 || n != 0 { + t.Fatalf("disabled moved to %v", split) + } + bad := 2.0 + mediaPanics(t, "split over 1", func(c *ui.Context) { + ImageCompare(c, mediaTestBitmap(4, 4), mediaTestBitmap(4, 4), &bad, ImageCompareOptions{}) + }) +} + +func TestImageCompareSnapshot(t *testing.T) { + split := 0.4 + before := mediaTestBitmap(160, 90) + after := ui.NewBitmap(mediaTestImage(90, 160)) + snapshot(t, "image_compare", 360, 220, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(320).Children(func() { + ImageCompare(c, before, after, &split, ImageCompareOptions{}) + }) + }) +} diff --git a/image_cropper.go b/image_cropper.go new file mode 100644 index 0000000..aac97bb --- /dev/null +++ b/image_cropper.go @@ -0,0 +1,245 @@ +package mujica + +import ( + "fmt" + "image" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageCrop is Rect in pixels of the turned image. +type ImageCrop struct { + Rect image.Rectangle + // Rotation is 0, 90, 180 or 270. + Rotation int +} + +// ImageCropperOptions configure an ImageCropper. +type ImageCropperOptions struct { + // Aspect locks width over height; zero is free. + Aspect float64 + // Height is the stage's height; zero means 320. + Height float32 + Disabled bool + Label string +} + +type cropperCache struct { + src image.Image + rot int + bmp *ui.Bitmap +} + +type cropperDrag struct { + mode int // 0 none, 1 move, 2-5 corners + start image.Rectangle + dx, dy float32 +} + +type cropperKey struct{} + +// ImageCropper edits *crop over src; empty Rect starts fitted. +func ImageCropper(c *ui.Context, src image.Image, crop *ImageCrop, opts ImageCropperOptions) ChoiceResult { + if src == nil || src.Bounds().Empty() || opts.Aspect < 0 || opts.Height < 0 { + panic("mujica: ImageCropper needs an image, Aspect >= 0 and Height >= 0") + } + if crop.Rotation%90 != 0 || crop.Rotation < 0 || crop.Rotation >= 360 { + panic(fmt.Sprintf("mujica: ImageCropper Rotation %d is not 0, 90, 180 or 270", crop.Rotation)) + } + size := cropperSize(src, crop.Rotation) + if crop.Rect.Empty() { + crop.Rect = cropperFit(size, opts.Aspect) + } + if !crop.Rect.In(image.Rectangle{Max: size}) { + panic(fmt.Sprintf("mujica: ImageCropper Rect %v outside the %v image", crop.Rect, size)) + } + if opts.Aspect > 0 && math.Abs(float64(crop.Rect.Dx())-float64(crop.Rect.Dy())*opts.Aspect) > 1+opts.Aspect { + panic(fmt.Sprintf("mujica: ImageCropper Rect %v does not keep Aspect %v", crop.Rect, opts.Aspect)) + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + old := *crop + label := opts.Label + if label == "" { + label = msg(c, "image_cropper.label") + } + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + col.Children(func() { + stage := ui.Box(c).FillWidth().Height(h).Focusable().Clip().Radius(theme.ControlRadius). + Background(videoBlack).Label(label).Cursor(ui.CursorMove) + internal.Ring(stage, k.Focus, theme.ControlRadius) + r := stage.Bounds() + scale, ox, oy := cropperPlace(r, size) + live := !stage.IsDisabled() + if live && scale > 0 { + cropperPointer(stage, crop, r, size, scale, ox, oy, opts.Aspect) + cropperKeys(stage, crop, size, opts.Aspect) + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + left := iconAction(c, mediaRotateLeft, msg(c, "image_cropper.rotateLeft")) + right := iconAction(c, mediaRotate, msg(c, "image_cropper.rotateRight")) + switch { + case live && (right.Clicked() || stage.Shortcut(0, ui.KeyR)): + cropperRotate(crop, size, 90, opts.Aspect) + case live && (left.Clicked() || stage.Shortcut(ui.Shift, ui.KeyR)): + cropperRotate(crop, size, 270, opts.Aspect) + } + ui.Text(c, cropperReadout(*crop)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + }) + stage.Description(cropperReadout(*crop)) + // Turned pixels are copied only when image or rotation change. + cache := ui.Local(stage, cropperKey{}, func() cropperCache { return cropperCache{} }) + if cache.src != src || cache.rot != crop.Rotation || cache.bmp == nil { + *cache = cropperCache{src, crop.Rotation, ui.NewBitmap(cropperTurn(src, crop.Rotation))} + } + size := cropperSize(src, crop.Rotation) + bmp, rect := cache.bmp, crop.Rect + stage.Draw(func(p *ui.Painter, r ui.Rect) { + scale, ox, oy := cropperPlace(r, size) + img := ui.Rect{X: ox, Y: oy, W: float32(size.X) * scale, H: float32(size.Y) * scale} + p.Image(bmp, img, ui.FillBox) + f := ui.Rect{X: internal.Snap(p, ox+float32(rect.Min.X)*scale), Y: internal.Snap(p, oy+float32(rect.Min.Y)*scale), + W: internal.Snap(p, float32(rect.Dx())*scale), H: internal.Snap(p, float32(rect.Dy())*scale)} + shade := ui.RGBA(0, 0, 0, 0.55) + p.Fill(ui.Rect{X: img.X, Y: img.Y, W: img.W, H: f.Y - img.Y}, shade, 0) + p.Fill(ui.Rect{X: img.X, Y: f.Y + f.H, W: img.W, H: img.Y + img.H - f.Y - f.H}, shade, 0) + p.Fill(ui.Rect{X: img.X, Y: f.Y, W: f.X - img.X, H: f.H}, shade, 0) + p.Fill(ui.Rect{X: f.X + f.W, Y: f.Y, W: img.X + img.W - f.X - f.W, H: f.H}, shade, 0) + light := ui.Hex("#F2EBDD") + p.Stroke(f, light, 0, 1) + for i := 1; i < 3; i++ { + p.Fill(ui.Rect{X: f.X + f.W*float32(i)/3, Y: f.Y, W: 1, H: f.H}, light.Alpha(0.4), 0) + p.Fill(ui.Rect{X: f.X, Y: f.Y + f.H*float32(i)/3, W: f.W, H: 1}, light.Alpha(0.4), 0) + } + for _, pt := range [4][2]float32{{f.X, f.Y}, {f.X + f.W, f.Y}, {f.X, f.Y + f.H}, {f.X + f.W, f.Y + f.H}} { + p.FillPath(internal.Diamond(internal.Snap(p, pt[0]), internal.Snap(p, pt[1]), 12), light) + } + }) + }) + changed := *crop != old + if changed { + slog.Debug("mujica: image_cropper changed", "rect", crop.Rect, "rotation", crop.Rotation) + } + return ChoiceResult{Element: col, changed: changed} +} + +func cropperReadout(cr ImageCrop) string { + r := cr.Rect + return fmt.Sprintf("%d, %d · %d × %d · %d°", r.Min.X, r.Min.Y, r.Dx(), r.Dy(), cr.Rotation) +} + +// cropperSize is src's size after turning it rot degrees. +func cropperSize(src image.Image, rot int) image.Point { + s := src.Bounds().Size() + if rot == 90 || rot == 270 { + return image.Pt(s.Y, s.X) + } + return s +} + +// cropperFit is the largest centered aspect rectangle inside size. +func cropperFit(size image.Point, aspect float64) image.Rectangle { + w, h := size.X, size.Y + if aspect > 0 { + if float64(w) > float64(h)*aspect { + w = int(math.Round(float64(h) * aspect)) + } else { + h = int(math.Round(float64(w) / aspect)) + } + } + x, y := (size.X-w)/2, (size.Y-h)/2 + return image.Rect(x, y, x+w, y+h) +} + +// cropperPlace fits size in r: scale and top-left corner. +func cropperPlace(r ui.Rect, size image.Point) (scale, x, y float32) { + if r.W <= 0 { + return 0, 0, 0 + } + scale = min(r.W/float32(size.X), r.H/float32(size.Y)) + return scale, r.X + (r.W-float32(size.X)*scale)/2, r.Y + (r.H-float32(size.Y)*scale)/2 +} + +// cropperTurn copies src turned rot degrees clockwise. +func cropperTurn(src image.Image, rot int) image.Image { + if rot == 0 { + return src + } + b := src.Bounds() + size := cropperSize(src, rot) + out := image.NewRGBA(image.Rectangle{Max: size}) + for y := 0; y < b.Dy(); y++ { + for x := 0; x < b.Dx(); x++ { + var nx, ny int + switch rot { + case 90: + nx, ny = b.Dy()-1-y, x + case 180: + nx, ny = b.Dx()-1-x, b.Dy()-1-y + case 270: + nx, ny = y, b.Dx()-1-x + } + out.Set(nx, ny, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + return out +} + +// cropperRotate turns the crop d degrees; locked aspects refit. +func cropperRotate(cr *ImageCrop, size image.Point, d int, aspect float64) { + r := cr.Rect + for range d / 90 { + r = image.Rect(size.Y-r.Max.Y, r.Min.X, size.Y-r.Min.Y, r.Max.X) + size = image.Pt(size.Y, size.X) + } + cr.Rotation = (cr.Rotation + d) % 360 + cr.Rect = r + if aspect > 0 { + cr.Rect = cropperFit(size, aspect) + } +} + +// cropperPointer moves the frame, or resizes it by a corner. +func cropperPointer(stage *ui.Element, cr *ImageCrop, r ui.Rect, size image.Point, scale, ox, oy float32, aspect float64) { + d := ui.Local(stage, cropperDrag{}, func() cropperDrag { return cropperDrag{} }) + dx, dy, ok := stage.Dragged() + if !stage.Pressed() { + d.mode = 0 + return + } + if d.mode == 0 { + px, py, _ := stage.PointerPosition() + px, py = (px+r.X-ox)/scale, (py+r.Y-oy)/scale + d.start, d.dx, d.dy, d.mode = cr.Rect, 0, 0, cropperHit(cr.Rect, px, py, 14/scale) + } + if !ok || d.mode == 0 { + return + } + d.dx, d.dy = d.dx+dx/scale, d.dy+dy/scale + cr.Rect = cropperDragRect(d.start, d.mode, int(math.Round(float64(d.dx))), int(math.Round(float64(d.dy))), size, aspect) +} + +// cropperHit says what a press grabs: corner, inside or nothing. +func cropperHit(r image.Rectangle, x, y, slop float32) int { + corners := [4][2]int{{r.Min.X, r.Min.Y}, {r.Max.X, r.Min.Y}, {r.Min.X, r.Max.Y}, {r.Max.X, r.Max.Y}} + for i, c := range corners { + if abs32(x-float32(c[0])) <= slop && abs32(y-float32(c[1])) <= slop { + return 2 + i + } + } + if x >= float32(r.Min.X) && x <= float32(r.Max.X) && y >= float32(r.Min.Y) && y <= float32(r.Max.Y) { + return 1 + } + return 0 +} + +func abs32(v float32) float32 { return float32(math.Abs(float64(v))) } diff --git a/image_cropper_geom.go b/image_cropper_geom.go new file mode 100644 index 0000000..7d39e42 --- /dev/null +++ b/image_cropper_geom.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "image" + "math" + + "github.com/egoist/mygo/ui" +) + +// cropperMin is the smallest crop side in pixels. +const cropperMin = 8 + +// cropperDragRect moves (mode 1) or corner-resizes (2-5) start. +func cropperDragRect(start image.Rectangle, mode, dx, dy int, size image.Point, aspect float64) image.Rectangle { + if mode == 1 { + dx = max(-start.Min.X, min(size.X-start.Max.X, dx)) + dy = max(-start.Min.Y, min(size.Y-start.Max.Y, dy)) + return start.Add(image.Pt(dx, dy)) + } + corners := [4]image.Point{start.Min, {start.Max.X, start.Min.Y}, {start.Min.X, start.Max.Y}, start.Max} + moving, anchor := corners[mode-2], corners[5-mode] + x := max(0, min(size.X, moving.X+dx)) + y := max(0, min(size.Y, moving.Y+dy)) + sx, sy := 1, 1 + room := image.Pt(size.X-anchor.X, size.Y-anchor.Y) + if x < anchor.X { + sx, room.X = -1, anchor.X + } + if y < anchor.Y { + sy, room.Y = -1, anchor.Y + } + w, h := cropperClampSize(max(cropperMin, (x-anchor.X)*sx), max(cropperMin, (y-anchor.Y)*sy), room, aspect) + return image.Rectangle{Min: anchor, Max: anchor.Add(image.Pt(w*sx, h*sy))}.Canon() +} + +// cropperClampSize fits w×h inside room, keeping aspect when locked. +func cropperClampSize(w, h int, room image.Point, aspect float64) (int, int) { + w, h = min(w, room.X), min(h, room.Y) + if aspect <= 0 { + return w, h + } + h = int(math.Round(float64(w) / aspect)) + if h > room.Y { + h = room.Y + w = int(math.Round(float64(h) * aspect)) + } + return w, h +} + +// cropperKeys: arrows move, Shift+arrows resize, 1% per press. +func cropperKeys(stage *ui.Element, cr *ImageCrop, size image.Point, aspect float64) { + step := max(1, size.X/100) + r := cr.Rect + move := func(dx, dy int) { cr.Rect = cropperDragRect(r, 1, dx, dy, size, aspect) } + grow := func(dw, dh int) { + w, h := max(cropperMin, r.Dx()+dw), max(cropperMin, r.Dy()+dh) + if aspect > 0 && dw == 0 { + w = int(math.Round(float64(h) * aspect)) + } + w, h = cropperClampSize(w, h, image.Pt(size.X-r.Min.X, size.Y-r.Min.Y), aspect) + cr.Rect = image.Rectangle{Min: r.Min, Max: r.Min.Add(image.Pt(w, h))} + } + switch { + case stage.Shortcut(0, ui.KeyLeft): + move(-step, 0) + case stage.Shortcut(0, ui.KeyRight): + move(step, 0) + case stage.Shortcut(0, ui.KeyUp): + move(0, -step) + case stage.Shortcut(0, ui.KeyDown): + move(0, step) + case stage.Shortcut(ui.Shift, ui.KeyRight): + grow(step, 0) + case stage.Shortcut(ui.Shift, ui.KeyLeft): + grow(-step, 0) + case stage.Shortcut(ui.Shift, ui.KeyDown): + grow(0, step) + case stage.Shortcut(ui.Shift, ui.KeyUp): + grow(0, -step) + } +} diff --git a/image_cropper_test.go b/image_cropper_test.go new file mode 100644 index 0000000..9ba67a5 --- /dev/null +++ b/image_cropper_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "image" + "testing" + + "github.com/egoist/mygo/ui" +) + +func cropperTester(src image.Image, crop *ImageCrop, opts ImageCropperOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(440).Children(func() { + if ImageCropper(c, src, crop, opts).Changed() { + *n++ + } + }) + }, 500, 420) +} + +func TestImageCropperDefaultAndKeys(t *testing.T) { + crop, n := ImageCrop{}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 1, Height: 200}, &n) + if crop.Rect != image.Rect(100, 0, 300, 200) { + t.Fatalf("default %v", crop.Rect) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if crop.Rect != image.Rect(104, 0, 304, 200) { + t.Fatalf("Right: %v", crop.Rect) + } + tt.Key(ui.Shift, ui.KeyLeft) + if crop.Rect.Dx() != crop.Rect.Dy() || crop.Rect.Dx() != 196 { + t.Fatalf("Shift+Left keeps 1:1: %v", crop.Rect) + } + if n != 2 || !tt.HasText("104, 0 · 196 × 196 · 0°") { + t.Fatalf("changes %d, texts %v", n, tt.Texts()) + } +} + +func TestImageCropperRotate(t *testing.T) { + crop, n := ImageCrop{Rect: image.Rect(10, 20, 110, 70)}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Height: 200}, &n) + if err := tt.Click("Rotate right"); err != nil { + t.Fatal(err) + } + if crop.Rotation != 90 || crop.Rect != image.Rect(130, 10, 180, 110) { + t.Fatalf("rotated %+v", crop) + } + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyR) + if crop.Rotation != 0 || crop.Rect != image.Rect(10, 20, 110, 70) { + t.Fatalf("rotated back %+v", crop) + } +} + +func TestImageCropperDragCorner(t *testing.T) { + crop, n := ImageCrop{Rect: image.Rect(0, 0, 200, 100)}, 0 + tt := cropperTester(mediaTestImage(400, 200), &crop, ImageCropperOptions{Aspect: 2, Height: 200}, &n) + r, _ := tt.Find("Crop area") + scale := min(r.W/400, r.H/200) + ox, oy := r.X+(r.W-400*scale)/2, r.Y+(r.H-200*scale)/2 + cx, cy := ox+200*scale, oy+100*scale + tt.Press(cx, cy) + tt.Move(cx+50*scale, cy+5*scale) + tt.Move(cx+100*scale, cy+10*scale) + tt.Release(cx+100*scale, cy+10*scale) + if crop.Rect.Min != (image.Point{}) || crop.Rect.Dx() < 290 || crop.Rect.Dx() > 310 || crop.Rect.Dx() != 2*crop.Rect.Dy() { + t.Fatalf("corner drag %v", crop.Rect) + } +} + +func TestImageCropperDisabledAndBad(t *testing.T) { + crop, n := ImageCrop{}, 0 + tt := cropperTester(mediaTestImage(100, 100), &crop, ImageCropperOptions{Disabled: true}, &n) + start := crop + _ = tt.Click("Rotate right") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if crop != start || n != 0 { + t.Fatalf("disabled cropper changed %+v", crop) + } + img := mediaTestImage(100, 100) + mediaPanics(t, "outside", func(c *ui.Context) { + ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 200, 10)}, ImageCropperOptions{}) + }) + mediaPanics(t, "rotation", func(c *ui.Context) { ImageCropper(c, img, &ImageCrop{Rotation: 45}, ImageCropperOptions{}) }) + mediaPanics(t, "aspect", func(c *ui.Context) { + ImageCropper(c, img, &ImageCrop{Rect: image.Rect(0, 0, 50, 10)}, ImageCropperOptions{Aspect: 1}) + }) +} + +func TestImageCropperSnapshot(t *testing.T) { + img := mediaTestImage(320, 180) + crop := ImageCrop{Rect: image.Rect(60, 30, 220, 150)} + snapshot(t, "image_cropper", 440, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(400).Children(func() { + ImageCropper(c, img, &crop, ImageCropperOptions{Height: 220}) + }) + }) +} diff --git a/image_grid.go b/image_grid.go new file mode 100644 index 0000000..8161285 --- /dev/null +++ b/image_grid.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// GridImage is one picture of an ImageGrid; the caller loads it. +type GridImage struct { + Source ui.ImageSource + Alt string + // Loading and Err show the caller's load in progress or failure. + Loading bool + Err error +} + +// ImageGridResult is an ImageGrid's element and a retry the user asked for. +type ImageGridResult struct { + Element *ui.Element + retry int +} + +// Retried returns the index of the failed picture whose retry was pressed this frame. +func (r ImageGridResult) Retried() (int, bool) { return r.retry, r.retry >= 0 } + +// imageGridState is the viewer opened from the grid. +type imageGridState struct { + open bool + index int +} + +// ImageGrid lays out 1 to 9 pictures; a click on a loaded one opens the ImageViewer. +func ImageGrid(c *ui.Context, images []GridImage) ImageGridResult { + n := len(images) + if n < 1 || n > 9 { + panic("mujica: ImageGrid shows 1 to 9 images") + } + k := Tokens(c) + u := Density(c).Unit() + cols := 3 + switch n { + case 1: + cols = 1 + case 2, 4: + cols = 2 + } + cell := float32(120) + if n == 1 { + cell = 280 + } + r := ImageGridResult{retry: -1} + r.Element = ui.Grid(c).Columns(cols).Gap(u).AlignSelf(ui.Start) + st := ui.Local(r.Element, "viewer", func() imageGridState { return imageGridState{} }) + var viewable []ViewerImage + var at []int + for i, im := range images { + if im.Source != nil && !im.Loading && im.Err == nil { + at = append(at, i) + viewable = append(viewable, ViewerImage{Source: im.Source, Alt: im.Alt}) + } + } + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + for i, im := range images { + loaded := im.Source != nil && !im.Loading && im.Err == nil + // Only a loaded picture is a button; a failed one keeps its retry. + var b *ui.Element + if loaded { + b = ui.ButtonBase(c).Label(im.Alt) + internal.Ring(b, k.Focus, theme.ControlRadius) + } else { + b = ui.Box(c) + } + b.Size(cell, cell).Radius(theme.ControlRadius).Clip().Children(func() { + v := Image(c, im.Source, ImageOptions{Width: cell, Height: cell, Crop: true, Alt: im.Alt, Loading: im.Loading, Err: im.Err}) + if v.Retry && !disabled { + r.retry = i + slog.Debug("mujica: image_grid retry", "index", i) + } + }) + if loaded && b.Clicked() && !disabled { + for vi, ii := range at { + if ii == i { + st.open, st.index = true, vi + } + } + slog.Debug("mujica: image_grid open", "index", i) + } + } + }) + if disabled { + st.open = false + } + if st.open && len(viewable) > 0 { + st.index = min(st.index, len(viewable)-1) + ImageViewer(c, &st.open, &st.index, viewable) + } + return r +} diff --git a/image_grid_test.go b/image_grid_test.go new file mode 100644 index 0000000..e5e65fd --- /dev/null +++ b/image_grid_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "errors" + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +// imageGridPicture is a plain colored picture standing in for a photo. +func imageGridPicture(color string) ui.ImageSource { + return ui.MustParseSVG([]byte(fmt.Sprintf(``, color))) +} + +func imageGridSet(n int) []GridImage { + colors := []string{"#7D2034", "#AD9164", "#355E80", "#326247", "#805714", "#6A5B55", "#9C3C53", "#D2C3AF", "#211C1D"} + out := make([]GridImage, n) + for i := range out { + out[i] = GridImage{Source: imageGridPicture(colors[i]), Alt: fmt.Sprintf("Photo %d", i+1)} + } + return out +} + +func TestImageGridLayoutsByCount(t *testing.T) { + for n, cols := range map[int]int{1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 9: 3} { + var xs map[float32]bool + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ImageGrid(c, imageGridSet(n)) + }, 900, 900) + tt.Frame() + xs = map[float32]bool{} + for i := range n { + r, ok := tt.Find(fmt.Sprintf("Photo %d", i+1)) + if !ok { + t.Fatalf("%d images: Photo %d missing", n, i+1) + } + xs[r.X] = true + } + if len(xs) != cols { + t.Errorf("%d images in %d columns, want %d", n, len(xs), cols) + } + } +} + +func TestImageGridOpensViewerAndRetries(t *testing.T) { + imgs := imageGridSet(3) + imgs[1] = GridImage{Alt: "Lost", Err: errors.New("404")} + retried := -1 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if i, ok := ImageGrid(c, imgs).Retried(); ok { + retried = i + } + }, 800, 600) + if err := tt.Click("Photo 3"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Image 2 of 2") { + t.Fatalf("viewer not at the third picture (second viewable): %q", tt.Texts()) + } + tt.Key(0, ui.KeyEscape) + if err := tt.Click("Retry"); err != nil { + t.Fatal(err) + } + if retried != 1 { + t.Fatalf("retried %d, want 1", retried) + } +} + +func TestImageGridCountLimits(t *testing.T) { + for _, n := range []int{0, 10} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%d images: no panic", n) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ImageGrid(c, make([]GridImage, n)) + }, 100, 100) + }() + } +} + +func TestImageGridSnapshot(t *testing.T) { + imgs := imageGridSet(5) + imgs[3] = GridImage{Alt: "Loading", Loading: true} + imgs[4] = GridImage{Alt: "Lost", Err: errors.New("404")} + snapshot(t, "image_grid", 420, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { ImageGrid(c, imgs) }) + }) +} diff --git a/image_thumbnail.go b/image_thumbnail.go new file mode 100644 index 0000000..27d0c71 --- /dev/null +++ b/image_thumbnail.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ImageThumbnailOptions configure an ImageThumbnail. +type ImageThumbnailOptions struct { + // Width is the thumbnail's width; zero means 96. + Width float32 + // Aspect is width over height, cropped; zero is 1. + Aspect float32 + Selected bool + // Loading and Err show the load state at one size. + Loading bool + Err error + // Alt names the picture; required. + Alt string + Disabled bool +} + +// ImageThumbnail is a cropped picture; Changed reports activation. +func ImageThumbnail(c *ui.Context, src ui.ImageSource, opts ImageThumbnailOptions) ChoiceResult { + if opts.Width < 0 || opts.Aspect < 0 { + panic("mujica: ImageThumbnail needs Width and Aspect >= 0") + } + if opts.Alt == "" { + panic("mujica: ImageThumbnail needs Alt") + } + w, aspect := opts.Width, opts.Aspect + if w == 0 { + w = 96 + } + if aspect == 0 { + aspect = 1 + } + k := Tokens(c) + h := w / aspect + b := ui.ButtonBase(c).Size(w, h).Radius(theme.ControlRadius).Clip(). + Disabled(opts.Disabled).Label(opts.Alt).Selected(opts.Selected).Background(k.SurfaceHover) + internal.Ring(b, k.Focus, theme.ControlRadius) + edge, width := k.Border, theme.BorderWidth + switch { + case opts.Selected: + edge, width = k.AccentText, 2 + case b.Hovered(): + edge = k.ControlBorder + } + b.Border(width, edge).Transition(colorFade) + b.Children(func() { + switch { + case opts.Err != nil: + b.Description(opts.Err.Error()).Tooltip(opts.Err.Error()) + ui.Column(c).Gap(4).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(20, 20).TextColor(k.Danger) + if h >= 64 { + ui.Text(c, msg(c, "image_thumbnail.failed")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine() + } + }) + case opts.Loading: + Spinner(c, SpinnerOptions{Size: min(20, h/2), Label: msg(c, "image_thumbnail.loading")}) + case src == nil: + ui.Icon(c, icons.Must("image")).Size(20, 20).TextColor(k.TextMuted) + default: + ui.Image(c, src).Fit(ui.Cover).Size(w, h) + } + if opts.Selected { + // A diamond mark, so selection never rests on color alone. + ui.Box(c).Absolute().Top(4).Right(4).Size(14, 14).PassThrough().Draw(func(p *ui.Painter, r ui.Rect) { + cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2) + p.FillPath(internal.Diamond(cx, cy, 14), k.Surface) + p.FillPath(internal.Diamond(cx, cy, 10), k.AccentText) + }) + } + }) + clicked := b.Clicked() && !b.IsDisabled() + if clicked { + slog.Debug("mujica: image_thumbnail click", "alt", opts.Alt) + } + return ChoiceResult{Element: b, changed: clicked} +} diff --git a/image_thumbnail_test.go b/image_thumbnail_test.go new file mode 100644 index 0000000..fe83abf --- /dev/null +++ b/image_thumbnail_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "errors" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestImageThumbnailClickAndKeys(t *testing.T) { + n := 0 + bmp := mediaTestBitmap(120, 80) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).AlignItems(ui.Start).Children(func() { + if ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Aspect: 4.0 / 3}).Changed() { + n++ + } + }) + }, 300, 200) + r, ok := tt.Find("Hall") + if !ok || r.W != 96 || r.H != 72 { + t.Fatalf("bounds %v", r) + } + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 3 { + t.Fatalf("activations %d", n) + } +} + +func TestImageThumbnailStatesKeepSize(t *testing.T) { + var got []ui.Rect + for _, o := range []ImageThumbnailOptions{{}, {Loading: true}, {Err: errors.New("404")}, {Selected: true}} { + o.Alt, o.Width = "Hall", 120 + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).AlignItems(ui.Start).Children(func() { + r = ImageThumbnail(c, mediaTestBitmap(40, 40), o).Element.Bounds() + }) + }, 300, 200) + tt.Frame() + got = append(got, r) + } + for _, r := range got { + if r != got[0] || r.W != 120 || r.H != 120 { + t.Fatalf("sizes %v", got) + } + } +} + +func TestImageThumbnailDisabledAndBad(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ImageThumbnail(c, nil, ImageThumbnailOptions{Alt: "Hall", Disabled: true}).Changed() { + n++ + } + }, 300, 200) + _ = tt.Click("Hall") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("disabled thumbnail activated") + } + mediaPanics(t, "no alt", func(c *ui.Context) { ImageThumbnail(c, nil, ImageThumbnailOptions{}) }) +} + +func TestImageThumbnailSnapshot(t *testing.T) { + bmp := mediaTestBitmap(160, 90) + snapshot(t, "image_thumbnail", 520, 120, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Selected: true}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Loading: true}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Err: errors.New("404")}) + ImageThumbnail(c, bmp, ImageThumbnailOptions{Alt: "Hall", Width: 80, Disabled: true}) + }) + }) +} diff --git a/inline_diff.go b/inline_diff.go new file mode 100644 index 0000000..bcbca60 --- /dev/null +++ b/inline_diff.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// InlineDiffOptions configure an InlineDiff. +type InlineDiffOptions struct { + // Mono sets the lines in the code font. + Mono bool +} + +type inlineDiffCache struct { + from, to string + a, b []internal.WordSpan + done bool +} + +// InlineDiff shows old and new lines with changed words marked. +func InlineDiff(c *ui.Context, from, to string, opts InlineDiffOptions) *ui.Element { + k := Tokens(c) + col := ui.Column(c).AlignItems(ui.Stretch).Shrink(0).Radius(theme.ControlRadius).Clip() + cache := ui.Local(col, "words", func() *inlineDiffCache { return &inlineDiffCache{} }) + w := *cache + if !w.done || w.from != from || w.to != to { + w.a, w.b = internal.WordDiff(from, to) + w.from, w.to, w.done = from, to, true + } + if opts.Mono { + col.Font(theme.MonoFont) + } + col.Children(func() { + inlineDiffLine(c, "−", msg(c, "inline_diff.removed"), w.a, k.Danger, k) + inlineDiffLine(c, "+", msg(c, "inline_diff.added"), w.b, k.Success, k) + }) + return col +} + +// inlineDiffLine shows one side with its changed words marked. +func inlineDiffLine(c *ui.Context, sign, name string, spans []internal.WordSpan, col ui.Color, k theme.Tokens) { + u := Density(c).Unit() + out := make([]ui.Span, 0, len(spans)) + plain := "" + for _, s := range spans { + plain += s.Text + sp := ui.Span{Text: s.Text} + switch s.Op { + case internal.DiffDelete: + sp.Background, sp.Strikethrough, sp.DecorationColor = col.Mix(k.Surface, 0.7), true, col + case internal.DiffInsert: + sp.Background, sp.Underline, sp.DecorationColor = col.Mix(k.Surface, 0.7), true, col + } + out = append(out, sp) + } + if len(out) == 0 { + out = append(out, ui.Span{Text: " "}) + } + ui.Row(c).AlignItems(ui.Start).Gap(u*2).Padding(u, u*3).Background(col.Mix(k.Surface, 0.9)).Label(name + ": " + plain). + Children(func() { + ui.Text(c, sign).Font(theme.MonoFont).FontWeight(700).TextColor(col).Shrink(0) + ui.RichText(c, out...).TextColor(k.Text).MinWidth(0) + }) +} diff --git a/inline_diff_test.go b/inline_diff_test.go new file mode 100644 index 0000000..1f78728 --- /dev/null +++ b/inline_diff_test.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestInlineDiffNamesAndShowsBothLines(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + InlineDiff(c, "The court meets at noon", "The court meets at dusk", InlineDiffOptions{}) + }, 600, 120) + if _, ok := tt.Find("Old: The court meets at noon"); !ok { + t.Fatalf("old line: %q", tt.Texts()) + } + if _, ok := tt.Find("New: The court meets at dusk"); !ok { + t.Fatalf("new line: %q", tt.Texts()) + } +} + +func TestInlineDiffCachesWords(t *testing.T) { + from, to := "a b", "a c" + var cache *inlineDiffCache + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + e := InlineDiff(c, from, to, InlineDiffOptions{Mono: true}) + cache = *ui.Local(e, "words", func() *inlineDiffCache { return nil }) + }, 300, 100) + first := &cache.a[0] + tt.Frame() + if &cache.a[0] != first { + t.Fatal("word diff recomputed without a change") + } + to = "a d" + tt.Frame() + if cache.b[1].Text != "d" { + t.Fatalf("new text not diffed: %+v", cache.b) + } +} + +func TestInlineDiffSnapshot(t *testing.T) { + snapshot(t, "inline_diff", 560, 200, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + InlineDiff(c, "The court meets at noon in the east hall.", "The council meets at dusk in the east hall.", InlineDiffOptions{}) + InlineDiff(c, "return a + b", "return a - total", InlineDiffOptions{Mono: true}) + }) + }) +} diff --git a/internal/chart.go b/internal/chart.go new file mode 100644 index 0000000..47df0ea --- /dev/null +++ b/internal/chart.go @@ -0,0 +1,258 @@ +package internal + +import ( + "math" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// Linear maps domain D0..D1 onto pixels R0..R1. +type Linear struct{ D0, D1, R0, R1 float64 } + +// Map returns the pixel of v. +func (s Linear) Map(v float64) float32 { + if s.D1 == s.D0 { + return float32((s.R0 + s.R1) / 2) + } + return float32(s.R0 + (v-s.D0)/(s.D1-s.D0)*(s.R1-s.R0)) +} + +// Invert returns the value at pixel px. +func (s Linear) Invert(px float32) float64 { + if s.R1 == s.R0 { + return s.D0 + } + return s.D0 + (float64(px)-s.R0)/(s.R1-s.R0)*(s.D1-s.D0) +} + +// Nice widens lo..hi to round steps of about n ticks. +func Nice(lo, hi float64, n int) (nlo, nhi, step float64) { + if n < 1 || math.IsNaN(lo) || math.IsNaN(hi) || lo > hi { + panic("internal: bad Nice domain") + } + if lo == hi { + lo, hi = lo-1, hi+1 + } + raw := (hi - lo) / float64(n) + mag := math.Pow(10, math.Floor(math.Log10(raw))) + step = 10 * mag + for _, m := range []float64{1, 2, 5} { + if m*mag >= raw { + step = m * mag + break + } + } + return math.Floor(lo/step) * step, math.Ceil(hi/step) * step, step +} + +// Ticks lists the multiples of step from lo to hi. +func Ticks(lo, hi, step float64) []float64 { + var out []float64 + for i := math.Ceil(lo/step - 1e-9); i*step <= hi+step*1e-9; i++ { + out = append(out, i*step) + } + return out +} + +// FormatTick writes v with as many decimals as step needs. +func FormatTick(v, step float64) string { + d := max(0, int(-math.Floor(math.Log10(step)+1e-9))) + if math.Abs(v) < step*1e-9 { + v = 0 + } + return strconv.FormatFloat(v, 'f', d, 64) +} + +// timeSteps are the tick spacings a time axis chooses from. +var timeSteps = []struct { + d time.Duration + layout string +}{ + {time.Second, "15:04:05"}, {5 * time.Second, "15:04:05"}, {15 * time.Second, "15:04:05"}, {30 * time.Second, "15:04:05"}, + {time.Minute, "15:04"}, {5 * time.Minute, "15:04"}, {15 * time.Minute, "15:04"}, {30 * time.Minute, "15:04"}, + {time.Hour, "15:04"}, {3 * time.Hour, "15:04"}, {6 * time.Hour, "Jan 2 15:04"}, {12 * time.Hour, "Jan 2 15:04"}, + {24 * time.Hour, "Jan 2"}, {7 * 24 * time.Hour, "Jan 2"}, {30 * 24 * time.Hour, "Jan 2006"}, {365 * 24 * time.Hour, "2006"}, +} + +// TimeTicks returns about n round times and their layout. +func TimeTicks(lo, hi time.Time, n int) ([]time.Time, string) { + if n < 1 || hi.Before(lo) { + panic("internal: bad TimeTicks domain") + } + span := hi.Sub(lo) / time.Duration(n) + step := timeSteps[len(timeSteps)-1] + for _, s := range timeSteps { + if s.d >= span { + step = s + break + } + } + var out []time.Time + for t := lo.Truncate(step.d); !t.After(hi); t = t.Add(step.d) { + if !t.Before(lo) { + out = append(out, t) + } + } + return out, step.layout +} + +// Band splits R0..R1 into N bands, each Pad empty. +type Band struct { + N int + R0, R1 float32 + Pad float32 +} + +// Step is the room one band takes. +func (b Band) Step() float32 { return (b.R1 - b.R0) / float32(max(b.N, 1)) } + +// Width is a band's drawn width. +func (b Band) Width() float32 { return b.Step() * (1 - b.Pad) } + +// Start is where band i is drawn from. +func (b Band) Start(i int) float32 { return b.R0 + b.Step()*float32(i) + b.Step()*b.Pad/2 } + +// Center is the middle of band i. +func (b Band) Center(i int) float32 { return b.R0 + b.Step()*(float32(i)+0.5) } + +// Palette returns six series colors at 3:1 against Surface. +func Palette(k theme.Tokens) []ui.Color { + out := []ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Ornament, k.TextMuted} + for i, col := range out { + for t := float32(0.1); theme.Contrast(col, k.Surface) < 3; t += 0.1 { + col = out[i].Mix(k.Text, t) + } + out[i] = col + } + return out +} + +// Ramp is the sequential color for t in 0..1. +func Ramp(k theme.Tokens, t float64) ui.Color { + return k.Surface.Mix(k.AccentText, float32(0.12+0.88*max(0, min(t, 1)))) +} + +// Marker draws series shape i centered at (cx, cy). +func Marker(p *ui.Painter, i int, cx, cy, size float32, col ui.Color) { + cx, cy = Snap(p, cx), Snap(p, cy) + h := size / 2 + var path ui.Path + switch i % 5 { + case 0: + path.Circle(cx, cy, h) + case 1: + h *= 0.85 + path.MoveTo(cx-h, cy-h).LineTo(cx+h, cy-h).LineTo(cx+h, cy+h).LineTo(cx-h, cy+h).Close() + case 2: + p.FillPath(Diamond(cx, cy, size*1.2), col) + return + case 3: + path.MoveTo(cx, cy-h).LineTo(cx+h, cy+h*0.8).LineTo(cx-h, cy+h*0.8).Close() + default: + path.MoveTo(cx-h, cy-h*0.8).LineTo(cx+h, cy-h*0.8).LineTo(cx, cy+h).Close() + } + p.FillPath(&path, col) +} + +// Arc appends a polyline from a0 to a1, clockwise radians. +func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool) { + n := max(2, int(math.Ceil(math.Abs(a1-a0)/(math.Pi/48)))) + for i := 0; i <= n; i++ { + x, y := Polar(cx, cy, r, a0+(a1-a0)*float64(i)/float64(n)) + if i == 0 && move { + path.MoveTo(x, y) + } else { + path.LineTo(x, y) + } + } +} + +// Polar returns the point r from center at angle a. +func Polar(cx, cy, r float32, a float64) (float32, float32) { + return cx + r*float32(math.Sin(a)), cy - r*float32(math.Cos(a)) +} + +// Sector returns a ring slice; zero r0 makes a wedge. +func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path { + var path ui.Path + Arc(&path, cx, cy, r1, a0, a1, true) + if r0 > 0 { + Arc(&path, cx, cy, r0, a1, a0, false) + } else { + path.LineTo(cx, cy) + } + path.Close() + return &path +} + +// AxisY draws grid lines and tick labels left of plot. +func AxisY(p *ui.Painter, plot ui.Rect, s Linear, ticks []float64, label func(float64) string, size float32, k theme.Tokens) { + for _, t := range ticks { + y := s.Map(t) + Rule(p, plot.X, plot.X+plot.W, y, k.Border) + txt := label(t) + w, h := p.MeasureText(0, ui.Span{Text: txt, Size: size}) + p.Text(plot.X-w-6, y-h/2, txt, size, k.TextMuted) + } +} + +// AxisX draws a baseline and labels centered at xs. +func AxisX(p *ui.Painter, plot ui.Rect, xs []float32, labels []string, size float32, k theme.Tokens) { + Rule(p, plot.X, plot.X+plot.W, plot.Y+plot.H, k.ControlBorder) + right := float32(math.Inf(-1)) + for i, x := range xs { + w, _ := p.MeasureText(0, ui.Span{Text: labels[i], Size: size}) + at := min(max(x-w/2, plot.X-12), plot.X+plot.W+12-w) + // Labels that would overlap the last one drawn are skipped. + if at < right+6 { + continue + } + p.Text(at, plot.Y+plot.H+6, labels[i], size, k.TextMuted) + right = at + w + } +} + +// Nearest returns the closest point within reach, or -1. +func Nearest(n int, at func(i int) (float32, float32), x, y, reach float32, byX bool) int { + best, dist := -1, reach*reach + for i := range n { + px, py := at(i) + d := (px - x) * (px - x) + if !byX { + d += (py - y) * (py - y) + } + if d <= dist { + best, dist = i, d + } + } + return best +} + +// Columns keeps first, lowest, highest and last per pixel column. +func Columns(n int, x func(i int) float32, y func(i int) float64) []int { + var out []int + for i := 0; i < n; { + col := math.Floor(float64(x(i))) + lo, hi, j := i, i, i + for ; j < n && math.Floor(float64(x(j))) == col; j++ { + if y(j) < y(lo) { + lo = j + } + if y(j) > y(hi) { + hi = j + } + } + out = append(out, i) + for _, k := range []int{min(lo, hi), max(lo, hi), j - 1} { + if k != out[len(out)-1] { + out = append(out, k) + } + } + i = j + } + return out +} diff --git a/internal/chart_test.go b/internal/chart_test.go new file mode 100644 index 0000000..f8eca08 --- /dev/null +++ b/internal/chart_test.go @@ -0,0 +1,101 @@ +package internal + +import ( + "fmt" + "math" + "testing" + "time" + + "mujicaui/theme" +) + +func TestNiceAndTicks(t *testing.T) { + for _, tc := range []struct { + lo, hi float64 + n int + nlo, nhi, stp float64 + }{ + {0, 97, 5, 0, 100, 20}, + {-3.2, 7.9, 4, -5, 10, 5}, + {0.012, 0.087, 5, 0, 0.1, 0.02}, + {5, 5, 4, 4, 6, 0.5}, + } { + lo, hi, step := Nice(tc.lo, tc.hi, tc.n) + if math.Abs(lo-tc.nlo) > 1e-9 || math.Abs(hi-tc.nhi) > 1e-9 || math.Abs(step-tc.stp) > 1e-9 { + t.Errorf("Nice(%v, %v, %d) = %v %v %v", tc.lo, tc.hi, tc.n, lo, hi, step) + } + } + if got := fmt.Sprint(Ticks(-5, 10, 5)); got != "[-5 0 5 10]" { + t.Errorf("ticks %s", got) + } + if FormatTick(0.30000000000000004, 0.1) != "0.3" || FormatTick(1500, 500) != "1500" || FormatTick(-1e-17, 0.5) != "0.0" { + t.Error("FormatTick") + } +} + +func TestNicePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an inverted domain") + } + }() + Nice(2, 1, 4) +} + +func TestTimeTicks(t *testing.T) { + lo := time.Date(2026, 10, 5, 9, 7, 0, 0, time.UTC) + ticks, layout := TimeTicks(lo, lo.Add(2*time.Hour), 4) + if layout != "15:04" || len(ticks) != 4 || ticks[0].Format("15:04") != "09:30" || ticks[3].Format("15:04") != "11:00" { + t.Fatalf("ticks %v layout %s", ticks, layout) + } +} + +func TestLinearAndBand(t *testing.T) { + s := Linear{D0: 0, D1: 10, R0: 100, R1: 0} + if s.Map(5) != 50 || s.Invert(25) != 7.5 { + t.Fatal("Linear") + } + b := Band{N: 4, R0: 0, R1: 200, Pad: 0.5} + if b.Step() != 50 || b.Width() != 25 || b.Start(1) != 62.5 || b.Center(3) != 175 { + t.Fatal("Band") + } +} + +func TestPaletteContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + pal := Palette(k) + seen := map[[3]uint8]bool{} + for i, c := range pal { + if r := theme.Contrast(c, k.Surface); r < 3 { + t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r) + } + seen[[3]uint8{c.R, c.G, c.B}] = true + } + if len(pal) != 6 || len(seen) != 6 { + t.Errorf("dark=%v palette not six distinct colors", k.Dark) + } + } +} + +func TestNearest(t *testing.T) { + pts := [][2]float32{{0, 0}, {10, 10}, {20, 0}} + at := func(i int) (float32, float32) { return pts[i][0], pts[i][1] } + if Nearest(3, at, 9, 2, 30, false) != 1 || Nearest(3, at, 18, 50, 30, true) != 2 || Nearest(3, at, 100, 100, 5, false) != -1 { + t.Fatal("Nearest") + } +} + +func TestColumnsBounded(t *testing.T) { + const n = 100_000 + x := func(i int) float32 { return float32(i) * 400 / n } + y := func(i int) float64 { return math.Sin(float64(i)) } + got := Columns(n, x, y) + if len(got) > 4*400+4 || got[0] != 0 || got[len(got)-1] != n-1 { + t.Fatalf("%d points kept, first %d last %d", len(got), got[0], got[len(got)-1]) + } + for i := 1; i < len(got); i++ { + if got[i] <= got[i-1] { + t.Fatal("indices out of order") + } + } +} diff --git a/internal/diff.go b/internal/diff.go index ccc9071..01dda62 100644 --- a/internal/diff.go +++ b/internal/diff.go @@ -1,8 +1,11 @@ package internal -import "strings" +import ( + "strings" + "unicode" +) -// DiffOp says whether a diff line is kept, removed or added. +// DiffOp says a line is kept, removed or added. type DiffOp uint8 const ( @@ -11,14 +14,14 @@ const ( DiffInsert ) -// DiffLine is one line of a line diff with its 1-based line numbers; 0 means absent. +// DiffLine is a numbered diff line; 0 means absent. type DiffLine struct { Op DiffOp Text string Old, New int } -// SplitLines splits text into lines without their line breaks; "" has no lines. +// SplitLines splits text into lines; "" has none. func SplitLines(s string) []string { if s == "" { return nil @@ -26,7 +29,7 @@ func SplitLines(s string) []string { return strings.Split(strings.TrimSuffix(s, "\n"), "\n") } -// LineDiff returns a shortest edit script turning a into b (Myers, O((N+M)D)). +// LineDiff returns a shortest edit script from a to b. func LineDiff(a, b []string) []DiffLine { // Common prefix and suffix need no search. pre := 0 @@ -49,7 +52,7 @@ func LineDiff(a, b []string) []DiffLine { return out } -// myers appends the edit script of a to b, numbering from offsets ao and bo. +// myers appends the edit script, numbering from ao and bo. func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine { n, m := len(a), len(b) total := n + m @@ -80,7 +83,7 @@ func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine { } } } - // Walk the trace back from (n, m), collecting moves in reverse. + // Walk the trace back, collecting moves in reverse. var rev []DiffLine x, y := n, m for d := end; d >= 0; d-- { @@ -119,6 +122,61 @@ func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine { return out } +// WordSpan is a run of a line that a word diff kept, removed or added. +type WordSpan struct { + Op DiffOp + Text string +} + +// WordDiff compares two lines by words: from holds kept and removed spans, to kept and added. +func WordDiff(a, b string) (from, to []WordSpan) { + add := func(spans []WordSpan, op DiffOp, s string) []WordSpan { + if n := len(spans); n > 0 && spans[n-1].Op == op { + spans[n-1].Text += s + return spans + } + return append(spans, WordSpan{op, s}) + } + for _, d := range LineDiff(wordTokens(a), wordTokens(b)) { + switch d.Op { + case DiffEqual: + from, to = add(from, DiffEqual, d.Text), add(to, DiffEqual, d.Text) + case DiffDelete: + from = add(from, DiffDelete, d.Text) + case DiffInsert: + to = add(to, DiffInsert, d.Text) + } + } + return from, to +} + +// wordTokens splits s into words, runs of spaces and single other characters. +func wordTokens(s string) []string { + var out []string + kind := func(r rune) int { + switch { + case r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r): + return 1 + case unicode.IsSpace(r): + return 2 + } + return 0 + } + start, prev := 0, -1 + for i, r := range s { + k := kind(r) + if i > start && (k != prev || k == 0) { + out = append(out, s[start:i]) + start = i + } + prev = k + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} + // DiffStats counts the added and removed lines of a diff. func DiffStats(d []DiffLine) (added, removed int) { for _, l := range d { diff --git a/internal/diff_word_test.go b/internal/diff_word_test.go new file mode 100644 index 0000000..29bdb1d --- /dev/null +++ b/internal/diff_word_test.go @@ -0,0 +1,38 @@ +package internal + +import ( + "slices" + "testing" +) + +func TestWordTokens(t *testing.T) { + got := wordTokens("let x_1 = foo(bar); ok") + want := []string{"let", " ", "x_1", " ", "=", " ", "foo", "(", "bar", ")", ";", " ", "ok"} + if !slices.Equal(got, want) { + t.Fatalf("tokens %q", got) + } + if wordTokens("") != nil { + t.Fatal("empty line has tokens") + } +} + +func TestWordDiff(t *testing.T) { + from, to := WordDiff("return a + b", "return a - total") + wantFrom := []WordSpan{{DiffEqual, "return a "}, {DiffDelete, "+"}, {DiffEqual, " "}, {DiffDelete, "b"}} + wantTo := []WordSpan{{DiffEqual, "return a "}, {DiffInsert, "-"}, {DiffEqual, " "}, {DiffInsert, "total"}} + if !slices.Equal(from, wantFrom) || !slices.Equal(to, wantTo) { + t.Fatalf("from %+v to %+v", from, to) + } + join := func(s []WordSpan) (out string) { + for _, w := range s { + out += w.Text + } + return + } + for _, p := range [][2]string{{"", "new text"}, {"old text", ""}, {"same", "same"}, {"中文 词语", "中文 句子"}} { + f, to := WordDiff(p[0], p[1]) + if join(f) != p[0] || join(to) != p[1] { + t.Errorf("%q→%q rebuilt %q→%q", p[0], p[1], join(f), join(to)) + } + } +} diff --git a/internal/display.go b/internal/display.go index 3a00828..76a35ab 100644 --- a/internal/display.go +++ b/internal/display.go @@ -6,6 +6,12 @@ import ( "github.com/egoist/mygo/ui" ) +// Rule draws a pixel-aligned 1 DIP horizontal line at y. +func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) { + yy := ruleY(p, y) + p.Line(x0, yy, x1, yy, 1, c) +} + // ruleY moves y to where a 1 DIP line fills whole device pixels. func ruleY(p *ui.Painter, y float32) float32 { s := float64(p.Scale()) diff --git a/internal/git.go b/internal/git.go new file mode 100644 index 0000000..6d0f166 --- /dev/null +++ b/internal/git.go @@ -0,0 +1,76 @@ +package internal + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CodeSpans colors a code line; inBlock carries block comments. +func CodeSpans(k theme.Tokens, language, line string, inBlock bool) ([]ui.Span, bool) { + toks, open := Highlight(language, line, inBlock) + spans := make([]ui.Span, 0, len(toks)) + for _, t := range toks { + s := ui.Span{Text: t.Text, Color: k.Text} + switch t.Kind { + case TokKeyword: + s.Color, s.Weight = k.AccentText, 600 + case TokString: + s.Color = k.Success + case TokNumber: + s.Color = k.Info + case TokComment: + s.Color, s.Italic = k.TextMuted, true + case TokType: + s.Color = k.Warning + case TokPunct: + s.Color = k.TextMuted + } + spans = append(spans, s) + } + return spans, open +} + +// ansiLight and ansiDark are 16 colors readable on Surface. +var ( + ansiLight = [16]string{"#211C1D", "#A12635", "#326247", "#805714", "#355E80", "#7A3B78", "#2F6B6B", "#6A5B55", + "#6A5B55", "#B0303F", "#3B7352", "#8E6417", "#3F6E94", "#8A4787", "#367A7A", "#211C1D"} + ansiDark = [16]string{"#A89A95", "#F0A1AA", "#94C6A4", "#DFC080", "#9DBDDA", "#D9A6D6", "#8FCFCF", "#F2EBDD", + "#BCAEAA", "#F5B7BE", "#ADD6B9", "#E9CF99", "#B5CDE4", "#E4BDE2", "#A9DCDC", "#FFFFFF"} + ansiCube = [6]uint8{0, 95, 135, 175, 215, 255} +) + +// AnsiResolve resolves a color; ok is false for default. +func AnsiResolve(dark bool, col AnsiColor) (ui.Color, bool) { + switch col.Kind { + case AnsiRGB: + return ui.RGB(col.R, col.G, col.B), true + case AnsiIndexed: + i := col.Index + switch { + case i < 16 && dark: + return ui.Hex(ansiDark[i]), true + case i < 16: + return ui.Hex(ansiLight[i]), true + case i < 232: + i -= 16 + return ui.RGB(ansiCube[i/36], ansiCube[i/6%6], ansiCube[i%6]), true + default: + g := 8 + 10*(i-232) + return ui.RGB(g, g, g), true + } + } + return ui.Color{}, false +} + +// StripAnsi drops SGR escapes from s, keeping its text. +func StripAnsi(s string) string { + spans, _ := ParseAnsi(s, AnsiStyle{}) + var b strings.Builder + for _, sp := range spans { + b.WriteString(sp.Text) + } + return b.String() +} diff --git a/internal/git_test.go b/internal/git_test.go new file mode 100644 index 0000000..d5831b8 --- /dev/null +++ b/internal/git_test.go @@ -0,0 +1,41 @@ +package internal + +import ( + "testing" + + "mujicaui/theme" +) + +func TestAnsiPaletteReadable(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + for i := range 16 { + col, ok := AnsiResolve(k.Dark, AnsiColor{Kind: AnsiIndexed, Index: uint8(i)}) + if !ok { + t.Fatalf("index %d unresolved", i) + } + if r := theme.Contrast(col, k.Surface); r < 4.5 { + t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r) + } + } + } + if c, _ := AnsiResolve(false, AnsiColor{Kind: AnsiIndexed, Index: 196}); c.R != 255 || c.G != 0 || c.B != 0 { + t.Errorf("cube 196 = %v", c) + } + if c, _ := AnsiResolve(false, AnsiColor{Kind: AnsiIndexed, Index: 244}); c.R != 128 { + t.Errorf("gray 244 = %v", c) + } + if _, ok := AnsiResolve(true, AnsiColor{}); ok { + t.Error("default color resolved") + } +} + +func TestCodeSpansColorKeywords(t *testing.T) { + k := theme.Light() + spans, _ := CodeSpans(k, "go", "func main() // hi", false) + if spans[0].Text != "func" || spans[0].Color != k.AccentText { + t.Fatalf("keyword span %+v", spans[0]) + } + if last := spans[len(spans)-1]; last.Color != k.TextMuted || !last.Italic { + t.Fatalf("comment span %+v", last) + } +} diff --git a/internal/markdown.go b/internal/markdown.go new file mode 100644 index 0000000..8608cac --- /dev/null +++ b/internal/markdown.go @@ -0,0 +1,305 @@ +package internal + +import ( + "strconv" + "strings" +) + +// BlockKind is the kind of a Markdown block. +type BlockKind uint8 + +const ( + BlockParagraph BlockKind = iota + BlockHeading + BlockList + BlockQuote + BlockCode + BlockTable + BlockRule +) + +// MdSpan is a run of inline text with one style. +type MdSpan struct { + Text string + // URL is set for a link. + URL string + Strong, Em, Code, Strike bool +} + +// MdItem is one list item. +type MdItem struct { + // Number is the ordinal of an ordered item, 0 for a bullet. + Number int + Depth int + Spans []MdSpan +} + +// TableAlign is a table column's alignment. +type TableAlign uint8 + +const ( + AlignLeft TableAlign = iota + AlignCenter + AlignRight +) + +// MdBlock is one parsed block. +type MdBlock struct { + Kind BlockKind + // Level is a heading's level, 1 to 6. + Level int + // Spans hold a paragraph's, heading's or quote's text. + Spans []MdSpan + Items []MdItem + // Lang and Code hold a fenced code block; Open is true until its fence closes. + Lang, Code string + Open bool + Header []string + Align []TableAlign + Rows [][]string +} + +// Markdown is a document parsed incrementally as its source grows. +type Markdown struct { + src string + Blocks []MdBlock + starts []int +} + +// Update parses src and returns the index of the first block parsed anew. +// When src extends the last source, blocks before the last stay as they are. +func (m *Markdown) Update(src string) int { + if src == m.src && m.starts != nil { + return len(m.Blocks) + } + from := 0 + if len(m.Blocks) > 0 && strings.HasPrefix(src, m.src) { + from = len(m.Blocks) - 1 + } + off := 0 + if from > 0 { + off = m.starts[from] + } + m.Blocks, m.starts = m.Blocks[:from], m.starts[:from] + m.src = src + parseBlocks(m, src, off) + return from +} + +// mdLine is one source line and its byte offset. +type mdLine struct { + text string + off int +} + +func parseBlocks(m *Markdown, src string, off int) { + var lines []mdLine + for off < len(src) { + end := strings.IndexByte(src[off:], '\n') + if end < 0 { + lines = append(lines, mdLine{src[off:], off}) + break + } + lines = append(lines, mdLine{strings.TrimRight(src[off:off+end], "\r"), off}) + off += end + 1 + } + for i := 0; i < len(lines); { + l := lines[i].text + if strings.TrimSpace(l) == "" { + i++ + continue + } + start := lines[i].off + var b MdBlock + switch { + case mdFence(l) != "": + b, i = parseFenced(lines, i) + case mdHeadingLevel(l) > 0: + lv := mdHeadingLevel(l) + b = MdBlock{Kind: BlockHeading, Level: lv, Spans: ParseInline(strings.TrimSpace(strings.TrimRight(l[lv:], "# ")))} + i++ + case mdIsRule(l): + b = MdBlock{Kind: BlockRule} + i++ + case strings.HasPrefix(strings.TrimLeft(l, " "), ">"): + b, i = parseQuote(lines, i) + case mdListMarker(l) != nil: + b, i = parseList(lines, i) + case i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil: + b, i = parseTable(lines, i) + default: + b, i = parseParagraph(lines, i) + } + m.Blocks = append(m.Blocks, b) + m.starts = append(m.starts, start) + } +} + +// mdStarts reports whether line l begins a block other than a paragraph. +func mdStarts(lines []mdLine, i int) bool { + l := lines[i].text + return strings.TrimSpace(l) == "" || mdFence(l) != "" || mdHeadingLevel(l) > 0 || mdIsRule(l) || + strings.HasPrefix(strings.TrimLeft(l, " "), ">") || mdListMarker(l) != nil || + i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil +} + +func parseParagraph(lines []mdLine, i int) (MdBlock, int) { + var parts []string + for ; i < len(lines) && (len(parts) == 0 || !mdStarts(lines, i)); i++ { + parts = append(parts, strings.TrimSpace(lines[i].text)) + } + return MdBlock{Kind: BlockParagraph, Spans: ParseInline(strings.Join(parts, " "))}, i +} + +// mdFence returns the fence a line opens, "```" or "~~~", or "". +func mdFence(l string) string { + t := strings.TrimLeft(l, " ") + for _, f := range []string{"```", "~~~"} { + if strings.HasPrefix(t, f) { + return f + } + } + return "" +} + +func parseFenced(lines []mdLine, i int) (MdBlock, int) { + fence := mdFence(lines[i].text) + b := MdBlock{Kind: BlockCode, Open: true, Lang: strings.ToLower(strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(lines[i].text), fence[:1])))} + var code []string + for i++; i < len(lines); i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i].text), fence) && strings.Trim(strings.TrimSpace(lines[i].text), fence[:1]) == "" { + b.Open = false + i++ + break + } + code = append(code, lines[i].text) + } + b.Code = strings.Join(code, "\n") + return b, i +} + +func mdHeadingLevel(l string) int { + n := 0 + for n < len(l) && l[n] == '#' { + n++ + } + if n == 0 || n > 6 || n < len(l) && l[n] != ' ' { + return 0 + } + return n +} + +func mdIsRule(l string) bool { + t := strings.ReplaceAll(strings.TrimSpace(l), " ", "") + if len(t) < 3 { + return false + } + return strings.Count(t, t[:1]) == len(t) && strings.ContainsAny(t[:1], "-*_") +} + +func parseQuote(lines []mdLine, i int) (MdBlock, int) { + var parts []string + for ; i < len(lines); i++ { + t := strings.TrimLeft(lines[i].text, " ") + if !strings.HasPrefix(t, ">") { + break + } + parts = append(parts, strings.TrimSpace(t[1:])) + } + return MdBlock{Kind: BlockQuote, Spans: ParseInline(strings.Join(parts, " "))}, i +} + +// mdMarker is a list item's marker. +type mdMarker struct { + number, depth, width int +} + +func mdListMarker(l string) *mdMarker { + indent := len(l) - len(strings.TrimLeft(l, " ")) + t := l[indent:] + if len(t) >= 2 && strings.ContainsRune("-*+", rune(t[0])) && t[1] == ' ' { + return &mdMarker{depth: indent / 2, width: indent + 2} + } + d := 0 + for d < len(t) && t[d] >= '0' && t[d] <= '9' { + d++ + } + if d == 0 || d > 9 || d+1 >= len(t) || !strings.ContainsRune(".)", rune(t[d])) || t[d+1] != ' ' { + return nil + } + n, _ := strconv.Atoi(t[:d]) + return &mdMarker{number: max(n, 1), depth: indent / 2, width: indent + d + 2} +} + +func parseList(lines []mdLine, i int) (MdBlock, int) { + b := MdBlock{Kind: BlockList} + var texts []string + for ; i < len(lines); i++ { + l := lines[i].text + if mk := mdListMarker(l); mk != nil { + b.Items = append(b.Items, MdItem{Number: mk.number, Depth: mk.depth}) + texts = append(texts, strings.TrimSpace(l[mk.width:])) + continue + } + if mdStarts(lines, i) { + break + } + // A lazy line continues the item above. + texts[len(texts)-1] += " " + strings.TrimSpace(l) + } + for j := range b.Items { + b.Items[j].Spans = ParseInline(texts[j]) + } + return b, i +} + +// mdCells splits a table row into trimmed cells. +func mdCells(l string) []string { + t := strings.TrimSpace(l) + t = strings.TrimSuffix(strings.TrimPrefix(t, "|"), "|") + cells := strings.Split(t, "|") + for i := range cells { + cells[i] = strings.TrimSpace(cells[i]) + } + return cells +} + +// mdDelimiter parses a table's delimiter row, or returns nil. +func mdDelimiter(l string) []TableAlign { + if !strings.Contains(l, "-") { + return nil + } + var out []TableAlign + for _, c := range mdCells(l) { + left, right := strings.HasPrefix(c, ":"), strings.HasSuffix(c, ":") + if strings.Trim(c, ":") == "" || strings.Trim(c, ":-") != "" { + return nil + } + switch { + case left && right: + out = append(out, AlignCenter) + case right: + out = append(out, AlignRight) + default: + out = append(out, AlignLeft) + } + } + return out +} + +func parseTable(lines []mdLine, i int) (MdBlock, int) { + b := MdBlock{Kind: BlockTable, Header: mdCells(lines[i].text), Align: mdDelimiter(lines[i+1].text)} + n := len(b.Header) + for len(b.Align) < n { + b.Align = append(b.Align, AlignLeft) + } + b.Align = b.Align[:n] + for i += 2; i < len(lines) && strings.Contains(lines[i].text, "|") && strings.TrimSpace(lines[i].text) != ""; i++ { + row := mdCells(lines[i].text) + for len(row) < n { + row = append(row, "") + } + b.Rows = append(b.Rows, row[:n]) + } + return b, i +} diff --git a/internal/markdown_inline.go b/internal/markdown_inline.go new file mode 100644 index 0000000..db9e160 --- /dev/null +++ b/internal/markdown_inline.go @@ -0,0 +1,128 @@ +package internal + +import "strings" + +// mdStyle is the inline style in effect. +type mdStyle struct{ strong, em, strike bool } + +// ParseInline splits text into styled spans: emphasis, code, strikethrough and links. +// An opener without its closer stays literal, as while a message streams in. +func ParseInline(s string) []MdSpan { + var out []MdSpan + parseInline(s, mdStyle{}, &out) + return out +} + +func mdEmit(out *[]MdSpan, sp MdSpan) { + if sp.Text == "" { + return + } + if n := len(*out); n > 0 { + last := &(*out)[n-1] + if sp.URL == "" && last.URL == "" && last.Strong == sp.Strong && last.Em == sp.Em && last.Code == sp.Code && last.Strike == sp.Strike { + last.Text += sp.Text + return + } + } + *out = append(*out, sp) +} + +func parseInline(s string, st mdStyle, out *[]MdSpan) { + plain := func(t string) { + mdEmit(out, MdSpan{Text: t, Strong: st.strong, Em: st.em, Strike: st.strike}) + } + for i := 0; i < len(s); { + switch c := s[i]; { + case c == '\\' && i+1 < len(s) && strings.IndexByte("\\`*_~[]()#!>-|", s[i+1]) >= 0: + plain(s[i+1 : i+2]) + i += 2 + continue + case c == '`': + if end := strings.IndexByte(s[i+1:], '`'); end >= 0 { + mdEmit(out, MdSpan{Text: s[i+1 : i+1+end], Code: true, Strong: st.strong, Em: st.em, Strike: st.strike}) + i += end + 2 + continue + } + case c == '[': + if text, url, n := mdLink(s[i:]); n > 0 { + mdEmit(out, MdSpan{Text: text, URL: url, Strong: st.strong, Em: st.em, Strike: st.strike}) + i += n + continue + } + case c == '*' || c == '_' || c == '~': + if n := mdDelimited(s, i, st, out); n > 0 { + i += n + continue + } + } + j := i + 1 + for j < len(s) && strings.IndexByte("\\`[*_~", s[j]) < 0 { + j++ + } + plain(s[i:j]) + i = j + } +} + +// mdDelimited parses emphasis or strikethrough opening at i and returns its length, or 0. +func mdDelimited(s string, i int, st mdStyle, out *[]MdSpan) int { + c := s[i] + n := 1 + if i+1 < len(s) && s[i+1] == c { + n = 2 + } + if c == '~' && n != 2 { + return 0 + } + // Intraword underscores stay literal, as in snake_case. + if c == '_' && i > 0 && mdWord(s[i-1]) { + return 0 + } + marker := s[i : i+n] + body := i + n + if body >= len(s) || s[body] == ' ' { + return 0 + } + end := -1 + for j := body + 1; j+n <= len(s); j++ { + if s[j:j+n] == marker && s[j-1] != ' ' && (n == 2 || j+1 >= len(s) || s[j+1] != c) { + end = j + break + } + } + if end < 0 { + return 0 + } + inner := st + switch { + case c == '~': + inner.strike = true + case n == 2: + inner.strong = true + default: + inner.em = true + } + parseInline(s[body:end], inner, out) + return end + n - i +} + +func mdWord(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' +} + +// mdLink parses [text](url) at the start of s and returns its length, or 0. +func mdLink(s string) (text, url string, n int) { + close := strings.Index(s, "](") + if close < 1 || strings.IndexByte(s[1:close], '[') >= 0 { + return "", "", 0 + } + end := strings.IndexByte(s[close+2:], ')') + if end < 0 { + return "", "", 0 + } + url = strings.TrimSpace(s[close+2 : close+2+end]) + if url == "" || strings.ContainsAny(url, " \n") { + return "", "", 0 + } + return s[1:close], url, close + 3 + end +} diff --git a/internal/markdown_test.go b/internal/markdown_test.go new file mode 100644 index 0000000..373baeb --- /dev/null +++ b/internal/markdown_test.go @@ -0,0 +1,108 @@ +package internal + +import ( + "reflect" + "strings" + "testing" +) + +const mdSample = "# Title\n\nSome **bold** and *em* text with `code`\nand a [link](https://x.io).\n\n" + + "- one\n- two\n - nested\n3. three\n\n> quoted *line*\n\n```go\nfunc f() {}\n```\n\n" + + "| a | b |\n|:--|--:|\n| 1 | 2 |\n| 3 |\n\n---\n\nend ~~old~~ new" + +func TestMarkdownBlocks(t *testing.T) { + var m Markdown + m.Update(mdSample) + var kinds []BlockKind + for _, b := range m.Blocks { + kinds = append(kinds, b.Kind) + } + want := []BlockKind{BlockHeading, BlockParagraph, BlockList, BlockQuote, BlockCode, BlockTable, BlockRule, BlockParagraph} + if !reflect.DeepEqual(kinds, want) { + t.Fatalf("kinds %v, want %v", kinds, want) + } + b := m.Blocks + if b[0].Level != 1 || b[0].Spans[0].Text != "Title" { + t.Errorf("heading %+v", b[0]) + } + p := b[1].Spans + if p[1] != (MdSpan{Text: "bold", Strong: true}) || p[3] != (MdSpan{Text: "em", Em: true}) || p[5] != (MdSpan{Text: "code", Code: true}) { + t.Errorf("paragraph spans %+v", p) + } + if p[7] != (MdSpan{Text: "link", URL: "https://x.io"}) { + t.Errorf("link %+v", p[7]) + } + it := b[2].Items + if len(it) != 4 || it[2].Depth != 1 || it[2].Spans[0].Text != "nested" || it[3].Number != 3 { + t.Errorf("list items %+v", it) + } + if b[3].Spans[1] != (MdSpan{Text: "line", Em: true}) { + t.Errorf("quote %+v", b[3].Spans) + } + if b[4].Lang != "go" || b[4].Code != "func f() {}" || b[4].Open { + t.Errorf("code %+v", b[4]) + } + tb := b[5] + if !reflect.DeepEqual(tb.Header, []string{"a", "b"}) || !reflect.DeepEqual(tb.Align, []TableAlign{AlignLeft, AlignRight}) || + !reflect.DeepEqual(tb.Rows, [][]string{{"1", "2"}, {"3", ""}}) { + t.Errorf("table %+v", tb) + } + if b[7].Spans[1] != (MdSpan{Text: "old", Strike: true}) { + t.Errorf("strike %+v", b[7].Spans) + } +} + +func TestMarkdownInlineLiterals(t *testing.T) { + for in, want := range map[string]string{ + "2 * 3 * 4": "2 * 3 * 4", + "snake_case_name": "snake_case_name", + "**unclosed": "**unclosed", + `\*not em\*`: "*not em*", + "[no link]": "[no link]", + } { + sp := ParseInline(in) + if len(sp) != 1 || sp[0].Text != want || sp[0].Em || sp[0].Strong { + t.Errorf("%q gave %+v", in, sp) + } + } +} + +// TestMarkdownIncremental streams the sample byte by byte and checks finished blocks are kept. +func TestMarkdownIncremental(t *testing.T) { + var m Markdown + for i := 1; i <= len(mdSample); i++ { + before := append([]MdBlock(nil), m.Blocks...) + from := m.Update(mdSample[:i]) + if from < len(before)-1 { + t.Fatalf("at %d reparsed from %d of %d blocks", i, from, len(before)) + } + for j := 0; j < from; j++ { + if !reflect.DeepEqual(m.Blocks[j], before[j]) { + t.Fatalf("at %d finished block %d changed", i, j) + } + } + } + var whole Markdown + whole.Update(mdSample) + if !reflect.DeepEqual(m.Blocks, whole.Blocks) { + t.Fatal("streamed parse differs from a whole parse") + } + if m.Update(mdSample) != len(m.Blocks) { + t.Fatal("an unchanged source parsed again") + } + if m.Update("# Other") != 0 || len(m.Blocks) != 1 { + t.Fatal("a replaced source kept old blocks") + } +} + +func TestMarkdownOpenFence(t *testing.T) { + var m Markdown + m.Update("```py\nprint(1)\n") + if len(m.Blocks) != 1 || !m.Blocks[0].Open || m.Blocks[0].Code != "print(1)" { + t.Fatalf("open fence %+v", m.Blocks) + } + m.Update("```py\nprint(1)\n```\nafter") + if len(m.Blocks) != 2 || m.Blocks[0].Open || !strings.Contains(m.Blocks[1].Spans[0].Text, "after") { + t.Fatalf("closed fence %+v", m.Blocks) + } +} diff --git a/level2_quotes.go b/level2_quotes.go new file mode 100644 index 0000000..0df1966 --- /dev/null +++ b/level2_quotes.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// L2Level is a level's price, size and order count. +type L2Level struct { + Price, Size float64 + Orders int +} + +// Level2Data holds the bid and ask levels, best first. +type Level2Data struct { + Bids, Asks []L2Level +} + +// Level2QuotesOptions configure a Level2Quotes. +type Level2QuotesOptions struct { + // Decimals of prices and sizes; zero means 2 and 0. + Decimals, SizeDecimals int + // Levels shown each side; zero means 5. + Levels int +} + +// level2Mark is a level's last values and size move. +type level2Mark struct { + price, size float64 + dir int + at time.Time + seen bool +} + +// Level2Quotes lists bid and ask levels; changes flash and mark. +func Level2Quotes(c *ui.Context, data Level2Data, opts Level2QuotesOptions) *ui.Element { + dec := priceDecimals("Level2Quotes", opts.Decimals) + if opts.SizeDecimals < 0 || opts.Levels < 0 { + panic("mujica: Level2Quotes SizeDecimals and Levels cannot be negative") + } + n := opts.Levels + if n == 0 { + n = 5 + } + u := Density(c).Unit() + e := ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Role(ui.RoleTable).Label(msg(c, "level2_quotes.label")) + marks := ui.Local(e, "marks", func() map[string]*level2Mark { return map[string]*level2Mark{} }) + e.Children(func() { + for _, side := range []OrderSide{SideBuy, SideSell} { + levels := data.Bids + if side == SideSell { + levels = data.Asks + } + ui.Column(c).Grow(1).Basis(0).MinWidth(0).Children(func() { + for i := range n { + var l *L2Level + if i < len(levels) { + l = &levels[i] + } + level2Row(c, *marks, side, i, l, dec, opts.SizeDecimals) + } + }) + } + }) + return e +} + +// level2Row is one level with its move mark. +func level2Row(c *ui.Context, marks map[string]*level2Mark, side OrderSide, i int, l *L2Level, dec, sizeDec int) { + k := Tokens(c) + d := Density(c) + name := fmt.Sprintf(msg(c, fmt.Sprintf("level2_quotes.side%d", side)), i+1) + key := fmt.Sprint(side, i) + m := marks[key] + if m == nil { + m = &level2Mark{} + marks[key] = m + } + row := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). + Radius(theme.ControlRadius).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) + if l == nil { + row.Children(func() { + ui.Text(c, name).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Width(28) + ui.Text(c, "—").TextColor(k.TextMuted) + }) + return + } + if m.seen && (l.Price != m.price || l.Size != m.size) { + // A new price is a new level, not a move. + m.dir, m.at = 0, c.Now() + if l.Price == m.price { + m.dir = trendSign(l.Size - m.size) + } + slog.Debug("mujica: level2_quotes level changed", "level", name) + } + m.price, m.size, m.seen = l.Price, l.Size, true + if dur := Motion(c, priceTextFlash); dur > 0 && !m.at.IsZero() { + if t := c.Now().Sub(m.at); t < dur { + row.Background(k.Selection.Alpha(1 - float32(float64(t)/float64(dur)))) + c.AnimationFrame() + } + } + col := trendColor(c, side.sign()) + sizeText := statisticFormat(l.Size, sizeDec) + orders := fmt.Sprintf(msg(c, "level2_quotes.orders"), l.Orders) + row.Role(ui.RoleRow).Label(name + " " + statisticFormat(l.Price, dec) + " " + sizeText + " " + orders) + row.Children(func() { + ui.Text(c, name).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Width(28) + ui.Text(c, statisticFormat(l.Price, dec)).TextColor(col).FontFeatures("tnum").FontWeight(600).Grow(1).Basis(0).SingleLine() + ui.Row(c).Gap(2).AlignItems(ui.Center).Justify(ui.End).Grow(1).Basis(0).Children(func() { + if m.dir != 0 { + ui.Icon(c, trendIcon(m.dir)).Size(12, 12).TextColor(trendColor(c, m.dir)) + } + ui.Text(c, sizeText).FontFeatures("tnum").SingleLine() + }) + ui.Text(c, fmt.Sprint(l.Orders)).TextColor(k.TextMuted).FontFeatures("tnum").TextAlign(ui.End).Width(36).SingleLine() + }) +} diff --git a/level2_quotes_test.go b/level2_quotes_test.go new file mode 100644 index 0000000..492b5fe --- /dev/null +++ b/level2_quotes_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "bytes" + "testing" + + "github.com/egoist/mygo/ui" +) + +func level2Sample() Level2Data { + return Level2Data{ + Bids: []L2Level{{100.00, 500, 12}, {99.99, 300, 4}}, + Asks: []L2Level{{100.01, 200, 3}, {100.02, 900, 21}, {100.03, 50, 1}}, + } +} + +func TestLevel2QuotesLevels(t *testing.T) { + tt := tradingTester(GreenUp, 600, 300, func(c *ui.Context) { + ui.Box(c).Width(560).Children(func() { Level2Quotes(c, level2Sample(), Level2QuotesOptions{Levels: 3}) }) + }) + for _, name := range []string{"B1 100.00 500 12 orders", "S2 100.02 900 21 orders"} { + if _, ok := tt.Find(name); !ok { + t.Fatalf("no row %q in %q", name, tt.Texts()) + } + } + if !tt.HasText("B3") || !tt.HasText("—") { + t.Fatal("missing third bid not shown as empty") + } +} + +// A changed level marks its size move and flashes. +func TestLevel2QuotesHighlightsChangedLevel(t *testing.T) { + data := level2Sample() + var marks *map[string]*level2Mark + tt := tradingTester(GreenUp, 600, 300, func(c *ui.Context) { + e := Level2Quotes(c, data, Level2QuotesOptions{Levels: 2}) + marks = ui.Local(e, "marks", func() map[string]*level2Mark { return nil }) + }) + before := bytes.Clone(tt.Image().Pix) + data.Bids = []L2Level{{100.00, 650, 13}, {99.99, 300, 4}} + tt.Frame() + m := (*marks)["1 0"] + if m.dir != 1 || m.at.IsZero() { + t.Fatalf("bid 1 mark %+v", m) + } + if (*marks)["1 1"].dir != 0 || !(*marks)["1 1"].at.IsZero() { + t.Fatal("unchanged level marked") + } + if bytes.Equal(before, tt.Image().Pix) { + t.Fatal("nothing drawn for the change") + } + data.Bids = []L2Level{{100.01, 650, 13}, {99.99, 300, 4}} + tt.Frame() + if (*marks)["1 0"].dir != 0 { + t.Fatal("new price read as a size move") + } +} + +func TestLevel2QuotesSnapshot(t *testing.T) { + snapshot(t, "level2_quotes", 600, 230, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { Level2Quotes(c, level2Sample(), Level2QuotesOptions{}) }) + }) +} diff --git a/leverage_slider.go b/leverage_slider.go new file mode 100644 index 0000000..dd2e101 --- /dev/null +++ b/leverage_slider.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// LeverageSliderOptions configure a LeverageSlider. +type LeverageSliderOptions struct { + // Levels are the rising leverages; nil means 1× to 100×. + Levels []float64 + // Warning and Danger start the risk zones; zero omits one. + Warning, Danger float64 + // Label names the slider; empty uses the built-in "Leverage". + Label string + Disabled bool +} + +type leverageSliderState struct { + draft float64 + seen float64 +} + +// LeverageSlider drafts a leverage; Apply sets *value. +func LeverageSlider(c *ui.Context, value *float64, opts LeverageSliderOptions) ChoiceResult { + levels := opts.Levels + if levels == nil { + levels = []float64{1, 2, 3, 5, 10, 20, 50, 100} + } + if len(levels) < 2 || !slices.IsSorted(levels) || slices.Index(levels, *value) < 0 { + panic(fmt.Sprintf("mujica: LeverageSlider value %v must be one of at least two rising Levels", *value)) + } + if opts.Warning < 0 || opts.Danger < 0 || opts.Danger != 0 && opts.Danger <= opts.Warning { + panic("mujica: LeverageSlider Danger must be above Warning") + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "leverage_slider.label") + } + var r ChoiceResult + r.Element = ui.Column(c).Gap(d.Unit() * 2).MinWidth(260).Disabled(opts.Disabled) + st := ui.Local(r.Element, "lev", func() leverageSliderState { return leverageSliderState{draft: *value, seen: *value} }) + if *value != st.seen { + st.draft, st.seen = *value, *value + } + disabled := r.Element.IsDisabled() + if disabled { + st.draft = *value + } + zone := func(v float64) (string, ui.Color, string) { + switch { + case opts.Danger > 0 && v >= opts.Danger: + return msg(c, "leverage_slider.danger"), k.Danger, "circle-alert" + case opts.Warning > 0 && v >= opts.Warning: + return msg(c, "leverage_slider.warning"), k.Warning, "triangle-alert" + } + return "", k.TextMuted, "" + } + r.Element.Children(func() { + note, col, icon := zone(st.draft) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, label).TextColor(k.TextMuted).Grow(1) + ui.Text(c, leverageText(st.draft)).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).FontFeatures("tnum") + }) + leverageTrack(c, st, levels, disabled, zone) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Children(func() { + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(col).Grow(1).Children(func() { + if icon != "" { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).Role(ui.RoleStatus) + } + }) + if Button(c, msg(c, "leverage_slider.apply"), ButtonOptions{Disabled: disabled || st.draft == *value}).Clicked() && !disabled { + *value, st.seen, r.changed = st.draft, st.draft, true + slog.Debug("mujica: leverage_slider applied", "leverage", *value) + } + }) + }) + return r +} + +func leverageText(v float64) string { return statisticFormat(v, 0) + "×" } + +// leverageTrack is the slider with zones, ticks and thumb. +func leverageTrack(c *ui.Context, st *leverageSliderState, levels []float64, disabled bool, zone func(float64) (string, ui.Color, string)) { + k := Tokens(c) + n := len(levels) + at := slices.Index(levels, st.draft) + set := func(i int) { + i = max(0, min(n-1, i)) + if levels[i] != st.draft && !disabled { + st.draft = levels[i] + slog.Debug("mujica: leverage_slider draft", "leverage", st.draft) + } + } + const pad = 10 + t := ui.Box(c).Height(theme.MinHitSize+16).FillWidth().Padding(0, pad).Focusable().Role(ui.RoleSlider). + Label(msg(c, "leverage_slider.label")).Description(leverageText(st.draft)) + t.FocusRing(false) + if !disabled { + switch { + case t.Shortcut(0, ui.KeyRight), t.Shortcut(0, ui.KeyUp): + set(at + 1) + case t.Shortcut(0, ui.KeyLeft), t.Shortcut(0, ui.KeyDown): + set(at - 1) + case t.Shortcut(0, ui.KeyHome): + set(0) + case t.Shortcut(0, ui.KeyEnd): + set(n - 1) + } + if _, _, ok := t.Dragged(); ok { + x, _, _ := t.PointerPosition() + if w := t.Bounds().W - 2*pad; w > 0 { + set(int(math.Round(float64((x - pad) / w * float32(n-1))))) + } + } + } + at = slices.Index(levels, st.draft) + t.Draw(func(p *ui.Painter, r ui.Rect) { + x0, w := r.X+pad, r.W-2*pad + y := r.Y + 14 + xs := func(i int) float32 { return internal.Snap(p, x0+w*float32(i)/float32(n-1)) } + // Zone bands run under the track. + for i := 0; i < n-1; i++ { + _, col, icon := zone(levels[i+1]) + if icon != "" { + p.Fill(ui.Rect{X: xs(i), Y: y + 4, W: xs(i+1) - xs(i), H: 3}, col, 0) + } + } + p.Line(x0, y, x0+w, y, 2, k.ControlBorder) + p.Line(x0, y, xs(at), y, 2, k.AccentText) + size := FontSize(c, theme.CaptionSize) + widest, _ := p.MeasureText(0, ui.Span{Text: leverageText(levels[n-1]), Size: size}) + // Crowded labels skip levels, counted back from the last. + every := max(1, int(math.Ceil(float64((widest+8)/(w/float32(n-1)))))) + for i, v := range levels { + p.Line(xs(i), y+4, xs(i), y+8, 1, k.TextMuted) + if (n-1-i)%every == 0 { + tw, _ := p.MeasureText(0, ui.Span{Text: leverageText(v), Size: size}) + p.Text(xs(i)-tw/2, y+11, leverageText(v), size, k.TextMuted) + } + } + p.FillPath(internal.Diamond(xs(at), y, 16), k.Surface) + p.FillPath(internal.Diamond(xs(at), y, 13), k.AccentText) + if t.FocusVisible() { + internal.FocusRing(p, ui.Rect{X: xs(at) - 8, Y: y - 8, W: 16, H: 16}, theme.ControlRadius, k.Focus) + } + }) +} diff --git a/leverage_slider_test.go b/leverage_slider_test.go new file mode 100644 index 0000000..fbbf4ce --- /dev/null +++ b/leverage_slider_test.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +type leverageProbe struct { + value float64 + opts LeverageSliderOptions + applied int +} + +func (p *leverageProbe) tester(density theme.Density) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: density}) + ui.Column(c).Padding(10).Children(func() { + if LeverageSlider(c, &p.value, p.opts).Changed() { + p.applied++ + } + }) + }, 420, 200) +} + +func TestLeverageSliderAppliesOnlyOnConfirm(t *testing.T) { + p := &leverageProbe{value: 5, opts: LeverageSliderOptions{Warning: 10, Danger: 50}} + tt := p.tester(theme.Default) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Leverage") { + t.Fatal("slider not focusable") + } + tt.Key(0, ui.KeyRight) + if p.value != 5 || !tt.HasText("10×") || !tt.HasText("High risk") { + t.Fatalf("draft: value %v texts %q", p.value, tt.Texts()) + } + tt.Key(0, ui.KeyEnd) + if !tt.HasText("Very high risk") { + t.Fatalf("danger zone: %q", tt.Texts()) + } + if err := tt.Click("Apply"); err != nil { + t.Fatal(err) + } + if p.value != 100 || p.applied != 1 { + t.Fatalf("apply: %v %d", p.value, p.applied) + } + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(0, ui.KeyHome) + if p.value != 100 || !tt.HasText("1×") { + t.Fatalf("home moved value: %v", p.value) + } +} + +func TestLeverageSliderPointerAndHitArea(t *testing.T) { + p := &leverageProbe{value: 1, opts: LeverageSliderOptions{Levels: []float64{1, 2, 3}}} + tt := p.tester(theme.Compact) + // The track is at least 28 DIPs tall, even Compact. + tt.ClickAt(10+400-10, 70) + if !tt.HasText("3×") { + t.Fatalf("pointer: %q", tt.Texts()) + } + tt.ClickAt(30, 70) + if !tt.HasText("1×") || p.applied != 0 { + t.Fatalf("pointer back: %q", tt.Texts()) + } +} + +func TestLeverageSliderDisabledAndBadValue(t *testing.T) { + p := &leverageProbe{value: 2, opts: LeverageSliderOptions{Disabled: true}} + tt := p.tester(theme.Default) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + _ = tt.Click("Apply") + if p.value != 2 || tt.HasText("3×") || !tt.HasText("2×") { + t.Fatal("disabled slider moved") + } + defer func() { + if recover() == nil { + t.Fatal("value outside Levels accepted") + } + }() + (&leverageProbe{value: 4}).tester(theme.Default) +} + +func TestLeverageSliderSnapshot(t *testing.T) { + v := 20.0 + snapshot(t, "leverage_slider", 360, 160, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + LeverageSlider(c, &v, LeverageSliderOptions{Warning: 10, Danger: 50}) + }) + }) +} diff --git a/lightbox.go b/lightbox.go new file mode 100644 index 0000000..4b19858 --- /dev/null +++ b/lightbox.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Lightbox fills the window with images[*index] while *open. +func Lightbox(c *ui.Context, open *bool, index *int, images []ViewerImage) ChoiceResult { + var v ChoiceResult + if !*open { + return v + } + if len(images) == 0 || *index < 0 || *index >= len(images) { + panic("mujica: Lightbox index out of range") + } + for _, im := range images { + viewerSize(im.Source) + } + u := Density(c).Unit() + w, h := c.Size() + light := ui.Hex("#F2EBDD") + v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + back.Background(ui.RGBA(0, 0, 0, 0.92)) + panel.Size(w, h).Padding(u * 4).Gap(u * 3).TextColor(light).Label(msg(c, "lightbox.title")). + Opacity(overlayEnter(c, panel)) + goTo := func(i int) { + if i < 0 || i >= len(images) || i == *index { + return + } + *index, v.changed = i, true + c.Announce(fmt.Sprintf(msg(c, "lightbox.position"), i+1, len(images))) + slog.Debug("mujica: lightbox image", "index", i) + } + switch { + case panel.Shortcut(0, ui.KeyLeft): + goTo(*index - 1) + case panel.Shortcut(0, ui.KeyRight): + goTo(*index + 1) + case panel.Shortcut(0, ui.KeyHome): + goTo(0) + case panel.Shortcut(0, ui.KeyEnd): + goTo(len(images) - 1) + } + img := images[*index] + ui.Row(c).AlignItems(ui.Center).Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "lightbox.position"), *index+1, len(images))).FontFeatures("tnum").Grow(1) + if lightboxButton(c, "x", "lightbox.close", false).Clicked() { + *open = false + } + }) + ui.Row(c).Grow(1).MinHeight(0).Gap(u * 3).AlignItems(ui.Center).Children(func() { + if b := lightboxButton(c, "chevron-left", "lightbox.previous", *index == 0); b.Clicked() && !b.IsDisabled() { + goTo(*index - 1) + } + ui.Box(c).Grow(1).FillHeight().MinWidth(0).Center().Children(func() { + ui.Image(c, img.Source).Fit(ui.Contain).Fill().Label(img.Alt) + }) + if b := lightboxButton(c, "chevron-right", "lightbox.next", *index == len(images)-1); b.Clicked() && !b.IsDisabled() { + goTo(*index + 1) + } + }) + if img.Alt != "" { + ui.Text(c, img.Alt).TextAlign(ui.Center).FillWidth().MaxLines(3) + } + }) + if !*open { + slog.Debug("mujica: lightbox close") + } + return v +} + +// lightboxButton is a 40 DIP icon button on the backdrop. +func lightboxButton(c *ui.Context, icon, key string, disabled bool) *ui.Element { + light := ui.Hex("#F2EBDD") + b := iconAction(c, icons.Must(icon), msg(c, key)).Size(40, 40).TextColor(light).Disabled(disabled) + // The theme's focus color is too dark on the backdrop. + internal.Ring(b, light, theme.ControlRadius) + if b.Hovered() { + b.Background(ui.RGBA(255, 255, 255, 0.12)).TextColor(ui.Hex("#FFFFFF")) + } + return b +} diff --git a/lightbox_test.go b/lightbox_test.go new file mode 100644 index 0000000..d50e70c --- /dev/null +++ b/lightbox_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func lightboxImages() []ViewerImage { + return []ViewerImage{ + {Source: mediaTestBitmap(160, 90), Alt: "The grand hall"}, + {Source: mediaTestBitmap(90, 160), Alt: "The east tower"}, + {Source: mediaTestBitmap(120, 120), Alt: "The garden"}, + } +} + +func TestLightboxBrowseAndClose(t *testing.T) { + open, index, n := false, 0, 0 + images := lightboxImages() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Open gallery", ButtonOptions{}).Clicked() { + open = true + } + if Lightbox(c, &open, &index, images).Changed() { + n++ + } + }, 640, 480) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !open || !tt.HasText("The grand hall") || !tt.HasText("1 of 3") { + t.Fatalf("not open: %v", tt.Texts()) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if index != 2 || n != 2 || !tt.HasText("The garden") { + t.Fatalf("index %d changes %d", index, n) + } + if err := tt.Click("Previous image"); err != nil { + t.Fatal(err) + } + if index != 1 { + t.Fatalf("previous: %d", index) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + if !tt.Focused("Open gallery") { + t.Fatal("focus not restored to the opener") + } +} + +func TestLightboxBadIndex(t *testing.T) { + open, index := true, 5 + mediaPanics(t, "index", func(c *ui.Context) { Lightbox(c, &open, &index, lightboxImages()) }) +} + +func TestLightboxSnapshot(t *testing.T) { + open, index := true, 0 + images := lightboxImages() + snapshot(t, "lightbox", 520, 360, func(c *ui.Context) { + Lightbox(c, &open, &index, images) + }) +} diff --git a/line_chart.go b/line_chart.go new file mode 100644 index 0000000..44a09c2 --- /dev/null +++ b/line_chart.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "math" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// LineChartOptions configure a LineChart. +type LineChartOptions 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 + // Format writes readout values; nil uses six digits. + Format func(v float64) string +} + +// LineChart draws series as lines; NaN breaks them. +func LineChart(c *ui.Context, series []ChartSeries, opts LineChartOptions) 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) }) + lo, hi, ok := chartYRange(ch, series, false, nil) + if !ok { + n = 0 + } + tops := make([][]float64, len(series)) + for i, s := range series { + tops[i] = s.Values + } + return chartLines(ch, chartLineData{series: series, tops: tops, ax: ax, lo: lo, hi: hi, n: n, format: opts.Format}) +} + +// chartYRange is the memoized y domain of visible series. +func chartYRange(ch *chart, series []ChartSeries, zero bool, tops [][]float64) (lo, hi float64, ok bool) { + d := chartMemo(ch, "y", chartSeriesID(ch, series)+chartID(tops), func() [3]float64 { + src := series + if tops != nil { + src = make([]ChartSeries, len(series)) + for i, s := range series { + src[i] = ChartSeries{Name: s.Name, Values: tops[i]} + } + } + lo, hi, ok := chartYDomain(src, ch.hidden, zero) + if !ok { + return [3]float64{} + } + return [3]float64{lo, hi, 1} + }) + return d[0], d[1], d[2] == 1 +} + +// chartLineData is what line and area charts draw. +type chartLineData struct { + series []ChartSeries + tops, bases [][]float64 + ax chartX + lo, hi float64 + n int + format func(float64) string +} + +func chartLines(ch *chart, d chartLineData) ChartResult { + c, k := ch.c, Tokens(ch.c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + keys := make([]chartKey, len(d.series)) + for i, s := range d.series { + keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + } + var ticks []float64 + var step float64 + lo, hi := d.lo, d.hi + if d.n > 0 { + lo, hi, step = internal.Nice(lo, hi, 4) + ticks = internal.Ticks(lo, hi, step) + } + geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { + plot := chartInset(r, 52, 26) + return plot, d.ax.scale(plot), internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + } + spec := chartSpec{n: d.n, keys: keys, toggle: true} + spec.pick = func(r ui.Rect, x, y float32) int { + plot, xs, _ := geom(r) + if !(ui.Rect{X: plot.X - 6, Y: plot.Y, W: plot.W + 12, H: plot.H}).Contains(x, y) { + return -1 + } + return d.ax.nearest(xs.Invert(x)) + } + spec.readout = func(i int) string { + parts := []string{d.ax.label(i)} + for _, s := range d.series { + if !ch.hidden(s.Name) { + parts = append(parts, s.Name+" "+chartValue(c, s.Values[i], d.format)) + } + } + return strings.Join(parts, " · ") + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, xs, ys := geom(r) + internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k) + tx, tl := d.ax.ticks(plot) + internal.AxisX(p, plot, tx, tl, size, k) + x := func(i int) float32 { return xs.Map(d.ax.vals[i]) } + pt := func(i int, v float64) (float32, float32) { return internal.Snap(p, x(i)), internal.Snap(p, ys.Map(v)) } + for si, s := range d.series { + if ch.hidden(s.Name) { + continue + } + col, top := keys[si].Color, d.tops[si] + chartRuns(top, x, func(idx []int) { + var line ui.Path + for j, i := range idx { + if j == 0 { + line.MoveTo(pt(i, top[i])) + } else { + line.LineTo(pt(i, top[i])) + } + } + if d.bases != nil { + fill := line + for j := len(idx) - 1; j >= 0; j-- { + fill.LineTo(pt(idx[j], max(lo, min(hi, d.bases[si][idx[j]])))) + } + fill.Close() + p.FillPath(&fill, col.Alpha(0.22)) + } + p.StrokePath(&line, 2, col) + }) + // Sparse markers name each series by shape, not only color. + every := max(1, int(float32(d.n)/max(plot.W/48, 1))) + for i := 0; i < d.n; i += every { + if !math.IsNaN(top[i]) { + internal.Marker(p, si, x(i), ys.Map(top[i]), 7, col) + } + } + } + if active < 0 { + return + } + ax0 := internal.Snap(p, x(active)) + p.Line(ax0, plot.Y, ax0, plot.Y+plot.H, 1, k.TextMuted) + for si, s := range d.series { + if v := d.tops[si][active]; !ch.hidden(s.Name) && !math.IsNaN(v) { + internal.Marker(p, si, ax0, ys.Map(v), 13, k.Surface) + internal.Marker(p, si, ax0, ys.Map(v), 9, keys[si].Color) + } + } + } + res, _ := ch.build(spec) + return res +} diff --git a/line_chart_test.go b/line_chart_test.go new file mode 100644 index 0000000..a933b4d --- /dev/null +++ b/line_chart_test.go @@ -0,0 +1,194 @@ +package mujica + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type chartHarness struct { + res ChartResult + changes int + disabled bool + build func(c *ui.Context) ChartResult +} + +func (h *chartHarness) view(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(16).Width(480).Disabled(h.disabled).Children(func() { + h.res = h.build(c) + if h.res.Changed() { + h.changes++ + } + }) +} + +// chartReadout returns the readout line, the text after the plot. +func chartReadout(tt *ui.Tester) string { + texts := tt.Texts() + return texts[len(texts)-1] +} + +func lineSeries() []ChartSeries { + return []ChartSeries{ + {Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}}, + {Name: "Candles", Values: []float64{5, 2, 6, 7, 1}}, + } +} + +func TestLineChartKeyboardReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + if !strings.Contains(chartReadout(tt), "arrow keys") { + t.Fatalf("hint %q", chartReadout(tt)) + } + tabTo(t, tt, "Chapel") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "1 · Pews 5 · Candles 2" || h.res.Point != 1 || h.changes != 2 { + t.Fatalf("readout %q point %d", got, h.res.Point) + } + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" { + t.Fatalf("gap readout %q", got) + } + if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "2 · Pews no value · Candles 6" { + t.Fatalf("announcements %q", a) + } + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) + if h.res.Point != 4 { + t.Fatalf("End then Right: %d", h.res.Point) + } + tt.Key(0, ui.KeyEscape) + if h.res.Point != -1 { + t.Fatalf("Escape kept point %d", h.res.Point) + } +} + +func TestLineChartHoverAndToggle(t *testing.T) { + var toggled string + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}, X: []float64{0, 10, 20, 30, 40}}) + if s, ok := r.Toggled(); ok { + toggled = s + } + return r + }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + // The plot spans 52..W-12; x 30 is three quarters across. + px := b.X + 52 + (b.W-64)*0.75 + tt.Move(px, b.Y+b.H/2) + if got := chartReadout(tt); got != "30 · Pews 4 · Candles 7" { + t.Fatalf("hover readout %q", got) + } + if err := tt.Click("Candles"); err != nil { + t.Fatal(err) + } + tt.Move(px, b.Y+b.H/2) + if got := chartReadout(tt); got != "30 · Pews 4" || toggled != "Candles" { + t.Fatalf("hidden series still read: %q toggled %q", got, toggled) + } +} + +func TestLineChartTimeAxisAndStates(t *testing.T) { + t0 := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + times := []time.Time{t0, t0.Add(time.Hour), t0.Add(2 * time.Hour), t0.Add(3 * time.Hour), t0.Add(4 * time.Hour)} + status := DataReady + var series []ChartSeries + var heights []float32 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := LineChart(c, series, LineChartOptions{Frame: ChartFrame{Label: "Chapel", Status: status}, Times: times[:len(series[0].Values)]}) + return r + }} + series = lineSeries() + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Chapel") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 10:00:00 · ") { + t.Fatalf("time readout %q", got) + } + heights = append(heights, h.res.Element.Bounds().H) + for _, st := range []DataStatus{DataLoading, DataFailed} { + status = st + tt.Frame() + heights = append(heights, h.res.Element.Bounds().H) + } + if !tt.HasText("Retry") || heights[0] != heights[1] || heights[1] != heights[2] { + t.Fatalf("states changed size: %v", heights) + } + status, series = DataReady, []ChartSeries{{Name: "A", Values: []float64{math.NaN()}}} + tt.Frame() + if !tt.HasText("No data") { + t.Fatalf("all-missing chart: %q", tt.Texts()) + } +} + +func TestLineChartDisabledIgnoresKeys(t *testing.T) { + h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult { + return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + for range 4 { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + } + if h.res.Point != -1 { + t.Fatalf("disabled chart moved to %d", h.res.Point) + } +} + +func TestLineChartBadOptions(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "lengths": func(c *ui.Context) { + LineChart(c, []ChartSeries{{Name: "a", Values: []float64{1}}, {Name: "b"}}, LineChartOptions{Frame: ChartFrame{Label: "x"}}) + }, + "order": func(c *ui.Context) { + LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "x"}, X: []float64{0, 2, 1, 3, 4}}) + }, + "no label": func(c *ui.Context) { LineChart(c, lineSeries(), LineChartOptions{}) }, + "duplicate": func(c *ui.Context) { + LineChart(c, []ChartSeries{{Name: "a"}, {Name: "a"}}, LineChartOptions{Frame: ChartFrame{Label: "x"}}) + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); build(c) }, 300, 300) + }() + } +} + +func TestLineChartLarge(t *testing.T) { + vals := make([]float64, 100_000) + for i := range vals { + vals[i] = math.Sin(float64(i) / 500) + } + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return LineChart(c, []ChartSeries{{Name: "Wave", Values: vals}}, LineChartOptions{Frame: ChartFrame{Label: "Tide"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Tide") + tt.Key(0, ui.KeyEnd) + if h.res.Point != 99_999 || !strings.HasPrefix(chartReadout(tt), "99999 · Wave ") { + t.Fatalf("End: %d %q", h.res.Point, chartReadout(tt)) + } +} + +func TestLineChartSnapshot(t *testing.T) { + snapshot(t, "line_chart", 520, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel", Height: 240}}) + }) + }) +} diff --git a/link_preview_card.go b/link_preview_card.go new file mode 100644 index 0000000..697ace7 --- /dev/null +++ b/link_preview_card.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// LinkPreview is a link's metadata, fetched by the caller. +type LinkPreview struct { + // URL is the link; required. + URL string + // Title is required; Description, Site and Thumbnail may be empty. + Title, Description, Site string + Thumbnail ui.ImageSource +} + +// LinkPreviewCard shows a link's site, title, description and thumbnail; Clicked opens it. +func LinkPreviewCard(c *ui.Context, p LinkPreview) *ui.Element { + if p.URL == "" || p.Title == "" { + panic("mujica: LinkPreviewCard needs URL and Title") + } + k := Tokens(c) + u := Density(c).Unit() + b := ui.ButtonBase(c).MaxWidth(420).Gap(u*3).Padding(u*2).AlignItems(ui.Start).Justify(ui.Start). + Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Label(p.Title).Tooltip(p.URL).Transition(colorFade) + internal.Ring(b, k.Focus, theme.CardRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Column(c).Grow(1).MinWidth(0).Gap(u).Children(func() { + site := p.Site + if site == "" { + site = p.URL + } + ui.Text(c, site).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + ui.Text(c, p.Title).FontWeight(600).TextColor(k.AccentText).MaxLines(2) + if p.Description != "" { + ui.Text(c, p.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).MaxLines(2) + } + }) + if p.Thumbnail != nil { + ui.Image(c, p.Thumbnail).Fit(ui.Cover).Size(64, 64).Radius(theme.ControlRadius).Shrink(0) + } + }) + return b +} diff --git a/link_preview_card_test.go b/link_preview_card_test.go new file mode 100644 index 0000000..527f4e9 --- /dev/null +++ b/link_preview_card_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var linkPreviewSample = LinkPreview{ + URL: "https://example.com/versailles", Title: "The Palace of Versailles", + Description: "A royal château in the Île-de-France region, seat of the court from 1682.", Site: "example.com", +} + +func TestLinkPreviewCardShowsMetadataAndOpens(t *testing.T) { + opened := 0 + p := linkPreviewSample + p.Thumbnail = imageGridPicture("#7D2034") + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if LinkPreviewCard(c, p).Clicked() { + opened++ + } + }, 500, 200) + for _, s := range []string{"example.com", "The Palace of Versailles", "A royal château"} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } + _ = tt.Click("The Palace of Versailles") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if opened != 2 { + t.Fatalf("opened %d", opened) + } +} + +func TestLinkPreviewCardSiteFallsBackToURL(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + LinkPreviewCard(c, LinkPreview{URL: "https://a.io/x", Title: "X"}) + }, 400, 200) + if !tt.HasText("https://a.io/x") { + t.Fatalf("texts %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic without a title") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + LinkPreviewCard(c, LinkPreview{URL: "https://a.io"}) + }, 100, 100) +} + +func TestLinkPreviewCardSnapshot(t *testing.T) { + p := linkPreviewSample + p.Thumbnail = imageGridPicture("#AD9164") + snapshot(t, "link_preview_card", 460, 130, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { LinkPreviewCard(c, p) }) + }) +} diff --git a/list.go b/list.go index 32205b0..439d023 100644 --- a/list.go +++ b/list.go @@ -75,7 +75,7 @@ func (v ListView) Changed() bool { return v.changed } // Submitted reports Enter or a double click on an item. func (v ListView) Submitted() bool { return v.submitted } -// Retried reports a click on the retry action of a failed load. +// Retried reports a click on a failed load's retry. func (v ListView) Retried() bool { return v.retry } // List shows n items, building only those in view, as rows or a grid. diff --git a/log_viewer.go b/log_viewer.go new file mode 100644 index 0000000..33099db --- /dev/null +++ b/log_viewer.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// LogLevel is a log line's severity. +type LogLevel int + +const ( + LogDebug LogLevel = iota + LogInfo + LogWarn + LogError +) + +// LogLine is one entry of a LogViewer. +type LogLine struct { + Time time.Time + Level LogLevel + Text string +} + +// LogViewerState is a LogViewer's filters, highlight and place. +type LogViewerState struct { + // Hidden levels are filtered out. + Hidden [4]bool + // Query is highlighted in the shown lines. + Query string + // Follow keeps the newest line in view; it starts on. + Follow bool + rows ui.ListState + shown []int + scanned int + hidden [4]bool + started bool +} + +// LogViewerOptions configure a LogViewer. +type LogViewerOptions struct { + Disabled bool +} + +// LogViewer shows filtered, highlighted log lines, virtualized. +func LogViewer(c *ui.Context, lines []LogLine, s *LogViewerState, opts LogViewerOptions) ChoiceResult { + if s == nil { + panic("mujica: LogViewer needs a state") + } + if !s.started { + s.started, s.Follow = true, true + } + var res ChoiceResult + res.Element = codePanel(c).Disabled(opts.Disabled) + res.Element.Children(func() { + codeHeader(c, func() { + for lv := LogDebug; lv <= LogError; lv++ { + on := !s.Hidden[lv] + if Toggle(c, &on, logViewerName(c, lv), ToggleOptions{Disabled: opts.Disabled}).Changed() { + s.Hidden[lv] = !on + res.changed = true + slog.Debug("mujica: log_viewer level", "level", lv, "shown", on) + } + } + SearchInput(c, &s.Query, SearchInputOptions{Disabled: opts.Disabled, Placeholder: msg(c, "log_viewer.highlight")}).Element.Grow(1).MinWidth(0) + was := s.Follow + if Toggle(c, &s.Follow, msg(c, "log_viewer.follow"), ToggleOptions{Disabled: opts.Disabled}).Changed() { + res.changed = true + slog.Debug("mujica: log_viewer follow", "on", s.Follow) + if s.Follow && !was { + s.rows.ScrollToEnd() + } + } + }) + logViewerFilter(lines, s) + if len(s.shown) == 0 { + codeCaption(c, msg(c, "log_viewer.empty")).Padding(Density(c).Unit() * 4) + return + } + s.rows.FollowEnd = s.Follow + s.rows.Key = func(i int) any { return s.shown[i] } + s.rows.Label = func(i int) string { return lines[s.shown[i]].Text } + d := Density(c) + ui.List(c, &s.rows, len(s.shown), func(i int) { + logViewerRow(c, lines[s.shown[i]], s.Query) + }).Grow(1).MinHeight(d.ControlHeight()*3).Padding(d.Unit(), 0) + }) + return res +} + +// logViewerFilter extends or redoes the shown lines. +func logViewerFilter(lines []LogLine, s *LogViewerState) { + if s.hidden != s.Hidden || s.scanned > len(lines) { + s.hidden, s.scanned, s.shown = s.Hidden, 0, s.shown[:0] + } + for i := s.scanned; i < len(lines); i++ { + lv := lines[i].Level + if lv < LogDebug || lv > LogError { + panic("mujica: LogLine level out of range") + } + if !s.Hidden[lv] { + s.shown = append(s.shown, i) + } + } + s.scanned = len(lines) +} + +func logViewerName(c *ui.Context, lv LogLevel) string { + return msg(c, [...]string{"log_viewer.debug", "log_viewer.info", "log_viewer.warn", "log_viewer.error"}[lv]) +} + +func logViewerRow(c *ui.Context, l LogLine, query string) { + k := Tokens(c) + d := Density(c) + col := [...]ui.Color{k.TextMuted, k.Info, k.Warning, k.Danger}[l.Level] + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + if !l.Time.IsZero() { + codeMono(c, l.Time.Format("15:04:05.000")).TextColor(k.TextMuted).Shrink(0) + } + ui.Text(c, fmt.Sprintf("%-5s", logViewerName(c, l.Level))).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)). + FontWeight(700).TextColor(col).SingleLine().Shrink(0) + ui.RichText(c, codeMatchSpans(c, l.Text, query)...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)). + SingleLine().Grow(1).MinWidth(0) + }) +} diff --git a/log_viewer_test.go b/log_viewer_test.go new file mode 100644 index 0000000..7d8c01c --- /dev/null +++ b/log_viewer_test.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func logViewerLines(n int) []LogLine { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + lines := make([]LogLine, n) + for i := range lines { + lines[i] = LogLine{Time: at.Add(time.Duration(i) * time.Millisecond), Level: LogLevel(i % 4), Text: fmt.Sprintf("event %d", i)} + } + return lines +} + +func logViewerTester(lines *[]LogLine, s *LogViewerState, disabled bool) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + LogViewer(c, *lines, s, LogViewerOptions{Disabled: disabled}).Element.Height(400) + }, 760, 420) +} + +func logViewerRows(tt *ui.Tester) int { + n := 0 + for _, s := range tt.Texts() { + if strings.HasPrefix(s, "event ") { + n++ + } + } + return n +} + +func TestLogViewerHundredThousandLines(t *testing.T) { + lines := logViewerLines(100_000) + s := &LogViewerState{} + tt := logViewerTester(&lines, s, false) + if !tt.HasText("event 99999") { + t.Fatal("follow did not start at the tail") + } + if n := logViewerRows(tt); n == 0 || n > 40 { + t.Fatalf("built %d rows of 100000", n) + } + // Hiding a level refilters every line once. + if err := tt.Click("ERROR"); err != nil { + t.Fatal(err) + } + if len(s.shown) != 75_000 || tt.HasText("event 99999") { + t.Fatalf("shown %d after hiding ERROR", len(s.shown)) + } + if n := logViewerRows(tt); n > 40 { + t.Fatalf("built %d rows after filtering", n) + } +} + +func TestLogViewerFollowsAppends(t *testing.T) { + lines := logViewerLines(50) + s := &LogViewerState{} + tt := logViewerTester(&lines, s, false) + lines = append(lines, LogLine{Level: LogInfo, Text: "event fresh"}) + tt.Frame() + if !tt.HasText("event fresh") || len(s.shown) != 51 { + t.Fatalf("appended line not followed; shown %d", len(s.shown)) + } + if err := tt.Click("Follow"); err != nil { + t.Fatal(err) + } + if s.Follow { + t.Fatal("Follow toggle did not turn off") + } + tt.Scroll(300, 200, 0, -2000) + lines = append(lines, LogLine{Level: LogInfo, Text: "event later"}) + tt.Frame() + if tt.HasText("event later") { + t.Fatal("paused viewer jumped to the new line") + } +} + +func TestLogViewerHighlight(t *testing.T) { + lines := []LogLine{{Level: LogWarn, Text: "disk nearly full"}} + s := &LogViewerState{Query: "FULL"} + tt := logViewerTester(&lines, s, false) + if !tt.HasText("disk nearly full") || !tt.HasText("WARN") { + t.Fatalf("texts %q", tt.Texts()) + } + var spans []ui.Span + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + spans = codeMatchSpans(c, "disk nearly full", "FULL") + }, 10, 10) + if len(spans) != 2 || spans[1].Text != "full" || !spans[1].Underline || spans[1].Background.A == 0 { + t.Fatalf("highlight spans %+v", spans) + } +} + +func TestLogViewerDisabledAndEmpty(t *testing.T) { + lines := logViewerLines(8) + s := &LogViewerState{} + tt := logViewerTester(&lines, s, true) + _ = tt.Click("ERROR") + if s.Hidden[LogError] { + t.Fatal("disabled viewer changed its filter") + } + var none []LogLine + tt = logViewerTester(&none, &LogViewerState{}, false) + if !tt.HasText("No log lines") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestLogViewerSnapshot(t *testing.T) { + snapshot(t, "log_viewer", 640, 230, func(c *ui.Context) { + s := ui.Local(c.Root(), "snap", func() *LogViewerState { return &LogViewerState{Query: "seal"} }) + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + lines := []LogLine{ + {at, LogDebug, "loading ledger index"}, + {at.Add(12 * time.Millisecond), LogInfo, "court opened by the chancellor"}, + {at.Add(40 * time.Millisecond), LogWarn, "seal of decree 41 is faded"}, + {at.Add(91 * time.Millisecond), LogError, "seal of decree 42 missing"}, + } + ui.Column(c).Padding(12).Children(func() { + LogViewer(c, lines, *s, LogViewerOptions{}).Element.Height(206) + }) + }) +} diff --git a/margin_indicator.go b/margin_indicator.go new file mode 100644 index 0000000..4c59696 --- /dev/null +++ b/margin_indicator.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// MarginIndicatorOptions configure a MarginIndicator. Zero lines mean 80% and 100%. +type MarginIndicatorOptions struct { + // Warning and Liquidation are margin ratios, Warning below Liquidation. + Warning, Liquidation float64 + // Label names the indicator; empty uses the built-in "Margin ratio". + Label string +} + +// MarginIndicator shows maintenance over equity against its two lines. +func MarginIndicator(c *ui.Context, ratio float64, opts MarginIndicatorOptions) *ui.Element { + warn, liq := opts.Warning, opts.Liquidation + if warn == 0 && liq == 0 { + warn, liq = 0.8, 1 + } + if !(warn > 0 && warn < liq) || ratio < 0 || math.IsNaN(ratio) { + panic(fmt.Sprintf("mujica: MarginIndicator ratio %v, lines %v and %v: need 0 < Warning < Liquidation", ratio, warn, liq)) + } + k := Tokens(c) + u := Density(c).Unit() + label := opts.Label + if label == "" { + label = msg(c, "margin_indicator.label") + } + status, col, icon := msg(c, "margin_indicator.safe"), k.Success, "circle-check" + switch { + case ratio >= liq: + status, col, icon = msg(c, "margin_indicator.liquidation"), k.Danger, "circle-x" + case ratio >= warn: + status, col, icon = msg(c, "margin_indicator.warning"), k.Warning, "triangle-alert" + } + pct := func(v float64) string { return statisticFormat(v*100, 1) + "%" } + top := math.Max(liq*1.25, ratio) + e := ui.Column(c).Gap(u * 2).MinWidth(220).Role(ui.RoleMeter).Label(label). + Description(fmt.Sprintf(msg(c, "margin_indicator.spoken"), pct(ratio), status, pct(warn), pct(liq))) + e.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, pct(ratio)).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").FontWeight(600).SingleLine() + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, status).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine() + }) + }) + ui.Box(c).Height(14).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) { + bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: 8} + p.Fill(bar, k.SurfaceHover, theme.ControlRadius) + p.Fill(ui.Rect{X: bar.X, Y: bar.Y, W: bar.W * float32(ratio/top), H: bar.H}, col, theme.ControlRadius) + // The liquidation line is drawn heavier than the warning line. + for i, line := range []float64{warn, liq} { + x := bar.X + bar.W*float32(line/top) + p.Line(x, r.Y, x, r.Y+r.H, 1.5+float32(i)*1.5, k.Text) + } + }) + ui.Row(c).Gap(u * 4).Wrap().Children(func() { + ui.Text(c, msg(c, "margin_indicator.warning_line")+" "+pct(warn)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Text(c, msg(c, "margin_indicator.liquidation_line")+" "+pct(liq)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + }) + return e +} diff --git a/margin_indicator_test.go b/margin_indicator_test.go new file mode 100644 index 0000000..0eefd39 --- /dev/null +++ b/margin_indicator_test.go @@ -0,0 +1,39 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMarginIndicatorStates(t *testing.T) { + ratio := 0.42 + tt := tradingTester(GreenUp, 400, 160, func(c *ui.Context) { + ui.Box(c).Width(300).Children(func() { MarginIndicator(c, ratio, MarginIndicatorOptions{}) }) + }) + for _, tc := range []struct { + ratio float64 + want string + }{{0.42, "Safe"}, {0.8, "Margin call"}, {1.2, "Liquidation"}} { + ratio = tc.ratio + tt.Frame() + if !tt.HasText(tc.want) { + t.Fatalf("%v: %q", tc.ratio, tt.Texts()) + } + } + if !tt.HasText("Warning line 80.0%") || !tt.HasText("Liquidation line 100.0%") || !tt.HasText("120.0%") { + t.Fatalf("lines: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("warning above liquidation accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { MarginIndicator(c, 1, MarginIndicatorOptions{Warning: 2, Liquidation: 1}) }) +} + +func TestMarginIndicatorSnapshot(t *testing.T) { + snapshot(t, "margin_indicator", 340, 120, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { MarginIndicator(c, 0.86, MarginIndicatorOptions{}) }) + }) +} diff --git a/markdown_view.go b/markdown_view.go new file mode 100644 index 0000000..d06a6e7 --- /dev/null +++ b/markdown_view.go @@ -0,0 +1,126 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// MarkdownResult is a MarkdownView's element and the link the user opened this frame. +type MarkdownResult struct { + Element *ui.Element + link string +} + +// Link returns the URL of the link clicked or activated this frame. +func (r MarkdownResult) Link() (string, bool) { return r.link, r.link != "" } + +// MarkdownView renders a Markdown subset; a growing src parses only its last block again. +func MarkdownView(c *ui.Context, src string) MarkdownResult { + u := Density(c).Unit() + var r MarkdownResult + r.Element = ui.Column(c).Gap(u * 3).MinWidth(0) + doc := ui.Local(r.Element, "doc", func() *internal.Markdown { return &internal.Markdown{} }) + if from := (*doc).Update(src); from < len((*doc).Blocks) { + slog.Debug("mujica: markdown_view parse", "from", from, "blocks", len((*doc).Blocks)) + } + r.Element.Children(func() { + for i := range (*doc).Blocks { + b := &(*doc).Blocks[i] + ui.Column(c).Key(i).MinWidth(0).Children(func() { + if url := markdownBlock(c, b); url != "" { + r.link = url + } + }) + } + }) + if r.link != "" { + slog.Debug("mujica: markdown_view link") + } + return r +} + +// markdownBlock builds one block and returns a link opened in it. +func markdownBlock(c *ui.Context, b *internal.MdBlock) string { + k := Tokens(c) + u := Density(c).Unit() + switch b.Kind { + case internal.BlockHeading: + size := []float32{theme.H1Size, theme.H2Size, theme.H3Size}[min(b.Level, 3)-1] + t := ui.RichText(c).Font(theme.SerifFont).FontSize(FontSize(c, size)).FontWeight(600).Role(ui.RoleText) + return markdownSpans(c, t, b.Spans) + case internal.BlockParagraph: + return markdownSpans(c, ui.RichText(c), b.Spans) + case internal.BlockQuote: + var link string + ui.Row(c).Padding(0, 0, 0, u*3).BorderWidth(0, 0, 0, 3).BorderColor(k.Border).Children(func() { + link = markdownSpans(c, ui.RichText(c).TextColor(k.TextMuted).Grow(1), b.Spans) + }) + return link + case internal.BlockList: + var link string + ui.Column(c).Gap(u).Role(ui.RoleList).Children(func() { + for _, it := range b.Items { + ui.Row(c).Gap(u*2).AlignItems(ui.Start).Padding(0, 0, 0, float32(it.Depth)*u*5).Role(ui.RoleListItem).Children(func() { + mark := "•" + if it.Number > 0 { + mark = fmt.Sprintf("%d.", it.Number) + } + ui.Text(c, mark).TextColor(k.TextMuted).FontFeatures("tnum").MinWidth(u * 4).Shrink(0) + if l := markdownSpans(c, ui.RichText(c).Grow(1).MinWidth(0), it.Spans); l != "" { + link = l + } + }) + } + }) + return link + case internal.BlockCode: + CodeBlock(c, b.Code, CodeBlockOptions{Language: b.Lang}) + case internal.BlockTable: + align := make([]ui.Align, len(b.Align)) + for i, a := range b.Align { + align[i] = []ui.Align{ui.Start, ui.Center, ui.End}[a] + } + TableBlock(c, b.Header, b.Rows, TableBlockOptions{Align: align}) + case internal.BlockRule: + ui.Box(c).Height(u * 4).Draw(func(p *ui.Painter, r ui.Rect) { + internal.RuleDiamond(p, r, u*2, 5, k.Ornament) + }) + } + return "" +} + +// markdownSpans fills paragraph t with styled spans; links are focusable and return their URL when used. +func markdownSpans(c *ui.Context, t *ui.Element, spans []internal.MdSpan) string { + k := Tokens(c) + var link string + t.MinWidth(0).Children(func() { + for _, sp := range spans { + e := ui.Text(c, sp.Text) + if sp.Strong { + e.FontWeight(700) + } + if sp.Em { + e.Italic() + } + if sp.Strike { + e.Strikethrough() + } + if sp.Code { + e.Font(theme.MonoFont).TextBackground(k.SurfaceHover) + } + if sp.URL == "" { + continue + } + e.TextColor(k.AccentText).Underline().Focusable().Role(ui.RoleLink).Cursor(ui.CursorPointer).Tooltip(sp.URL) + if e.Clicked() && !e.IsDisabled() { + link = sp.URL + } + } + }) + return link +} diff --git a/markdown_view_test.go b/markdown_view_test.go new file mode 100644 index 0000000..9f39b2f --- /dev/null +++ b/markdown_view_test.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +const markdownViewSample = "# Decree\n\nBy order of **the Queen**, *all* guests use `the east door`.\n\n" + + "- Arrive at nine\n- Wear [court dress](https://example.com/dress)\n\n> Silence in the hall.\n\n" + + "```go\nfunc bow() {}\n```\n\n| Room | Floor |\n|---|--:|\n| Hall | 1 |\n\n---\n\nEnd." + +func TestMarkdownViewRendersSubset(t *testing.T) { + src := markdownViewSample + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Scroll(c).Fill().Children(func() { MarkdownView(c, src) }) + }, 600, 900) + for _, s := range []string{"Decree", "the Queen", "all", "the east door", "Arrive at nine", "court dress", + "Silence in the hall.", "func", "bow", "Room", "Hall", "End."} { + if !tt.HasText(s) { + t.Errorf("missing %q in %q", s, tt.Texts()) + } + } +} + +func TestMarkdownViewLinkByClickAndKeyboard(t *testing.T) { + var got []string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if url, ok := MarkdownView(c, "See [the guide](https://x.io/guide).").Link(); ok { + got = append(got, url) + } + }, 400, 200) + if err := tt.Click("the guide"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyEnter) + if len(got) != 2 || got[0] != "https://x.io/guide" || got[1] != got[0] { + t.Fatalf("links %q", got) + } +} + +func TestMarkdownViewStreamsWithoutReparsingFinishedBlocks(t *testing.T) { + src := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Scroll(c).Fill().Children(func() { MarkdownView(c, src) }) + }, 600, 900) + for _, chunk := range strings.SplitAfter(markdownViewSample, " ") { + src += chunk + tt.Frame() + } + if !tt.HasText("End.") || !tt.HasText("Decree") { + t.Fatalf("streamed render incomplete: %q", tt.Texts()) + } +} + +func TestMarkdownViewSnapshot(t *testing.T) { + snapshot(t, "markdown_view", 560, 720, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { MarkdownView(c, markdownViewSample) }) + }) +} diff --git a/mcp_server_list.go b/mcp_server_list.go new file mode 100644 index 0000000..649fdaa --- /dev/null +++ b/mcp_server_list.go @@ -0,0 +1,144 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// MCPState is a server connection; zero is stopped. +type MCPState int + +const ( + MCPStopped MCPState = iota + MCPConnecting + MCPConnected + MCPError +) + +// MCPServer is one configured MCP server; the caller owns it. +type MCPServer struct { + ID string + Name string + Transport string + State MCPState + Tools int + // Error explains MCPError. + Error string +} + +// MCPAction is a list request; zero is none. +type MCPAction int + +const ( + MCPNone MCPAction = iota + MCPStart + MCPStop + MCPReconnect +) + +// MCPServerListOptions configure an MCPServerList. +type MCPServerListOptions struct { + // MaxHeight caps the scrolling list; zero means 360. + MaxHeight float32 + Disabled bool + Still bool +} + +// MCPServerListResult reports this frame's request. +type MCPServerListResult struct { + Element *ui.Element + Action MCPAction + // Server is the ID the action is for. + Server string +} + +// MCPServerList lists MCP servers with start, stop and reconnect. +func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions) MCPServerListResult { + seen := map[string]bool{} + for i, s := range servers { + if s.ID == "" || s.Name == "" || seen[s.ID] || s.Tools < 0 || s.State < MCPStopped || s.State > MCPError { + panic(fmt.Sprintf("mujica: MCPServerList server %d needs a unique ID, a Name, a known State and Tools >= 0", i)) + } + seen[s.ID] = true + } + if opts.MaxHeight < 0 { + panic("mujica: MCPServerList MaxHeight is negative") + } + maxH := opts.MaxHeight + if maxH == 0 { + maxH = 360 + } + var r MCPServerListResult + r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "mcp_server_list.label")) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + if len(servers) == 0 { + agentCaption(c, msg(c, "mcp_server_list.empty")) + return + } + k := Tokens(c) + agentRows(c, len(servers), func(i int) { + if a := mcpServerRow(c, servers[i], locked, opts.Still); a != MCPNone && !locked { + r.Action, r.Server = a, servers[i].ID + } + }).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). + Dividers(theme.BorderWidth, k.Border) + }) + if r.Action != MCPNone { + slog.Debug("mujica: mcp_server_list action", "server", r.Server, "action", int(r.Action)) + } + return r +} + +// mcpServerRow builds one server, returning its pressed action. +func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction { + k := Tokens(c) + d := Density(c) + u := d.Unit() + words := [...]string{"stopped", "connecting", "connected", "error"} + word := msg(c, "mcp_server_list."+words[s.State]) + act := MCPNone + ui.Row(c).Key(s.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3). + Label(s.Name + ", " + word).Children(func() { + switch s.State { + case MCPConnecting: + agentMark(c, AgentRunning, 16, still) + case MCPConnected: + agentMark(c, AgentDone, 16, still) + case MCPError: + agentMark(c, AgentFailed, 16, still) + default: + ui.Icon(c, icons.Must("wifi-off")).Size(16, 16).TextColor(k.TextMuted) + } + ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, s.Name).FontWeight(600).SingleLine() + agentCaption(c, s.Transport).Font(theme.MonoFont) + }) + line := word + if s.State == MCPConnected { + line += " · " + fmt.Sprintf(msg(c, "mcp_server_list.tools"), s.Tools) + } + agentCaption(c, line) + if s.State == MCPError && s.Error != "" { + ui.Text(c, s.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Selectable() + } + }) + if s.State == MCPError && Button(c, msg(c, "mcp_server_list.reconnect"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() { + act = MCPReconnect + } + if s.State == MCPStopped { + if Button(c, msg(c, "mcp_server_list.start"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() { + act = MCPStart + } + } else if Button(c, msg(c, "mcp_server_list.stop"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() { + act = MCPStop + } + }) + return act +} diff --git a/mcp_server_list_test.go b/mcp_server_list_test.go new file mode 100644 index 0000000..a3954ee --- /dev/null +++ b/mcp_server_list_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var mcpServersSample = []MCPServer{ + {ID: "fs", Name: "filesystem", Transport: "stdio", State: MCPConnected, Tools: 12}, + {ID: "gh", Name: "github", Transport: "http", State: MCPError, Error: "401 Unauthorized"}, + {ID: "db", Name: "postgres", Transport: "stdio", State: MCPStopped}, + {ID: "web", Name: "browser", Transport: "sse", State: MCPConnecting}, +} + +func mcpTester(servers []MCPServer, opts MCPServerListOptions, got *[]MCPServerListResult) *ui.Tester { + opts.Still = true + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := MCPServerList(c, servers, opts) + if r.Action != MCPNone { + *got = append(*got, r) + } + }, 600, 460) +} + +func TestMCPServerListStatesAndActions(t *testing.T) { + var got []MCPServerListResult + tt := mcpTester(mcpServersSample, MCPServerListOptions{}, &got) + for _, s := range []string{"Connected · 12 tools", "Connection failed", "401 Unauthorized", "Stopped", "Connecting"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + _ = tt.Click("Reconnect") + _ = tt.Click("Start") + if len(got) != 2 || got[0].Action != MCPReconnect || got[0].Server != "gh" || got[1].Action != MCPStart || got[1].Server != "db" { + t.Fatalf("actions %+v", got) + } + tt = mcpTester(mcpServersSample, MCPServerListOptions{}, &got) + tt.Key(0, ui.KeyTab) // filesystem's Stop + tt.Key(0, ui.KeyEnter) + if len(got) != 3 || got[2].Action != MCPStop || got[2].Server != "fs" { + t.Fatalf("keyboard stop: %+v", got) + } +} + +func TestMCPServerListDisabledAndEmpty(t *testing.T) { + var got []MCPServerListResult + tt := mcpTester(mcpServersSample, MCPServerListOptions{Disabled: true}, &got) + _ = tt.Click("Reconnect") + _ = tt.Click("Start") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(got) != 0 { + t.Fatalf("disabled list acted: %+v", got) + } + tt = mcpTester(nil, MCPServerListOptions{}, &got) + if !tt.HasText("No MCP servers") { + t.Fatalf("empty: %q", tt.Texts()) + } +} + +func TestMCPServerListManyServersVirtualized(t *testing.T) { + servers := make([]MCPServer, 100000) + for i := range servers { + servers[i] = MCPServer{ID: fmt.Sprint(i), Name: fmt.Sprintf("server-%d", i), State: MCPConnected, Tools: i % 30} + } + var got []MCPServerListResult + tt := mcpTester(servers, MCPServerListOptions{}, &got) + if n := len(tt.Texts()); n > 300 { + t.Fatalf("%d texts built for 100000 servers", n) + } +} + +func TestMCPServerListRejectsDuplicateIDs(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + var got []MCPServerListResult + mcpTester([]MCPServer{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, MCPServerListOptions{}, &got) +} + +func TestMCPServerListSnapshot(t *testing.T) { + snapshot(t, "mcp_server_list", 560, 330, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { MCPServerList(c, mcpServersSample, MCPServerListOptions{Still: true}) }) + }) +} diff --git a/media_controls.go b/media_controls.go new file mode 100644 index 0000000..a0913d1 --- /dev/null +++ b/media_controls.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// LoopMode is how playback repeats. The zero value repeats nothing. +type LoopMode int + +const ( + LoopOff LoopMode = iota + LoopAll + LoopOne +) + +// next cycles off, all, one. +func (m LoopMode) next() LoopMode { return (m + 1) % 3 } + +// MediaControlsState is what the caller's playback reports. +type MediaControlsState struct { + Playing bool + Loop LoopMode + Shuffle bool + // NoPrevious and NoNext disable those buttons at the ends. + NoPrevious, NoNext bool +} + +// MediaControlsOptions are the callbacks; every one is required. +type MediaControlsOptions struct { + OnPrevious, OnPlay, OnPause, OnNext func() + OnLoop func(LoopMode) + OnShuffle func(bool) + Disabled bool +} + +// MediaControls are previous, play, next, loop and shuffle buttons. +func MediaControls(c *ui.Context, st MediaControlsState, opts MediaControlsOptions) *ui.Element { + if opts.OnPrevious == nil || opts.OnPlay == nil || opts.OnPause == nil || opts.OnNext == nil || opts.OnLoop == nil || opts.OnShuffle == nil { + panic("mujica: MediaControls needs every callback") + } + if st.Loop < LoopOff || st.Loop > LoopOne { + panic(fmt.Sprintf("mujica: unknown LoopMode %d", st.Loop)) + } + row := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar). + Label(msg(c, "media_controls.label")).Disabled(opts.Disabled) + row.Children(func() { + shuffle := mediaToggleAction(c, mediaShuffle, msg(c, "media_controls.shuffle"), st.Shuffle) + if shuffle.Clicked() && !shuffle.IsDisabled() { + slog.Debug("mujica: media_controls shuffle", "on", !st.Shuffle) + opts.OnShuffle(!st.Shuffle) + } + prev := iconAction(c, mediaPrev, msg(c, "media_controls.previous")).Disabled(st.NoPrevious) + if prev.Clicked() && !prev.IsDisabled() { + slog.Debug("mujica: media_controls previous") + opts.OnPrevious() + } + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + next := iconAction(c, mediaNext, msg(c, "media_controls.next")).Disabled(st.NoNext) + if next.Clicked() && !next.IsDisabled() { + slog.Debug("mujica: media_controls next") + opts.OnNext() + } + icon := mediaRepeat + if st.Loop == LoopOne { + icon = mediaRepeatOne + } + loop := mediaToggleAction(c, icon, msg(c, []string{"media_controls.loopOff", "media_controls.loopAll", "media_controls.loopOne"}[st.Loop]), st.Loop != LoopOff) + if loop.Clicked() && !loop.IsDisabled() { + slog.Debug("mujica: media_controls loop", "mode", st.Loop.next()) + opts.OnLoop(st.Loop.next()) + } + }) + return row +} diff --git a/media_controls_test.go b/media_controls_test.go new file mode 100644 index 0000000..9b2e220 --- /dev/null +++ b/media_controls_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type controlsCalls struct { + prev, play, pause, next int + loops []LoopMode + shuffles []bool +} + +func (m *controlsCalls) opts() MediaControlsOptions { + return MediaControlsOptions{OnPrevious: func() { m.prev++ }, OnPlay: func() { m.play++ }, OnPause: func() { m.pause++ }, + OnNext: func() { m.next++ }, OnLoop: func(l LoopMode) { m.loops = append(m.loops, l) }, + OnShuffle: func(on bool) { m.shuffles = append(m.shuffles, on) }} +} + +func controlsTester(st *MediaControlsState, o MediaControlsOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { MediaControls(c, *st, o) }) + }, 400, 120) +} + +func TestMediaControlsClicks(t *testing.T) { + st := MediaControlsState{Loop: LoopAll} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + for _, name := range []string{"Previous", "Play", "Next", "Repeat all", "Shuffle"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + if m.prev != 1 || m.play != 1 || m.next != 1 || len(m.loops) != 1 || m.loops[0] != LoopOne || len(m.shuffles) != 1 || !m.shuffles[0] { + t.Fatalf("calls %+v", m) + } +} + +func TestMediaControlsArrowsMoveFocus(t *testing.T) { + st := MediaControlsState{} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Shuffle") { + t.Fatal("Tab did not enter at Shuffle") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Play") { + t.Fatal("arrows did not reach Play") + } + tt.Key(0, ui.KeyEnter) + if m.play != 1 { + t.Fatalf("Enter: play %d", m.play) + } +} + +func TestMediaControlsEndsAndDisabled(t *testing.T) { + st := MediaControlsState{NoPrevious: true, NoNext: true} + var m controlsCalls + tt := controlsTester(&st, m.opts()) + _ = tt.Click("Previous") + _ = tt.Click("Next") + o := m.opts() + o.Disabled = true + off := controlsTester(&MediaControlsState{}, o) + _ = off.Click("Play") + _ = off.Click("Shuffle") + if m.prev != 0 || m.next != 0 || m.play != 0 || len(m.shuffles) != 0 { + t.Fatalf("blocked buttons reported %+v", m) + } +} + +func TestMediaControlsBadOptions(t *testing.T) { + mediaPanics(t, "no callbacks", func(c *ui.Context) { MediaControls(c, MediaControlsState{}, MediaControlsOptions{}) }) + var m controlsCalls + mediaPanics(t, "bad loop", func(c *ui.Context) { MediaControls(c, MediaControlsState{Loop: 7}, m.opts()) }) +} + +func TestMediaControlsSnapshot(t *testing.T) { + var m controlsCalls + snapshot(t, "media_controls", 300, 120, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + MediaControls(c, MediaControlsState{Playing: true, Loop: LoopOne, Shuffle: true}, m.opts()) + MediaControls(c, MediaControlsState{NoPrevious: true}, m.opts()) + }) + }) +} diff --git a/media_helpers_test.go b/media_helpers_test.go new file mode 100644 index 0000000..b6e7fc4 --- /dev/null +++ b/media_helpers_test.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "image" + "image/color" + "testing" + + "github.com/egoist/mygo/ui" +) + +// mediaTestImage is a w×h picture whose left half is wine and right half ivory. +func mediaTestImage(w, h int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + c := color.RGBA{125, 32, 52, 255} + if x >= w/2 { + c = color.RGBA{242, 235, 221, 255} + } + if y < h/8 { + c = color.RGBA{173, 145, 100, 255} + } + img.Set(x, y, c) + } + } + return img +} + +func mediaTestBitmap(w, h int) *ui.Bitmap { return ui.NewBitmap(mediaTestImage(w, h)) } + +// mediaPanics fails t unless fn panics. +func mediaPanics(t *testing.T, name string, fn func(c *ui.Context)) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + fn(c) + }, 400, 300) + tt.Frame() +} diff --git a/media_icons.go b/media_icons.go new file mode 100644 index 0000000..ed5de5f --- /dev/null +++ b/media_icons.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" +) + +// mediaSVG wraps stroked 24×24 path data as an icon. +func mediaSVG(body string) *ui.SVG { + return ui.MustParseSVG([]byte(`` + body + ``)) +} + +// Media glyphs drawn for this group. +var ( + mediaPlay = mediaSVG(``) + mediaPause = mediaSVG(``) + mediaPrev = mediaSVG(``) + mediaNext = mediaSVG(``) + mediaRepeat = mediaSVG(``) + mediaRepeatOne = mediaSVG(``) + mediaShuffle = mediaSVG(``) + mediaVolume = mediaSVG(``) + mediaMute = mediaSVG(``) + mediaRotate = mediaSVG(``) + mediaRotateLeft = mediaSVG(``) + mediaMirror = mediaSVG(``) + mediaPiP = mediaSVG(``) + mediaFullscreen = mediaSVG(``) + mediaCaptions = mediaSVG(``) + mediaRecord = mediaSVG(``) + mediaStop = mediaSVG(``) + mediaMic = mediaSVG(``) + mediaCamera = mediaSVG(``) + mediaSpeaker = mediaSVG(``) + mediaRect = mediaSVG(``) + mediaArrow = mediaSVG(``) + mediaText = mediaSVG(``) + mediaPointer = mediaSVG(``) +) + +// mediaClock writes d as m:ss, or h:mm:ss past an hour. +func mediaClock(d time.Duration) string { + if d < 0 { + d = 0 + } + s := int(d / time.Second) + if s >= 3600 { + return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60) + } + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/media_palette_test.go b/media_palette_test.go new file mode 100644 index 0000000..3deed2a --- /dev/null +++ b/media_palette_test.go @@ -0,0 +1,24 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TestMediaChartInksContrast keeps every chart ink at 3:1 against Surface in both themes. +func TestMediaChartInksContrast(t *testing.T) { + for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} { + inks := map[string]ui.Color{ + "played (AccentText)": k.AccentText, "unplayed (TextMuted)": k.TextMuted, "cursor (Text)": k.Text, + "mic safe": micZone(k, 0), "mic loud": micZone(k, micSegments*7/10), "mic clip": micZone(k, micSegments-1), + } + for name, c := range inks { + if r := theme.Contrast(c, k.Surface); r < 3 { + t.Errorf("dark=%v %s: %.2f:1 against Surface", k.Dark, name, r) + } + } + } +} diff --git a/memory_panel.go b/memory_panel.go new file mode 100644 index 0000000..fd275f5 --- /dev/null +++ b/memory_panel.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MemoryEntry is one memory; the caller persists it. +type MemoryEntry struct { + ID string + Text string + // Source says where it came from, as "Chat, 3 Oct". + Source string +} + +// MemoryPanelOptions configure a MemoryPanel. +type MemoryPanelOptions struct { + // MaxHeight caps the scrolling list; zero means 360. + MaxHeight float32 + Disabled bool +} + +// MemoryPanelResult reports this frame's in-place edits. +type MemoryPanelResult struct { + Element *ui.Element + // Edited and Deleted are changed IDs, else "". + Edited string + Deleted string +} + +type memoryPanelState struct { + query, editing, draft string + confirm string + // The filter reruns only when query or entries change. + key string + shown []int +} + +// MemoryPanel lists memories with search, inline edit and confirmed delete. +func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) MemoryPanelResult { + if entries == nil { + panic("mujica: MemoryPanel needs entries") + } + if opts.MaxHeight < 0 { + panic("mujica: MemoryPanel MaxHeight is negative") + } + maxH := opts.MaxHeight + if maxH == 0 { + maxH = 360 + } + u := Density(c).Unit() + var r MemoryPanelResult + r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "memory_panel.title")) + st := ui.Local(r.Element, "mujica.memory", func() memoryPanelState { return memoryPanelState{} }) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1) + agentCaption(c, fmt.Sprintf(msg(c, "memory_panel.count"), len(*entries))) + }) + SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "memory_panel.search"), Disabled: locked}) + memoryFilter(st, *entries) + if len(st.shown) == 0 { + agentCaption(c, msg(c, "memory_panel.empty")) + return + } + agentRows(c, len(st.shown), func(i int) { + idx := st.shown[i] + ui.Column(c).Key((*entries)[idx].ID).Children(func() { memoryRow(c, entries, idx, st, locked, &r) }) + }).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, Tokens(c).Border) + }) + if r.Deleted != "" { + for i, e := range *entries { + if e.ID == r.Deleted { + *entries = append((*entries)[:i:i], (*entries)[i+1:]...) + break + } + } + st.key = "" // refilter + } + if r.Edited != "" || r.Deleted != "" { + slog.Debug("mujica: memory_panel change", "edited", r.Edited, "deleted", r.Deleted) + } + return r +} + +// memoryFilter recomputes matches when inputs change. +func memoryFilter(st *memoryPanelState, entries []MemoryEntry) { + key := fmt.Sprintf("%s\x00%d", st.query, len(entries)) + if len(entries) > 0 { + key += fmt.Sprintf("\x00%p", &entries[0]) + } + if key == st.key && st.shown != nil { + return + } + st.key = key + q := strings.ToLower(strings.TrimSpace(st.query)) + st.shown = st.shown[:0] + for i, e := range entries { + if q == "" || strings.Contains(strings.ToLower(e.Text), q) || strings.Contains(strings.ToLower(e.Source), q) { + st.shown = append(st.shown, i) + } + } + if st.shown == nil { + st.shown = []int{} + } +} + +// memoryRow shows one entry or its editor. +func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelState, locked bool, r *MemoryPanelResult) { + e := &(*entries)[idx] + if e.ID == "" { + panic(fmt.Sprintf("mujica: MemoryPanel entry %d needs an ID", idx)) + } + u := Density(c).Unit() + ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() { + if st.editing == e.ID && !locked { + TextArea(c, &st.draft, TextAreaOptions{Label: msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus() + ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + if Button(c, msg(c, "memory_panel.cancel"), ButtonOptions{Variant: Secondary}).Clicked() { + st.editing = "" + } + blank := strings.TrimSpace(st.draft) == "" + if Button(c, msg(c, "memory_panel.save"), ButtonOptions{Disabled: blank}).Clicked() && !blank { + e.Text, st.editing, st.key = strings.TrimSpace(st.draft), "", "" + r.Edited = e.ID + } + }) + return + } + ui.Text(c, e.Text).Selectable() + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + agentCaption(c, e.Source).Grow(1) + if agentToggle(c, msg(c, "memory_panel.edit"), "", locked) { + st.editing, st.draft = e.ID, e.Text + } + del := ui.Row(c) + del.Children(func() { + if agentToggle(c, msg(c, "memory_panel.delete"), "trash-2", locked) { + st.confirm = e.ID + } + }) + open := st.confirm == e.ID && !locked + res := Popconfirm(c, del, &open, PopconfirmOptions{Title: msg(c, "memory_panel.confirm"), Description: e.Text, + ConfirmLabel: msg(c, "memory_panel.delete"), Danger: true}) + if st.confirm == e.ID && !open { + st.confirm = "" + } + if res.Confirmed { + r.Deleted = e.ID + } + }) + }) +} diff --git a/memory_panel_test.go b/memory_panel_test.go new file mode 100644 index 0000000..3d2db38 --- /dev/null +++ b/memory_panel_test.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func memorySample() []MemoryEntry { + return []MemoryEntry{ + {ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat, 3 Oct"}, + {ID: "2", Text: "The quartet needs two days' notice.", Source: "Plan, 1 Oct"}, + {ID: "3", Text: "Claret is served after nine.", Source: "Ledger"}, + } +} + +func memoryTester(entries *[]MemoryEntry, opts MemoryPanelOptions, got *[]MemoryPanelResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := MemoryPanel(c, entries, opts) + if r.Edited != "" || r.Deleted != "" { + *got = append(*got, r) + } + }, 560, 560) +} + +func TestMemoryPanelSearchEditDelete(t *testing.T) { + entries := memorySample() + var got []MemoryPanelResult + tt := memoryTester(&entries, MemoryPanelOptions{}, &got) + if !tt.HasText("3 entries") || !tt.HasText("Claret is served after nine.") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Search memory") + tt.Type("quartet") + if tt.HasText("Claret is served after nine.") || !tt.HasText("The quartet needs two days' notice.") { + t.Fatalf("search: %q", tt.Texts()) + } + _ = tt.Click("Edit") + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("The quartet needs a week's notice.") + _ = tt.Click("Save") + if entries[1].Text != "The quartet needs a week's notice." || len(got) != 1 || got[0].Edited != "2" { + t.Fatalf("edit: %+v %+v", entries[1], got) + } + _ = tt.Click("Delete") + if !tt.HasText("Forget this memory?") || len(entries) != 3 { + t.Fatal("delete did not ask first") + } + _ = tt.Click("Cancel") + if len(entries) != 3 { + t.Fatal("cancel deleted") + } + _ = tt.Click("Delete") + tt.Key(0, ui.KeyTab) // Cancel has the focus; Tab to Delete + tt.Key(0, ui.KeyEnter) + if len(entries) != 2 || got[1].Deleted != "2" || !tt.HasText("No memories match") { + t.Fatalf("delete: %+v %+v %q", entries, got, tt.Texts()) + } +} + +func TestMemoryPanelDisabled(t *testing.T) { + entries := memorySample() + var got []MemoryPanelResult + tt := memoryTester(&entries, MemoryPanelOptions{Disabled: true}, &got) + _ = tt.Click("Edit") + _ = tt.Click("Delete") + if tt.HasText("Save") || tt.HasText("Forget this memory?") || len(got) != 0 { + t.Fatal("disabled panel acted") + } +} + +func TestMemoryPanelManyEntriesVirtualized(t *testing.T) { + entries := make([]MemoryEntry, 100000) + for i := range entries { + entries[i] = MemoryEntry{ID: fmt.Sprint(i), Text: fmt.Sprintf("fact %d", i), Source: "import"} + } + var got []MemoryPanelResult + tt := memoryTester(&entries, MemoryPanelOptions{}, &got) + if n := len(tt.Texts()); n > 300 { + t.Fatalf("%d texts built for 100000 entries", n) + } + _ = tt.Click("Search memory") + tt.Type("fact 99999") + if !tt.HasText("fact 99999") || tt.HasText("fact 0") { + t.Fatal("search over many entries failed") + } +} + +func TestMemoryPanelSnapshot(t *testing.T) { + snapshot(t, "memory_panel", 520, 360, func(c *ui.Context) { + entries := memorySample() + ui.Column(c).Padding(16).Children(func() { MemoryPanel(c, &entries, MemoryPanelOptions{}) }) + }) +} diff --git a/message_actions.go b/message_actions.go new file mode 100644 index 0000000..ba28375 --- /dev/null +++ b/message_actions.go @@ -0,0 +1,111 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// MessageAction is one action on a chat message. +type MessageAction int + +const ( + ActionCopy MessageAction = iota + 1 + ActionRegenerate + ActionEdit + ActionLike + ActionDislike + ActionSpeak + ActionShare +) + +// MessageFeedback is the user's rating of an assistant message. +type MessageFeedback int + +const ( + FeedbackNone MessageFeedback = iota + FeedbackLiked + FeedbackDisliked +) + +// MessageActionsOptions configure MessageActions. +type MessageActionsOptions struct { + // Feedback holds the rating; nil hides like and dislike. + Feedback *MessageFeedback +} + +// MessageActionsResult is the toolbar's element and the action chosen this frame. +type MessageActionsResult struct { + Element *ui.Element + action MessageAction +} + +// Action returns the action the user chose this frame. +func (r MessageActionsResult) Action() (MessageAction, bool) { return r.action, r.action != 0 } + +// messageActionIcons names each action's icon and copy key. +var messageActionIcons = map[MessageAction][2]string{ + ActionCopy: {"copy", "message_actions.copy"}, + ActionRegenerate: {"refresh-cw", "message_actions.regenerate"}, + ActionEdit: {"pencil", "message_actions.edit"}, + ActionLike: {"thumbs-up", "message_actions.like"}, + ActionDislike: {"thumbs-down", "message_actions.dislike"}, + ActionSpeak: {"volume-2", "message_actions.speak"}, + ActionShare: {"share-2", "message_actions.share"}, +} + +// messageActionsFor lists the actions a role's messages offer. +func messageActionsFor(role MessageRole, rated bool) []MessageAction { + switch role { + case MessageUser: + return []MessageAction{ActionCopy, ActionEdit} + case MessageAssistant: + if rated { + return []MessageAction{ActionCopy, ActionRegenerate, ActionLike, ActionDislike, ActionSpeak, ActionShare} + } + return []MessageAction{ActionCopy, ActionRegenerate, ActionSpeak, ActionShare} + case MessageSystem: + return []MessageAction{ActionCopy} + case MessageError: + return []MessageAction{ActionCopy, ActionRegenerate} + } + panic("mujica: unknown MessageRole") +} + +// MessageActions is a toolbar of the actions a role's message offers; arrows move between them. +func MessageActions(c *ui.Context, role MessageRole, opts MessageActionsOptions) MessageActionsResult { + var r MessageActionsResult + r.Element = ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).FocusGroup(ui.Horizontal). + Role(ui.RoleToolbar).Label(msg(c, "message_actions.label")) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + for _, a := range messageActionsFor(role, opts.Feedback != nil) { + ic := messageActionIcons[a] + hit := false + if a == ActionLike || a == ActionDislike { + want := FeedbackLiked + if a == ActionDislike { + want = FeedbackDisliked + } + on := *opts.Feedback == want + if codeBlockToggle(c, ic[0], msg(c, ic[1]), &on) && !disabled { + hit = true + if on { + *opts.Feedback = want + } else { + *opts.Feedback = FeedbackNone + } + } + } else { + hit = iconAction(c, icons.Must(ic[0]), msg(c, ic[1])).Tooltip(msg(c, ic[1])).Clicked() && !disabled + } + if hit { + r.action = a + slog.Debug("mujica: message_actions action", "action", a) + } + } + }) + return r +} diff --git a/message_actions_test.go b/message_actions_test.go new file mode 100644 index 0000000..6c41739 --- /dev/null +++ b/message_actions_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMessageActionsByRole(t *testing.T) { + for role, want := range map[MessageRole][]string{ + MessageUser: {"Copy", "Edit"}, + MessageAssistant: {"Copy", "Regenerate", "Good response", "Bad response", "Read aloud", "Share"}, + MessageSystem: {"Copy"}, + MessageError: {"Copy", "Regenerate"}, + } { + fb := FeedbackNone + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MessageActions(c, role, MessageActionsOptions{Feedback: &fb}) + }, 400, 100) + for _, name := range want { + if _, ok := tt.Find(name); !ok { + t.Errorf("role %v: missing %q", role, name) + } + } + if _, ok := tt.Find("Edit"); ok != (role == MessageUser) { + t.Errorf("role %v: edit shown %v", role, ok) + } + } +} + +func TestMessageActionsChooseAndRate(t *testing.T) { + fb := FeedbackNone + var got []MessageAction + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if a, ok := MessageActions(c, MessageAssistant, MessageActionsOptions{Feedback: &fb}).Action(); ok { + got = append(got, a) + } + }, 400, 100) + _ = tt.Click("Regenerate") + _ = tt.Click("Good response") + if fb != FeedbackLiked { + t.Fatalf("feedback %v after like", fb) + } + _ = tt.Click("Bad response") + if fb != FeedbackDisliked { + t.Fatalf("feedback %v after dislike", fb) + } + _ = tt.Click("Bad response") + if fb != FeedbackNone { + t.Fatalf("feedback %v after undoing dislike", fb) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + // Tab returns to the last focused action, Dislike; Right moves to Read aloud. + want := []MessageAction{ActionRegenerate, ActionLike, ActionDislike, ActionDislike, ActionSpeak} + if len(got) != len(want) { + t.Fatalf("actions %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("actions %v, want %v", got, want) + } + } +} + +func TestMessageActionsHideRatingAndDisable(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + if _, ok := MessageActions(c, MessageAssistant, MessageActionsOptions{}).Action(); ok { + n++ + } + }) + }, 400, 100) + if _, ok := tt.Find("Good response"); ok { + t.Fatal("rating shown without Feedback") + } + _ = tt.Click("Copy") + if n != 0 { + t.Fatal("disabled actions fired") + } +} + +func TestMessageActionsSnapshot(t *testing.T) { + fb := FeedbackLiked + snapshot(t, "message_actions", 300, 70, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { MessageActions(c, MessageAssistant, MessageActionsOptions{Feedback: &fb}) }) + }) +} diff --git a/message_bubble.go b/message_bubble.go new file mode 100644 index 0000000..a4377ae --- /dev/null +++ b/message_bubble.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// MessageRole is who a chat message comes from. +type MessageRole int + +const ( + MessageUser MessageRole = iota + MessageAssistant + MessageSystem + MessageError +) + +func (r MessageRole) key() string { + switch r { + case MessageUser: + return "message_bubble.user" + case MessageAssistant: + return "message_bubble.assistant" + case MessageSystem: + return "message_bubble.system" + case MessageError: + return "message_bubble.error" + } + panic("mujica: unknown MessageRole") +} + +// MessageBubbleOptions configure a MessageBubble. +type MessageBubbleOptions struct { + // Name shows above the bubble; empty shows none. + Name string + // Avatar builds the picture beside the bubble. + Avatar func() + // MaxWidth caps the bubble in DIPs; zero means 560. + MaxWidth float32 +} + +// MessageBubble frames one chat message by role: users right, others left, system centered. +func MessageBubble(c *ui.Context, role MessageRole, opts MessageBubbleOptions, content func()) *ui.Element { + if opts.MaxWidth < 0 { + panic("mujica: MessageBubble MaxWidth must not be negative") + } + if content == nil { + panic("mujica: MessageBubble needs content") + } + maxW := opts.MaxWidth + if maxW == 0 { + maxW = 560 + } + k := Tokens(c) + u := Density(c).Unit() + label := msg(c, role.key()) + if opts.Name != "" { + label += ", " + opts.Name + } + row := ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Role(ui.RoleGroup).Label(label) + switch role { + case MessageUser: + row.Justify(ui.End) + case MessageSystem: + row.Justify(ui.Center) + } + row.Children(func() { + if role == MessageSystem { + ui.Row(c).MaxWidth(maxW).Padding(u, u*3).Gap(u * 2).Radius(theme.ControlRadius). + Background(k.SurfaceHover).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)). + Children(content) + return + } + if opts.Avatar != nil && role != MessageUser { + opts.Avatar() + } + ui.Column(c).Gap(u).MaxWidth(maxW).Shrink(1).MinWidth(0).AlignItems(messageBubbleAlign(role)).Children(func() { + if opts.Name != "" { + ui.Text(c, opts.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + b := ui.Column(c).Padding(u*2, u*3).Gap(u * 2).Radius(theme.CardRadius).MinWidth(0) + switch role { + case MessageUser: + b.Background(k.Selection) + case MessageError: + b.Background(k.Surface).Border(theme.BorderWidth, k.Danger) + default: + b.Background(k.Surface).Border(theme.BorderWidth, k.Border) + } + b.Children(func() { + if role != MessageError { + content() + return + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0).TextColor(k.Danger) + ui.Column(c).Gap(u * 2).Grow(1).MinWidth(0).Children(content) + }) + }) + }) + if opts.Avatar != nil && role == MessageUser { + opts.Avatar() + } + }) + return row +} + +func messageBubbleAlign(r MessageRole) ui.Align { + if r == MessageUser { + return ui.End + } + return ui.Start +} diff --git a/message_bubble_test.go b/message_bubble_test.go new file mode 100644 index 0000000..df5676c --- /dev/null +++ b/message_bubble_test.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMessageBubbleRolesAlignAndLimit(t *testing.T) { + var user, bot, sys ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(600).Children(func() { + MessageBubble(c, MessageUser, MessageBubbleOptions{Name: "Ada", MaxWidth: 200}, func() { + user = ui.Text(c, "A long user message that must wrap inside the bubble's width limit.").Bounds() + }) + MessageBubble(c, MessageAssistant, MessageBubbleOptions{Avatar: func() { Avatar(c, "Bot", AvatarOptions{}) }}, func() { + bot = ui.Text(c, "Hello").Bounds() + }) + MessageBubble(c, MessageSystem, MessageBubbleOptions{}, func() { sys = ui.Text(c, "Joined").Bounds() }) + MessageBubble(c, MessageError, MessageBubbleOptions{}, func() { ui.Text(c, "Timeout") }) + }) + }, 600, 400) + tt.Frame() + if user.W > 200 || user.X < 300 { + t.Errorf("user bubble %+v not right-aligned within 200", user) + } + if bot.X > 100 || sys.X < 200 || sys.X > 300 { + t.Errorf("assistant at %v, system at %v", bot.X, sys.X) + } + for _, s := range []string{"Ada", "Bot", "Error"} { + if !tt.HasText(s) { + t.Errorf("missing %q", s) + } + } +} + +func TestMessageBubbleBadOptions(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a negative MaxWidth") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MessageBubble(c, MessageUser, MessageBubbleOptions{MaxWidth: -1}, func() {}) + }, 100, 100) +} + +func TestMessageBubbleSnapshot(t *testing.T) { + snapshot(t, "message_bubble", 520, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + MessageBubble(c, MessageUser, MessageBubbleOptions{Name: "Marie"}, func() { ui.Text(c, "Is the ball tonight?") }) + MessageBubble(c, MessageAssistant, MessageBubbleOptions{Name: "Herald", Avatar: func() { Avatar(c, "Herald", AvatarOptions{}) }}, + func() { ui.Text(c, "Yes, at nine, in the Hall of Mirrors.") }) + MessageBubble(c, MessageSystem, MessageBubbleOptions{}, func() { ui.Text(c, "Herald joined the chat") }) + MessageBubble(c, MessageError, MessageBubbleOptions{}, func() { ui.Text(c, "The messenger did not return.") }) + }) + }) +} diff --git a/message_editor.go b/message_editor.go new file mode 100644 index 0000000..06439b5 --- /dev/null +++ b/message_editor.go @@ -0,0 +1,57 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" +) + +// MessageEditorOptions configure a MessageEditor. +type MessageEditorOptions struct { + // Original is the sent text; saving it unchanged is not allowed. + Original string +} + +// MessageEditorResult is the editor's element and its outcome this frame. +type MessageEditorResult struct { + Element *ui.Element + saved, canceled bool +} + +// Saved reports a save; the caller makes a new branch from the draft. +func (r MessageEditorResult) Saved() bool { return r.saved } + +// Canceled reports Cancel or Escape. +func (r MessageEditorResult) Canceled() bool { return r.canceled } + +// MessageEditor edits a sent message in *draft; Cmd+Enter saves, Escape cancels. +func MessageEditor(c *ui.Context, draft *string, opts MessageEditorOptions) MessageEditorResult { + if draft == nil { + panic("mujica: MessageEditor needs a draft") + } + u := Density(c).Unit() + var r MessageEditorResult + r.Element = ui.Column(c).Gap(u * 2).MinWidth(0) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + area := TextArea(c, draft, TextAreaOptions{Label: msg(c, "message_editor.label"), MaxHeight: 240}) + ok := strings.TrimSpace(*draft) != "" && *draft != opts.Original + escape := area.Input.Shortcut(0, ui.KeyEscape) + ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + if Button(c, msg(c, "message_editor.cancel"), ButtonOptions{Variant: Secondary}).Clicked() || escape { + r.canceled = !disabled + } + if Button(c, msg(c, "message_editor.save"), ButtonOptions{Disabled: !ok}).Clicked() || area.Submitted() && ok { + r.saved = !disabled + } + }) + }) + if r.saved { + slog.Debug("mujica: message_editor save", "chars", len(*draft)) + } + if r.canceled { + slog.Debug("mujica: message_editor cancel") + } + return r +} diff --git a/message_editor_test.go b/message_editor_test.go new file mode 100644 index 0000000..1a75412 --- /dev/null +++ b/message_editor_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func messageEditorTester(draft *string, saves, cancels *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := MessageEditor(c, draft, MessageEditorOptions{Original: "Hello"}) + if r.Saved() { + *saves++ + } + if r.Canceled() { + *cancels++ + } + }, 400, 300) +} + +func TestMessageEditorSaveRules(t *testing.T) { + draft, saves, cancels := "Hello", 0, 0 + tt := messageEditorTester(&draft, &saves, &cancels) + _ = tt.Click("Save") + _ = tt.Click("Edit message") + tt.Key(ui.Cmd, ui.KeyEnter) + if saves != 0 { + t.Fatal("saved unchanged text") + } + tt.Type(" there") + tt.Key(ui.Cmd, ui.KeyEnter) + _ = tt.Click("Save") + if saves != 2 || draft != "Hello there" { + t.Fatalf("saves %d draft %q", saves, draft) + } + _ = tt.Click("Edit message") + tt.Command("selectAll") + tt.Type(" ") + _ = tt.Click("Save") + if saves != 2 { + t.Fatal("saved blank text") + } +} + +func TestMessageEditorCancel(t *testing.T) { + draft, saves, cancels := "Hello", 0, 0 + tt := messageEditorTester(&draft, &saves, &cancels) + _ = tt.Click("Cancel") + _ = tt.Click("Edit message") + tt.Key(0, ui.KeyEscape) + if cancels != 2 || saves != 0 { + t.Fatalf("cancels %d saves %d", cancels, saves) + } +} + +func TestMessageEditorSnapshot(t *testing.T) { + draft := "Could you seat the ambassador nearer the throne?" + snapshot(t, "message_editor", 420, 200, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { MessageEditor(c, &draft, MessageEditorOptions{Original: "Seat the ambassador."}) }) + }) +} diff --git a/message_header.go b/message_header.go new file mode 100644 index 0000000..f45adde --- /dev/null +++ b/message_header.go @@ -0,0 +1,70 @@ +package mujica + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// MessageStatus is where a chat message is in its delivery. +type MessageStatus int + +const ( + // MessageNoStatus shows no status. + MessageNoStatus MessageStatus = iota + MessageSending + MessageStreaming + MessageSent + MessageFailed +) + +// MessageHeaderOptions configure a MessageHeader; zero fields are hidden. +type MessageHeaderOptions struct { + Model string + Time time.Time + Status MessageStatus +} + +// MessageHeader shows a message's model, time and status as icon and text. +func MessageHeader(c *ui.Context, opts MessageHeaderOptions) *ui.Element { + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).FontSize(small).TextColor(k.TextMuted) + row.Children(func() { + if opts.Model != "" { + ui.Text(c, opts.Model).FontWeight(600).TextColor(k.Text).SingleLine() + } + if !opts.Time.IsZero() { + ui.Text(c, opts.Time.Format(msg(c, "message_header.time"))).FontFeatures("tnum").SingleLine() + } + if opts.Status == MessageNoStatus { + return + } + key, icon, color := "", "", k.TextMuted + switch opts.Status { + case MessageSending: + key, icon = "message_header.sending", "clock" + case MessageStreaming: + key = "message_header.streaming" + case MessageSent: + key, icon = "message_header.sent", "check" + case MessageFailed: + key, icon, color = "message_header.failed", "circle-alert", k.Danger + default: + panic("mujica: unknown MessageStatus") + } + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(color).Children(func() { + if icon == "" { + Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, key)}) + } else { + ui.Icon(c, icons.Must(icon)).Size(12, 12) + } + ui.Text(c, msg(c, key)).SingleLine() + }) + }) + return row +} diff --git a/message_header_test.go b/message_header_test.go new file mode 100644 index 0000000..61f95b6 --- /dev/null +++ b/message_header_test.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestMessageHeaderFieldsAndStatus(t *testing.T) { + at := time.Date(2026, 3, 1, 14, 5, 0, 0, time.UTC) + for status, want := range map[MessageStatus]string{ + MessageSending: "Sending", MessageStreaming: "Generating", MessageSent: "Sent", MessageFailed: "Failed", + } { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: EnUS}) + MessageHeader(c, MessageHeaderOptions{Model: "claude", Time: at, Status: status}) + }, 400, 100) + for _, s := range []string{"claude", "14:05", want} { + if !tt.HasText(s) { + t.Errorf("status %v: missing %q in %q", status, s, tt.Texts()) + } + } + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MessageHeader(c, MessageHeaderOptions{}) + }, 400, 100) + if len(tt.Texts()) != 0 { + t.Fatalf("zero header shows %q", tt.Texts()) + } +} + +func TestMessageHeaderUnknownStatus(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an unknown status") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MessageHeader(c, MessageHeaderOptions{Status: 99}) + }, 100, 100) +} + +func TestMessageHeaderSnapshot(t *testing.T) { + at := time.Date(2026, 3, 1, 21, 30, 0, 0, time.UTC) + snapshot(t, "message_header", 360, 150, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + for _, s := range []MessageStatus{MessageSending, MessageStreaming, MessageSent, MessageFailed} { + MessageHeader(c, MessageHeaderOptions{Model: "herald-2", Time: at, Status: s}) + } + }) + }) +} diff --git a/message_list.go b/message_list.go new file mode 100644 index 0000000..fc5bf1c --- /dev/null +++ b/message_list.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MessageListState keeps a MessageList's place and what the user has seen. +type MessageListState struct { + list ui.ListState + seen int + started bool +} + +// ScrollToEnd shows the newest message and follows new ones again. +func (s *MessageListState) ScrollToEnd() { s.list.ScrollToEnd() } + +// AtEnd reports whether the newest message is in view. +func (s *MessageListState) AtEnd() bool { return s.list.AtEnd() } + +// MessageListOptions configure a MessageList. +type MessageListOptions struct { + // ID returns message i's stable identity; required. + ID func(i int) string + // Date returns message i's time; set, a separator heads each new day. + Date func(i int) time.Time + // Label returns message i's text for assistive technology and typing to find. + Label func(i int) string +} + +// MessageListResult is a MessageList's element and its unread count. +type MessageListResult struct { + Element *ui.Element + unread int +} + +// Unread returns how many messages arrived since the user scrolled away from the end. +func (r MessageListResult) Unread() int { return r.unread } + +// MessageList shows n messages, building only those in view, and follows the end until the user scrolls up. +func MessageList(c *ui.Context, s *MessageListState, n int, opts MessageListOptions, row func(i int)) MessageListResult { + if opts.ID == nil || row == nil { + panic("mujica: MessageList needs ID and row") + } + if n < 0 { + panic("mujica: MessageList with negative message count") + } + u := Density(c).Unit() + s.list.Key = func(i int) any { return opts.ID(i) } + s.list.Label = opts.Label + s.list.FollowEnd = true + at := !s.started || s.list.AtEnd() + if at || n < s.seen { + s.seen = n + } + s.started = true + r := MessageListResult{unread: n - s.seen} + r.Element = ui.Box(c).Grow(1).MinHeight(0) + r.Element.Children(func() { + ui.List(c, &s.list, n, func(i int) { + ui.Column(c).Padding(u*2, u*4).Gap(u * 2).Children(func() { + if opts.Date != nil && (i == 0 || !messageListSameDay(opts.Date(i-1), opts.Date(i))) { + messageListDay(c, opts.Date(i)) + } + row(i) + }) + }).Fill().Justify(ui.End).Label(msg(c, "message_list.label")) + jump := ScrollToBottomButton(c, !at, r.unread) + if jump.Clicked() && !r.Element.IsDisabled() { + s.list.ScrollToEnd() + s.seen = n + slog.Debug("mujica: message_list jump", "unread", r.unread) + } + }) + return r +} + +func messageListSameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + +// messageListDay is a separator naming the day: today, yesterday or the date. +func messageListDay(c *ui.Context, t time.Time) { + k := Tokens(c) + u := Density(c).Unit() + label := t.Format(msg(c, "message_list.date")) + now := c.Now().In(t.Location()) + switch { + case messageListSameDay(t, now): + label = msg(c, "message_list.today") + case messageListSameDay(t, now.AddDate(0, 0, -1)): + label = msg(c, "message_list.yesterday") + } + ui.Row(c).Gap(u*3).AlignItems(ui.Center).Padding(u*2, 0).Role(ui.RoleText).Label(label).Children(func() { + rule := func() { + ui.Box(c).Grow(1).Height(theme.BorderWidth).Background(k.Border) + } + rule() + ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + rule() + }) +} diff --git a/message_list_test.go b/message_list_test.go new file mode 100644 index 0000000..5aaf0a4 --- /dev/null +++ b/message_list_test.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// messageListApp is a chat of n messages with stable IDs. +type messageListApp struct { + ids []string + state MessageListState + built int + res MessageListResult +} + +func newMessageListApp(n int) *messageListApp { + a := &messageListApp{} + for i := range n { + a.ids = append(a.ids, fmt.Sprintf("m%d", i)) + } + return a +} + +func (a *messageListApp) view(c *ui.Context) { + Use(c, Settings{}) + a.built = 0 + day := time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC) + a.res = MessageList(c, &a.state, len(a.ids), MessageListOptions{ + ID: func(i int) string { return a.ids[i] }, + Date: func(i int) time.Time { return day.Add(time.Duration(i/3) * 24 * time.Hour) }, + }, func(i int) { + a.built++ + ui.Text(c, "Message "+a.ids[i]).Padding(8) + }) +} + +func TestMessageListVirtualizedAndFollowsEnd(t *testing.T) { + a := newMessageListApp(100000) + tt := ui.NewTester(a.view, 400, 300) + tt.Frame() + if a.built == 0 || a.built > 60 { + t.Fatalf("built %d rows of 100000", a.built) + } + if !tt.HasText("Message m99999") || !a.state.AtEnd() { + t.Fatal("list did not start at the newest message") + } + a.ids = append(a.ids, "m100000") + tt.Frame() + tt.Frame() + if !tt.HasText("Message m100000") || a.res.Unread() != 0 { + t.Fatalf("new message not followed, unread %d", a.res.Unread()) + } +} + +func TestMessageListStopsFollowingAndCountsUnread(t *testing.T) { + a := newMessageListApp(50) + tt := ui.NewTester(a.view, 400, 300) + tt.Frame() + tt.Scroll(200, 150, 0, -2000) + tt.Frame() + if a.state.AtEnd() { + t.Fatal("still at end after scrolling up") + } + a.ids = append(a.ids, "n1", "n2") + tt.Frame() + tt.Frame() + if tt.HasText("Message n2") || a.res.Unread() != 2 { + t.Fatalf("followed while scrolled up, unread %d", a.res.Unread()) + } + if err := tt.Click("Scroll to latest, 2 new"); err != nil { + t.Fatal(err) + } + tt.Frame() + tt.Frame() + if !tt.HasText("Message n2") || a.res.Unread() != 0 { + t.Fatalf("jump did not reach the end, unread %d", a.res.Unread()) + } +} + +func TestMessageListKeepsPlaceByID(t *testing.T) { + a := newMessageListApp(50) + tt := ui.NewTester(a.view, 400, 300) + tt.Frame() + tt.Scroll(200, 150, 0, -600) + tt.Frame() + r, _ := tt.Find("Message m40") + // Older messages load above; the one in view stays put. + a.ids = append([]string{"old1", "old2", "old3"}, a.ids...) + tt.Frame() + r2, ok := tt.Find("Message m40") + if !ok || r2.Y != r.Y { + t.Fatalf("m40 moved from %v to %v (found %v)", r.Y, r2.Y, ok) + } +} + +func TestMessageListDaySeparators(t *testing.T) { + a := newMessageListApp(6) + tt := ui.NewTester(a.view, 400, 600) + tt.Frame() + for _, d := range []string{"Mar 1, 2026", "Mar 2, 2026"} { + if !tt.HasText(d) { + t.Errorf("missing separator %q in %q", d, tt.Texts()) + } + } +} + +func TestMessageListNeedsID(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without ID") + } + }() + var s MessageListState + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MessageList(c, &s, 1, MessageListOptions{}, func(int) {}) + }, 200, 100) +} + +func TestMessageListSnapshot(t *testing.T) { + snapshot(t, "message_list", 420, 320, func(c *ui.Context) { + (*ui.Local(c.Root(), "app", func() *messageListApp { return newMessageListApp(6) })).view(c) + }) +} diff --git a/messages_agent.go b/messages_agent.go new file mode 100644 index 0000000..6bd824e --- /dev/null +++ b/messages_agent.go @@ -0,0 +1,127 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "agent_status.pending": {"Pending", "待开始"}, + "agent_status.planning": {"Planning", "规划中"}, + "agent_status.running": {"Running", "执行中"}, + "agent_status.waiting": {"Waiting", "等待中"}, + "agent_status.done": {"Done", "已完成"}, + "agent_status.failed": {"Failed", "失败"}, + "agent_status.skipped": {"Skipped", "已跳过"}, + + "tool_call_card.args": {"Arguments", "参数"}, + "tool_call_card.result": {"Result", "结果"}, + "tool_call_card.error": {"Error", "错误"}, + + "tool_call_group.count": {"%d tool calls", "%d 次工具调用"}, + "tool_call_group.failed": {"%d failed", "%d 次失败"}, + + "tool_approval_dialog.title": {"Allow this tool to run?", "允许运行此工具?"}, + "tool_approval_dialog.tool": {"Tool", "工具"}, + "tool_approval_dialog.allow": {"Allow", "允许"}, + "tool_approval_dialog.deny": {"Deny", "拒绝"}, + + "permission_prompt.deny": {"Deny", "拒绝"}, + "permission_prompt.always": {"Always allow", "始终允许"}, + "permission_prompt.once": {"Allow once", "允许一次"}, + "permission_prompt.scope": {"Always allowing covers", "始终允许的范围"}, + + "agent_step_list.current": {"Current", "当前"}, + "agent_step_list.retry": {"Retry", "重试"}, + + "agent_progress.label": {"Progress", "进度"}, + "sub_agent_tree.children": {"%d sub-agents", "%d 个子智能体"}, + + "file_change_card.stats": {"%d added, %d removed lines", "新增 %d 行,删除 %d 行"}, + "file_change_card.pending": {"Pending", "待审阅"}, + "file_change_card.accepted": {"Accepted", "已接受"}, + "file_change_card.rejected": {"Rejected", "已拒绝"}, + "file_change_card.new": {"New file", "新文件"}, + "file_change_card.deleted": {"Deleted", "已删除"}, + "file_change_card.open": {"Open diff", "打开差异"}, + "file_change_card.undo": {"Undo", "撤销"}, + "file_change_card.reject": {"Reject", "拒绝"}, + "file_change_card.accept": {"Accept", "接受"}, + + "multi_file_diff_review.title": {"Review changes", "审阅变更"}, + "multi_file_diff_review.progress": {"%d of %d files reviewed", "已审阅 %d / %d 个文件"}, + "multi_file_diff_review.accept_all": {"Accept all", "全部接受"}, + "multi_file_diff_review.empty": {"No changes to review", "没有需要审阅的变更"}, + + "command_execution_card.abort": {"Abort", "中止"}, + "command_execution_card.exit": {"Exit %d", "退出码 %d"}, + "command_execution_card.output": {"Output", "输出"}, + "command_execution_card.no_output": {"No output", "没有输出"}, + + "screenshot_stream.label": {"Agent screen", "智能体画面"}, + "screenshot_stream.waiting": {"Waiting for frames", "等待画面"}, + "screenshot_stream.prev": {"Previous frame", "上一帧"}, + "screenshot_stream.next": {"Next frame", "下一帧"}, + "screenshot_stream.pause": {"Pause", "暂停"}, + "screenshot_stream.live": {"Go live", "回到实时"}, + "screenshot_stream.counter": {"Frame %d of %d", "第 %d / %d 帧"}, + "screenshot_stream.following": {"Live", "实时"}, + + "artifact_panel.copy": {"Copy", "复制"}, + "artifact_panel.copied": {"Copied", "已复制"}, + "artifact_panel.export": {"Export", "导出"}, + "artifact_panel.preview": {"Preview", "预览"}, + "artifact_panel.source": {"Source", "源码"}, + + "artifact_version_switcher.label": {"Versions", "版本"}, + "artifact_version_switcher.older": {"Older version", "较早版本"}, + "artifact_version_switcher.newer": {"Newer version", "较新版本"}, + "artifact_version_switcher.count": {"%d of %d", "%d / %d"}, + "artifact_version_switcher.compare": {"Compare with %s", "与 %s 比较"}, + + "sandbox_status.starting": {"Starting", "启动中"}, + "sandbox_status.running": {"Running", "运行中"}, + "sandbox_status.stopped": {"Stopped", "已停止"}, + "sandbox_status.failed": {"Failed", "故障"}, + "sandbox_status.high": {"Near the limit", "接近上限"}, + "sandbox_status.left": {"%s left", "剩余 %s"}, + "sandbox_status.expired": {"Time is up", "已到时"}, + + "mcp_server_list.label": {"MCP servers", "MCP 服务器"}, + "mcp_server_list.empty": {"No MCP servers", "没有 MCP 服务器"}, + "mcp_server_list.stopped": {"Stopped", "已停止"}, + "mcp_server_list.connecting": {"Connecting", "连接中"}, + "mcp_server_list.connected": {"Connected", "已连接"}, + "mcp_server_list.error": {"Connection failed", "连接失败"}, + "mcp_server_list.tools": {"%d tools", "%d 个工具"}, + "mcp_server_list.start": {"Start", "启动"}, + "mcp_server_list.stop": {"Stop", "停止"}, + "mcp_server_list.reconnect": {"Reconnect", "重新连接"}, + + "memory_panel.title": {"Memory", "记忆"}, + "memory_panel.count": {"%d entries", "%d 条"}, + "memory_panel.search": {"Search memory", "搜索记忆"}, + "memory_panel.empty": {"No memories match", "没有匹配的记忆"}, + "memory_panel.edit": {"Edit", "编辑"}, + "memory_panel.save": {"Save", "保存"}, + "memory_panel.cancel": {"Cancel", "取消"}, + "memory_panel.delete": {"Delete", "删除"}, + "memory_panel.confirm": {"Forget this memory?", "删除这条记忆?"}, + + "checkpoint_list.label": {"Checkpoints", "检查点"}, + "checkpoint_list.empty": {"No checkpoints yet", "暂无检查点"}, + "checkpoint_list.current": {"Current", "当前"}, + "checkpoint_list.rollback": {"Roll back", "回滚"}, + "checkpoint_list.confirm": {"Roll back to “%s”?", "回滚到“%s”?"}, + "checkpoint_list.warning": {"Changes made after this checkpoint will be discarded.", "此检查点之后的更改将被丢弃。"}, + + "human_input_request.free": {"Or write your own answer", "或填写你的回答"}, + "human_input_request.send": {"Send answer", "发送回答"}, + "human_input_request.left": {"Answer within %s", "请在 %s 内回答"}, + "human_input_request.timeout": {"The agent stopped waiting", "智能体已停止等待"}, + + "agent_plan.title": {"Plan", "计划"}, + "agent_plan.progress": {"%d / %d done", "已完成 %d / %d"}, + "agent_plan.doing": {"In progress", "进行中"}, + "agent_plan.skip": {"Skip", "跳过"}, + "agent_plan.restore": {"Restore", "恢复"}, + "agent_plan.add": {"Add", "添加"}, + "agent_plan.placeholder": {"Add a step", "新增一项"}, + }) +} diff --git a/messages_calendar.go b/messages_calendar.go new file mode 100644 index 0000000..af24774 --- /dev/null +++ b/messages_calendar.go @@ -0,0 +1,195 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" +) + +// calendarCount formats a count, using "_one" for one. +func calendarCount(c *ui.Context, key string, n int) string { + if n == 1 { + key += "_one" + } + return fmt.Sprintf(msg(c, key), n) +} + +func init() { + addMessages(map[string][2]string{ + "event_chip.all_day": {"All day", "全天"}, + "event_chip.untitled": {"(No title)", "(无标题)"}, + + "attendee_list.label": {"Attendees", "参与者"}, + "attendee_list.summary": {"%d accepted · %d maybe · %d declined · %d awaiting", "%d 人接受 · %d 人待定 · %d 人拒绝 · %d 人未回复"}, + "attendee_list.empty": {"No attendees yet.", "暂无参与者。"}, + "attendee_list.organizer": {"Organizer", "组织者"}, + "attendee_list.optional": {"Optional", "可选"}, + "attendee_list.remove": {"Remove %s", "移除 %s"}, + "attendee_list.pending": {"Awaiting reply", "未回复"}, + "attendee_list.accepted": {"Accepted", "已接受"}, + "attendee_list.tentative": {"Maybe", "待定"}, + "attendee_list.declined": {"Declined", "已拒绝"}, + + "recurrence_editor.label": {"Recurrence", "重复规则"}, + "recurrence_editor.repeat": {"Repeat", "重复"}, + "recurrence_editor.none": {"Does not repeat", "不重复"}, + "recurrence_editor.daily": {"Daily", "每天"}, + "recurrence_editor.weekly": {"Weekly", "每周"}, + "recurrence_editor.monthly": {"Monthly", "每月"}, + "recurrence_editor.yearly": {"Yearly", "每年"}, + "recurrence_editor.custom": {"Custom", "自定义"}, + "recurrence_editor.every": {"Every", "每"}, + "recurrence_editor.interval": {"Interval", "间隔"}, + "recurrence_editor.unit": {"Unit", "单位"}, + "recurrence_editor.days": {"days", "天"}, + "recurrence_editor.weeks": {"weeks", "周"}, + "recurrence_editor.months": {"months", "个月"}, + "recurrence_editor.years": {"years", "年"}, + "recurrence_editor.on_days": {"On days", "重复于"}, + "recurrence_editor.ends": {"Ends", "结束"}, + "recurrence_editor.forever": {"Never", "永不"}, + "recurrence_editor.after": {"After", "次数"}, + "recurrence_editor.until": {"On date", "截止日期"}, + "recurrence_editor.count": {"Occurrences", "重复次数"}, + "recurrence_editor.until_date": {"End date", "结束日期"}, + "recurrence_editor.no_days": {"Choose at least one day.", "请至少选择一天。"}, + "recurrence_editor.bad_count": {"Repeat at least once.", "重复次数至少为 1。"}, + "recurrence_editor.bad_until": {"The end date must not precede the start.", "结束日期不能早于开始日期。"}, + "recurrence_editor.never": {"Does not repeat", "不重复"}, + "recurrence_editor.every_n": {"Every %d %s", "每 %d %s"}, + "recurrence_editor.on": {" on %s", ",于%s"}, + "recurrence_editor.list_sep": {", ", "、"}, + "recurrence_editor.times": {", %d times", ",共 %d 次"}, + "recurrence_editor.times_one": {", %d time", ",共 %d 次"}, + "recurrence_editor.until_text": {", until %v", ",至 %v"}, + + "reminder_picker.label": {"Reminders", "提醒"}, + "reminder_picker.at_start": {"At start", "开始时"}, + "reminder_picker.minutes": {"%d minutes before", "提前 %d 分钟"}, + "reminder_picker.minutes_one": {"%d minute before", "提前 %d 分钟"}, + "reminder_picker.hours": {"%d hours before", "提前 %d 小时"}, + "reminder_picker.hours_one": {"%d hour before", "提前 %d 小时"}, + "reminder_picker.days": {"%d days before", "提前 %d 天"}, + "reminder_picker.days_one": {"%d day before", "提前 %d 天"}, + "reminder_picker.weeks": {"%d weeks before", "提前 %d 周"}, + "reminder_picker.weeks_one": {"%d week before", "提前 %d 周"}, + + "calendar_month_view.more": {"+%d", "+%d"}, + "calendar_month_view.more_label": {"%d more events", "另有 %d 项"}, + "calendar_month_view.more_label_one": {"%d more event", "另有 %d 项"}, + + "current_time_indicator.label": {"Now, %s", "当前时间 %s"}, + + "calendar_week_view.all_day": {"All day", "全天"}, + "calendar_week_view.prev": {"Previous week", "上一周"}, + "calendar_week_view.next": {"Next week", "下一周"}, + "calendar_week_view.title": {"%[1]s %[2]d – %[3]s %[4]d, %[5]d", "%[5]d年%[1]s%[2]d日 – %[3]s%[4]d日"}, + "calendar_week_view.resize": {"Resize %s", "调整 %s 的时长"}, + "calendar_week_view.too_short": {"An event must end after it starts.", "结束时间须晚于开始时间。"}, + "calendar_week_view.column": {"Schedule, %s", "%s 日程"}, + "calendar_day_view.prev": {"Previous day", "前一天"}, + "calendar_day_view.next": {"Next day", "后一天"}, + + "agenda_view.label": {"Agenda", "日程"}, + "agenda_view.today": {"Today", "今天"}, + "agenda_view.empty": {"No events in the next %d days.", "未来 %d 天没有日程。"}, + + "year_view.events": {"%d events", "%d 项日程"}, + "year_view.events_one": {"%d event", "%d 项日程"}, + + "event_popover.close": {"Close", "关闭"}, + "event_popover.edit": {"Edit", "编辑"}, + "event_popover.delete": {"Delete", "删除"}, + "event_popover.guests": {"%d guests", "%d 位参与者"}, + "event_popover.guests_one": {"%d guest", "%d 位参与者"}, + + "week_picker.label": {"Week", "周"}, + "week_picker.placeholder": {"Select week", "选择周"}, + + "duration_picker.label": {"Duration", "时长"}, + "duration_picker.hours": {"Hours", "小时数"}, + "duration_picker.minutes": {"Minutes", "分钟数"}, + "duration_picker.seconds": {"Seconds", "秒数"}, + "duration_picker.hours_unit": {"h", "小时"}, + "duration_picker.minutes_unit": {"min", "分"}, + "duration_picker.seconds_unit": {"s", "秒"}, + "duration_picker.range": {"Choose a duration from %s to %s.", "请选择 %s 至 %s 之间的时长。"}, + + "timezone_select.label": {"Time zone", "时区"}, + "timezone_select.placeholder": {"Select time zone", "选择时区"}, + "timezone_select.search": {"Search city or offset", "搜索城市或偏移"}, + "timezone_select.empty": {"No matching time zone.", "没有匹配的时区。"}, + "timezone_select.common": {"Common", "常用"}, + "timezone_select.all": {"All time zones", "全部时区"}, + + "availability_picker.label": {"Availability", "空闲时段"}, + "availability_picker.mine": {"you are free", "你有空"}, + "availability_picker.others": {"%d of %d others free", "其他 %[2]d 人中 %[1]d 人有空"}, + + "event_editor.label": {"Event", "日程"}, + "event_editor.title": {"Title", "标题"}, + "event_editor.all_day": {"All day", "全天"}, + "event_editor.starts": {"Starts", "开始"}, + "event_editor.ends": {"Ends", "结束"}, + "event_editor.where": {"Location", "地点"}, + "event_editor.guests": {"Guests", "参与者"}, + "event_editor.guests_help": {"Type a name and press Enter.", "输入姓名后按回车。"}, + "event_editor.reminders": {"Reminders", "提醒"}, + "event_editor.save": {"Save", "保存"}, + "event_editor.need_title": {"Enter a title.", "请输入标题。"}, + "event_editor.need_start": {"Choose a start.", "请选择开始时间。"}, + "event_editor.end_order": {"The end must be after the start.", "结束须晚于开始。"}, + + "cron_editor.expression": {"Expression", "表达式"}, + "cron_editor.label": {"Cron schedule", "Cron 表达式"}, + "cron_editor.next": {"Next runs", "下次运行"}, + "cron_editor.never": {"No run in the next five years.", "未来五年内不会运行。"}, + "cron_editor.repeated": {"repeated hour, first run", "重复时段,取首次"}, + "cron_editor.minute": {"Minute", "分钟"}, + "cron_editor.hour": {"Hour", "小时"}, + "cron_editor.dom": {"Day of month", "日"}, + "cron_editor.month": {"Month", "月"}, + "cron_editor.dow": {"Day of week", "星期"}, + "cron_editor.err_count": {"Use five fields, not %s.", "需要 5 个字段,而不是 %s 个。"}, + "cron_editor.err_empty": {"an empty list item", "列表项为空"}, + "cron_editor.err_step": {"step %q must be a positive number", "步长 %q 须为正整数"}, + "cron_editor.err_number": {"%q is not a number", "%q 不是数字"}, + "cron_editor.err_range": {"%s is outside %d–%d", "%s 超出 %d–%d"}, + "cron_editor.err_backwards": {"range %s runs backwards", "范围 %s 起止颠倒"}, + "cron_editor.field_error": {"%s: %s.", "%s:%s。"}, + "cron_editor.invalid": {"Invalid", "无效"}, + "cron_editor.any": {"Every value", "任意"}, + "cron_editor.every": {"Every %s", "每 %s"}, + "cron_editor.every_in": {"Every %s in %s", "%[2]s 内每 %[1]s"}, + + "relative_time.now": {"just now", "刚刚"}, + "relative_time.ago": {"%s ago", "%s前"}, + "relative_time.in": {"in %s", "%s后"}, + "relative_time.minutes": {"%d minutes", "%d 分钟"}, + "relative_time.minutes_one": {"%d minute", "%d 分钟"}, + "relative_time.hours": {"%d hours", "%d 小时"}, + "relative_time.hours_one": {"%d hour", "%d 小时"}, + "relative_time.days": {"%d days", "%d 天"}, + "relative_time.days_one": {"%d day", "%d 天"}, + "relative_time.months": {"%d months", "%d 个月"}, + "relative_time.months_one": {"%d month", "%d 个月"}, + "relative_time.years": {"%d years", "%d 年"}, + "relative_time.years_one": {"%d year", "%d 年"}, + + "countdown.label": {"Time left", "剩余时间"}, + "countdown.done": {"%s: time is up.", "%s:时间到。"}, + "countdown.days_clock": {"%dd", "%d天"}, + "countdown.d": {"%d d", "%d 天"}, + "countdown.h": {"%d h", "%d 小时"}, + "countdown.m": {"%d min", "%d 分"}, + "countdown.s": {"%d s", "%d 秒"}, + + "stopwatch.label": {"Stopwatch", "秒表"}, + "stopwatch.start": {"Start", "开始"}, + "stopwatch.pause": {"Pause", "暂停"}, + "stopwatch.resume": {"Resume", "继续"}, + "stopwatch.lap": {"Lap", "计次"}, + "stopwatch.reset": {"Reset", "复位"}, + "stopwatch.lap_n": {"Lap %d", "第 %d 圈"}, + }) +} diff --git a/messages_charts1.go b/messages_charts1.go new file mode 100644 index 0000000..e207570 --- /dev/null +++ b/messages_charts1.go @@ -0,0 +1,30 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "chart.empty": {"No data", "暂无数据"}, + "chart.hint": {"Focus the chart and use the arrow keys to read values", "聚焦图表后用方向键读取数值"}, + "chart.missing": {"no value", "无数值"}, + "pie_chart.other": {"Other", "其他"}, + "pie_chart.total": {"Total", "总计"}, + "scatter_chart.size": {"size", "大小"}, + "scatter_chart.selected": {"selected", "已选"}, + "calendar_heatmap.prev": {"Previous year", "上一年"}, + "calendar_heatmap.next": {"Next year", "下一年"}, + "calendar_heatmap.less": {"Less", "少"}, + "calendar_heatmap.more": {"More", "多"}, + "box_plot.readout": {"%s · median %s · quartiles %s–%s · whiskers %s–%s · %d outliers", "%s · 中位数 %s · 四分位 %s–%s · 须线 %s–%s · 离群点 %d 个"}, + "waterfall_chart.running": {"running total", "累计"}, + "funnel_chart.readout": {"%s · %s · %s of the stage before · %s of the first", "%s · %s · 较上一阶段 %s · 占首阶段 %s"}, + "bullet_chart.actual": {"Actual", "实际"}, + "bullet_chart.target": {"Target", "目标"}, + "bullet_chart.band": {"Range %d: %s–%s", "区间 %d:%s–%s"}, + "chart.back": {"Back", "返回"}, + "chart.opens": {"Enter opens", "回车展开"}, + "sankey_chart.in": {"in", "流入"}, + "sankey_chart.out": {"out", "流出"}, + "network_graph.readout": {"%s · %d links: %s", "%s · %d 条连线:%s"}, + "realtime_chart.pause": {"Pause", "暂停"}, + "realtime_chart.resume": {"Resume", "继续"}, + }) +} diff --git a/messages_chatmsg.go b/messages_chatmsg.go new file mode 100644 index 0000000..2ebff69 --- /dev/null +++ b/messages_chatmsg.go @@ -0,0 +1,57 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "message_bubble.user": {"You", "你"}, + "message_bubble.assistant": {"Assistant", "助手"}, + "message_bubble.system": {"System", "系统"}, + "message_bubble.error": {"Error", "错误"}, + "message_header.time": {"15:04", "15:04"}, + "message_header.sending": {"Sending", "发送中"}, + "message_header.streaming": {"Generating", "生成中"}, + "message_header.sent": {"Sent", "已发送"}, + "message_header.failed": {"Failed", "失败"}, + "message_list.label": {"Messages", "消息"}, + "message_list.date": {"Jan 2, 2006", "2006年1月2日"}, + "message_list.today": {"Today", "今天"}, + "message_list.yesterday": {"Yesterday", "昨天"}, + "scroll_to_bottom_button.label": {"Scroll to latest", "回到最新"}, + "scroll_to_bottom_button.unread": {"Scroll to latest, %d new", "回到最新,%d 条新消息"}, + "code_block.plain": {"text", "文本"}, + "code_block.wrap": {"Wrap lines", "自动换行"}, + "code_block.copy": {"Copy code", "复制代码"}, + "code_block.copied": {"Copied", "已复制"}, + "code_block.collapse": {"Collapse", "折叠"}, + "code_block.expand": {"Show %d lines", "展开 %d 行"}, + "table_block.title": {"Table", "表格"}, + "table_block.copy": {"Copy as TSV", "复制为 TSV"}, + "file_message.open": {"Open %s, %s", "打开 %s,%s"}, + "file_message.download": {"Download %s", "下载 %s"}, + "file_message.bytes": {"%d B", "%d 字节"}, + "audio_message.play": {"Play", "播放"}, + "audio_message.pause": {"Pause", "暂停"}, + "audio_message.seek": {"Playback position", "播放位置"}, + "quote_reply.label": {"Reply to %s: %s", "回复 %s:%s"}, + "message_actions.label": {"Message actions", "消息操作"}, + "message_actions.copy": {"Copy", "复制"}, + "message_actions.regenerate": {"Regenerate", "重新生成"}, + "message_actions.edit": {"Edit", "编辑"}, + "message_actions.like": {"Good response", "赞"}, + "message_actions.dislike": {"Bad response", "踩"}, + "message_actions.speak": {"Read aloud", "朗读"}, + "message_actions.share": {"Share", "分享"}, + "message_editor.label": {"Edit message", "编辑消息"}, + "message_editor.cancel": {"Cancel", "取消"}, + "message_editor.save": {"Save", "保存"}, + "branch_navigator.label": {"Branch %d of %d", "第 %d 个分支,共 %d 个"}, + "branch_navigator.previous": {"Previous branch", "上一个分支"}, + "branch_navigator.next": {"Next branch", "下一个分支"}, + "error_message.title": {"Something went wrong", "出错了"}, + "error_message.retry": {"Retry", "重试"}, + "error_message.wait": {"Retry in %d s", "%d 秒后重试"}, + "error_message.limited": {"Rate limited", "已限流"}, + "thinking_block.done": {"Thought for %d s", "思考了 %d 秒"}, + "thinking_block.thinking": {"Thinking… %d s", "思考中… %d 秒"}, + "typing_indicator.label": {"%s is typing…", "%s 正在输入…"}, + }) +} diff --git a/messages_chatsession.go b/messages_chatsession.go new file mode 100644 index 0000000..94f4957 --- /dev/null +++ b/messages_chatsession.go @@ -0,0 +1,123 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "conversation_list.pinned": {"Pinned", "置顶"}, + "conversation_list.today": {"Today", "今天"}, + "conversation_list.yesterday": {"Yesterday", "昨天"}, + "conversation_list.week": {"Previous 7 days", "7 天内"}, + "conversation_list.older": {"Older", "更早"}, + "conversation_list.untitled": {"New conversation", "新对话"}, + + "conversation_item.actions": {"Conversation actions", "对话操作"}, + "conversation_item.rename": {"Rename", "重命名"}, + "conversation_item.pin": {"Pin", "置顶"}, + "conversation_item.unpin": {"Unpin", "取消置顶"}, + "conversation_item.pinned": {"Pinned", "已置顶"}, + "conversation_item.export": {"Export", "导出"}, + "conversation_item.delete": {"Delete", "删除"}, + + "conversation_search.searching": {"Searching", "正在搜索"}, + "conversation_search.no_results": {"No conversations match", "没有匹配的对话"}, + "conversation_search.results": {"Search results", "搜索结果"}, + "conversation_search.retry": {"Retry", "重试"}, + + "project_list.expanded": {"expanded", "已展开"}, + "project_list.collapsed": {"collapsed", "已收起"}, + "project_list.expand": {"Show conversations in %s", "展开 %s 的对话"}, + "project_list.collapse": {"Hide conversations in %s", "收起 %s 的对话"}, + "project_list.move_to": {"Move %s", "移动 %s"}, + "project_list.move_group": {"Move to project", "移入项目"}, + + "project_knowledge_panel.title": {"Project knowledge", "项目知识"}, + "project_knowledge_panel.add": {"Add files", "添加文件"}, + "project_knowledge_panel.empty": {"No files yet", "暂无文件"}, + "project_knowledge_panel.tokens": {"%d tokens", "%d tokens"}, + "project_knowledge_panel.remove": {"Remove %s", "移除 %s"}, + "project_knowledge_panel.usage": {"%d of %d tokens (%d%%)", "已用 %d / %d tokens(%d%%)"}, + "project_knowledge_panel.near": {"Nearly full", "即将用满"}, + "project_knowledge_panel.over": {"Over capacity", "超出容量"}, + + "prompt_library.all": {"All", "全部"}, + "prompt_library.search": {"Search prompts", "搜索提示"}, + "prompt_library.no_results": {"No prompts match", "没有匹配的提示"}, + "prompt_library.pick": {"Choose a prompt to preview", "选择一个提示以预览"}, + "prompt_library.insert": {"Insert", "插入"}, + + "feedback_form.up.title": {"What did you like?", "哪里令你满意?"}, + "feedback_form.down.title": {"What went wrong?", "哪里出了问题?"}, + "feedback_form.up.accurate": {"Accurate", "准确"}, + "feedback_form.up.helpful": {"Helpful", "有帮助"}, + "feedback_form.up.clear": {"Clear", "清晰"}, + "feedback_form.down.wrong": {"Incorrect", "内容错误"}, + "feedback_form.down.unhelpful": {"Not helpful", "没有帮助"}, + "feedback_form.down.unsafe": {"Harmful or unsafe", "有害或不安全"}, + "feedback_form.down.style": {"Too long or unclear", "冗长或不清楚"}, + "feedback_form.comment": {"Tell us more (optional)", "补充说明(可选)"}, + "feedback_form.submit": {"Submit", "提交"}, + "feedback_form.cancel": {"Cancel", "取消"}, + + "date_separator.today": {"Today", "今天"}, + "date_separator.yesterday": {"Yesterday", "昨天"}, + "date_separator.short": {"%s %d", "%s%d日"}, + + "thinking_indicator.thinking": {"Thinking", "正在思考"}, + + "stop_generating_button.stop": {"Stop generating", "停止生成"}, + + "system_prompt_editor.title": {"System prompt", "系统提示"}, + "system_prompt_editor.unsaved": {"Unsaved changes", "未保存"}, + "system_prompt_editor.count": {"%d characters", "%d 字"}, + "system_prompt_editor.restore": {"Restore default", "恢复默认"}, + "system_prompt_editor.save": {"Save", "保存"}, + + "cost_estimator.kind": {"Kind", "类别"}, + "cost_estimator.tokens": {"Tokens", "Tokens"}, + "cost_estimator.cost": {"Cost", "费用"}, + "cost_estimator.input": {"Input", "输入"}, + "cost_estimator.output": {"Output", "输出"}, + "cost_estimator.total": {"Total", "合计"}, + "cost_estimator.total_label": {"Estimated cost %s", "预计费用 %s"}, + "cost_estimator.rates": {"Rates per million tokens: input %s, output %s", "每百万 tokens 单价:输入 %s,输出 %s"}, + + "shared_conversation_view.shared_by": {"Shared by %s", "分享者:%s"}, + "shared_conversation_view.someone": {"someone", "未知"}, + "shared_conversation_view.read_only": {"Read only", "只读"}, + "shared_conversation_view.copy_link": {"Copy link", "复制链接"}, + "shared_conversation_view.user": {"You", "你"}, + "shared_conversation_view.assistant": {"Assistant", "助手"}, + "shared_conversation_view.system": {"System", "系统"}, + + "conversation_export.format": {"Format", "格式"}, + "conversation_export.text": {"Plain text", "纯文本"}, + "conversation_export.range": {"Messages", "消息范围"}, + "conversation_export.all": {"All (%d)", "全部(%d)"}, + "conversation_export.last": {"Last", "最近"}, + "conversation_export.selected": {"Selected (%d)", "已选(%d)"}, + "conversation_export.preview": {"Preview", "预览"}, + "conversation_export.export": {"Export", "导出"}, + + "regenerate_menu.regenerate": {"Regenerate", "重新生成"}, + "regenerate_menu.again": {"Try again", "重试"}, + "regenerate_menu.model": {"Try another model", "换模型重试"}, + "regenerate_menu.variants": {"Rewrite", "改写"}, + "regenerate_menu.longer": {"Longer", "更长"}, + "regenerate_menu.shorter": {"Shorter", "更短"}, + "regenerate_menu.simpler": {"Simpler", "更简单"}, + "regenerate_menu.detailed": {"More detailed", "更详细"}, + + "artifact_card.code": {"Code", "代码"}, + "artifact_card.document": {"Document", "文档"}, + "artifact_card.web": {"Web page", "网页"}, + "artifact_card.open": {"Open", "打开"}, + "artifact_card.writing": {"Writing", "正在生成"}, + + "chat_empty_state.new.title": {"Start a conversation", "开始新对话"}, + "chat_empty_state.new.description": {"Ask a question or pick a suggestion.", "提出问题,或选择一个建议。"}, + "chat_empty_state.new.action": {"New conversation", "新建对话"}, + "chat_empty_state.no_results.action": {"Clear search", "清除搜索"}, + "chat_empty_state.offline.title": {"You are offline", "当前离线"}, + "chat_empty_state.offline.description": {"Messages will send when the connection returns.", "恢复连接后即可发送消息。"}, + "chat_empty_state.offline.action": {"Retry", "重试"}, + }) +} diff --git a/messages_code.go b/messages_code.go new file mode 100644 index 0000000..3f210e4 --- /dev/null +++ b/messages_code.go @@ -0,0 +1,101 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "terminal.label": {"Terminal", "终端"}, + "terminal_search.label": {"Find in terminal", "在终端中查找"}, + "terminal_tabs.label": {"Terminals", "终端"}, + "terminal_tabs.empty": {"No terminals open", "没有打开的终端"}, + "terminal_tabs.confirm": {"Close “%s”?", "关闭“%s”?"}, + "terminal_tabs.busy": {"A process is still running in this terminal and will be ended.", "此终端中仍有进程在运行,关闭后将结束它。"}, + "terminal_tabs.close": {"Close", "关闭"}, + "terminal_tabs.running": {"Process running", "进程运行中"}, + "terminal_tabs.rename": {"Terminal name", "终端名称"}, + "terminal_tabs.closeTab": {"Close %s", "关闭 %s"}, + "command_history.search": {"Search history", "搜索历史"}, + "command_history.empty": {"No commands yet", "暂无命令"}, + "command_history.none": {"No matching commands", "没有匹配的命令"}, + "command_history.replay": {"Run %s again", "重新运行 %s"}, + "command_history.exit": {"exit %d", "退出码 %d"}, + "log_viewer.highlight": {"Highlight", "高亮关键字"}, + "log_viewer.follow": {"Follow", "跟随"}, + "log_viewer.empty": {"No log lines", "没有日志"}, + "log_viewer.debug": {"DEBUG", "调试"}, + "log_viewer.info": {"INFO", "信息"}, + "log_viewer.warn": {"WARN", "警告"}, + "log_viewer.error": {"ERROR", "错误"}, + "process_list.empty": {"No processes", "没有进程"}, + "process_list.name": {"Name", "名称"}, + "process_list.pid": {"PID", "PID"}, + "process_list.cpu": {"CPU", "CPU"}, + "process_list.memory": {"Memory", "内存"}, + "process_list.actions": {"End", "结束"}, + "process_list.end": {"End %s", "结束 %s"}, + "process_list.confirm": {"End “%s” (PID %d)?", "结束“%s”(PID %d)?"}, + "process_list.warning": {"Unsaved work in this process will be lost.", "该进程中未保存的内容将会丢失。"}, + "process_list.endButton": {"End process", "结束进程"}, + "code_viewer.empty": {"No code", "没有代码"}, + "code_viewer.fold": {"Fold line %d", "折叠第 %d 行"}, + "code_viewer.unfold": {"Unfold line %d", "展开第 %d 行"}, + "minimap.label": {"Minimap", "代码缩略图"}, + "minimap.range": {"Lines %d to %d of %d", "第 %d 至 %d 行,共 %d 行"}, + "find_widget.toggleReplace": {"Toggle replace", "切换替换"}, + "find_widget.case": {"Match case", "区分大小写"}, + "find_widget.word": {"Whole word", "全字匹配"}, + "find_widget.regex": {"Regular expression", "正则表达式"}, + "find_widget.invalid": {"Invalid pattern: %s", "表达式无效:%s"}, + "find_widget.replace": {"Replace with", "替换为"}, + "find_widget.replaceOne": {"Replace", "替换"}, + "find_widget.replaceAll": {"Replace all", "全部替换"}, + "search_panel.include": {"Files to include", "包含的文件"}, + "search_panel.exclude": {"Files to exclude", "排除的文件"}, + "search_panel.idle": {"Type to search in files", "输入内容以在文件中搜索"}, + "search_panel.none": {"No results", "没有结果"}, + "search_panel.summary": {"%d results in %d files", "%d 个结果,位于 %d 个文件"}, + "search_panel.fold": {"Fold %s", "收起 %s"}, + "search_panel.unfold": {"Unfold %s", "展开 %s"}, + "problems_panel.errors": {"Errors %d", "错误 %d"}, + "problems_panel.warnings": {"Warnings %d", "警告 %d"}, + "problems_panel.infos": {"Info %d", "信息 %d"}, + "problems_panel.error": {"Error", "错误"}, + "problems_panel.warning": {"Warning", "警告"}, + "problems_panel.info": {"Info", "信息"}, + "problems_panel.empty": {"No problems", "没有问题"}, + "problems_panel.at": {"Ln %d, Col %d", "第 %d 行,第 %d 列"}, + "output_panel.channel": {"Output channel", "输出通道"}, + "output_panel.clear": {"Clear output", "清空输出"}, + "output_panel.empty": {"No output", "没有输出"}, + "symbol_outline.filter": {"Filter symbols", "筛选符号"}, + "symbol_outline.empty": {"No symbols", "没有符号"}, + "completion_menu.label": {"Suggestions", "补全建议"}, + "debug_toolbar.label": {"Debug", "调试"}, + "debug_toolbar.start": {"Start", "开始"}, + "debug_toolbar.continue": {"Continue", "继续"}, + "debug_toolbar.pause": {"Pause", "暂停"}, + "debug_toolbar.stepOver": {"Step over", "单步跳过"}, + "debug_toolbar.stepInto": {"Step into", "单步进入"}, + "debug_toolbar.stepOut": {"Step out", "单步跳出"}, + "debug_toolbar.restart": {"Restart", "重启"}, + "debug_toolbar.stop": {"Stop", "停止"}, + "breakpoint_list.label": {"Breakpoints", "断点"}, + "breakpoint_list.empty": {"No breakpoints", "没有断点"}, + "breakpoint_list.enable": {"Enable %s:%d", "启用 %s:%d"}, + "breakpoint_list.edit": {"Edit condition of %s:%d", "编辑 %s:%d 的条件"}, + "breakpoint_list.condition": {"Condition", "条件"}, + "breakpoint_list.placeholder": {"Break when expression is true", "表达式为真时中断"}, + "breakpoint_list.when": {"when %s", "当 %s"}, + "call_stack.empty": {"Not paused", "未暂停"}, + "call_stack.frames": {"depth %d", "深度 %d"}, + "variables_panel.empty": {"No variables", "没有变量"}, + "variables_panel.value": {"Value", "值"}, + "variables_panel.changed": {"Changed", "已变化"}, + "hex_viewer.label": {"Hex view", "十六进制视图"}, + "hex_viewer.empty": {"No bytes", "没有数据"}, + "hex_viewer.at": {"Offset %d, %d bytes selected", "偏移 %d,已选 %d 字节"}, + "flamegraph.search": {"Highlight frames", "高亮帧"}, + "flamegraph.empty": {"No profile", "没有性能数据"}, + "flamegraph.label": {"Flame graph", "火焰图"}, + "flamegraph.hint": {"Point at a frame, or focus the graph and use the arrows", "指向一帧,或聚焦图表后用方向键"}, + "flamegraph.readout": {"%s: %.1f %s, %.1f%% of total", "%s:%.1f %s,占总计 %.1f%%"}, + }) +} diff --git a/messages_git.go b/messages_git.go new file mode 100644 index 0000000..445a4cd --- /dev/null +++ b/messages_git.go @@ -0,0 +1,114 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "git_status_badge.modified": {"Modified", "已修改"}, + "git_status_badge.added": {"Added", "已添加"}, + "git_status_badge.deleted": {"Deleted", "已删除"}, + "git_status_badge.untracked": {"Untracked", "未跟踪"}, + "git_status_badge.renamed": {"Renamed", "已重命名"}, + "git_status_badge.copied": {"Copied", "已复制"}, + + "diff_stat.label": {"%d additions, %d deletions", "新增 %d 行,删除 %d 行"}, + + "command_block.copy_command": {"Copy command", "复制命令"}, + "command_block.copy_output": {"Copy output", "复制输出"}, + "command_block.exit": {"exit %d", "退出码 %d"}, + "command_block.no_output": {"No output", "无输出"}, + "command_block.output": {"Command output", "命令输出"}, + "command_block.running": {"Running", "运行中"}, + + "commit_list.empty": {"No commits", "没有提交"}, + + "branch_list.ahead": {"%d ahead of upstream", "领先上游 %d 个提交"}, + "branch_list.behind": {"%d behind upstream", "落后上游 %d 个提交"}, + "branch_list.current": {"current", "当前"}, + "branch_list.empty": {"No branches", "没有分支"}, + + "branch_selector.create": {"Create branch", "新建分支"}, + "branch_selector.empty": {"No matching branches", "没有匹配的分支"}, + "branch_selector.label": {"Branch", "分支"}, + "branch_selector.search": {"Search branches", "搜索分支"}, + "branch_selector.local": {"Local", "本地"}, + "branch_selector.remote": {"Remote", "远端"}, + + "tag_list.empty": {"No tags", "没有标签"}, + "tag_list.jump": {"Go to commit", "跳转到提交"}, + + "stash_list.apply": {"Apply", "应用"}, + "stash_list.pop": {"Pop", "弹出"}, + "stash_list.drop": {"Drop", "删除"}, + "stash_list.empty": {"No stashes", "没有贮藏"}, + + "blame_view.line": {"Line %d, %s, %s", "第 %d 行,%s,%s"}, + + "file_history.title": {"History", "历史"}, + "file_history.diff": {"View changes", "查看差异"}, + "file_history.empty": {"No history for this file", "此文件没有历史"}, + + "pull_request_card.open": {"Open", "开放"}, + "pull_request_card.draft": {"Draft", "草稿"}, + "pull_request_card.merged": {"Merged", "已合并"}, + "pull_request_card.closed": {"Closed", "已关闭"}, + "pull_request_card.files": {"%d files", "%d 个文件"}, + "pull_request_card.passed": {"%d checks passed", "%d 项检查通过"}, + "pull_request_card.failed": {"%d checks failed", "%d 项检查失败"}, + "pull_request_card.pending": {"%d checks pending", "%d 项检查进行中"}, + "pull_request_card.approved": {"approved", "已批准"}, + "pull_request_card.changes": {"changes requested", "要求修改"}, + "pull_request_card.review_pending": {"review pending", "待评审"}, + + "review_comment.placeholder": {"Write a reply", "写下回复"}, + "review_comment.reply": {"Reply", "回复"}, + "review_comment.field": {"Reply text", "回复内容"}, + "review_comment.resolve": {"Resolve", "解决"}, + "review_comment.reopen": {"Reopen", "重新打开"}, + "review_comment.resolved": {"Resolved", "已解决"}, + + "changes_list.staged": {"Staged changes (%d)", "已暂存的更改(%d)"}, + "changes_list.unstaged": {"Changes (%d)", "更改(%d)"}, + "changes_list.stage": {"Stage", "暂存"}, + "changes_list.unstage": {"Unstage", "取消暂存"}, + "changes_list.stage_all": {"Stage all", "全部暂存"}, + "changes_list.unstage_all": {"Unstage all", "全部取消暂存"}, + + "diff_viewer.added": {"Added", "新增"}, + "diff_viewer.deleted": {"Deleted", "删除"}, + "diff_viewer.fold": {"%d unchanged lines", "%d 行未更改"}, + + "inline_diff.removed": {"Old", "原文"}, + "inline_diff.added": {"New", "新文"}, + + "three_way_merge.resolved": {"%d of %d conflicts resolved", "已解决 %d / %d 个冲突"}, + "three_way_merge.ours": {"Local", "本地"}, + "three_way_merge.base": {"Base", "基准"}, + "three_way_merge.theirs": {"Remote", "远端"}, + "three_way_merge.result": {"Result", "结果"}, + "three_way_merge.unresolved": {"Unresolved", "未解决"}, + "three_way_merge.empty": {"No lines", "无内容"}, + "three_way_merge.take_ours": {"Take local", "采纳本地"}, + "three_way_merge.take_theirs": {"Take remote", "采纳远端"}, + "three_way_merge.take_both": {"Take both", "两者都要"}, + "three_way_merge.take_base": {"Keep base", "保留基准"}, + + "conflict_resolver.resolved": {"%d of %d conflicts resolved", "已解决 %d / %d 个冲突"}, + "conflict_resolver.broken": {"Conflict markers are not closed after line %d", "第 %d 行起的冲突标记未闭合"}, + "conflict_resolver.none": {"No conflicts", "没有冲突"}, + "conflict_resolver.title": {"Conflict %d · line %d", "冲突 %d · 第 %d 行"}, + "conflict_resolver.open": {"unresolved", "未解决"}, + "conflict_resolver.done": {"resolved", "已解决"}, + "conflict_resolver.current": {"Current", "当前"}, + "conflict_resolver.incoming": {"Incoming", "传入"}, + "conflict_resolver.take_current": {"Accept current", "采纳当前"}, + "conflict_resolver.take_incoming": {"Accept incoming", "采纳传入"}, + "conflict_resolver.take_both": {"Accept both", "两者都采纳"}, + "conflict_resolver.reset": {"Undo", "撤销"}, + + "commit_input.title": {"Summary", "提交标题"}, + "commit_input.body": {"Description", "提交说明"}, + "commit_input.count": {"%d of %d characters", "%d / %d 字符"}, + "commit_input.long": {"Summary is %d characters over the suggested length", "标题超出建议长度 %d 个字符"}, + "commit_input.commit": {"Commit", "提交"}, + "commit_input.amend": {"Amend", "修订"}, + }) +} diff --git a/messages_media.go b/messages_media.go new file mode 100644 index 0000000..fcb1f82 --- /dev/null +++ b/messages_media.go @@ -0,0 +1,106 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "image_cropper.label": {"Crop area", "裁剪区域"}, + "image_cropper.rotateLeft": {"Rotate left", "向左旋转"}, + "image_cropper.rotateRight": {"Rotate right", "向右旋转"}, + + "image_annotator.label": {"Annotations", "标注"}, + "image_annotator.tool": {"Tool", "工具"}, + "image_annotator.select": {"Select", "选择"}, + "image_annotator.rect": {"Rectangle", "矩形"}, + "image_annotator.arrow": {"Arrow", "箭头"}, + "image_annotator.text": {"Text", "文字"}, + "image_annotator.textLabel": {"Annotation text", "标注文字"}, + "image_annotator.note": {"Note", "注释"}, + "image_annotator.delete": {"Delete annotation", "删除标注"}, + "image_annotator.count": {"%d annotations", "%d 个标注"}, + "image_annotator.selected": {"Annotation %d of %d selected: %s", "已选中第 %d 个标注(共 %d 个):%s"}, + + "image_thumbnail.failed": {"Failed to load", "加载失败"}, + "image_thumbnail.loading": {"Loading", "加载中"}, + + "lightbox.title": {"Image", "图片"}, + "lightbox.close": {"Close", "关闭"}, + "lightbox.position": {"%d of %d", "第 %d 张,共 %d 张"}, + "lightbox.previous": {"Previous image", "上一张"}, + "lightbox.next": {"Next image", "下一张"}, + + "video_player.label": {"Video", "视频"}, + "video_player.empty": {"No video", "暂无视频"}, + "video_player.subtitles": {"Subtitles", "字幕"}, + "video_player.pip": {"Picture in picture", "画中画"}, + "video_player.fullscreen": {"Full screen", "全屏"}, + + "video_scrubber.label": {"Timeline", "时间轴"}, + + "video_thumbnail_strip.label": {"Frames", "缩略帧"}, + "video_thumbnail_strip.position": {"Frame %d of %d, %s", "第 %d 帧,共 %d 帧,%s"}, + "video_thumbnail_strip.previous": {"Previous frame", "上一帧"}, + "video_thumbnail_strip.next": {"Next frame", "下一帧"}, + + "audio_waveform.label": {"Waveform", "波形"}, + "audio_waveform.empty": {"No audio", "暂无音频"}, + + "audio_spectrum.label": {"Spectrum", "频谱"}, + "audio_spectrum.empty": {"No signal", "无信号"}, + "audio_spectrum.band": {"Band %d", "第 %d 段"}, + + "volume_control.label": {"Volume", "音量"}, + "volume_control.mute": {"Mute", "静音"}, + "volume_control.unmute": {"Unmute", "取消静音"}, + + "playback_speed_control.label": {"Playback speed", "播放速度"}, + + "media_controls.label": {"Playback", "播放控制"}, + "media_controls.play": {"Play", "播放"}, + "media_controls.pause": {"Pause", "暂停"}, + "media_controls.previous": {"Previous", "上一首"}, + "media_controls.next": {"Next", "下一首"}, + "media_controls.shuffle": {"Shuffle", "随机播放"}, + "media_controls.loopOff": {"Repeat off", "不循环"}, + "media_controls.loopAll": {"Repeat all", "列表循环"}, + "media_controls.loopOne": {"Repeat one", "单曲循环"}, + + "playlist.empty": {"The playlist is empty", "播放列表为空"}, + "playlist.playing": {"Playing", "正在播放"}, + "playlist.remove": {"Remove", "移除"}, + + "subtitle_editor.start": {"Start", "开始"}, + "subtitle_editor.end": {"End", "结束"}, + "subtitle_editor.text": {"Text", "文本"}, + "subtitle_editor.add": {"Add cue", "添加字幕"}, + "subtitle_editor.remove": {"Remove cue", "删除字幕"}, + "subtitle_editor.empty": {"No cues yet", "还没有字幕"}, + "subtitle_editor.badTime": {"Use m:ss.mmm, as 1:05.250", "请使用 m:ss.mmm 格式,例如 1:05.250"}, + "subtitle_editor.order": {"End must be after start", "结束时间须晚于开始时间"}, + "subtitle_editor.overlap": {"Overlaps cue %d", "与第 %d 条重叠"}, + + "screen_recorder_controls.label": {"Screen recording", "屏幕录制"}, + "screen_recorder_controls.idle": {"Ready", "就绪"}, + "screen_recorder_controls.recording": {"Recording", "录制中"}, + "screen_recorder_controls.paused": {"Paused", "已暂停"}, + "screen_recorder_controls.start": {"Record", "开始录制"}, + "screen_recorder_controls.pause": {"Pause", "暂停"}, + "screen_recorder_controls.resume": {"Resume", "继续"}, + "screen_recorder_controls.stop": {"Stop", "停止"}, + + "camera_preview.label": {"Camera", "摄像头"}, + "camera_preview.noSignal": {"No signal", "无信号"}, + "camera_preview.mirror": {"Mirror", "镜像"}, + + "mic_level_meter.label": {"Microphone level", "麦克风电平"}, + "mic_level_meter.peak": {"peak", "峰值"}, + + "device_selector.label": {"Devices", "设备"}, + "device_selector.microphone": {"Microphone", "麦克风"}, + "device_selector.camera": {"Camera", "摄像头"}, + "device_selector.speaker": {"Speaker", "扬声器"}, + "device_selector.choose": {"Choose a device", "选择设备"}, + "device_selector.none": {"No devices found", "未找到设备"}, + + "image_compare.before": {"Before", "之前"}, + "image_compare.after": {"After", "之后"}, + }) +} diff --git a/messages_trading.go b/messages_trading.go new file mode 100644 index 0000000..8f72fe7 --- /dev/null +++ b/messages_trading.go @@ -0,0 +1,161 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "price_text.up": {"up", "上涨"}, + "price_text.down": {"down", "下跌"}, + "price_text.flat": {"unchanged", "平"}, + + "ticker_tape.label": {"Market ticker", "行情滚动条"}, + "ticker_tape.pause_hint": {"Pauses under the pointer or keyboard focus", "指针悬停或键盘聚焦时暂停"}, + "ticker_tape.previous": {"Previous page", "上一页"}, + "ticker_tape.next": {"Next page", "下一页"}, + + "spread_indicator.title": {"Spread", "价差"}, + "spread_indicator.value": {"%s (%s%%)", "%s(%s%%)"}, + "spread_indicator.wide": {"Wide spread", "价差过大"}, + "spread_indicator.crossed": {"Crossed market", "买卖价倒挂"}, + + "bid_ask_bar.label": {"Bid and ask volume", "买卖力量"}, + "bid_ask_bar.buy": {"Buy %s%% · %s", "买 %s%% · %s"}, + "bid_ask_bar.sell": {"%s · Sell %s%%", "%s · 卖 %s%%"}, + + "risk_meter.label": {"Risk level", "风险等级"}, + "risk_meter.value": {"Level %d of %d, %s.", "第 %d 级,共 %d 级,%s。"}, + "risk_meter.level1": {"Low", "低"}, + "risk_meter.level2": {"Moderately low", "中低"}, + "risk_meter.level3": {"Medium", "中"}, + "risk_meter.level4": {"Moderately high", "中高"}, + "risk_meter.level5": {"High", "高"}, + + "margin_indicator.label": {"Margin ratio", "保证金率"}, + "margin_indicator.safe": {"Safe", "安全"}, + "margin_indicator.warning": {"Margin call", "追加保证金"}, + "margin_indicator.liquidation": {"Liquidation", "强制平仓"}, + "margin_indicator.warning_line": {"Warning line", "警戒线"}, + "margin_indicator.liquidation_line": {"Liquidation line", "强平线"}, + "margin_indicator.spoken": {"%s, %s. Warning at %s, liquidation at %s.", "%s,%s。警戒线 %s,强平线 %s。"}, + + "pnl_display.label": {"Profit and loss", "盈亏"}, + "pnl_display.realized": {"Realized", "已实现"}, + "pnl_display.unrealized": {"Unrealized", "未实现"}, + "pnl_display.today": {"Today", "当日"}, + "pnl_display.masked": {"hidden", "已隐藏"}, + "pnl_display.hide": {"Hide amounts", "隐藏金额"}, + "pnl_display.show": {"Show amounts", "显示金额"}, + + "order_book.label": {"Order book", "盘口"}, + "order_book.group": {"Group", "合并档位"}, + "order_book.price": {"Price", "价格"}, + "order_book.size": {"Size", "数量"}, + "order_book.total": {"Total", "累计"}, + "order_book.side1": {"Bid", "买"}, + "order_book.side2": {"Ask", "卖"}, + "order_book.level": {"%s %s, size %s", "%s %s,数量 %s"}, + + "level2_quotes.label": {"Level 2 quotes", "多档行情"}, + "level2_quotes.side1": {"B%d", "买%d"}, + "level2_quotes.side2": {"S%d", "卖%d"}, + "level2_quotes.orders": {"%d orders", "%d 笔委托"}, + + "time_and_sales.title": {"Time and sales", "逐笔成交"}, + "time_and_sales.pause": {"Pause", "暂停跟随"}, + "time_and_sales.resume": {"Resume", "继续跟随"}, + "time_and_sales.resume_count": {"Resume (%d new)", "继续跟随(%d 笔新成交)"}, + "time_and_sales.time": {"Time", "时间"}, + "time_and_sales.price": {"Price", "价格"}, + "time_and_sales.size": {"Size", "数量"}, + "time_and_sales.large": {"large trade", "大单"}, + "time_and_sales.empty": {"No trades yet", "暂无成交"}, + + "depth_ladder.label": {"Price ladder", "价格阶梯"}, + "depth_ladder.bid": {"Bid", "买量"}, + "depth_ladder.price": {"Price", "价格"}, + "depth_ladder.ask": {"Ask", "卖量"}, + "depth_ladder.last": {"Last price", "最新价"}, + "depth_ladder.order1": {"Buy at %s", "以 %s 买入"}, + "depth_ladder.order2": {"Sell at %s", "以 %s 卖出"}, + "depth_ladder.recenter": {"Center on last price", "回到最新价"}, + + "order_entry.label": {"Order entry", "下单"}, + "order_entry.side": {"Side", "方向"}, + "order_entry.buy": {"Buy", "买入"}, + "order_entry.sell": {"Sell", "卖出"}, + "order_entry.side1": {"Buy", "买入"}, + "order_entry.side2": {"Sell", "卖出"}, + "order_entry.type": {"Order type", "订单类型"}, + "order_entry.type0": {"Limit", "限价"}, + "order_entry.type1": {"Market", "市价"}, + "order_entry.type2": {"Stop", "止损"}, + "order_entry.type3": {"Stop limit", "条件限价"}, + "order_entry.quantity": {"Quantity", "数量"}, + "order_entry.price": {"Limit price", "限价"}, + "order_entry.trigger": {"Trigger price", "触发价"}, + "order_entry.tif": {"Time in force", "有效期"}, + "order_entry.tif0": {"Day", "当日有效"}, + "order_entry.tif1": {"Good till canceled", "撤销前有效"}, + "order_entry.tif2": {"Immediate or cancel", "立即成交剩余撤销"}, + "order_entry.tif3": {"Fill or kill", "全部成交否则撤销"}, + "order_entry.estimate": {"Estimated amount", "预估金额"}, + "order_entry.submit1": {"Buy %s", "买入 %s"}, + "order_entry.submit2": {"Sell %s", "卖出 %s"}, + "order_entry.quantity_positive": {"Enter a quantity above zero.", "请输入大于零的数量。"}, + "order_entry.quantity_max": {"At most %s.", "最多 %s。"}, + "order_entry.price_positive": {"Enter a price above zero.", "请输入大于零的价格。"}, + "order_entry.trigger_positive": {"Enter a trigger price above zero.", "请输入大于零的触发价。"}, + "order_entry.trigger_above": {"A buy stop triggers above the last price.", "买入止损的触发价须高于最新价。"}, + "order_entry.trigger_below": {"A sell stop triggers below the last price.", "卖出止损的触发价须低于最新价。"}, + + "quick_trade_buttons.label": {"Quick trade", "快速交易"}, + "quick_trade_buttons.quantity": {"Quantity", "数量"}, + "quick_trade_buttons.side1": {"Buy %s", "买入 %s"}, + "quick_trade_buttons.side2": {"Sell %s", "卖出 %s"}, + "quick_trade_buttons.confirm1": {"Buy %s %s?", "买入 %s %s?"}, + "quick_trade_buttons.confirm2": {"Sell %s %s?", "卖出 %s %s?"}, + "quick_trade_buttons.market": {"The order fills at the market price.", "订单将以市价成交。"}, + + "position_table.symbol": {"Symbol", "代码"}, + "position_table.quantity": {"Quantity", "数量"}, + "position_table.cost": {"Avg cost", "成本价"}, + "position_table.price": {"Price", "现价"}, + "position_table.value": {"Market value", "市值"}, + "position_table.pnl": {"Unrealized P/L", "浮动盈亏"}, + "position_table.pnl_percent": {"P/L %", "盈亏比例"}, + "position_table.action": {"Action", "操作"}, + "position_table.close": {"Close", "平仓"}, + "position_table.empty": {"No positions", "暂无持仓"}, + + "order_table.side": {"Side", "方向"}, + "order_table.market": {"Market", "市价"}, + "order_table.filled": {"Filled", "成交"}, + "order_table.status": {"Status", "状态"}, + "order_table.status0": {"Open", "未成交"}, + "order_table.status1": {"Partly filled", "部分成交"}, + "order_table.status2": {"Filled", "已成交"}, + "order_table.status3": {"Canceled", "已撤销"}, + "order_table.status4": {"Rejected", "已拒绝"}, + "order_table.filter": {"Order status", "委托状态"}, + "order_table.filter0": {"All", "全部"}, + "order_table.filter1": {"Working", "进行中"}, + "order_table.filter2": {"Filled", "已成交"}, + "order_table.filter3": {"Closed", "已关闭"}, + "order_table.cancel": {"Cancel", "撤单"}, + "order_table.empty": {"No orders", "暂无委托"}, + + "trade_history_table.time": {"Time", "时间"}, + "trade_history_table.fee": {"Fee", "手续费"}, + "trade_history_table.range": {"Date range", "日期范围"}, + "trade_history_table.all": {"All dates", "全部日期"}, + "trade_history_table.export": {"Export", "导出"}, + "trade_history_table.empty": {"No trades in this range", "该范围内无成交"}, + + "leverage_slider.label": {"Leverage", "杠杆"}, + "leverage_slider.warning": {"High risk", "高风险"}, + "leverage_slider.danger": {"Very high risk", "极高风险"}, + "leverage_slider.apply": {"Apply", "确认"}, + + "order_confirm_dialog.title": {"Confirm order", "确认下单"}, + "order_confirm_dialog.confirm": {"Place order", "确认下单"}, + "order_confirm_dialog.cancel": {"Cancel", "取消"}, + }) +} diff --git a/mic_level_meter.go b/mic_level_meter.go new file mode 100644 index 0000000..d3aef5c --- /dev/null +++ b/mic_level_meter.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MicLevelMeterOptions configure a MicLevelMeter. +type MicLevelMeterOptions struct { + // Hold keeps the peak mark this long; zero is 1.5s. + Hold time.Duration + // Label names the meter for assistive technology. + Label string +} + +const micSegments = 24 + +type micPeak struct { + level float32 + at time.Time +} + +// MicLevelMeter shows level as segments, held peak and dB. +func MicLevelMeter(c *ui.Context, level float32, opts MicLevelMeterOptions) *ui.Element { + if level < 0 || level > 1 || math.IsNaN(float64(level)) || opts.Hold < 0 { + panic("mujica: MicLevelMeter needs level in 0..1 and Hold >= 0") + } + hold := opts.Hold + if hold == 0 { + hold = 1500 * time.Millisecond + } + k := Tokens(c) + label := opts.Label + if label == "" { + label = msg(c, "mic_level_meter.label") + } + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize) + pk := ui.Local(row, micPeak{}, func() micPeak { return micPeak{} }) + now := c.Now() + switch { + case level >= pk.level: + *pk = micPeak{level, now} + case now.Sub(pk.at) >= hold: + *pk = micPeak{level, now} + default: + // Repaint once when the hold ends; nothing runs while idle. + c.After(hold - now.Sub(pk.at)) + } + peak := pk.level + row.Role(ui.RoleMeter).Label(label).Description(micDB(level) + ", " + msg(c, "mic_level_meter.peak") + " " + micDB(peak)) + row.Children(func() { + ui.Box(c).Height(12).MinWidth(120).Grow(1).Draw(func(p *ui.Painter, r ui.Rect) { + gap := float32(2) + w := (r.W - gap*(micSegments-1)) / micSegments + lit := int(math.Round(float64(level * micSegments))) + mark := int(math.Round(float64(peak*micSegments))) - 1 + for i := range micSegments { + x := r.X + float32(i)*(w+gap) + seg := ui.Rect{X: x, Y: r.Y, W: w, H: r.H} + col := micZone(k, i) + switch { + case i < lit: + p.Fill(seg, col, 1) + case i == mark: + p.Fill(seg, col, 1) + p.Fill(ui.Rect{X: x + 1, Y: r.Y + 2, W: w - 2, H: r.H - 4}, k.Surface, 0) + default: + p.Stroke(seg, k.Border, 1, 1) + } + } + }) + ui.Text(c, micDB(level)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum"). + TextColor(k.TextMuted).MinWidth(56).TextAlign(ui.End).SingleLine() + }) + return row +} + +// micZone colors the safe, loud and clipping zones. +func micZone(k theme.Tokens, i int) ui.Color { + switch { + case i >= micSegments*9/10: + return k.Danger + case i >= micSegments*7/10: + return k.Warning + } + return k.Success +} + +// micDB writes a full-scale level in decibels. +func micDB(v float32) string { + if v <= 0 { + return "-∞ dB" + } + // Adding 0 turns a rounded -0 into 0. + return fmt.Sprintf("%.0f dB", math.Round(20*math.Log10(float64(v)))+0) +} diff --git a/mic_level_meter_test.go b/mic_level_meter_test.go new file mode 100644 index 0000000..1e9736b --- /dev/null +++ b/mic_level_meter_test.go @@ -0,0 +1,62 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestMicLevelMeterPeakHold(t *testing.T) { + level := float32(1) + var e *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + e = MicLevelMeter(c, level, MicLevelMeterOptions{Hold: 40 * time.Millisecond}) + }, 400, 100) + if !tt.HasText("0 dB") { + t.Fatalf("texts %v", tt.Texts()) + } + level = 0.1 + tt.Frame() + if !tt.HasText("-20 dB") { + t.Fatalf("level readout %v", tt.Texts()) + } + if r, ok := tt.Find("Microphone level"); !ok || r.W == 0 { + t.Fatal("meter not named") + } + if !strings.Contains(micTestDescription(e), "peak 0 dB") { + t.Fatal("peak not held") + } + time.Sleep(60 * time.Millisecond) + tt.Frame() + if !strings.Contains(micTestDescription(e), "peak -20 dB") { + t.Fatal("peak did not fall after the hold") + } +} + +// micTestDescription rebuilds the meter's spoken value from its state. +func micTestDescription(e *ui.Element) string { + pk := *ui.Local(e, micPeak{}, func() micPeak { return micPeak{} }) + return "peak " + micDB(pk.level) +} + +func TestMicLevelMeterDB(t *testing.T) { + for v, want := range map[float32]string{0: "-∞ dB", 1: "0 dB", 0.5: "-6 dB"} { + if got := micDB(v); got != want { + t.Errorf("%v: %s, want %s", v, got, want) + } + } + mediaPanics(t, "over full scale", func(c *ui.Context) { MicLevelMeter(c, 1.2, MicLevelMeterOptions{}) }) +} + +func TestMicLevelMeterSnapshot(t *testing.T) { + snapshot(t, "mic_level_meter", 360, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(8).Children(func() { + MicLevelMeter(c, 0.35, MicLevelMeterOptions{}) + MicLevelMeter(c, 0.8, MicLevelMeterOptions{}) + MicLevelMeter(c, 0.97, MicLevelMeterOptions{}) + }) + }) +} diff --git a/minimap.go b/minimap.go new file mode 100644 index 0000000..3ba6490 --- /dev/null +++ b/minimap.go @@ -0,0 +1,134 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// MinimapView is the editor's first line and line count. +type MinimapView struct{ Top, Visible int } + +// MinimapOptions configure a Minimap. +type MinimapOptions struct { + // Language colors the strokes as CodeViewer does. + Language string + // Width is the map's width in DIPs; zero is 96. + Width float32 + Disabled bool +} + +// Line height and character width on the map. +const ( + minimapLine float32 = 2 + minimapChar float32 = 1 +) + +// Minimap draws code small and moves the visible frame. +func Minimap(c *ui.Context, lines []string, view *MinimapView, opts MinimapOptions) ChoiceResult { + if view == nil { + panic("mujica: Minimap needs a view") + } + if opts.Width < 0 || view.Visible < 0 || view.Top < 0 { + panic("mujica: Minimap width, Top and Visible must not be negative") + } + k := Tokens(c) + w := opts.Width + if w == 0 { + w = 96 + } + n := len(lines) + var res ChoiceResult + move := func(top int) { + top = max(0, min(top, n-view.Visible)) + if top != view.Top { + view.Top = top + res.changed = true + slog.Debug("mujica: minimap move", "top", top) + } + } + e := ui.Box(c).Width(w).Shrink(0).MinHeight(theme.MinHitSize*2).Focusable().Clip().Cursor(ui.CursorPointer). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Disabled(opts.Disabled). + Label(msg(c, "minimap.label")).Description(fmt.Sprintf(msg(c, "minimap.range"), min(view.Top+1, n), min(view.Top+view.Visible, n), n)) + res.Element = e + h := e.Bounds().H + offset := minimapOffset(n, *view, h) + if !e.IsDisabled() { + if _, _, held := e.Dragged(); held { + _, y, _ := e.PointerPosition() + move(int((y+offset)/minimapLine) - view.Visible/2) + } + switch { + case e.Shortcut(0, ui.KeyDown): + move(view.Top + 1) + case e.Shortcut(0, ui.KeyUp): + move(view.Top - 1) + case e.Shortcut(0, ui.KeyPageDown): + move(view.Top + max(view.Visible, 1)) + case e.Shortcut(0, ui.KeyPageUp): + move(view.Top - max(view.Visible, 1)) + case e.Shortcut(0, ui.KeyHome): + move(0) + case e.Shortcut(0, ui.KeyEnd): + move(n) + } + } + internal.Ring(e, k.Focus, theme.ControlRadius) + v := *view + e.Draw(func(p *ui.Painter, r ui.Rect) { + if r.H != h { + c.Invalidate() + } + p.Clip(r, theme.ControlRadius, func() { + off := minimapOffset(n, v, r.H) + first := int(off / minimapLine) + for i := first; i < n && float32(i-first)*minimapLine < r.H+minimapLine; i++ { + y := internal.Snap(p, r.Y+float32(i)*minimapLine-off) + minimapStrokes(p, k, opts.Language, lines[i], r.X+6, y, r.X+r.W-6) + } + fy := internal.Snap(p, r.Y+float32(v.Top)*minimapLine-off) + frame := ui.Rect{X: r.X + 1, Y: fy, W: r.W - 2, H: max(internal.Snap(p, float32(v.Visible)*minimapLine), 4)} + p.Fill(frame, k.Selection.Alpha(0.55), 0) + p.Stroke(frame, k.AccentText, 0, 1) + }) + }) + return res +} + +// minimapOffset scrolls a tall map with the view. +func minimapOffset(n int, v MinimapView, h float32) float32 { + total := float32(n) * minimapLine + if total <= h || n <= v.Visible { + return 0 + } + return float32(v.Top) / float32(n-v.Visible) * (total - h) +} + +// minimapStrokes draws a line's tokens as short bars, skipping blanks. +func minimapStrokes(p *ui.Painter, k theme.Tokens, lang, line string, x0, y, x1 float32) { + line = strings.ReplaceAll(line, "\t", " ") + toks, _ := internal.Highlight(lang, line, false) + col := 0 + for _, t := range toks { + runes := []rune(t.Text) + start := -1 + for i, r := range append(runes, ' ') { + if r != ' ' && start < 0 { + start = i + } + if r == ' ' && start >= 0 { + x := x0 + float32(col+start)*minimapChar + if x < x1 { + p.Fill(ui.Rect{X: x, Y: y, W: min(float32(i-start)*minimapChar, x1-x), H: minimapLine * 0.7}, codeTokenColor(k, t.Kind).Alpha(0.75), 0) + } + start = -1 + } + } + col += len(runes) + } +} diff --git a/minimap_test.go b/minimap_test.go new file mode 100644 index 0000000..c538fd0 --- /dev/null +++ b/minimap_test.go @@ -0,0 +1,116 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func minimapLines(n int) []string { + lines := make([]string, n) + for i := range lines { + lines[i] = fmt.Sprintf("\tvalue%d := compute(%d) // note", i, i) + } + return lines +} + +func minimapTester(lines []string, v *MinimapView, disabled bool, moves *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := Minimap(c, lines, v, MinimapOptions{Language: "go", Disabled: disabled}) + r.Element.Height(200) + if r.Changed() { + *moves++ + } + }, 200, 220) +} + +func TestMinimapClickAndDragJump(t *testing.T) { + v := &MinimapView{Visible: 20} + moves := 0 + tt := minimapTester(minimapLines(80), v, false, &moves) + // y=100 is line 50; centered, top is 40. + tt.Press(40, 100) + if v.Top != 40 { + t.Fatalf("press moved top to %d, want 40", v.Top) + } + tt.Move(40, 150) + tt.Release(40, 150) + if v.Top != 60 { + t.Fatalf("drag clamped top to %d, want 60 (80-20)", v.Top) + } + if moves < 2 { + t.Fatalf("Changed reported %d times", moves) + } +} + +func TestMinimapKeys(t *testing.T) { + v := &MinimapView{Visible: 20} + moves := 0 + tt := minimapTester(minimapLines(500), v, false, &moves) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Minimap") { + t.Fatal("Tab did not focus the minimap") + } + tt.Key(0, ui.KeyPageDown) + tt.Key(0, ui.KeyDown) + if v.Top != 21 { + t.Fatalf("PageDown+Down gave %d", v.Top) + } + tt.Key(0, ui.KeyEnd) + if v.Top != 480 { + t.Fatalf("End gave %d", v.Top) + } + tt.Key(0, ui.KeyHome) + if v.Top != 0 { + t.Fatalf("Home gave %d", v.Top) + } +} + +func TestMinimapScrollsWhenTaller(t *testing.T) { + if off := minimapOffset(1000, MinimapView{Top: 980, Visible: 20}, 200); off != 1800 { + t.Fatalf("offset at the end %v, want 1800", off) + } + if off := minimapOffset(50, MinimapView{Top: 10, Visible: 20}, 200); off != 0 { + t.Fatalf("short map offset %v", off) + } +} + +func TestMinimapDisabled(t *testing.T) { + v := &MinimapView{Visible: 20} + moves := 0 + tt := minimapTester(minimapLines(80), v, true, &moves) + tt.Press(40, 100) + tt.Release(40, 100) + if v.Top != 0 || moves != 0 { + t.Fatalf("disabled minimap moved to %d", v.Top) + } +} + +func TestMinimapNegativePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("negative Visible did not panic") + } + }() + minimapTester(nil, &MinimapView{Visible: -1}, false, new(int)) +} + +func TestMinimapSnapshot(t *testing.T) { + snapshot(t, "minimap", 140, 220, func(c *ui.Context) { + v := ui.Local(c.Root(), "snap", func() *MinimapView { return &MinimapView{Top: 12, Visible: 24} }) + ui.Column(c).Padding(12).Children(func() { + Minimap(c, codeMinimapSnapshotLines(), *v, MinimapOptions{Language: "go"}).Element.Height(196) + }) + }) +} + +func codeMinimapSnapshotLines() []string { + var lines []string + for i := range 12 { + lines = append(lines, codeViewerSample...) + lines = append(lines, fmt.Sprintf("// section %d", i), "") + } + return lines +} diff --git a/multi_file_diff_review.go b/multi_file_diff_review.go new file mode 100644 index 0000000..8d1eaf4 --- /dev/null +++ b/multi_file_diff_review.go @@ -0,0 +1,158 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// MultiFileDiffReviewOptions configure a MultiFileDiffReview. +type MultiFileDiffReviewOptions struct { + // Height is 420 DIPs when zero. + Height float32 + Disabled bool +} + +type multiFileDiffState struct{ selected int } + +// MultiFileDiffReview reviews many file changes, one diff at a time. +func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult { + if decisions == nil || len(*decisions) != len(files) { + panic("mujica: MultiFileDiffReview needs one decision per file") + } + if opts.Height < 0 { + panic("mujica: MultiFileDiffReview Height is negative") + } + seen := map[string]bool{} + reviewed := 0 + for i, f := range files { + if f.Path == "" || seen[f.Path] { + panic(fmt.Sprintf("mujica: MultiFileDiffReview file %d needs a unique Path", i)) + } + seen[f.Path] = true + reviewed += agentB2I((*decisions)[i] != FilePending) + } + h := opts.Height + if h == 0 { + h = 420 + } + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = agentCard(c).Height(h).Disabled(opts.Disabled).Label(msg(c, "multi_file_diff_review.title")) + st := ui.Local(r.Element, "mujica.mfdr", func() multiFileDiffState { return multiFileDiffState{} }) + st.selected = max(0, min(st.selected, len(files)-1)) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600) + agentCaption(c, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1) + all := reviewed < len(files) + if Button(c, msg(c, "multi_file_diff_review.accept_all"), ButtonOptions{Variant: Secondary, Disabled: locked || !all}).Clicked() && !locked && all { + for i := range *decisions { + if (*decisions)[i] == FilePending { + (*decisions)[i] = FileAccepted + } + } + r.changed = true + } + }) + frac := 0.0 + if len(files) > 0 { + frac = float64(reviewed) / float64(len(files)) + } + agentBar(c, frac, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files))) + if len(files) == 0 { + agentCaption(c, msg(c, "multi_file_diff_review.empty")) + return + } + ui.Row(c).Grow(1).Shrink(1).Gap(u * 3).AlignItems(ui.Stretch).Children(func() { + row := func(i int) { + if multiFileRow(c, files[i], (*decisions)[i], i == st.selected) && !locked { + st.selected = i + slog.Debug("mujica: multi_file_diff_review select", "path", files[i].Path) + } + } + var list *ui.Element + if len(files) > agentVirtualRows { + list = ui.List(c, nil, len(files), row) + } else { + list = ui.Scroll(c).Children(func() { + for i := range files { + row(i) + } + }) + } + list.Width(240).Shrink(0).FocusGroup(ui.Vertical).Border(theme.BorderWidth, k.Border). + Radius(theme.ControlRadius).Padding(u) + ui.Column(c).Grow(1).Shrink(1).Gap(u * 2).Children(func() { + i := st.selected + if fileChangeDecide(c, &(*decisions)[i], files[i], locked) { + r.changed = true + } + }) + }) + }) + if r.changed { + slog.Debug("mujica: multi_file_diff_review decide", "reviewed", reviewed) + } + return r +} + +// multiFileRow is one listed file; it reports a choice. +func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bool { + k := Tokens(c) + u := Density(c).Unit() + row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(Density(c).ControlHeight()). + Padding(0, u*2).Radius(theme.ControlRadius).Label(f.Path) + internal.Ring(row, k.Focus, theme.ControlRadius) + dc := agentDiffOf(row, f) + switch { + case selected: + row.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) + }) + case row.Hovered(): + row.Background(k.SurfaceHover) + } + row.Children(func() { + agentDecisionIcon(c, d) + ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1) + agentDiffStats(c, dc.add, dc.del) + }) + return row.Clicked() +} + +// fileChangeDecide shows a full diff and its decision buttons. +func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool) bool { + u := Density(c).Unit() + changed := false + holder := ui.Column(c).Key(f.Path).Grow(1).Shrink(1).Gap(u * 2) + dc := agentDiffOf(holder, f) + holder.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, f.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1) + agentDecisionMark(c, *d) + }) + agentDiffView(c, dc.lines, 1e5).Grow(1).Font(theme.MonoFont) + ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + if *d != FilePending { + if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked { + *d, changed = FilePending, true + } + return + } + if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked { + *d, changed = FileRejected, true + } + if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked { + *d, changed = FileAccepted, true + } + }) + }) + return changed +} diff --git a/multi_file_diff_review_test.go b/multi_file_diff_review_test.go new file mode 100644 index 0000000..a8d5455 --- /dev/null +++ b/multi_file_diff_review_test.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func reviewFiles() []FileChange { + return []FileChange{ + fileChangeSample, + {Path: "court/menu.md", Old: "soup\nroast\n", New: "soup\nroast\ntart\n"}, + {Path: "court/old.md", Old: "gone\n"}, + } +} + +func reviewTester(d *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(8).Children(func() { + if MultiFileDiffReview(c, d, files, opts).Changed() { + *changes++ + } + }) + }, 760, 520) +} + +func TestMultiFileDiffReviewPerFileAndAll(t *testing.T) { + files := reviewFiles() + d := make([]FileDecision, len(files)) + changes := 0 + tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &changes) + if !tt.HasText("0 of 3 files reviewed") || !tt.HasText("Guests: 42") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("court/menu.md") + if !tt.HasText("tart") || tt.HasText("Guests: 42") { + t.Fatal("choosing a file did not show its diff") + } + _ = tt.Click("Reject") + if d[1] != FileRejected || changes != 1 || !tt.HasText("1 of 3 files reviewed") { + t.Fatalf("reject: %v", d) + } + _ = tt.Click("Accept all") + if d[0] != FileAccepted || d[1] != FileRejected || d[2] != FileAccepted || !tt.HasText("3 of 3 files reviewed") { + t.Fatalf("accept all: %v", d) + } +} + +func TestMultiFileDiffReviewKeyboard(t *testing.T) { + files := reviewFiles() + d := make([]FileDecision, len(files)) + n := 0 + tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &n) + _ = tt.Click("court/ledger.md") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("gone") || tt.HasText("Guests: 42") { + t.Fatalf("keyboard choice: %q", tt.Texts()) + } +} + +func TestMultiFileDiffReviewDisabledAndEmpty(t *testing.T) { + files := reviewFiles() + d := make([]FileDecision, len(files)) + n := 0 + tt := reviewTester(&d, files, MultiFileDiffReviewOptions{Disabled: true}, &n) + _ = tt.Click("Accept all") + _ = tt.Click("Accept") + _ = tt.Click("court/menu.md") + if n != 0 || d[0] != FilePending || tt.HasText("tart") { + t.Fatalf("disabled review acted: %v", d) + } + var none []FileDecision + tt = reviewTester(&none, nil, MultiFileDiffReviewOptions{}, &n) + if !tt.HasText("No changes to review") { + t.Fatalf("empty: %q", tt.Texts()) + } +} + +func TestMultiFileDiffReviewManyFilesVirtualized(t *testing.T) { + files := make([]FileChange, 100000) + for i := range files { + files[i] = FileChange{Path: fmt.Sprintf("f%06d.txt", i), Old: "a\n", New: "b\n"} + } + d := make([]FileDecision, len(files)) + n := 0 + tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &n) + if got := len(tt.Texts()); got > 400 { + t.Fatalf("%d texts built for 100000 files", got) + } + if !tt.HasText("f000000.txt") { + t.Fatal("first file missing") + } +} + +func TestMultiFileDiffReviewNeedsMatchingDecisions(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + d := []FileDecision{} + n := 0 + reviewTester(&d, reviewFiles(), MultiFileDiffReviewOptions{}, &n) +} + +func TestMultiFileDiffReviewSnapshot(t *testing.T) { + snapshot(t, "multi_file_diff_review", 760, 420, func(c *ui.Context) { + files := reviewFiles() + d := []FileDecision{FilePending, FileAccepted, FileRejected} + ui.Column(c).Padding(12).Children(func() { + MultiFileDiffReview(c, &d, files, MultiFileDiffReviewOptions{Height: 390}) + }) + }) +} diff --git a/network_graph.go b/network_graph.go new file mode 100644 index 0000000..c9f368e --- /dev/null +++ b/network_graph.go @@ -0,0 +1,263 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// GraphNode is a node placed at X, Y unless forced. +type GraphNode struct { + ID, Label string + X, Y float64 +} + +// GraphEdge joins two nodes by ID. +type GraphEdge struct{ From, To string } + +// NetworkGraphOptions configure a NetworkGraph. +type NetworkGraphOptions struct { + Frame ChartFrame + // Force lays nodes out by a simple force simulation. + Force bool +} + +// NetworkGraphState holds node positions, zoom and pan. +type NetworkGraphState struct { + pos map[string][2]float64 + lo, hi [2]float64 + layout string + zoom float32 + panX float32 + panY float32 + drag string + dragging bool +} + +// Position returns where node id is, in layout units. +func (s *NetworkGraphState) Position(id string) (x, y float64, ok bool) { + p, ok := s.pos[id] + return p[0], p[1], ok +} + +// Zoom returns the view's scale, 1 when fitted. +func (s *NetworkGraphState) Zoom() float32 { return max(s.zoom, 0) } + +// ResetView fits the graph again. +func (s *NetworkGraphState) ResetView() { s.zoom, s.panX, s.panY = 1, 0, 0 } + +// networkForceLimit bounds the nodes the O(n²) force layout takes. +const networkForceLimit = 500 + +// NetworkGraph draws nodes and edges; drag, pan and zoom. +func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges []GraphEdge, opts NetworkGraphOptions) ChartResult { + at := map[string]int{} + for i, n := range nodes { + if _, dup := at[n.ID]; dup || n.ID == "" || n.Label == "" { + panic("mujica: NetworkGraph nodes need unique IDs and labels") + } + at[n.ID] = i + } + links := make([][2]int, len(edges)) + for i, e := range edges { + f, okf := at[e.From] + t, okt := at[e.To] + if !okf || !okt { + panic("mujica: NetworkGraph edge ends must be node IDs") + } + links[i] = [2]int{f, t} + } + if opts.Force && len(nodes) > networkForceLimit { + panic("mujica: NetworkGraph Force takes at most 500 nodes") + } + if s.zoom == 0 { + s.zoom = 1 + } + if key := chartID(nodes) + chartID(edges) + fmt.Sprint(opts.Force); s.layout != key { + s.layout, s.pos = key, networkLayout(nodes, links, opts.Force) + // The view fits the layout; dragging does not refit. + s.lo, s.hi = [2]float64{math.Inf(1), math.Inf(1)}, [2]float64{math.Inf(-1), math.Inf(-1)} + for _, p := range s.pos { + s.lo, s.hi = [2]float64{min(s.lo[0], p[0]), min(s.lo[1], p[1])}, [2]float64{max(s.hi[0], p[0]), max(s.hi[1], p[1])} + } + slog.Debug("mujica: network_graph layout", "nodes", len(nodes)) + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + lo, hi := s.lo, s.hi + // screen maps a layout point into r, zoomed and panned. + screen := func(r ui.Rect, p [2]float64) (float32, float32) { + span := max(hi[0]-lo[0], hi[1]-lo[1], 1e-9) + fit := float64(min(r.W, r.H)-48) / span + cx, cy := (lo[0]+hi[0])/2, (lo[1]+hi[1])/2 + x := float32((p[0]-cx)*fit)*s.zoom + r.X + r.W/2 + s.panX + y := float32((p[1]-cy)*fit)*s.zoom + r.Y + r.H/2 + s.panY + return x, y + } + unit := func(r ui.Rect) float64 { + return max(hi[0]-lo[0], hi[1]-lo[1], 1e-9) / float64(min(r.W, r.H)-48) / float64(s.zoom) + } + pt := func(r ui.Rect, i int) (float32, float32) { return screen(r, s.pos[nodes[i].ID]) } + spec := chartSpec{n: len(nodes)} + spec.readout = func(i int) string { + var near []string + for _, l := range links { + switch i { + case l[0]: + near = append(near, nodes[l[1]].Label) + case l[1]: + near = append(near, nodes[l[0]].Label) + } + } + return fmt.Sprintf(msg(c, "network_graph.readout"), nodes[i].Label, len(near), strings.Join(near, ", ")) + } + spec.pick = func(r ui.Rect, x, y float32) int { + return internal.Nearest(len(nodes), func(i int) (float32, float32) { return pt(r, i) }, x, y, 12, false) + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + p.Clip(r, 0, func() { + for _, l := range links { + x0, y0 := pt(r, l[0]) + x1, y1 := pt(r, l[1]) + var line ui.Path + line.MoveTo(internal.Snap(p, x0), internal.Snap(p, y0)).LineTo(internal.Snap(p, x1), internal.Snap(p, y1)) + w, col := float32(1.25), k.ControlBorder + if active == l[0] || active == l[1] { + w, col = 2.5, k.Text + } + p.StrokePath(&line, w, col) + } + for i, n := range nodes { + x, y := pt(r, i) + internal.Marker(p, 0, x, y, 14, k.Surface) + internal.Marker(p, 0, x, y, 11, pal[i%len(pal)]) + if i == active { + var ring ui.Path + ring.Circle(internal.Snap(p, x), internal.Snap(p, y), 10) + p.StrokePath(&ring, 2, k.Text) + } + chartText(p, n.Label, x, y+9, size, k.Text, 0.5, 0) + } + }) + } + res, plot := ch.build(spec) + if plot == nil || plot.IsDisabled() { + return res + } + hit := func(x, y float32) string { + b := plot.Bounds() + if i := spec.pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 { + return nodes[i].ID + } + return "" + } + networkInput(plot, s, ch.st.focus, nodes, unit, hit) + return res +} + +// networkInput drags, pans, zooms and moves the focused node. +func networkInput(plot *ui.Element, s *NetworkGraphState, focus int, nodes []GraphNode, unit func(ui.Rect) float64, hit func(x, y float32) string) { + b := plot.Bounds() + r := ui.Rect{W: b.W, H: b.H} + move := func(id string, dx, dy float32) { + p := s.pos[id] + s.pos[id] = [2]float64{p[0] + float64(dx)*unit(r), p[1] + float64(dy)*unit(r)} + } + zoom := func(f float32) { s.zoom = max(0.25, min(8, s.zoom*f)) } + if dx, dy, held := plot.Dragged(); held { + if !s.dragging { + x, y, _ := plot.PointerPosition() + s.dragging, s.drag = true, hit(x, y) + } + if s.drag != "" { + move(s.drag, dx, dy) + } else { + s.panX, s.panY = s.panX+dx, s.panY+dy + } + } else if s.dragging { + s.dragging = false + slog.Debug("mujica: network_graph drag", "node", s.drag) + } + plot.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind != ui.InputScroll || ev.DY == 0 || plot.IsDisabled() { + return false + } + if ev.DY < 0 { + zoom(1.2) + } else { + zoom(1 / 1.2) + } + return true + }) + switch { + case plot.Shortcut(0, ui.KeyEqual), plot.Shortcut(ui.Shift, ui.KeyEqual): + zoom(1.2) + case plot.Shortcut(0, ui.KeyMinus): + zoom(1 / 1.2) + case plot.Shortcut(0, ui.Key0): + s.ResetView() + } + if focus < 0 || focus >= len(nodes) { + return + } + for key, d := range map[ui.Key][2]float32{ui.KeyLeft: {-12, 0}, ui.KeyRight: {12, 0}, ui.KeyUp: {0, -12}, ui.KeyDown: {0, 12}} { + if plot.Shortcut(ui.Shift, key) { + move(nodes[focus].ID, d[0], d[1]) + } + } +} + +// networkLayout uses X, Y, or fixed force steps. +func networkLayout(nodes []GraphNode, links [][2]int, force bool) map[string][2]float64 { + pos := make([][2]float64, len(nodes)) + for i, n := range nodes { + pos[i] = [2]float64{n.X, n.Y} + if force { + a := 2 * math.Pi * float64(i) / float64(len(nodes)) + pos[i] = [2]float64{math.Cos(a), math.Sin(a)} + } + } + if force && len(nodes) > 1 { + ideal := math.Sqrt(4.0 / float64(len(nodes))) + for step := range 300 { + heat := 0.1 * (1 - float64(step)/300) + move := make([][2]float64, len(nodes)) + for i := range pos { + for j := range pos { + if i == j { + continue + } + dx, dy := pos[i][0]-pos[j][0], pos[i][1]-pos[j][1] + d := max(math.Hypot(dx, dy), 1e-6) + f := ideal * ideal / d + move[i][0], move[i][1] = move[i][0]+dx/d*f, move[i][1]+dy/d*f + } + } + for _, l := range links { + dx, dy := pos[l[0]][0]-pos[l[1]][0], pos[l[0]][1]-pos[l[1]][1] + d := max(math.Hypot(dx, dy), 1e-6) + f := d * d / ideal + move[l[0]][0], move[l[0]][1] = move[l[0]][0]-dx/d*f, move[l[0]][1]-dy/d*f + move[l[1]][0], move[l[1]][1] = move[l[1]][0]+dx/d*f, move[l[1]][1]+dy/d*f + } + for i, m := range move { + d := max(math.Hypot(m[0], m[1]), 1e-9) + pos[i][0] += m[0] / d * min(d, heat) + pos[i][1] += m[1] / d * min(d, heat) + } + } + } + out := make(map[string][2]float64, len(nodes)) + for i, n := range nodes { + out[n.ID] = pos[i] + } + return out +} diff --git a/network_graph_test.go b/network_graph_test.go new file mode 100644 index 0000000..7b45133 --- /dev/null +++ b/network_graph_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var graphNodes = []GraphNode{{"a", "Abbot", 0, 0}, {"b", "Prior", 10, 0}, {"c", "Cellarer", 10, 10}, {"d", "Novice", 0, 10}} +var graphEdges = []GraphEdge{{"a", "b"}, {"a", "c"}, {"c", "d"}} + +func TestNetworkGraphReadoutAndKeys(t *testing.T) { + var s NetworkGraphState + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "House") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Abbot · 2 links: Prior, Cellarer" { + t.Fatalf("readout %q", got) + } + x0, y0, _ := s.Position("a") + tt.Key(ui.Shift, ui.KeyRight) + if x, y, _ := s.Position("a"); x <= x0 || y != y0 { + t.Fatalf("Shift+Right left the node at %v,%v", x, y) + } + tt.Key(0, ui.KeyEqual) + if s.Zoom() < 1.19 { + t.Fatalf("zoom %v", s.Zoom()) + } + tt.Key(0, ui.Key0) + if s.Zoom() != 1 { + t.Fatalf("reset zoom %v", s.Zoom()) + } +} + +func TestNetworkGraphDragNodeAndPan(t *testing.T) { + var s NetworkGraphState + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + // The 10×10 layout fits 240-48 DIPs, centered in the plot. + cx, cy := b.X+b.W/2, b.Y+120 + ax, ay := cx-96, cy-96 + tt.Press(ax, ay) + tt.Move(ax+20, ay) + tt.Move(ax+48, ay) + tt.Release(ax+48, ay) + if x, y, _ := s.Position("a"); math.Abs(x-2.5) > 0.2 || math.Abs(y) > 0.01 { + t.Fatalf("dragged node to %v,%v", x, y) + } + tt.Press(cx, cy) // the empty middle pans + tt.Move(cx+30, cy+10) + tt.Release(cx+30, cy+10) + if x, _, _ := s.Position("b"); x != 10 { + t.Fatalf("panning moved a node: %v", x) + } + if got := s.panX; got != 30 { + t.Fatalf("pan %v", got) + } +} + +func TestNetworkGraphForceLayout(t *testing.T) { + pos := networkLayout(graphNodes, [][2]int{{0, 1}, {0, 2}, {2, 3}}, true) + again := networkLayout(graphNodes, [][2]int{{0, 1}, {0, 2}, {2, 3}}, true) + for id, p := range pos { + if again[id] != p || math.IsNaN(p[0]) { + t.Fatalf("layout not deterministic for %s", id) + } + } + d := func(a, b string) float64 { return math.Hypot(pos[a][0]-pos[b][0], pos[a][1]-pos[b][1]) } + if d("c", "d") >= d("b", "d") { + t.Fatalf("linked nodes not closer: %v vs %v", d("c", "d"), d("b", "d")) + } +} + +func TestNetworkGraphBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an unknown edge end") + } + }() + var s NetworkGraphState + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NetworkGraph(c, &s, graphNodes, []GraphEdge{{"a", "z"}}, NetworkGraphOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestNetworkGraphSnapshot(t *testing.T) { + snapshot(t, "network_graph", 520, 320, func(c *ui.Context) { + var s NetworkGraphState + ui.Column(c).Padding(16).Children(func() { + NetworkGraph(c, &s, graphNodes, append(graphEdges, GraphEdge{"b", "d"}, GraphEdge{"b", "c"}), NetworkGraphOptions{Frame: ChartFrame{Label: "House"}, Force: true}) + }) + }) +} diff --git a/order_book.go b/order_book.go new file mode 100644 index 0000000..dbf2871 --- /dev/null +++ b/order_book.go @@ -0,0 +1,260 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// OrderSide is the buy or sell side. +type OrderSide int + +const ( + SideBuy OrderSide = iota + 1 + SideSell +) + +// sign is +1 for buying and -1 for selling. +func (s OrderSide) sign() int { + switch s { + case SideBuy: + return 1 + case SideSell: + return -1 + } + panic(fmt.Sprintf("mujica: unknown OrderSide %d", s)) +} + +// BookLevel is a price and the size resting there. +type BookLevel struct { + Price, Size float64 +} + +// OrderBookData is the book; bump Version on in-place changes. +type OrderBookData struct { + Bids, Asks []BookLevel + Version int +} + +// OrderBookOptions configure an OrderBook. +type OrderBookOptions struct { + // Decimals of prices and sizes; zero means 2 and 0. + Decimals, SizeDecimals int + // Depth is the levels shown each side; zero means 8. + Depth int + // Groups are the offered aggregation steps; nil shows raw levels. + Groups []float64 + Disabled bool +} + +// OrderBookState is an OrderBook's chosen grouping and its aggregated view. +type OrderBookState struct { + // Group is the aggregation step; zero takes Groups[0]. + Group float64 + cache orderBookCache +} + +type orderBookCache struct { + key bookKey + valid bool + bids, asks []BookLevel +} + +// bookKey tells when a book or its grouping changed. +type bookKey struct { + bid, ask *BookLevel + nb, na int + version int + group float64 +} + +func bookKeyOf(book OrderBookData, group float64) bookKey { + first := func(ls []BookLevel) *BookLevel { + if len(ls) == 0 { + return nil + } + return &ls[0] + } + return bookKey{first(book.Bids), first(book.Asks), len(book.Bids), len(book.Asks), book.Version, group} +} + +// OrderBookResult is a built OrderBook and its clicked level. +type OrderBookResult struct { + Element *ui.Element + price float64 + side OrderSide +} + +// Clicked returns the clicked level's price and side. +func (r OrderBookResult) Clicked() (price float64, side OrderSide, ok bool) { + return r.price, r.side, r.side != 0 +} + +// OrderBook shows clickable asks over bids with depth bars. +func OrderBook(c *ui.Context, s *OrderBookState, book OrderBookData, opts OrderBookOptions) OrderBookResult { + dec := priceDecimals("OrderBook", opts.Decimals) + if opts.SizeDecimals < 0 || opts.Depth < 0 { + panic("mujica: OrderBook SizeDecimals and Depth cannot be negative") + } + depth := opts.Depth + if depth == 0 { + depth = 8 + } + if len(opts.Groups) > 0 && s.Group == 0 { + s.Group = opts.Groups[0] + } + if s.Group != 0 && !slices.Contains(opts.Groups, s.Group) { + panic(fmt.Sprintf("mujica: OrderBook Group %v is not one of Groups", s.Group)) + } + orderBookRefresh(s, book, dec) + k := Tokens(c) + d := Density(c) + var r OrderBookResult + r.Element = ui.Column(c).Gap(d.Unit()).MinWidth(240).Disabled(opts.Disabled).Role(ui.RoleTable).Label(msg(c, "order_book.label")) + r.Element.Children(func() { + if len(opts.Groups) > 0 { + var choices []SelectOption[float64] + for _, g := range opts.Groups { + choices = append(choices, SelectOption[float64]{Value: g, Label: statisticFormat(g, dec)}) + } + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "order_book.group")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + if Select(c, &s.Group, choices, SelectOptions{Label: msg(c, "order_book.group"), Disabled: opts.Disabled}).Changed() { + slog.Debug("mujica: order_book group", "step", s.Group) + } + }) + } + asks := s.cache.asks[:min(depth, len(s.cache.asks))] + bids := s.cache.bids[:min(depth, len(s.cache.bids))] + total := max(orderBookSum(asks), orderBookSum(bids)) + orderBookHeader(c) + list := ui.Column(c).FocusGroup(ui.Vertical) + list.Children(func() { + cum := orderBookSum(asks) + for i := len(asks) - 1; i >= 0; i-- { + orderBookRow(c, &r, asks[i], cum, total, SideSell, dec, opts.SizeDecimals) + cum -= asks[i].Size + } + if len(asks) > 0 && len(bids) > 0 { + ui.Row(c).Padding(d.Unit(), d.Unit()*2).BorderWidth(theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + SpreadIndicator(c, bids[0].Price, asks[0].Price, SpreadIndicatorOptions{Decimals: dec}) + }) + } + cum = 0 + for _, l := range bids { + cum += l.Size + orderBookRow(c, &r, l, cum, total, SideBuy, dec, opts.SizeDecimals) + } + }) + }) + if r.side != 0 { + slog.Debug("mujica: order_book clicked", "side", r.side) + } + return r +} + +func orderBookSum(ls []BookLevel) float64 { + var t float64 + for _, l := range ls { + t += l.Size + } + return t +} + +// orderBookHeader names the columns. +func orderBookHeader(c *ui.Context) { + k := Tokens(c) + d := Density(c) + ui.Row(c).Padding(0, d.Unit()*2).Children(func() { + for i, key := range []string{"order_book.price", "order_book.size", "order_book.total"} { + t := ui.Text(c, msg(c, key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1).Basis(0) + if i > 0 { + t.TextAlign(ui.End) + } + } + }) +} + +// orderBookRow is one clickable level with its depth bar. +func orderBookRow(c *ui.Context, r *OrderBookResult, l BookLevel, cum, total float64, side OrderSide, dec, sizeDec int) { + k := Tokens(c) + d := Density(c) + col := trendColor(c, side.sign()) + price := statisticFormat(l.Price, dec) + b := ui.ButtonBase(c).Key(fmt.Sprint(side, price)).MinHeight(d.ControlHeight()). + Padding(0, d.Unit()*2).Radius(theme.ControlRadius). + Label(fmt.Sprintf(msg(c, "order_book.level"), msg(c, fmt.Sprintf("order_book.side%d", side)), price, statisticFormat(l.Size, sizeDec))) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + share := float32(0) + if total > 0 { + share = float32(cum / total) + } + b.Draw(func(p *ui.Painter, rect ui.Rect) { + w := rect.W * share + p.Fill(ui.Rect{X: rect.X + rect.W - w, Y: rect.Y + 2, W: w, H: rect.H - 4}, col.Alpha(0.14), 0) + }) + b.Children(func() { + ui.Text(c, price).TextColor(col).FontFeatures("tnum").FontWeight(600).Grow(1).Basis(0).SingleLine() + ui.Text(c, statisticFormat(l.Size, sizeDec)).FontFeatures("tnum").TextAlign(ui.End).Grow(1).Basis(0).SingleLine() + ui.Text(c, statisticFormat(cum, sizeDec)).FontFeatures("tnum").TextColor(k.TextMuted).TextAlign(ui.End).Grow(1).Basis(0).SingleLine() + }) + if b.Clicked() && !b.IsDisabled() { + r.price, r.side = l.Price, side + } +} + +// orderBookRefresh regroups only when book or grouping changed. +func orderBookRefresh(s *OrderBookState, book OrderBookData, dec int) { + key := bookKeyOf(book, s.Group) + if s.cache.valid && s.cache.key == key { + return + } + s.cache = orderBookCache{key: key, valid: true, + bids: orderBookGroup(book.Bids, s.Group, dec, false), asks: orderBookGroup(book.Asks, s.Group, dec, true)} +} + +// orderBookGroup merges levels into group steps, best first. +func orderBookGroup(ls []BookLevel, group float64, dec int, up bool) []BookLevel { + scale := math.Pow(10, float64(dec)) + step := int64(1) + if group != 0 { + step = int64(math.Round(group * scale)) + if step < 1 || math.Abs(group*scale-float64(step)) > 1e-6 { + panic(fmt.Sprintf("mujica: OrderBook group %v is not a whole number of %d-decimal ticks", group, dec)) + } + } + sizes := map[int64]float64{} + for _, l := range ls { + if l.Price < 0 || l.Size < 0 { + panic("mujica: OrderBook levels need prices and sizes of zero or more") + } + t := int64(math.Round(l.Price * scale)) + // Bids round down, asks up: groups never beat real prices. + b := t / step * step + if up && b < t { + b += step + } + sizes[b] += l.Size + } + out := make([]BookLevel, 0, len(sizes)) + for t, sz := range sizes { + out = append(out, BookLevel{Price: float64(t) / scale, Size: sz}) + } + slices.SortFunc(out, func(a, b BookLevel) int { + if up { + return cmp.Compare(a.Price, b.Price) + } + return cmp.Compare(b.Price, a.Price) + }) + return out +} diff --git a/order_book_test.go b/order_book_test.go new file mode 100644 index 0000000..b0d6485 --- /dev/null +++ b/order_book_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func orderBookSample() OrderBookData { + return OrderBookData{ + Bids: []BookLevel{{100.00, 5}, {99.99, 3}, {99.94, 2}, {99.90, 4}}, + Asks: []BookLevel{{100.01, 1}, {100.02, 2}, {100.07, 6}}, + } +} + +func TestOrderBookClickReportsPrice(t *testing.T) { + var s OrderBookState + var price float64 + var side OrderSide + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { + if p, sd, ok := OrderBook(c, &s, orderBookSample(), OrderBookOptions{}).Clicked(); ok { + price, side = p, sd + } + }) + if err := tt.Click("Ask 100.02, size 2"); err != nil { + t.Fatal(err) + } + if price != 100.02 || side != SideSell { + t.Fatalf("clicked %v %v", price, side) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if price != 100.01 || side != SideSell { + t.Fatalf("keyboard %v %v", price, side) + } + if !tt.HasText("0.01 (0.010%)") { + t.Fatalf("spread missing: %q", tt.Texts()) + } +} + +func TestOrderBookGroupsLevelsByTicks(t *testing.T) { + s := OrderBookState{Group: 0.05} + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { + OrderBook(c, &s, orderBookSample(), OrderBookOptions{Groups: []float64{0.01, 0.05, 0.1}}) + }) + // Bids round down, asks round up. + want := []BookLevel{{100.00, 5}, {99.95, 3}, {99.90, 6}} + for i, l := range want { + if s.cache.bids[i] != l { + t.Fatalf("bids %v", s.cache.bids) + } + } + if s.cache.asks[0] != (BookLevel{100.05, 3}) || s.cache.asks[1] != (BookLevel{100.10, 6}) { + t.Fatalf("asks %v", s.cache.asks) + } + if !tt.HasText("99.95") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestOrderBookAggregatesOnlyOnChange(t *testing.T) { + var s OrderBookState + book := orderBookSample() + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { OrderBook(c, &s, book, OrderBookOptions{}) }) + first := &s.cache.bids[0] + tt.Frame() + if &s.cache.bids[0] != first { + t.Fatal("regrouped without a change") + } + book.Bids[0].Size = 9 + book.Version++ + tt.Frame() + if s.cache.bids[0].Size != 9 { + t.Fatal("version change ignored") + } +} + +func TestOrderBookDisabledAndBadGroup(t *testing.T) { + var s OrderBookState + clicked := false + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { + _, _, clicked = OrderBook(c, &s, orderBookSample(), OrderBookOptions{Disabled: true}).Clicked() + }) + _ = tt.Click("Bid 100.00, size 5") + if clicked { + t.Fatal("disabled book clicked") + } + defer func() { + if recover() == nil { + t.Fatal("off-tick group accepted") + } + }() + bad := OrderBookState{Group: 0.015} + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { + OrderBook(c, &bad, orderBookSample(), OrderBookOptions{Groups: []float64{0.015}}) + }) +} + +func TestOrderBookSnapshot(t *testing.T) { + snapshot(t, "order_book", 320, 420, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + s := ui.Local(c.Root(), "snap", func() OrderBookState { return OrderBookState{} }) + OrderBook(c, s, orderBookSample(), OrderBookOptions{Depth: 4, Groups: []float64{0.01, 0.05}}) + }) + }) +} + +// A 100,000-level book builds only the shown depth. +func TestOrderBookLargeBook(t *testing.T) { + var s OrderBookState + book := OrderBookData{} + for i := range 100_000 { + book.Bids = append(book.Bids, BookLevel{Price: 1000 - float64(i)/100, Size: 1}) + book.Asks = append(book.Asks, BookLevel{Price: 1000.01 + float64(i)/100, Size: 1}) + } + tt := tradingTester(GreenUp, 400, 900, func(c *ui.Context) { OrderBook(c, &s, book, OrderBookOptions{}) }) + if n := len(tt.Texts()); n > 80 { + t.Fatalf("built %d texts", n) + } + if len(s.cache.bids) != 100_000 || s.cache.bids[0].Price != 1000 { + t.Fatalf("bids %d", len(s.cache.bids)) + } +} diff --git a/order_confirm_dialog.go b/order_confirm_dialog.go new file mode 100644 index 0000000..357b5b0 --- /dev/null +++ b/order_confirm_dialog.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// OrderSummaryLine is one labeled value of an order summary. +type OrderSummaryLine struct { + Label, Value string +} + +// OrderConfirmDialogOptions configure an OrderConfirmDialog. Lines are required. +type OrderConfirmDialogOptions struct { + // Title is "Confirm order" when empty. + Title string + Lines []OrderSummaryLine + // Risk is a warning shown above the actions. + Risk string + // Acknowledge, when set, must be ticked before confirming. + Acknowledge string + // ConfirmLabel is "Place order" when empty. + ConfirmLabel string +} + +type orderConfirmState struct{ ack bool } + +// OrderConfirmDialog summarizes an order; Cancel has the focus. +func OrderConfirmDialog(c *ui.Context, open *bool, opts OrderConfirmDialogOptions) AlertDialogResult { + if len(opts.Lines) == 0 { + panic("mujica: OrderConfirmDialog needs summary Lines") + } + var res AlertDialogResult + k := Tokens(c) + u := Density(c).Unit() + title, confirm := opts.Title, opts.ConfirmLabel + if title == "" { + title = msg(c, "order_confirm_dialog.title") + } + if confirm == "" { + confirm = msg(c, "order_confirm_dialog.confirm") + } + was := *open + var st *orderConfirmState + Dialog(c, open, DialogOptions{Title: title, KeepOnBackdrop: true, HideClose: true, Actions: func() { + if Button(c, msg(c, "order_confirm_dialog.cancel"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { + res.Canceled = true + } + locked := opts.Acknowledge != "" && !st.ack + if Button(c, confirm, ButtonOptions{Disabled: locked}).Clicked() && !locked { + res.Confirmed = true + } + }}, func() { + list := ui.Column(c) + // The tick lives with the open dialog only. + st = ui.Local(list, "ack", func() orderConfirmState { return orderConfirmState{} }) + list.Children(func() { + for _, l := range opts.Lines { + ui.Row(c).Gap(u*4).MinHeight(Density(c).ControlHeight()).AlignItems(ui.Center). + BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + ui.Text(c, l.Label).TextColor(k.TextMuted).Grow(1) + ui.Text(c, l.Value).FontFeatures("tnum").FontWeight(600) + }) + } + }) + if opts.Risk != "" { + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Padding(u * 3).Radius(theme.ControlRadius).Background(k.Warning.Alpha(0.12)).Children(func() { + ui.Icon(c, icons.Must("triangle-alert")).Size(16, 16).TextColor(k.Warning).Shrink(0) + ui.Text(c, opts.Risk).Grow(1) + }) + } + if opts.Acknowledge != "" { + Checkbox(c, &st.ack, opts.Acknowledge, CheckboxOptions{}) + } + }) + if was && !*open && !res.Confirmed { + res.Canceled = true + } + if res.Confirmed || res.Canceled { + *open = false + slog.Debug("mujica: order_confirm_dialog close", "confirmed", res.Confirmed) + } + return res +} diff --git a/order_confirm_dialog_test.go b/order_confirm_dialog_test.go new file mode 100644 index 0000000..68d48f2 --- /dev/null +++ b/order_confirm_dialog_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func orderConfirmTester(open *bool, opts OrderConfirmDialogOptions, res *AlertDialogResult) *ui.Tester { + return tradingTester(GreenUp, 600, 500, func(c *ui.Context) { + if r := OrderConfirmDialog(c, open, opts); r.Confirmed || r.Canceled { + *res = r + } + }) +} + +var orderConfirmLines = []OrderSummaryLine{{"Symbol", "MUJC"}, {"Side", "Buy"}, {"Quantity", "100"}, {"Est. amount", "10,020.00"}} + +func TestOrderConfirmDialogFocusesCancel(t *testing.T) { + open := true + var res AlertDialogResult + tt := orderConfirmTester(&open, OrderConfirmDialogOptions{Lines: orderConfirmLines, Risk: "Leveraged products can lose more than the deposit."}, &res) + if !tt.Focused("Cancel") || !tt.HasText("10,020.00") || !tt.HasText("Leveraged products can lose more than the deposit.") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if !res.Canceled || open { + t.Fatalf("Enter on Cancel: %+v open %v", res, open) + } + open, res = true, AlertDialogResult{} + tt.Frame() + if err := tt.Click("Place order"); err != nil { + t.Fatal(err) + } + if !res.Confirmed || open { + t.Fatalf("confirm: %+v", res) + } +} + +func TestOrderConfirmDialogAcknowledgeAndEscape(t *testing.T) { + open := true + var res AlertDialogResult + tt := orderConfirmTester(&open, OrderConfirmDialogOptions{Lines: orderConfirmLines, Acknowledge: "I understand the risk"}, &res) + _ = tt.Click("Place order") + if res.Confirmed || !open { + t.Fatal("confirmed before acknowledging") + } + _ = tt.Click("I understand the risk") + _ = tt.Click("Place order") + if !res.Confirmed { + t.Fatal("acknowledged confirm refused") + } + open, res = true, AlertDialogResult{} + tt.Frame() + _ = tt.Click("Place order") + if res.Confirmed { + t.Fatal("acknowledgement survived reopening") + } + tt.Key(0, ui.KeyEscape) + if !res.Canceled || open { + t.Fatalf("escape: %+v", res) + } + defer func() { + if recover() == nil { + t.Fatal("empty summary accepted") + } + }() + open = true + orderConfirmTester(&open, OrderConfirmDialogOptions{}, &res) +} + +func TestOrderConfirmDialogSnapshot(t *testing.T) { + open := true + snapshot(t, "order_confirm_dialog", 560, 480, func(c *ui.Context) { + OrderConfirmDialog(c, &open, OrderConfirmDialogOptions{Lines: orderConfirmLines, + Risk: "Leveraged products can lose more than the deposit.", Acknowledge: "I understand the risk"}) + }) +} diff --git a/order_entry.go b/order_entry.go new file mode 100644 index 0000000..0c95398 --- /dev/null +++ b/order_entry.go @@ -0,0 +1,222 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// OrderType is limit, market, stop or stop limit. +type OrderType int + +const ( + OrderLimit OrderType = iota + OrderMarket + OrderStop + OrderStopLimit +) + +// TimeInForce is how long an order rests. +type TimeInForce int + +const ( + TIFDay TimeInForce = iota + TIFGoodTillCanceled + TIFImmediateOrCancel + TIFFillOrKill +) + +// OrderDraft is an order as entered. +type OrderDraft struct { + Side OrderSide + Type OrderType + Quantity float64 + Price float64 + Trigger float64 + TIF TimeInForce +} + +// OrderEntryOptions configure an OrderEntry. Symbol is required. +type OrderEntryOptions struct { + Symbol string + // Last is the last price; zero means unknown. + Last float64 + // TickSize and LotSize step prices and quantities; zero: 0.01, 1. + TickSize, LotSize float64 + // MaxQuantity caps the quantity; zero means no cap. + MaxQuantity float64 + // Initial is what Reset restores; a zero Side means buy. + Initial OrderDraft + // Validate adds the caller's rules, returning a message or "". + Validate func(o OrderDraft) string + // Check starts an async check, finished with OrderEntryState.Resolve. + Check func(o OrderDraft, version uint64) + Disabled bool +} + +// OrderEntryState holds the draft and its form state. +type OrderEntryState struct { + Draft OrderDraft + Form FormState + started bool +} + +// Resolve delivers Check's result; stale versions return false. +func (s *OrderEntryState) Resolve(version uint64, err string) bool { + return s.Form.Resolve("quantity", version, err) +} + +// EndSubmit finishes a submit; a formError shows, keeping the draft. +func (s *OrderEntryState) EndSubmit(formError string) { s.Form.EndSubmit(formError) } + +// OrderEntryResult is a built OrderEntry and its submitted order. +type OrderEntryResult struct { + Element *ui.Element + order OrderDraft + submitted bool +} + +// Submitted returns the checked order; call EndSubmit once placed. +func (r OrderEntryResult) Submitted() (OrderDraft, bool) { return r.order, r.submitted } + +// OrderEntry edits an order and submits it once checked. +func OrderEntry(c *ui.Context, s *OrderEntryState, opts OrderEntryOptions) OrderEntryResult { + if opts.Symbol == "" { + panic("mujica: OrderEntry needs a Symbol") + } + if opts.TickSize < 0 || opts.LotSize < 0 || opts.MaxQuantity < 0 || opts.Last < 0 { + panic("mujica: OrderEntry TickSize, LotSize, MaxQuantity and Last cannot be negative") + } + tick, lot := opts.TickSize, opts.LotSize + if tick == 0 { + tick = 0.01 + } + if lot == 0 { + lot = 1 + } + initial := opts.Initial + if initial.Side == 0 { + initial.Side = SideBuy + } + if !s.started { + s.started, s.Draft = true, initial + } + priceSpec := numberInputResolve(NumberInputOptions{Step: tick}) + k := Tokens(c) + u := Density(c).Unit() + var r OrderEntryResult + r.Element = ui.Column(c).Gap(u * 4).MaxWidth(360).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(msg(c, "order_entry.label")) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + side := 0 + if s.Draft.Side == SideSell { + side = 1 + } + sides := []Segment{{Label: msg(c, "order_entry.buy")}, {Label: msg(c, "order_entry.sell")}} + SegmentedControl(c, &side, sides, SegmentedControlOptions{Equal: true, Label: msg(c, "order_entry.side"), Disabled: disabled}) + s.Draft.Side = SideBuy + OrderSide(side) + var types []SelectOption[OrderType] + for t := OrderLimit; t <= OrderStopLimit; t++ { + types = append(types, SelectOption[OrderType]{Value: t, Label: msg(c, fmt.Sprintf("order_entry.type%d", t))}) + } + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() { + ui.Text(c, opts.Symbol).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1) + Select(c, &s.Draft.Type, types, SelectOptions{Label: msg(c, "order_entry.type"), Disabled: disabled}) + }) + submit := fmt.Sprintf(msg(c, fmt.Sprintf("order_entry.submit%d", s.Draft.Side)), opts.Symbol) + res := Form(c, &s.Form, FormOptions{SubmitLabel: submit}, func(f *FormScope) { + orderEntryFields(c, f, s, opts, initial, tick, lot, disabled) + }) + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "order_entry.estimate")).TextColor(k.TextMuted) + est := "—" + if p := orderEntryPrice(s.Draft, opts.Last); p > 0 && s.Draft.Quantity > 0 { + est = "≈ " + statisticFormat(p*s.Draft.Quantity, priceSpec.decimals) + } + ui.Text(c, est).FontFeatures("tnum").FontWeight(600) + }) + if res.Submitted() { + r.order, r.submitted = s.Draft, true + slog.Debug("mujica: order_entry submitted", "side", s.Draft.Side, "type", s.Draft.Type) + } + }) + return r +} + +// orderEntryPrice is the expected fill price, or 0 if unknown. +func orderEntryPrice(o OrderDraft, last float64) float64 { + switch o.Type { + case OrderLimit, OrderStopLimit: + return o.Price + case OrderStop: + return o.Trigger + } + return last +} + +// orderEntryFields registers the fields the order type needs. +func orderEntryFields(c *ui.Context, f *FormScope, s *OrderEntryState, opts OrderEntryOptions, initial OrderDraft, tick, lot float64, disabled bool) { + maxQ := opts.MaxQuantity + FormItem(c, f, "quantity", &s.Draft, FormItemOptions[OrderDraft]{Label: msg(c, "order_entry.quantity"), Required: true, Initial: initial, + Validate: func(o OrderDraft) string { return orderEntryCheck(c, o, opts, maxQ) }, + Async: opts.Check, + }, func() *ui.Element { + return NumberInput(c, &s.Draft.Quantity, NumberInputOptions{Step: lot, Disabled: disabled, Error: s.Form.Error("quantity")}).Input + }) + if s.Draft.Type == OrderStop || s.Draft.Type == OrderStopLimit { + FormItem(c, f, "trigger", &s.Draft.Trigger, FormItemOptions[float64]{Label: msg(c, "order_entry.trigger"), Required: true, Initial: initial.Trigger, + Validate: func(v float64) string { return orderEntryTrigger(c, s.Draft, opts.Last) }, + }, func() *ui.Element { + return NumberInput(c, &s.Draft.Trigger, NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("trigger")}).Input + }) + } + if s.Draft.Type == OrderLimit || s.Draft.Type == OrderStopLimit { + FormItem(c, f, "price", &s.Draft.Price, FormItemOptions[float64]{Label: msg(c, "order_entry.price"), Required: true, Initial: initial.Price, + Validate: func(v float64) string { + if v <= 0 { + return msg(c, "order_entry.price_positive") + } + return "" + }, + }, func() *ui.Element { + return NumberInput(c, &s.Draft.Price, NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("price")}).Input + }) + } + var tifs []SelectOption[TimeInForce] + for t := TIFDay; t <= TIFFillOrKill; t++ { + tifs = append(tifs, SelectOption[TimeInForce]{Value: t, Label: msg(c, fmt.Sprintf("order_entry.tif%d", t))}) + } + FormItem(c, f, "tif", &s.Draft.TIF, FormItemOptions[TimeInForce]{Label: msg(c, "order_entry.tif"), Initial: initial.TIF}, func() *ui.Element { + return Select(c, &s.Draft.TIF, tifs, SelectOptions{Label: msg(c, "order_entry.tif"), Disabled: disabled}).Element + }) +} + +// orderEntryCheck checks the quantity, then the caller's rules. +func orderEntryCheck(c *ui.Context, o OrderDraft, opts OrderEntryOptions, maxQ float64) string { + switch { + case o.Quantity <= 0: + return msg(c, "order_entry.quantity_positive") + case maxQ > 0 && o.Quantity > maxQ: + return fmt.Sprintf(msg(c, "order_entry.quantity_max"), statisticFormat(maxQ, 0)) + } + if opts.Validate != nil { + return opts.Validate(o) + } + return "" +} + +// orderEntryTrigger checks a stop's side of the last price. +func orderEntryTrigger(c *ui.Context, o OrderDraft, last float64) string { + switch { + case o.Trigger <= 0: + return msg(c, "order_entry.trigger_positive") + case last > 0 && o.Side == SideBuy && o.Trigger <= last: + return msg(c, "order_entry.trigger_above") + case last > 0 && o.Side == SideSell && o.Trigger >= last: + return msg(c, "order_entry.trigger_below") + } + return "" +} diff --git a/order_entry_test.go b/order_entry_test.go new file mode 100644 index 0000000..58e9a19 --- /dev/null +++ b/order_entry_test.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type orderEntryProbe struct { + s OrderEntryState + opts OrderEntryOptions + orders []OrderDraft + checks []uint64 + checked []OrderDraft +} + +func (p *orderEntryProbe) tester() *ui.Tester { + return tradingTester(GreenUp, 520, 700, func(c *ui.Context) { + if o, ok := OrderEntry(c, &p.s, p.opts).Submitted(); ok { + p.orders = append(p.orders, o) + } + }) +} + +func TestOrderEntryLimitSubmitOnce(t *testing.T) { + p := &orderEntryProbe{opts: OrderEntryOptions{Symbol: "MUJC", Last: 100}} + tt := p.tester() + _ = tt.Click("Quantity") + tt.Type("10") + _ = tt.Click("Limit price") + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("99.5") + if !tt.HasText("≈ 995.00") { + t.Fatalf("estimate: %q", tt.Texts()) + } + if err := tt.Click("Buy MUJC"); err != nil { + t.Fatal(err) + } + _ = tt.Click("Buy MUJC") + if len(p.orders) != 1 || p.orders[0] != (OrderDraft{Side: SideBuy, Type: OrderLimit, Quantity: 10, Price: 99.5}) { + t.Fatalf("orders %+v", p.orders) + } + p.s.EndSubmit("") + tt.Frame() + _ = tt.Click("Buy MUJC") + if len(p.orders) != 2 { + t.Fatalf("after EndSubmit: %d orders", len(p.orders)) + } +} + +func TestOrderEntryValidatesByType(t *testing.T) { + p := &orderEntryProbe{opts: OrderEntryOptions{Symbol: "MUJC", Last: 100, MaxQuantity: 50}} + tt := p.tester() + _ = tt.Click("Buy MUJC") + if len(p.orders) != 0 || !tt.HasText("Enter a quantity above zero.") || !tt.HasText("Required") { + t.Fatalf("empty order: %q", tt.Texts()) + } + if !tt.Focused("Quantity") { + t.Fatal("first error not focused") + } + tt.Type("80") + tt.Key(0, ui.KeyTab) + if !tt.HasText("At most 50.") { + t.Fatalf("max: %q", tt.Texts()) + } + p.s.Draft.Type, p.s.Draft.Side, p.s.Draft.Quantity, p.s.Draft.Trigger = OrderStop, SideSell, 5, 101 + tt.Frame() + _ = tt.Click("Sell MUJC") + if len(p.orders) != 0 || !tt.HasText("A sell stop triggers below the last price.") || tt.HasText("Limit price") { + t.Fatalf("stop: %q", tt.Texts()) + } + p.s.Draft.Trigger = 98 + tt.Frame() + _ = tt.Click("Sell MUJC") + if len(p.orders) != 1 || p.orders[0].Type != OrderStop || p.orders[0].Trigger != 98 { + t.Fatalf("orders %+v", p.orders) + } +} + +// An old draft's check cannot clear a newer draft. +func TestOrderEntryAsyncCheckIsVersioned(t *testing.T) { + p := &orderEntryProbe{} + p.opts = OrderEntryOptions{Symbol: "MUJC", Last: 100, Check: func(o OrderDraft, v uint64) { + p.checks, p.checked = append(p.checks, v), append(p.checked, o) + }} + p.s.started, p.s.Draft = true, OrderDraft{Side: SideBuy, Type: OrderMarket, Quantity: 10} + tt := p.tester() + _ = tt.Click("Buy MUJC") + if len(p.checks) != 1 || len(p.orders) != 0 { + t.Fatalf("checks %v orders %d", p.checks, len(p.orders)) + } + p.s.Draft.Quantity = 20 + tt.Frame() + if p.s.Resolve(p.checks[0], "") { + t.Fatal("stale check accepted") + } + tt.Frame() + if len(p.checks) != 2 || p.checked[1].Quantity != 20 || len(p.orders) != 0 { + t.Fatalf("checks %v orders %d", p.checks, len(p.orders)) + } + p.s.Resolve(p.checks[1], "Not enough buying power.") + tt.Frame() + tt.Frame() + if len(p.orders) != 0 || !tt.HasText("Not enough buying power.") { + t.Fatalf("failed check submitted: %q", tt.Texts()) + } +} + +func TestOrderEntryDisabledAndReset(t *testing.T) { + p := &orderEntryProbe{opts: OrderEntryOptions{Symbol: "MUJC", Disabled: true, Initial: OrderDraft{Quantity: 1, Price: 50}}} + tt := p.tester() + _ = tt.Click("Quantity") + tt.Type("9") + _ = tt.Click("Buy MUJC") + if p.s.Draft.Quantity != 1 || len(p.orders) != 0 { + t.Fatalf("disabled entry changed: %+v", p.s.Draft) + } + p.opts.Disabled = false + p.s.Draft.Quantity, p.s.Draft.Side = 7, SideSell + tt.Frame() + _ = tt.Click("Reset") + if p.s.Draft != (OrderDraft{Side: SideBuy, Quantity: 1, Price: 50}) { + t.Fatalf("reset: %+v", p.s.Draft) + } +} + +func TestOrderEntrySnapshot(t *testing.T) { + snapshot(t, "order_entry", 420, 520, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + s := ui.Local(c.Root(), "snap", func() OrderEntryState { return OrderEntryState{} }) + if !s.started { + s.started, s.Draft = true, OrderDraft{Side: SideBuy, Type: OrderStopLimit, Quantity: 25, Price: 101.5, Trigger: 101} + } + OrderEntry(c, s, OrderEntryOptions{Symbol: "MUJC", Last: 100.2}) + }) + }) +} diff --git a/order_table.go b/order_table.go new file mode 100644 index 0000000..7e558ff --- /dev/null +++ b/order_table.go @@ -0,0 +1,194 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// OrderStatus is where an order stands. +type OrderStatus int + +const ( + OrderOpen OrderStatus = iota + OrderPartial + OrderFilled + OrderCanceled + OrderRejected +) + +// working reports an order that can still fill. +func (s OrderStatus) working() bool { return s == OrderOpen || s == OrderPartial } + +// OrderRow is one order; ID must be stable across updates. +type OrderRow struct { + ID, Symbol string + Side OrderSide + Type OrderType + Price float64 + Quantity float64 + Filled float64 + Status OrderStatus +} + +// OrderFilter picks which orders an OrderTable shows. +type OrderFilter int + +const ( + OrdersAll OrderFilter = iota + OrdersWorking + OrdersFilled + OrdersClosed +) + +func (f OrderFilter) keeps(s OrderStatus) bool { + switch f { + case OrdersWorking: + return s.working() + case OrdersFilled: + return s == OrderFilled + case OrdersClosed: + return s == OrderCanceled || s == OrderRejected + } + return true +} + +// OrderTableOptions configure an OrderTable. +type OrderTableOptions struct { + // Decimals of prices and quantities; zero means 2 and 0. + Decimals, QuantityDecimals int + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// OrderTableState is the status filter and table state. +type OrderTableState struct { + Filter OrderFilter + Table DataTableState[string] + cache tradingRows[OrderRow] +} + +// OrderTable lists orders with filter, progress and Cancel. +func OrderTable(c *ui.Context, s *OrderTableState, rows []OrderRow, opts OrderTableOptions) TableActionResult { + dec := priceDecimals("OrderTable", opts.Decimals) + if opts.QuantityDecimals < 0 { + panic("mujica: OrderTable with negative QuantityDecimals") + } + qd := opts.QuantityDecimals + shown := s.cache.view(rows, opts.Version, s.Filter, func(o OrderRow) bool { + if o.Quantity <= 0 || o.Filled < 0 || o.Filled > o.Quantity { + panic(fmt.Sprintf("mujica: OrderTable order %s fills %v of %v", o.ID, o.Filled, o.Quantity)) + } + return s.Filter.keeps(o.Status) + }) + var r TableActionResult + text := func(id, key string, t func(OrderRow) string) DataColumn[OrderRow] { + return DataColumn[OrderRow]{ID: id, Title: msg(c, key), Sortable: true, Text: t, + Compare: func(a, b OrderRow) int { return cmp.Compare(t(a), t(b)) }} + } + cols := []DataColumn[OrderRow]{ + text("symbol", "position_table.symbol", func(o OrderRow) string { return o.Symbol }), + {ID: "side", Title: msg(c, "order_table.side"), Text: func(o OrderRow) string { return msg(c, fmt.Sprintf("order_entry.side%d", o.Side)) }, + Cell: func(o OrderRow) { tradingSide(c, o.Side) }}, + text("type", "order_entry.type", func(o OrderRow) string { return msg(c, fmt.Sprintf("order_entry.type%d", o.Type)) }), + {ID: "price", Title: msg(c, "order_book.price"), Align: ui.End, Sortable: true, + Compare: func(a, b OrderRow) int { return cmp.Compare(a.Price, b.Price) }, + Text: func(o OrderRow) string { + if o.Type == OrderMarket { + return msg(c, "order_table.market") + } + return statisticFormat(o.Price, dec) + }}, + {ID: "filled", Title: msg(c, "order_table.filled"), Width: 140, + Text: func(o OrderRow) string { + return statisticFormat(o.Filled, qd) + " / " + statisticFormat(o.Quantity, qd) + }, + Cell: func(o OrderRow) { orderTableProgress(c, o, qd) }}, + text("status", "order_table.status", func(o OrderRow) string { return msg(c, fmt.Sprintf("order_table.status%d", o.Status)) }), + {ID: "action", Title: msg(c, "position_table.action"), Width: 96, Text: func(OrderRow) string { return "" }, + Cell: func(o OrderRow) { + if o.Status.working() && Button(c, msg(c, "order_table.cancel"), ButtonOptions{Variant: Secondary}). + Label(msg(c, "order_table.cancel")+" "+o.Symbol).Clicked() { + r.id = o.ID + } + }}, + } + d := Density(c) + r.Element = ui.Column(c).Gap(d.Unit() * 2).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0) + r.Element.Children(func() { + var segs []Segment + for f := OrdersAll; f <= OrdersClosed; f++ { + segs = append(segs, Segment{Label: msg(c, fmt.Sprintf("order_table.filter%d", f))}) + } + at := int(s.Filter) + if SegmentedControl(c, &at, segs, SegmentedControlOptions{Label: msg(c, "order_table.filter"), Disabled: opts.Disabled}).Changed() { + s.Filter = OrderFilter(at) + slog.Debug("mujica: order_table filter", "filter", s.Filter) + } + r.Table = DataTable(c, &s.Table, shown, DataTableOptions[OrderRow, string]{Columns: cols, Key: func(o OrderRow) string { return o.ID }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "order_table.empty")}) + r.Table.Element.Grow(1) + }) + if r.Element.IsDisabled() { + r.id = "" + } + if r.id != "" { + slog.Debug("mujica: order_table cancel") + } + return r +} + +// orderTableProgress shows the filled share as bar and count. +func orderTableProgress(c *ui.Context, o OrderRow, qd int) { + k := Tokens(c) + share := float32(o.Filled / o.Quantity) + ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).FillWidth().Children(func() { + ui.Box(c).Size(48, 6).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.SurfaceHover, theme.ControlRadius) + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * share, H: r.H}, k.AccentText, theme.ControlRadius) + }) + ui.Text(c, statisticFormat(o.Filled, qd)+" / "+statisticFormat(o.Quantity, qd)).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) +} + +// tradingSide names a side with its arrow and trend color. +func tradingSide(c *ui.Context, side OrderSide) { + ui.Row(c).Gap(2).AlignItems(ui.Center).TextColor(trendColor(c, side.sign())).Children(func() { + ui.Icon(c, trendIcon(side.sign())).Size(12, 12) + ui.Text(c, msg(c, fmt.Sprintf("order_entry.side%d", side))).FontWeight(600).SingleLine() + }) +} + +// tradingRows is a filtered view, rebuilt only on change. +type tradingRows[R any] struct { + first *R + n, ver int + filter any + valid bool + visible []R +} + +func (t *tradingRows[R]) view(rows []R, version int, filter any, keep func(R) bool) []R { + var first *R + if len(rows) > 0 { + first = &rows[0] + } + if t.valid && t.first == first && t.n == len(rows) && t.ver == version && t.filter == filter { + return t.visible + } + t.first, t.n, t.ver, t.filter, t.valid = first, len(rows), version, filter, true + t.visible = make([]R, 0, len(rows)) + for _, r := range rows { + if keep(r) { + t.visible = append(t.visible, r) + } + } + return t.visible +} diff --git a/order_table_test.go b/order_table_test.go new file mode 100644 index 0000000..be31cfa --- /dev/null +++ b/order_table_test.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func orderTableSample() []OrderRow { + return []OrderRow{ + {ID: "o1", Symbol: "MUJC", Side: SideBuy, Type: OrderLimit, Price: 99.5, Quantity: 100, Filled: 40, Status: OrderPartial}, + {ID: "o2", Symbol: "AVE", Side: SideSell, Type: OrderMarket, Quantity: 10, Filled: 10, Status: OrderFilled}, + {ID: "o3", Symbol: "OBLV", Side: SideBuy, Type: OrderStop, Price: 12, Quantity: 5, Status: OrderCanceled}, + {ID: "o4", Symbol: "DOLO", Side: SideSell, Type: OrderLimit, Price: 7.25, Quantity: 30, Status: OrderOpen}, + } +} + +func orderTableTester(s *OrderTableState, rows *[]OrderRow, opts *OrderTableOptions, acted *string) *ui.Tester { + return tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(320).Children(func() { + if id, ok := OrderTable(c, s, *rows, *opts).Acted(); ok { + *acted = id + } + }) + }) +} + +func TestOrderTableFilterProgressAndCancel(t *testing.T) { + var s OrderTableState + rows, opts, acted := orderTableSample(), OrderTableOptions{}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + if !tt.HasText("40 / 100") || !tt.HasText("Partly filled") || !tt.HasText("Market") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Working"); err != nil { + t.Fatal(err) + } + if tt.HasText("AVE") || tt.HasText("OBLV") || !tt.HasText("DOLO") { + t.Fatalf("working filter: %q", tt.Texts()) + } + if _, ok := tt.Find("Cancel AVE"); ok { + t.Fatal("filled order offers cancel") + } + if err := tt.Click("Cancel DOLO"); err != nil { + t.Fatal(err) + } + if acted != "o4" { + t.Fatalf("acted %q", acted) + } + _ = tt.Click("Closed") + if !tt.HasText("OBLV") || tt.HasText("MUJC") { + t.Fatalf("closed filter: %q", tt.Texts()) + } +} + +func TestOrderTableFiltersOnlyOnChange(t *testing.T) { + var s OrderTableState + rows, opts, acted := orderTableSample(), OrderTableOptions{}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + view := s.cache.visible + tt.Frame() + if &s.cache.visible[0] != &view[0] { + t.Fatal("refiltered without a change") + } + rows[0].Status = OrderFilled + opts.Version++ + _ = tt.Click("Working") + if len(s.cache.visible) != 1 { + t.Fatalf("version change ignored: %d rows", len(s.cache.visible)) + } +} + +func TestOrderTableDisabledAndBadFill(t *testing.T) { + var s OrderTableState + rows, opts, acted := orderTableSample(), OrderTableOptions{Disabled: true}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + _ = tt.Click("Cancel DOLO") + _ = tt.Click("Working") + if acted != "" || s.Filter != OrdersAll { + t.Fatal("disabled table acted") + } + defer func() { + if recover() == nil { + t.Fatal("overfilled order accepted") + } + }() + var s2 OrderTableState + bad := []OrderRow{{ID: "x", Quantity: 1, Filled: 2}} + orderTableTester(&s2, &bad, &OrderTableOptions{}, &acted) +} + +func TestOrderTableSnapshot(t *testing.T) { + snapshot(t, "order_table", 960, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(280).Width(940).Children(func() { + s := ui.Local(c.Root(), "snap", func() OrderTableState { return OrderTableState{} }) + OrderTable(c, s, orderTableSample(), OrderTableOptions{}) + }) + }) +} + +// 100,000 orders filter once and build only visible rows. +func TestOrderTableLargeData(t *testing.T) { + var s OrderTableState + rows := make([]OrderRow, 100_000) + for i := range rows { + rows[i] = OrderRow{ID: fmt.Sprint(i), Symbol: fmt.Sprintf("S%05d", i), Side: SideBuy, Price: 1, Quantity: 10, Status: OrderStatus(i % 5)} + } + opts, acted := OrderTableOptions{}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + _ = tt.Click("Working") + if len(s.cache.visible) != 40_000 { + t.Fatalf("working rows %d", len(s.cache.visible)) + } + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d texts", n) + } +} diff --git a/output_panel.go b/output_panel.go new file mode 100644 index 0000000..e7c00df --- /dev/null +++ b/output_panel.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// OutputChannel is a named stream of ANSI lines. +type OutputChannel struct { + Name string + Lines []string +} + +// OutputPanelState is the channel, following and place. +type OutputPanelState struct { + Channel int + // Follow keeps the newest line in view; it starts on. + Follow bool + rows ui.ListState + shown int + started bool +} + +// OutputPanelOptions configure an OutputPanel. +type OutputPanelOptions struct { + Disabled bool +} + +// OutputPanelResult is an OutputPanel and what happened in this frame. +type OutputPanelResult struct { + Element *ui.Element + // Cleared names a channel to clear. + Changed bool + Cleared string +} + +// OutputPanel shows one channel, following its tail. +func OutputPanel(c *ui.Context, channels []OutputChannel, s *OutputPanelState, opts OutputPanelOptions) OutputPanelResult { + if s == nil || len(channels) == 0 { + panic("mujica: OutputPanel needs a state and a channel") + } + if s.Channel < 0 || s.Channel >= len(channels) { + panic("mujica: OutputPanel Channel out of range") + } + if !s.started { + s.started, s.Follow, s.shown = true, true, s.Channel + } + var res OutputPanelResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + res.Element.Children(func() { + codeHeader(c, func() { + options := make([]SelectOption[int], len(channels)) + for i, ch := range channels { + options[i] = SelectOption[int]{Value: i, Label: ch.Name} + } + if Select(c, &s.Channel, options, SelectOptions{Label: msg(c, "output_panel.channel"), Disabled: opts.Disabled}).Changed() { + res.Changed = true + slog.Debug("mujica: output_panel channel", "channel", s.Channel) + } + ui.Spacer(c) + was := s.Follow + if Toggle(c, &s.Follow, msg(c, "log_viewer.follow"), ToggleOptions{Disabled: opts.Disabled}).Changed() && s.Follow && !was { + s.rows.ScrollToEnd() + } + if iconAction(c, icons.Must("trash-2"), msg(c, "output_panel.clear")).Disabled(opts.Disabled).Clicked() && !disabled { + res.Cleared = channels[s.Channel].Name + slog.Debug("mujica: output_panel clear", "channel", s.Channel) + } + }) + if s.shown != s.Channel { + s.shown, s.rows = s.Channel, ui.ListState{} + } + lines := channels[s.Channel].Lines + if len(lines) == 0 { + codeCaption(c, msg(c, "output_panel.empty")).Padding(Density(c).Unit() * 4) + return + } + d := Density(c) + s.rows.FollowEnd = s.Follow + s.rows.Label = func(i int) string { return lines[i] } + ui.List(c, &s.rows, len(lines), func(i int) { + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).AlignItems(ui.Center).Children(func() { + ui.RichText(c, codeAnsiSpans(c, lines[i])...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).Grow(1).MinWidth(0) + }) + }).Grow(1).MinHeight(d.ControlHeight()*3).Padding(d.Unit(), 0) + }) + return res +} diff --git a/output_panel_test.go b/output_panel_test.go new file mode 100644 index 0000000..e6cad68 --- /dev/null +++ b/output_panel_test.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +type outputProbe struct { + channels []OutputChannel + s OutputPanelState + disabled bool + changed int + cleared []string +} + +func (p *outputProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := OutputPanel(c, p.channels, &p.s, OutputPanelOptions{Disabled: p.disabled}) + r.Element.Height(300) + if r.Changed { + p.changed++ + } + if r.Cleared != "" { + p.cleared = append(p.cleared, r.Cleared) + for i := range p.channels { + if p.channels[i].Name == r.Cleared { + p.channels[i].Lines = nil + } + } + } +} + +func newOutputProbe() *outputProbe { + var build []string + for i := range 200 { + build = append(build, fmt.Sprintf("compile unit %d", i)) + } + return &outputProbe{channels: []OutputChannel{{"Build", build}, {"Tests", []string{"\x1b[32mPASS\x1b[0m court", "\x1b[31mFAIL\x1b[0m ledger"}}}} +} + +func TestOutputPanelFollowsTail(t *testing.T) { + p := newOutputProbe() + tt := ui.NewTester(p.view, 520, 320) + if !tt.HasText("compile unit 199") || tt.HasText("compile unit 0") { + t.Fatal("panel did not start at the tail") + } + p.channels[0].Lines = append(p.channels[0].Lines, "linked") + tt.Frame() + if !tt.HasText("linked") { + t.Fatal("new output not followed") + } +} + +func TestOutputPanelSwitchAndClear(t *testing.T) { + p := newOutputProbe() + tt := ui.NewTester(p.view, 520, 320) + if err := tt.Click("Build"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Tests"); err != nil { + t.Fatal(err) + } + if p.s.Channel != 1 || p.changed != 1 || !tt.HasText("PASS court") { + t.Fatalf("channel %d changed %d texts %q", p.s.Channel, p.changed, tt.Texts()) + } + if err := tt.Click("Clear output"); err != nil { + t.Fatal(err) + } + if len(p.cleared) != 1 || p.cleared[0] != "Tests" || !tt.HasText("No output") { + t.Fatalf("cleared %v", p.cleared) + } +} + +func TestOutputPanelAnsiColors(t *testing.T) { + var spans []ui.Span + var ok ui.Color + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ok = Tokens(c).Success + spans = codeAnsiSpans(c, "\x1b[1;32mPASS\x1b[0m court") + }, 10, 10) + if len(spans) != 2 || spans[0].Color != ok || spans[0].Weight != 700 || spans[1].Color.A != 0 { + t.Fatalf("spans %+v", spans) + } +} + +func TestOutputPanelDisabledAndBadChannel(t *testing.T) { + p := newOutputProbe() + p.disabled = true + tt := ui.NewTester(p.view, 520, 320) + _ = tt.Click("Clear output") + if len(p.cleared) != 0 { + t.Fatal("disabled panel cleared") + } + defer func() { + if recover() == nil { + t.Fatal("channel out of range did not panic") + } + }() + p = newOutputProbe() + p.s.Channel = 5 + ui.NewTester(p.view, 520, 320) +} + +func TestOutputPanelSnapshot(t *testing.T) { + snapshot(t, "output_panel", 520, 200, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *outputProbe { + p := newOutputProbe() + p.s.Channel = 1 + return p + }) + ui.Column(c).Padding(12).Children(func() { + OutputPanel(c, (*p).channels, &(*p).s, OutputPanelOptions{}).Element.Height(176) + }) + }) +} diff --git a/permission_prompt.go b/permission_prompt.go new file mode 100644 index 0000000..ac13a5c --- /dev/null +++ b/permission_prompt.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PermissionChoice answers a PermissionPrompt; zero is no answer. +type PermissionChoice int + +const ( + PermissionNone PermissionChoice = iota + PermissionAllowOnce + PermissionAllowAlways + PermissionDeny +) + +// PermissionPromptOptions configure a PermissionPrompt. Title and Scope are required. +type PermissionPromptOptions struct { + Title string + Description string + // Scope says what "always" covers, as "Write access to ~/court". + Scope string +} + +// PermissionResult reports the choice made in this frame. +type PermissionResult struct { + Element *ui.Element + Choice PermissionChoice +} + +// PermissionPrompt asks allow once, always, or deny; Deny has focus. +func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) PermissionResult { + if opts.Title == "" || opts.Scope == "" { + panic("mujica: PermissionPrompt needs a Title and a Scope") + } + var r PermissionResult + was := *open + k := Tokens(c) + u := Density(c).Unit() + r.Element = Dialog(c, open, DialogOptions{ + Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true, + Actions: func() { + if Button(c, msg(c, "permission_prompt.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { + r.Choice = PermissionDeny + } + if Button(c, msg(c, "permission_prompt.always"), ButtonOptions{Variant: Outline}).Clicked() { + r.Choice = PermissionAllowAlways + } + if Button(c, msg(c, "permission_prompt.once"), ButtonOptions{}).Clicked() { + r.Choice = PermissionAllowOnce + } + }, + }, func() { + ui.Row(c).Gap(u*2).AlignItems(ui.Start).Padding(u*2, u*3).Background(k.Background).Radius(theme.ControlRadius). + Border(1, k.Border).Children(func() { + ui.Icon(c, icons.Must("lock")).Size(16, 16).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0) + ui.Column(c).Gap(u / 2).Shrink(1).Children(func() { + agentCaption(c, msg(c, "permission_prompt.scope")) + ui.Text(c, opts.Scope) + }) + }) + }) + if was && !*open && r.Choice == PermissionNone { + r.Choice = PermissionDeny // Escape + } + if r.Choice != PermissionNone { + slog.Debug("mujica: permission_prompt choose", "choice", int(r.Choice)) + *open = false + } + return r +} diff --git a/permission_prompt_test.go b/permission_prompt_test.go new file mode 100644 index 0000000..bba25c7 --- /dev/null +++ b/permission_prompt_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func permissionTester(open *bool, got *[]PermissionChoice) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Ask", ButtonOptions{}).Clicked() { + *open = true + } + r := PermissionPrompt(c, open, PermissionPromptOptions{Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger"}) + if r.Choice != PermissionNone { + *got = append(*got, r.Choice) + } + }, 600, 400) +} + +func TestPermissionPromptChoices(t *testing.T) { + for _, tc := range []struct { + tabs int + want PermissionChoice + }{{0, PermissionDeny}, {1, PermissionAllowAlways}, {2, PermissionAllowOnce}} { + open := false + var got []PermissionChoice + tt := permissionTester(&open, &got) + _ = tt.Click("Ask") + if !tt.Focused("Deny") || !tt.HasText("Write access to ~/court/ledger") { + t.Fatalf("Deny focused %v, texts %q", tt.Focused("Deny"), tt.Texts()) + } + for range tc.tabs { + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyEnter) + if len(got) != 1 || got[0] != tc.want || open { + t.Fatalf("tabs %d: got %v open %v", tc.tabs, got, open) + } + } +} + +func TestPermissionPromptEscapeAndBackdrop(t *testing.T) { + open := false + var got []PermissionChoice + tt := permissionTester(&open, &got) + _ = tt.Click("Ask") + tt.ClickAt(5, 395) + if !open || len(got) != 0 { + t.Fatal("backdrop acted") + } + tt.Key(0, ui.KeyEscape) + if len(got) != 1 || got[0] != PermissionDeny { + t.Fatalf("Escape gave %v", got) + } +} + +func TestPermissionPromptNeedsScope(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := true + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PermissionPrompt(c, &open, PermissionPromptOptions{Title: "x"}) + }, 200, 200) +} + +func TestPermissionPromptSnapshot(t *testing.T) { + snapshot(t, "permission_prompt", 600, 340, func(c *ui.Context) { + open := true + PermissionPrompt(c, &open, PermissionPromptOptions{Title: "Write to the ledger?", + Description: "The agent wants to record tonight's guests.", Scope: "Write access to ~/court/ledger and its subfolders"}) + }) +} diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..7024b6b --- /dev/null +++ b/pie_chart.go @@ -0,0 +1,146 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartSlice is one share of a PieChart or FunnelChart. +type ChartSlice struct { + Label string + Value float64 +} + +// PieChartOptions configure a PieChart. +type PieChartOptions struct { + Frame ChartFrame + // Donut cuts out the middle and writes the total there. + Donut bool + // MinAngle merges two or more narrower slices into Other. + MinAngle float64 + Format func(v float64) string +} + +// PieChart shows shares as slices labeled with percents. +func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartResult { + if opts.MinAngle < 0 || opts.MinAngle >= 360 { + panic("mujica: PieChart MinAngle must be in [0, 360)") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + parts := chartMemo(ch, "parts", [2]any{chartID(slices), opts.MinAngle}, func() []ChartSlice { + return pieMerge(slices, opts.MinAngle, msg(c, "pie_chart.other")) + }) + total := 0.0 + for _, s := range parts { + total += s.Value + } + n := len(parts) + if total == 0 { + n = 0 + } + keys := make([]chartKey, len(parts)) + for i, s := range parts { + keys[i] = chartKey{Name: s.Label, Color: pal[i%len(pal)], Shape: i} + } + // angle returns slice i's start and end, clockwise. + angle := func(i int) (float64, float64) { + a := 0.0 + for j := range i { + a += parts[j].Value + } + return 2 * math.Pi * a / total, 2 * math.Pi * (a + parts[i].Value) / total + } + geom := func(r ui.Rect) (cx, cy, r0, r1 float32) { + r1 = min(r.W, r.H)/2 - 8 + if opts.Donut { + r0 = r1 * 0.58 + } + return r.X + r.W/2, r.Y + r.H/2, r0, r1 + } + share := func(i int) string { return fmt.Sprintf("%.1f%%", 100*parts[i].Value/total) } + spec := chartSpec{n: n, keys: keys} + spec.readout = func(i int) string { + return parts[i].Label + " · " + chartValue(c, parts[i].Value, opts.Format) + " · " + share(i) + } + spec.pick = func(r ui.Rect, x, y float32) int { + cx, cy, r0, r1 := geom(r) + d := float32(math.Hypot(float64(x-cx), float64(y-cy))) + if d < r0 || d > r1 { + return -1 + } + a := math.Atan2(float64(x-cx), float64(cy-y)) + if a < 0 { + a += 2 * math.Pi + } + for i := range parts { + if a0, a1 := angle(i); a >= a0 && a < a1 { + return i + } + } + return -1 + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + cx, cy, r0, r1 := geom(r) + for i := range parts { + a0, a1 := angle(i) + if a1 <= a0 { + continue + } + grow := float32(0) + if i == active { + grow = 6 + } + p.FillPath(internal.Sector(cx, cy, r0, r1+grow, a0, a1), keys[i].Color) + p.StrokePath(internal.Sector(cx, cy, r0, r1+grow, a0, a1), 1.5, k.Surface) + // Wide slices carry their shape and percent. + if a1-a0 > 0.38 { + mx, my := internal.Polar(cx, cy, (r0+r1)/2+float32(chartBool(r0 == 0))*r1*0.12, (a0+a1)/2) + ink := chartInk(k, keys[i].Color) + internal.Marker(p, i, mx, my-8, 9, ink) + chartText(p, share(i), mx, my+2, size, ink, 0.5, 0) + } + } + if opts.Donut { + chartText(p, chartValue(c, total, opts.Format), cx, cy, FontSize(c, theme.H3Size), k.Text, 0.5, 0.75) + chartText(p, msg(c, "pie_chart.total"), cx, cy+4, size, k.TextMuted, 0.5, 0) + } + } + res, _ := ch.build(spec) + return res +} + +// pieMerge folds two or more narrow slices into other. +func pieMerge(slices []ChartSlice, minAngle float64, other string) []ChartSlice { + total := 0.0 + for _, s := range slices { + if s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) || s.Label == "" { + panic("mujica: PieChart slices need a label and a finite value ≥ 0") + } + total += s.Value + } + var keep []ChartSlice + small, rest := 0, 0.0 + for _, s := range slices { + if total > 0 && s.Value/total*360 < minAngle { + small++ + rest += s.Value + } + } + for _, s := range slices { + if small < 2 || total == 0 || s.Value/total*360 >= minAngle { + keep = append(keep, s) + } + } + if small >= 2 { + keep = append(keep, ChartSlice{Label: other, Value: rest}) + } + return keep +} diff --git a/pie_chart_test.go b/pie_chart_test.go new file mode 100644 index 0000000..addb559 --- /dev/null +++ b/pie_chart_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var pieSlices = []ChartSlice{{"Wax", 50}, {"Oil", 30}, {"Myrrh", 12}, {"Salt", 4}, {"Ash", 4}} + +func TestPieMerge(t *testing.T) { + got := pieMerge(pieSlices, 20, "Other") + if fmt.Sprint(got) != "[{Wax 50} {Oil 30} {Myrrh 12} {Other 8}]" { + t.Fatalf("merge 20°: %v", got) + } + if got := pieMerge(pieSlices[:4], 20, "Other"); len(got) != 4 { + t.Fatalf("a single narrow slice merged: %v", got) + } +} + +func TestPieChartReadoutAndHover(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20}) + }} + tt := ui.NewTester(h.view, 520, 420) + if !tt.HasText("Other") { + t.Fatalf("legend lacks Other: %q", tt.Texts()) + } + tabTo(t, tt, "Stores") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Other · 8 · 8.0%" { + t.Fatalf("readout %q", got) + } + b := h.res.Element.Bounds() + plotTop := b.Y + 38 // below the one-line legend + cy := plotTop + 120 + tt.Move(b.X+b.W/2+60, cy-40) // upper right: the first slice + if got := chartReadout(tt); got != "Wax · 50 · 50.0%" { + t.Fatalf("hover %q", got) + } + tt.Key(0, ui.KeyEscape) + tt.Move(b.X+b.W/2, cy) // the hole + if h.res.Point != -1 { + t.Fatalf("hole picked %d", h.res.Point) + } +} + +func TestPieChartBad(t *testing.T) { + for _, s := range [][]ChartSlice{{{"a", -1}}, {{"", 1}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v: no panic", s) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PieChart(c, s, PieChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) + }() + } + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return PieChart(c, []ChartSlice{{"a", 0}}, PieChartOptions{Frame: ChartFrame{Label: "x"}}) + }} + if tt := ui.NewTester(h.view, 300, 300); !tt.HasText("No data") { + t.Fatal("zero total is not empty") + } +} + +func TestPieChartSnapshot(t *testing.T) { + snapshot(t, "pie_chart", 640, 320, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + ui.Column(c).Width(296).Children(func() { + PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Pie", Height: 220}}) + }) + ui.Column(c).Width(296).Children(func() { + PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Donut", Height: 220}, Donut: true, MinAngle: 20}) + }) + }) + }) +} diff --git a/playback_speed_control.go b/playback_speed_control.go new file mode 100644 index 0000000..e3fe9d0 --- /dev/null +++ b/playback_speed_control.go @@ -0,0 +1,46 @@ +package mujica + +import ( + "log/slog" + "slices" + "strconv" + + "github.com/egoist/mygo/ui" +) + +// playbackRates are the default choices, 0.5× to 2×. +var playbackRates = []float64{0.5, 0.75, 1, 1.25, 1.5, 2} + +// PlaybackSpeedControlOptions configure a PlaybackSpeedControl. +type PlaybackSpeedControlOptions struct { + // Rates are the ascending choices; nil means 0.5× to 2×. + Rates []float64 + Disabled bool +} + +// PlaybackSpeedControl chooses *rate from Rates in a drop-down. +func PlaybackSpeedControl(c *ui.Context, rate *float64, opts PlaybackSpeedControlOptions) ChoiceResult { + rates := opts.Rates + if rates == nil { + rates = playbackRates + } + if len(rates) == 0 || !slices.IsSorted(rates) || rates[0] <= 0 { + panic("mujica: PlaybackSpeedControl needs positive Rates in ascending order") + } + if !slices.Contains(rates, *rate) { + panic("mujica: PlaybackSpeedControl rate is not one of Rates") + } + options := make([]SelectOption[float64], len(rates)) + for i, r := range rates { + options[i] = SelectOption[float64]{Value: r, Label: playbackLabel(r)} + } + res := Select(c, rate, options, SelectOptions{Disabled: opts.Disabled, Label: msg(c, "playback_speed_control.label")}) + res.Element.MinWidth(88).Width(96) + if res.Changed() { + slog.Debug("mujica: playback_speed changed", "rate", *rate) + } + return res +} + +// playbackLabel writes a rate as "1.25×". +func playbackLabel(r float64) string { return strconv.FormatFloat(r, 'f', -1, 64) + "×" } diff --git a/playback_speed_control_test.go b/playback_speed_control_test.go new file mode 100644 index 0000000..8d0dee2 --- /dev/null +++ b/playback_speed_control_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func speedTester(rate *float64, opts PlaybackSpeedControlOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + if PlaybackSpeedControl(c, rate, opts).Changed() { + *n++ + } + }) + }, 300, 400) +} + +func TestPlaybackSpeedChoose(t *testing.T) { + rate, n := 1.0, 0 + tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n) + if !tt.HasText("1×") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Playback speed"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"0.5×", "0.75×", "1.25×", "1.5×", "2×"} { + if !tt.HasText(want) { + t.Fatalf("no %s in %v", want, tt.Texts()) + } + } + if err := tt.Click("2×"); err != nil { + t.Fatal(err) + } + if rate != 2 || n != 1 { + t.Fatalf("rate %v changes %d", rate, n) + } +} + +func TestPlaybackSpeedKeyboardAndDisabled(t *testing.T) { + rate, n := 1.0, 0 + tt := speedTester(&rate, PlaybackSpeedControlOptions{}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if rate != 1.25 { + t.Fatalf("keyboard chose %v", rate) + } + rate2, n2 := 1.0, 0 + off := speedTester(&rate2, PlaybackSpeedControlOptions{Disabled: true}, &n2) + _ = off.Click("Playback speed") + _ = off.Click("2×") + if rate2 != 1 || n2 != 0 { + t.Fatalf("disabled chose %v", rate2) + } +} + +func TestPlaybackSpeedBadOptions(t *testing.T) { + odd := 3.0 + mediaPanics(t, "rate not offered", func(c *ui.Context) { PlaybackSpeedControl(c, &odd, PlaybackSpeedControlOptions{}) }) + one := 1.0 + mediaPanics(t, "unsorted", func(c *ui.Context) { + PlaybackSpeedControl(c, &one, PlaybackSpeedControlOptions{Rates: []float64{2, 1}}) + }) +} + +func TestPlaybackSpeedSnapshot(t *testing.T) { + rate := 1.25 + snapshot(t, "playback_speed_control", 200, 80, func(c *ui.Context) { + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + PlaybackSpeedControl(c, &rate, PlaybackSpeedControlOptions{}) + }) + }) +} diff --git a/playlist.go b/playlist.go new file mode 100644 index 0000000..c077165 --- /dev/null +++ b/playlist.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PlaylistItem is one track; ID must be unique and stable. +type PlaylistItem struct { + ID, Title, Subtitle string + Duration time.Duration +} + +// PlaylistOptions configure a Playlist. +type PlaylistOptions struct { + // Current is the playing item's ID; empty is none. + Current string + Disabled bool + // Height caps the list; zero means 320. + Height float32 +} + +// PlaylistResult is the playlist's element and this frame's events. +type PlaylistResult struct { + Element *ui.Element + // Played is the ID chosen by Enter or double click. + Played string + // Removed is the ID taken out of *items. + Removed string + // Moved reports a reorder of *items. + Moved bool +} + +type playlistKey struct{} + +// Playlist lists *items, building only rows in view. +func Playlist(c *ui.Context, items *[]PlaylistItem, opts PlaylistOptions) PlaylistResult { + if opts.Height < 0 { + panic("mujica: Playlist Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + seen := make(map[string]bool, len(*items)) + for _, it := range *items { + if it.ID == "" || seen[it.ID] { + panic("mujica: Playlist item IDs must be unique and non-empty: " + it.ID) + } + seen[it.ID] = true + } + k := Tokens(c) + var r PlaylistResult + box := ui.Column(c).Height(h).Disabled(opts.Disabled) + r.Element = box + st := ui.Local(box, playlistKey{}, func() *ListState[string] { return &ListState[string]{} }) + s := *st + list := *items + live := !box.IsDisabled() + s.rows.Reorder = nil + if live { + s.rows.Reorder = func(rows []int, to int) { + *items = playlistMove(*items, rows, to) + r.Moved = true + } + } + removeAt := -1 + box.Children(func() { + v := List(c, s, len(list), ListOptions[string]{ + Key: func(i int) string { return list[i].ID }, + Label: func(i int) string { return list[i].Title }, + Empty: msg(c, "playlist.empty"), + }, func(i int) { + playlistRow(c, k, list[i], i, list[i].ID == opts.Current) + if iconAction(c, icons.Must("x"), msg(c, "playlist.remove")+" "+list[i].Title).Clicked() && live { + removeAt = i + } + }) + v.Element.Grow(1) + if removeAt >= 0 { + r.Removed = list[removeAt].ID + *items = slices.Delete(*items, removeAt, removeAt+1) + return + } + sel, ok := s.Selected() + i := slices.IndexFunc(*items, func(it PlaylistItem) bool { return it.ID == sel }) + if !live || !ok || i < 0 { + return + } + switch { + case v.Submitted(): + r.Played = sel + case v.Element.Shortcut(0, ui.KeyDelete), v.Element.Shortcut(0, ui.KeyBackspace): + r.Removed = sel + *items = slices.Delete(*items, i, i+1) + case v.Element.Shortcut(ui.Alt, ui.KeyUp) && i > 0: + *items, r.Moved = playlistMove(*items, []int{i}, i-1), true + case v.Element.Shortcut(ui.Alt, ui.KeyDown) && i < len(*items)-1: + *items, r.Moved = playlistMove(*items, []int{i}, i+2), true + } + }) + switch { + case r.Played != "": + slog.Debug("mujica: playlist play", "id", r.Played) + case r.Removed != "": + slog.Debug("mujica: playlist remove", "id", r.Removed) + case r.Moved: + slog.Debug("mujica: playlist move") + } + return r +} + +// playlistRow shows a track's mark, title, subtitle and length. +func playlistRow(c *ui.Context, k theme.Tokens, it PlaylistItem, i int, playing bool) { + ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted).Shrink(0) + ui.Box(c).Width(20).Center().Shrink(0).Children(func() { + if playing { + ui.Icon(c, mediaPlay).Size(14, 14).TextColor(k.AccentText).Label(msg(c, "playlist.playing")) + } else { + ui.Textf(c, "%d", i+1).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + } + }) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + t := ui.Text(c, it.Title).SingleLine() + if playing { + t.TextColor(k.AccentText).FontWeight(600) + } + if it.Subtitle != "" { + ui.Text(c, it.Subtitle).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + if it.Duration > 0 { + ui.Text(c, mediaClock(it.Duration)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontFeatures("tnum") + } +} + +// playlistMove puts rows before index to; len means the end. +func playlistMove(items []PlaylistItem, rows []int, to int) []PlaylistItem { + moved := make([]PlaylistItem, 0, len(rows)) + rest := make([]PlaylistItem, 0, len(items)) + at := to + for i, it := range items { + if slices.Contains(rows, i) { + moved = append(moved, it) + if i < to { + at-- + } + continue + } + rest = append(rest, it) + } + return slices.Concat(rest[:at], moved, rest[at:]) +} diff --git a/playlist_test.go b/playlist_test.go new file mode 100644 index 0000000..fb3cb2f --- /dev/null +++ b/playlist_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func playlistItems(n int) []PlaylistItem { + items := make([]PlaylistItem, n) + for i := range items { + items[i] = PlaylistItem{ID: fmt.Sprint("t", i), Title: fmt.Sprint("Track ", i), Duration: time.Duration(180+i) * time.Second} + } + return items +} + +func playlistIDs(items []PlaylistItem) []string { + ids := make([]string, len(items)) + for i, it := range items { + ids[i] = it.ID + } + return ids +} + +func playlistTester(items *[]PlaylistItem, disabled bool, got *[]PlaylistResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(420).Children(func() { + r := Playlist(c, items, PlaylistOptions{Current: "t1", Disabled: disabled}) + if r.Played != "" || r.Removed != "" || r.Moved { + *got = append(*got, r) + } + }) + }, 500, 420) +} + +func TestPlaylistKeyboard(t *testing.T) { + items := playlistItems(4) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(got) != 1 || got[0].Played != "t0" { + t.Fatalf("Enter played %+v", got) + } + tt.Key(ui.Alt, ui.KeyDown) + if !slices.Equal(playlistIDs(items), []string{"t1", "t0", "t2", "t3"}) { + t.Fatalf("Alt+Down order %v", playlistIDs(items)) + } + tt.Key(0, ui.KeyDelete) + if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t3"}) || got[len(got)-1].Removed != "t0" { + t.Fatalf("Delete left %v, %+v", playlistIDs(items), got) + } +} + +func TestPlaylistRemoveButtonAndDrag(t *testing.T) { + items := playlistItems(4) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + if err := tt.Click("Remove Track 3"); err != nil { + t.Fatal(err) + } + if len(items) != 3 || got[0].Removed != "t3" { + t.Fatalf("remove button: %v", playlistIDs(items)) + } + a, _ := tt.Find("Track 0") + b, _ := tt.Find("Track 2") + tt.Press(a.X+4, a.Y+a.H/2) + tt.Move(a.X+4, a.Y+a.H/2+10) + tt.Move(b.X+4, b.Y+b.H-2) + tt.Release(b.X+4, b.Y+b.H-2) + if !slices.Equal(playlistIDs(items), []string{"t1", "t2", "t0"}) || !got[len(got)-1].Moved { + t.Fatalf("drag order %v", playlistIDs(items)) + } +} + +func TestPlaylistBuildsOnlyVisible(t *testing.T) { + items := playlistItems(100000) + var got []PlaylistResult + tt := playlistTester(&items, false, &got) + tt.Frame() + built := 0 + for _, s := range tt.Texts() { + if len(s) > 6 && s[:6] == "Track " { + built++ + } + } + if built == 0 || built > 40 { + t.Fatalf("built %d rows of 100000", built) + } +} + +func TestPlaylistDisabledAndBad(t *testing.T) { + items := playlistItems(3) + var got []PlaylistResult + tt := playlistTester(&items, true, &got) + _ = tt.Click("Remove Track 0") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDelete) + tt.Key(0, ui.KeyEnter) + if len(items) != 3 || len(got) != 0 { + t.Fatalf("disabled playlist changed: %v %+v", playlistIDs(items), got) + } + dup := []PlaylistItem{{ID: "a"}, {ID: "a"}} + mediaPanics(t, "duplicate", func(c *ui.Context) { Playlist(c, &dup, PlaylistOptions{}) }) +} + +func TestPlaylistSnapshot(t *testing.T) { + items := []PlaylistItem{ + {ID: "a", Title: "Ave Mujica", Subtitle: "Overture", Duration: 245 * time.Second}, + {ID: "b", Title: "Imprisoned XII", Subtitle: "Act I", Duration: 302 * time.Second}, + {ID: "c", Title: "KiLLKiSS", Subtitle: "Act II", Duration: 268 * time.Second}, + } + snapshot(t, "playlist", 440, 240, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(400).Children(func() { + Playlist(c, &items, PlaylistOptions{Current: "b", Height: 200}) + }) + }) +} diff --git a/pnl_display.go b/pnl_display.go new file mode 100644 index 0000000..605745d --- /dev/null +++ b/pnl_display.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PnL is profit and loss: realized, unrealized and today's. +type PnL struct { + Realized, Unrealized, Today float64 +} + +// PnLDisplayOptions configure a PnLDisplay. +type PnLDisplayOptions struct { + // Decimals of the amounts; zero means 2. + Decimals int + // Currency prefixes each amount, as "$". + Currency string +} + +// PnLDisplay shows three profits signed; *hidden masks them. +func PnLDisplay(c *ui.Context, pnl PnL, hidden *bool, opts PnLDisplayOptions) ChoiceResult { + dec := priceDecimals("PnLDisplay", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = ui.Row(c).Gap(u * 6).AlignItems(ui.Start).Wrap().Role(ui.RoleGroup).Label(msg(c, "pnl_display.label")) + r.Element.Children(func() { + for _, item := range []struct { + key string + v float64 + }{{"pnl_display.realized", pnl.Realized}, {"pnl_display.unrealized", pnl.Unrealized}, {"pnl_display.today", pnl.Today}} { + title := msg(c, item.key) + sign := trendSign(item.v) + text := statisticSigned(item.v, dec) + if opts.Currency != "" { + text = pnlDisplaySign(sign) + opts.Currency + statisticFormat(math.Abs(item.v), dec) + } + col := trendColor(c, sign) + spoken := title + ", " + trendWord(c, sign) + " " + text + if *hidden { + text, col, spoken = "••••", k.TextMuted, title+", "+msg(c, "pnl_display.masked") + } + ui.Column(c).Gap(u).Label(spoken).Role(ui.RoleText).Children(func() { + ui.Text(c, title).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleNone) + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(col).Role(ui.RoleNone).Children(func() { + if !*hidden { + ui.Icon(c, trendIcon(sign)).Size(16, 16) + } + ui.Text(c, text).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").FontWeight(600).SingleLine() + }) + }) + } + name := msg(c, "pnl_display.hide") + icon := icons.Must("eye-off") + if *hidden { + name, icon = msg(c, "pnl_display.show"), icons.Must("eye") + } + b := iconAction(c, icon, name) + if b.Clicked() && !b.IsDisabled() { + *hidden = !*hidden + r.changed = true + slog.Debug("mujica: pnl_display mask", "hidden", *hidden) + } + }) + return r +} + +func pnlDisplaySign(sign int) string { + switch { + case sign > 0: + return "+" + case sign < 0: + return "−" + } + return "" +} diff --git a/pnl_display_test.go b/pnl_display_test.go new file mode 100644 index 0000000..b258102 --- /dev/null +++ b/pnl_display_test.go @@ -0,0 +1,43 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPnLDisplaySignsAndMask(t *testing.T) { + hidden, n := false, 0 + tt := tradingTester(GreenUp, 520, 160, func(c *ui.Context) { + if PnLDisplay(c, PnL{Realized: 1200.5, Unrealized: -340, Today: 0}, &hidden, PnLDisplayOptions{Currency: "$"}).Changed() { + n++ + } + }) + for _, want := range []string{"+$1,200.50", "−$340.00", "$0.00"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + if err := tt.Click("Hide amounts"); err != nil { + t.Fatal(err) + } + if !hidden || n != 1 || tt.HasText("+$1,200.50") { + t.Fatalf("mask: hidden %v changes %d texts %q", hidden, n, tt.Texts()) + } + if _, ok := tt.Find("Realized, hidden"); !ok { + t.Fatal("masked amount still spoken") + } + tt.Key(0, ui.KeySpace) + if hidden || n != 2 { + t.Fatalf("keyboard toggle: hidden %v", hidden) + } +} + +func TestPnLDisplaySnapshot(t *testing.T) { + hidden := false + snapshot(t, "pnl_display", 460, 100, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + PnLDisplay(c, PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, PnLDisplayOptions{Currency: "$"}) + }) + }) +} diff --git a/position_table.go b/position_table.go new file mode 100644 index 0000000..d868894 --- /dev/null +++ b/position_table.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "cmp" + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// PositionRow is one holding; ID must be stable across updates. +type PositionRow struct { + ID, Symbol string + Quantity float64 + AvgCost float64 + Price float64 +} + +// MarketValue is the quantity at the last price. +func (p PositionRow) MarketValue() float64 { return p.Quantity * p.Price } + +// Unrealized is the floating profit against the average cost. +func (p PositionRow) Unrealized() float64 { return (p.Price - p.AvgCost) * p.Quantity } + +// UnrealizedPercent is the floating profit as a percent of cost. +func (p PositionRow) UnrealizedPercent() float64 { + if p.AvgCost == 0 { + return 0 + } + return (p.Price - p.AvgCost) / p.AvgCost * 100 +} + +// PositionTableOptions configure a PositionTable. +type PositionTableOptions struct { + // Decimals of prices and amounts; zero means 2. + Decimals int + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// TableActionResult is a trading table and its acted row. +type TableActionResult struct { + Element *ui.Element + Table DataTableView + id string +} + +// Acted returns the ID of the row acted on. +func (r TableActionResult) Acted() (string, bool) { return r.id, r.id != "" } + +// PositionTable lists holdings with floating profit and Close. +func PositionTable(c *ui.Context, s *DataTableState[string], rows []PositionRow, opts PositionTableOptions) TableActionResult { + dec := priceDecimals("PositionTable", opts.Decimals) + var r TableActionResult + num := func(id, key string, v func(PositionRow) float64) DataColumn[PositionRow] { + return DataColumn[PositionRow]{ID: id, Title: msg(c, key), Align: ui.End, Sortable: true, + Compare: func(a, b PositionRow) int { return cmp.Compare(v(a), v(b)) }, + Text: func(p PositionRow) string { return statisticFormat(v(p), dec) }} + } + pnl := num("pnl", "position_table.pnl", PositionRow.Unrealized) + pnl.Cell = func(p PositionRow) { tradingSigned(c, p.Unrealized(), statisticSigned(p.Unrealized(), dec)) } + pct := num("pct", "position_table.pnl_percent", PositionRow.UnrealizedPercent) + pct.Cell = func(p PositionRow) { + tradingSigned(c, p.UnrealizedPercent(), statisticSigned(p.UnrealizedPercent(), 2)+"%") + } + cols := []DataColumn[PositionRow]{ + {ID: "symbol", Title: msg(c, "position_table.symbol"), Sortable: true, + Compare: func(a, b PositionRow) int { return cmp.Compare(a.Symbol, b.Symbol) }, + Text: func(p PositionRow) string { return p.Symbol }}, + num("qty", "position_table.quantity", func(p PositionRow) float64 { return p.Quantity }), + num("cost", "position_table.cost", func(p PositionRow) float64 { return p.AvgCost }), + num("price", "position_table.price", func(p PositionRow) float64 { return p.Price }), + num("value", "position_table.value", PositionRow.MarketValue), + pnl, pct, + {ID: "action", Title: msg(c, "position_table.action"), Width: 96, + Text: func(PositionRow) string { return "" }, + Cell: func(p PositionRow) { + if Button(c, msg(c, "position_table.close"), ButtonOptions{Variant: Secondary}).Label(msg(c, "position_table.close") + " " + p.Symbol).Clicked() { + r.id = p.ID + } + }}, + } + r.Element = ui.Column(c).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0) + r.Element.Children(func() { + r.Table = DataTable(c, s, rows, DataTableOptions[PositionRow, string]{Columns: cols, Key: func(p PositionRow) string { return p.ID }, + Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: msg(c, "position_table.empty")}) + r.Table.Element.Grow(1) + }) + if r.Element.IsDisabled() { + r.id = "" + } + if r.id != "" { + slog.Debug("mujica: position_table close") + } + return r +} + +// tradingSigned shows a signed amount with its arrow. +func tradingSigned(c *ui.Context, v float64, text string) { + sign := trendSign(v) + ui.Row(c).Gap(2).AlignItems(ui.Center).Justify(ui.End).FillWidth().TextColor(trendColor(c, sign)).Children(func() { + ui.Icon(c, trendIcon(sign)).Size(12, 12) + ui.Text(c, text).FontFeatures("tnum").FontWeight(600).SingleLine() + }) +} diff --git a/position_table_test.go b/position_table_test.go new file mode 100644 index 0000000..95e4a40 --- /dev/null +++ b/position_table_test.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func positionSample() []PositionRow { + return []PositionRow{ + {ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}, + {ID: "p2", Symbol: "AVE", Quantity: 50, AvgCost: 90, Price: 88.2}, + } +} + +func TestPositionTableValuesAndClose(t *testing.T) { + var s DataTableState[string] + rows := positionSample() + closed := "" + tt := tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(300).Children(func() { + if id, ok := PositionTable(c, &s, rows, PositionTableOptions{}).Acted(); ok { + closed = id + } + }) + }) + for _, want := range []string{"41,208.00", "+1,208.00", "+3.02%", "−90.00", "−2.00%"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + if err := tt.Click("Close AVE"); err != nil { + t.Fatal(err) + } + if closed != "p2" { + t.Fatalf("closed %q", closed) + } +} + +// Selection follows the row ID when rows reorder and change. +func TestPositionTableStableIDs(t *testing.T) { + var s DataTableState[string] + rows := positionSample() + tt := tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(300).Children(func() { PositionTable(c, &s, rows, PositionTableOptions{}) }) + }) + _ = tt.Click("AVE") + rows = []PositionRow{{ID: "p3", Symbol: "OBLV", Quantity: 1, AvgCost: 1, Price: 1}, {ID: "p2", Symbol: "AVE", Quantity: 60, AvgCost: 90, Price: 91}, rows[0]} + tt.Frame() + if id, ok := s.Selected(); !ok || id != "p2" { + t.Fatalf("selection %q %v", id, ok) + } +} + +func TestPositionTableDisabledAndLargeData(t *testing.T) { + var s DataTableState[string] + rows := make([]PositionRow, 100_000) + for i := range rows { + rows[i] = PositionRow{ID: fmt.Sprint(i), Symbol: fmt.Sprintf("S%05d", i), Quantity: 1, AvgCost: 1, Price: 2} + } + closed := false + tt := tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(300).Children(func() { + _, closed = PositionTable(c, &s, rows, PositionTableOptions{Disabled: true}).Acted() + }) + }) + _ = tt.Click("Close S00000") + if closed { + t.Fatal("disabled table closed a position") + } + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d texts for 100,000 rows", n) + } +} + +func TestPositionTableSnapshot(t *testing.T) { + snapshot(t, "position_table", 1040, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(200).Width(1010).Children(func() { + s := ui.Local(c.Root(), "snap", func() DataTableState[string] { return DataTableState[string]{} }) + PositionTable(c, s, positionSample(), PositionTableOptions{}) + }) + }) +} diff --git a/price_change_badge.go b/price_change_badge.go new file mode 100644 index 0000000..5989747 --- /dev/null +++ b/price_change_badge.go @@ -0,0 +1,37 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// PriceChangeBadgeOptions configure a PriceChangeBadge. +type PriceChangeBadgeOptions struct { + // Decimals of the amount, zero meaning 2; percents show 2. + Decimals int + // HidePercent shows only the amount. + HidePercent bool +} + +// PriceChangeBadge shows a signed change and percent with an arrow. +func PriceChangeBadge(c *ui.Context, change, percent float64, opts PriceChangeBadgeOptions) *ui.Element { + dec := priceDecimals("PriceChangeBadge", opts.Decimals) + sign := trendSign(change) + if sign == 0 { + sign = trendSign(percent) + } + col := trendColor(c, sign) + u := Density(c).Unit() + text := statisticSigned(change, dec) + if !opts.HidePercent { + text += " (" + statisticSigned(percent, 2) + "%)" + } + e := ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u/2, u*2).Radius(theme.ControlRadius).Shrink(0). + Background(col.Alpha(0.12)).TextColor(col).Role(ui.RoleText).Label(trendWord(c, sign) + " " + text) + e.Children(func() { + ui.Icon(c, trendIcon(sign)).Size(14, 14) + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine() + }) + return e +} diff --git a/price_change_badge_test.go b/price_change_badge_test.go new file mode 100644 index 0000000..42a6a1d --- /dev/null +++ b/price_change_badge_test.go @@ -0,0 +1,35 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPriceChangeBadgeSignsAndWords(t *testing.T) { + tt := tradingTester(GreenUp, 400, 160, func(c *ui.Context) { + PriceChangeBadge(c, 1.25, 0.85, PriceChangeBadgeOptions{}) + PriceChangeBadge(c, -3, -1.5, PriceChangeBadgeOptions{Decimals: 1}) + PriceChangeBadge(c, 0, 0, PriceChangeBadgeOptions{HidePercent: true}) + }) + for _, want := range []string{"+1.25 (+0.85%)", "−3.0 (−1.50%)", "0.00"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + for _, name := range []string{"up +1.25 (+0.85%)", "down −3.0 (−1.50%)", "unchanged 0.00"} { + if _, ok := tt.Find(name); !ok { + t.Fatalf("not spoken: %q", name) + } + } +} + +func TestPriceChangeBadgeSnapshot(t *testing.T) { + snapshot(t, "price_change_badge", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(10).Children(func() { + PriceChangeBadge(c, 1.25, 0.85, PriceChangeBadgeOptions{}) + PriceChangeBadge(c, -3.4, -1.52, PriceChangeBadgeOptions{}) + PriceChangeBadge(c, 0, 0, PriceChangeBadgeOptions{}) + }) + }) +} diff --git a/price_text.go b/price_text.go new file mode 100644 index 0000000..f415a16 --- /dev/null +++ b/price_text.go @@ -0,0 +1,171 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Trend picks the rise color: green, or red (China). +type Trend int + +const ( + GreenUp Trend = iota + RedUp +) + +type trendKey struct{} + +// UseTrend sets the window's rise and fall colors. +func UseTrend(c *ui.Context, t Trend) { + if t != GreenUp && t != RedUp { + panic(fmt.Sprintf("mujica: unknown Trend %d", t)) + } + *ui.Local(c.Root(), trendKey{}, func() Trend { return GreenUp }) = t +} + +// trendColor colors a rise (sign > 0), fall or flat. +func trendColor(c *ui.Context, sign int) ui.Color { + k := Tokens(c) + up, down := k.Success, k.Danger + if *ui.Local(c.Root(), trendKey{}, func() Trend { return GreenUp }) == RedUp { + up, down = k.Danger, k.Success + } + switch { + case sign > 0: + return up + case sign < 0: + return down + } + return k.TextMuted +} + +// trendIcon is the arrow that says the direction without color. +func trendIcon(sign int) *ui.SVG { + switch { + case sign > 0: + return icons.Must("arrow-up") + case sign < 0: + return icons.Must("arrow-down") + } + return icons.Must("minus") +} + +// trendWord names the direction for assistive technology. +func trendWord(c *ui.Context, sign int) string { + switch { + case sign > 0: + return msg(c, "price_text.up") + case sign < 0: + return msg(c, "price_text.down") + } + return msg(c, "price_text.flat") +} + +func trendSign(v float64) int { + switch { + case v > 0: + return 1 + case v < 0: + return -1 + } + return 0 +} + +// priceDecimals resolves a Decimals option: zero means 2, negative panics. +func priceDecimals(name string, d int) int { + if d < 0 { + panic("mujica: " + name + " with negative Decimals") + } + if d == 0 { + return 2 + } + return d +} + +// priceTextFlash is the tint's fade, three state steps long. +const priceTextFlash = 3 * theme.StateDuration + +// PriceTextOptions configure a PriceText. +type PriceTextOptions struct { + // Decimals shown; zero means 2. + Decimals int + // NoGrouping drops the thousands separators. + NoGrouping bool + // Compare marks the price's move from Reference. + Compare bool + Reference float64 + // NoFlash keeps the price still when it changes. + NoFlash bool + // Size is the font size; zero is the body size. + Size float32 +} + +type priceTextState struct { + last float64 + seen bool + at time.Time + dir int +} + +// PriceText shows a price; a change tints it once. +func PriceText(c *ui.Context, value float64, opts PriceTextOptions) *ui.Element { + dec := priceDecimals("PriceText", opts.Decimals) + if opts.Size < 0 { + panic("mujica: PriceText with negative Size") + } + size := opts.Size + if size == 0 { + size = theme.BodySize + } + text := statisticFormat(value, dec) + if opts.NoGrouping { + text = priceUngrouped(value, dec) + } + e := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Padding(0, 2).Radius(theme.ControlRadius).Shrink(0) + st := ui.Local(e, "price", func() priceTextState { return priceTextState{} }) + if st.seen && value != st.last { + st.at, st.dir = c.Now(), trendSign(value-st.last) + slog.Debug("mujica: price_text changed", "dir", st.dir) + } + st.last, st.seen = value, true + sign := 0 + if opts.Compare { + sign = trendSign(value - opts.Reference) + } + col := Tokens(c).Text + spoken := text + if opts.Compare { + col = trendColor(c, sign) + spoken += ", " + trendWord(c, sign) + } + if d := Motion(c, priceTextFlash); d > 0 && !opts.NoFlash && !st.at.IsZero() { + if t := c.Now().Sub(st.at); t < d { + e.Background(trendColor(c, st.dir).Alpha(0.2 * float32(1-float64(t)/float64(d)))) + c.AnimationFrame() + } + } + e.Role(ui.RoleText).Label(spoken).TextColor(col) + e.Children(func() { + if opts.Compare { + ui.Icon(c, trendIcon(sign)).Size(size*0.85, size*0.85) + } + ui.Text(c, text).FontSize(FontSize(c, size)).FontFeatures("tnum").FontWeight(500).SingleLine() + }) + return e +} + +// priceUngrouped writes v with dec decimals and no separators. +func priceUngrouped(v float64, dec int) string { + s := fmt.Sprintf("%.*f", dec, math.Abs(v)) + if v < 0 && s != fmt.Sprintf("%.*f", dec, 0.0) { + return "−" + s + } + return s +} diff --git a/price_text_test.go b/price_text_test.go new file mode 100644 index 0000000..bb21a2c --- /dev/null +++ b/price_text_test.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// tradingTester runs view in the default theme with trend t. +func tradingTester(t Trend, w, h int, view func(c *ui.Context)) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + UseTrend(c, t) + ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() { view(c) }) + }, w, h) +} + +func TestPriceTextFormatAndTrend(t *testing.T) { + price := 12345.678 + tt := tradingTester(GreenUp, 300, 100, func(c *ui.Context) { + PriceText(c, price, PriceTextOptions{Decimals: 2, Compare: true, Reference: 12000}) + }) + if !tt.HasText("12,345.68") { + t.Fatalf("texts %q", tt.Texts()) + } + if _, ok := tt.Find("12,345.68, up"); !ok { + t.Fatal("trend not spoken") + } + price = 11000 + tt.Frame() + if _, ok := tt.Find("11,000.00, down"); !ok { + t.Fatalf("down not spoken: %q", tt.Texts()) + } + tt = tradingTester(GreenUp, 300, 100, func(c *ui.Context) { + PriceText(c, 1234.5, PriceTextOptions{Decimals: 3, NoGrouping: true}) + }) + if !tt.HasText("1234.500") { + t.Fatalf("ungrouped %q", tt.Texts()) + } +} + +func TestTrendColorsFollowUseTrend(t *testing.T) { + var up, down [2]ui.Color + for i, tr := range []Trend{GreenUp, RedUp} { + tradingTester(tr, 100, 100, func(c *ui.Context) { + up[i], down[i] = trendColor(c, 1), trendColor(c, -1) + }) + } + k := theme.Light() + if up[0] != k.Success || down[0] != k.Danger || up[1] != k.Danger || down[1] != k.Success { + t.Fatalf("up %v down %v", up, down) + } + defer func() { + if recover() == nil { + t.Fatal("unknown trend accepted") + } + }() + tradingTester(Trend(7), 100, 100, func(*ui.Context) {}) +} + +// A change tints once; reduced motion never tints. +func TestPriceTextFlashesOnceAndNotUnderReducedMotion(t *testing.T) { + for _, still := range []bool{false, true} { + prefs := ui.Preferences{ReduceMotion: still, TextScale: 1} + price := 10.0 + tt := tradingTester(GreenUp, 200, 60, func(c *ui.Context) { PriceText(c, price, PriceTextOptions{}) }) + tt.SetPreferences(prefs) + price = 10.5 + tt.Frame() + fresh := tradingTester(GreenUp, 200, 60, func(c *ui.Context) { PriceText(c, price, PriceTextOptions{}) }) + fresh.SetPreferences(prefs) + fresh.Frame() + same := bytes.Equal(tt.Image().Pix, fresh.Image().Pix) + if same == !still { + t.Fatalf("reduced motion %v: flashed %v", still, !same) + } + time.Sleep(priceTextFlash + 20*time.Millisecond) + tt.Frame() + if !bytes.Equal(tt.Image().Pix, fresh.Image().Pix) { + t.Fatalf("reduced motion %v: flash did not end", still) + } + } +} + +func TestPriceTextRejectsNegativeDecimals(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("negative decimals accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { PriceText(c, 1, PriceTextOptions{Decimals: -1}) }) +} + +func TestPriceTextSnapshot(t *testing.T) { + snapshot(t, "price_text", 320, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + PriceText(c, 18234.5, PriceTextOptions{Size: theme.H2Size, Compare: true, Reference: 18000}) + PriceText(c, 0.004521, PriceTextOptions{Decimals: 6, Compare: true, Reference: 0.0046}) + PriceText(c, 100, PriceTextOptions{Compare: true, Reference: 100}) + }) + }) +} diff --git a/problems_panel.go b/problems_panel.go new file mode 100644 index 0000000..5602f75 --- /dev/null +++ b/problems_panel.go @@ -0,0 +1,194 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// ProblemSeverity ranks a Problem. +type ProblemSeverity int + +const ( + ProblemError ProblemSeverity = iota + ProblemWarning + ProblemInfo +) + +// Problem is one diagnostic. +type Problem struct { + Severity ProblemSeverity + File string + Line, Col int + Message, Source string +} + +// ProblemsPanelState is a ProblemsPanel's filters, folded files and place. +type ProblemsPanelState struct { + // Hidden severities are filtered out. + Hidden [3]bool + // Collapsed holds folded file paths. + Collapsed ui.Selection[string] + rows ui.ListState + selected int + flat []problemRow + data *Problem + n, folded int + hidden [3]bool + started bool +} + +// problemRow is a file header or a problem. +type problemRow struct { + file string + problem int +} + +// ProblemsPanelOptions configure a ProblemsPanel. +type ProblemsPanelOptions struct { + Disabled bool +} + +// ProblemsPanelResult carries the problem opened. +type ProblemsPanelResult struct { + Element *ui.Element + Opened *Problem +} + +// ProblemsPanel lists diagnostics grouped by file. +func ProblemsPanel(c *ui.Context, problems []Problem, s *ProblemsPanelState, opts ProblemsPanelOptions) ProblemsPanelResult { + if s == nil { + panic("mujica: ProblemsPanel needs a state") + } + if !s.started { + s.started, s.selected = true, -1 + } + var counts [3]int + for _, p := range problems { + if p.Severity < ProblemError || p.Severity > ProblemInfo { + panic("mujica: Problem severity out of range") + } + counts[p.Severity]++ + } + var res ProblemsPanelResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + res.Element.Children(func() { + codeHeader(c, func() { + for sv := ProblemError; sv <= ProblemInfo; sv++ { + on := !s.Hidden[sv] + name := fmt.Sprintf(msg(c, problemsKeys[sv]), counts[sv]) + if Toggle(c, &on, name, ToggleOptions{Icon: icons.Must(problemsIcons[sv]), Disabled: opts.Disabled}).Changed() { + s.Hidden[sv] = !on + slog.Debug("mujica: problems_panel filter", "severity", sv, "shown", on) + } + } + }) + problemsGroup(problems, s) + if len(s.flat) == 0 { + codeCaption(c, msg(c, "problems_panel.empty")).Padding(Density(c).Unit() * 4) + return + } + s.rows.Selected = &s.selected + s.rows.Key = func(i int) any { return s.flat[i] } + s.rows.Label = func(i int) string { + if r := s.flat[i]; r.problem >= 0 { + return problems[r.problem].Message + } + return s.flat[i].file + } + list := codeRows(c, &s.rows, len(s.flat), func(i int) { problemsRow(c, problems, s, s.flat[i], disabled) }) + if disabled || s.selected < 0 { + return + } + r := s.flat[s.selected] + switch { + case list.Submitted() && r.problem >= 0: + p := problems[r.problem] + res.Opened = &p + slog.Debug("mujica: problems_panel open", "line", p.Line) + case list.Submitted(), + r.problem < 0 && !s.Collapsed.Has(r.file) && list.Shortcut(0, ui.KeyLeft), + r.problem < 0 && s.Collapsed.Has(r.file) && list.Shortcut(0, ui.KeyRight): + problemsFold(s, r.file) + } + }) + return res +} + +var ( + problemsKeys = [3]string{"problems_panel.errors", "problems_panel.warnings", "problems_panel.infos"} + problemsIcons = [3]string{"circle-x", "triangle-alert", "info"} +) + +func problemsFold(s *ProblemsPanelState, file string) { + if s.Collapsed.Has(file) { + s.Collapsed.Remove(file) + } else { + s.Collapsed.Add(file) + } + s.folded = -1 + slog.Debug("mujica: problems_panel fold", "folded", s.Collapsed.Has(file)) +} + +// problemsGroup regroups when data or filters change. +func problemsGroup(problems []Problem, s *ProblemsPanelState) { + var first *Problem + if len(problems) > 0 { + first = &problems[0] + } + if s.data == first && s.n == len(problems) && s.folded == s.Collapsed.Len() && s.hidden == s.Hidden { + return + } + s.data, s.n, s.folded, s.hidden = first, len(problems), s.Collapsed.Len(), s.Hidden + var idx []int + for i, p := range problems { + if !s.Hidden[p.Severity] { + idx = append(idx, i) + } + } + slices.SortStableFunc(idx, func(a, b int) int { + pa, pb := problems[a], problems[b] + return cmp.Or(cmp.Compare(pa.File, pb.File), cmp.Compare(pa.Line, pb.Line), cmp.Compare(pa.Col, pb.Col)) + }) + s.flat = s.flat[:0] + for j, i := range idx { + f := problems[i].File + if j == 0 || problems[idx[j-1]].File != f { + s.flat = append(s.flat, problemRow{f, -1}) + } + if !s.Collapsed.Has(f) { + s.flat = append(s.flat, problemRow{f, i}) + } + } + s.selected = min(s.selected, len(s.flat)-1) +} + +func problemsRow(c *ui.Context, problems []Problem, s *ProblemsPanelState, r problemRow, disabled bool) { + k := Tokens(c) + if r.problem < 0 { + chev, key := "chevron-down", "search_panel.fold" + if s.Collapsed.Has(r.file) { + chev, key = "chevron-right", "search_panel.unfold" + } + if iconAction(c, icons.Must(chev), fmt.Sprintf(msg(c, key), r.file)).Clicked() && !disabled { + problemsFold(s, r.file) + } + ui.Text(c, r.file).FontWeight(600).SingleLine().Grow(1).MinWidth(0) + return + } + p := problems[r.problem] + col := [3]ui.Color{k.Danger, k.Warning, k.Info}[p.Severity] + ui.Icon(c, icons.Must(problemsIcons[p.Severity])).Size(16, 16).Shrink(0).TextColor(col).Margin(0, 0, 0, 28). + Label(msg(c, [3]string{"problems_panel.error", "problems_panel.warning", "problems_panel.info"}[p.Severity])) + ui.Text(c, p.Message).SingleLine().Grow(1).MinWidth(0) + if p.Source != "" { + codeCaption(c, p.Source).Shrink(0) + } + codeCaption(c, fmt.Sprintf(msg(c, "problems_panel.at"), p.Line, p.Col)).FontFeatures("tnum").Shrink(0) +} diff --git a/problems_panel_test.go b/problems_panel_test.go new file mode 100644 index 0000000..2ecf38d --- /dev/null +++ b/problems_panel_test.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func problemsSample() []Problem { + return []Problem{ + {ProblemWarning, "court/seal.go", 30, 2, "unused variable wax", "vet"}, + {ProblemError, "court/seal.go", 12, 5, "undefined: Decree", "compiler"}, + {ProblemInfo, "docs/ledger.md", 4, 1, "line is long", "lint"}, + } +} + +type problemsProbe struct { + problems []Problem + s ProblemsPanelState + disabled bool + opened []Problem +} + +func (p *problemsProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := ProblemsPanel(c, p.problems, &p.s, ProblemsPanelOptions{Disabled: p.disabled}) + r.Element.Height(360) + if r.Opened != nil { + p.opened = append(p.opened, *r.Opened) + } +} + +func TestProblemsPanelGroupsSortedByLine(t *testing.T) { + p := &problemsProbe{problems: problemsSample()} + tt := ui.NewTester(p.view, 640, 380) + if !tt.HasText("Errors 1") || !tt.HasText("Warnings 1") || !tt.HasText("Info 1") { + t.Fatalf("counts missing: %q", tt.Texts()) + } + a, _ := tt.Find("undefined: Decree") + b, _ := tt.Find("unused variable wax") + if a.Y >= b.Y { + t.Fatal("problems are not sorted by line within the file") + } + if len(p.s.flat) != 5 || p.s.flat[0].file != "court/seal.go" || p.s.flat[3].file != "docs/ledger.md" { + t.Fatalf("rows %v", p.s.flat) + } +} + +func TestProblemsPanelFilterAndOpen(t *testing.T) { + p := &problemsProbe{problems: problemsSample()} + tt := ui.NewTester(p.view, 640, 380) + if err := tt.Click("Warnings 1"); err != nil { + t.Fatal(err) + } + if tt.HasText("unused variable wax") || !tt.HasText("undefined: Decree") { + t.Fatalf("filter kept warnings: %q", tt.Texts()) + } + r, _ := tt.Find("undefined: Decree") + tt.ClickAt(r.X+10, r.Y+r.H/2) + tt.ClickAt(r.X+10, r.Y+r.H/2) + if len(p.opened) != 1 || p.opened[0].Line != 12 || p.opened[0].Col != 5 { + t.Fatalf("opened %v", p.opened) + } +} + +func TestProblemsPanelKeyboardFold(t *testing.T) { + p := &problemsProbe{problems: problemsSample()} + tt := ui.NewTester(p.view, 640, 380) + for range 4 { + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyLeft) + if !p.s.Collapsed.Has("court/seal.go") || tt.HasText("undefined: Decree") { + t.Fatal("Left did not fold the file") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(p.opened) != 1 || p.opened[0].Message != "undefined: Decree" { + t.Fatalf("Enter opened %v", p.opened) + } +} + +func TestProblemsPanelDisabledAndEmpty(t *testing.T) { + p := &problemsProbe{problems: problemsSample(), disabled: true} + tt := ui.NewTester(p.view, 640, 380) + _ = tt.Click("Errors 1") + _ = tt.Click("Fold court/seal.go") + if p.s.Hidden[ProblemError] || p.s.Collapsed.Len() != 0 { + t.Fatal("disabled panel changed") + } + tt = ui.NewTester((&problemsProbe{}).view, 640, 380) + if !tt.HasText("No problems") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestProblemsPanelSnapshot(t *testing.T) { + snapshot(t, "problems_panel", 600, 240, func(c *ui.Context) { + s := ui.Local(c.Root(), "snap", func() *ProblemsPanelState { return &ProblemsPanelState{} }) + ui.Column(c).Padding(12).Children(func() { + ProblemsPanel(c, problemsSample(), *s, ProblemsPanelOptions{}).Element.Height(216) + }) + }) +} diff --git a/process_list.go b/process_list.go new file mode 100644 index 0000000..73b608e --- /dev/null +++ b/process_list.go @@ -0,0 +1,185 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Process is one row of a ProcessList. +type Process struct { + PID int + Name string + // CPU is percent of one core; Memory is resident bytes. + CPU float64 + Memory uint64 +} + +// ProcessListState is a ProcessList's sort, selection and pending end. +type ProcessListState struct { + // Sort is by name, pid, cpu or memory. + Sort ui.SortOrder + rows ui.ListState + selected int + order []int + sorted ui.SortOrder + data *Process + n int + confirm bool + ending Process + started bool +} + +// ProcessListOptions configure a ProcessList. +type ProcessListOptions struct { + Disabled bool +} + +// ProcessListResult carries a confirmed end request. +type ProcessListResult struct { + Element *ui.Element + // End is the confirmed process to end, or nil. + End *Process +} + +// ProcessList is a sortable process table with confirmed ending. +func ProcessList(c *ui.Context, procs []Process, s *ProcessListState, opts ProcessListOptions) ProcessListResult { + if s == nil { + panic("mujica: ProcessList needs a state") + } + if !s.started { + s.started, s.selected, s.Sort = true, -1, ui.SortOrder{Column: "cpu", Descending: true} + } + processListSort(procs, s) + k := Tokens(c) + var res ProcessListResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + if disabled { + s.confirm = false + } + ask := func(p Process) { + if !disabled { + s.ending, s.confirm = p, true + } + } + res.Element.Children(func() { + if len(procs) == 0 { + codeCaption(c, msg(c, "process_list.empty")).Padding(Density(c).Unit() * 4) + return + } + cols := []ui.TableColumn{ + {Title: msg(c, "process_list.name"), ID: "name", Sortable: true, MinWidth: 120}, + {Title: msg(c, "process_list.pid"), ID: "pid", Width: 80, Align: ui.End, Sortable: true}, + {Title: msg(c, "process_list.cpu"), ID: "cpu", Width: 90, Align: ui.End, Sortable: true}, + {Title: msg(c, "process_list.memory"), ID: "memory", Width: 100, Align: ui.End, Sortable: true}, + {Title: msg(c, "process_list.actions"), ID: "end", Width: 56, Fixed: true, Align: ui.Center}, + } + s.rows.Sort = &s.Sort + s.rows.Selected = &s.selected + s.rows.Key = func(i int) any { return procs[s.order[i]].PID } + s.rows.Label = func(i int) string { return procs[s.order[i]].Name } + restore := dataSelectTheme(c, 0) + table := ui.Table(c, &s.rows, cols, len(procs), func(row, col int) { + p := procs[s.order[row]] + switch col { + case 0: + ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Children(func() { + codeSelectMark(c, s.selected == row) + ui.Text(c, p.Name).SingleLine() + }) + case 1: + codeMono(c, fmt.Sprint(p.PID)) + case 2: + codeMono(c, fmt.Sprintf("%.1f%%", p.CPU)) + case 3: + codeMono(c, processListBytes(p.Memory)) + case 4: + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "process_list.end"), p.Name)).Clicked() { + ask(p) + } + } + }).Grow(1).MinHeight(Density(c).ControlHeight() * 4) + restore() + internal.Ring(table, k.Focus, theme.ControlRadius) + if table.Changed() { + processListSort(procs, s) + } + if table.Shortcut(0, ui.KeyDelete) && s.selected >= 0 && s.selected < len(procs) { + ask(procs[s.order[s.selected]]) + } + }) + if s.confirm { + r := AlertDialog(c, &s.confirm, AlertDialogOptions{ + Title: fmt.Sprintf(msg(c, "process_list.confirm"), s.ending.Name, s.ending.PID), + Description: msg(c, "process_list.warning"), + ConfirmLabel: msg(c, "process_list.endButton"), + }) + if r.Confirmed && !disabled { + p := s.ending + res.End = &p + slog.Debug("mujica: process_list end", "pid", p.PID) + } + } + return res +} + +// processListSort reorders when data or sort change. +func processListSort(procs []Process, s *ProcessListState) { + var first *Process + if len(procs) > 0 { + first = &procs[0] + } + if s.data == first && s.n == len(procs) && s.sorted == s.Sort && len(s.order) == len(procs) { + return + } + s.data, s.n, s.sorted = first, len(procs), s.Sort + s.order = s.order[:0] + for i := range procs { + s.order = append(s.order, i) + } + by := func(a, b int) int { + pa, pb := procs[a], procs[b] + switch s.Sort.Column { + case "name": + return strings.Compare(strings.ToLower(pa.Name), strings.ToLower(pb.Name)) + case "pid": + return cmp.Compare(pa.PID, pb.PID) + case "cpu": + return cmp.Compare(pa.CPU, pb.CPU) + case "memory": + return cmp.Compare(pa.Memory, pb.Memory) + case "": + return 0 + } + panic("mujica: ProcessList sorts by name, pid, cpu or memory, not " + s.Sort.Column) + } + slices.SortStableFunc(s.order, func(a, b int) int { + if s.Sort.Descending { + return by(b, a) + } + return by(a, b) + }) + slog.Debug("mujica: process_list sort", "by", s.Sort.Column, "descending", s.Sort.Descending) +} + +func processListBytes(b uint64) string { + const unit = 1024 + switch { + case b >= unit*unit*unit: + return fmt.Sprintf("%.1f GB", float64(b)/(unit*unit*unit)) + case b >= unit*unit: + return fmt.Sprintf("%.1f MB", float64(b)/(unit*unit)) + case b >= unit: + return fmt.Sprintf("%.0f KB", float64(b)/unit) + } + return fmt.Sprintf("%d B", b) +} diff --git a/process_list_test.go b/process_list_test.go new file mode 100644 index 0000000..a8a89cf --- /dev/null +++ b/process_list_test.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func processListSample() []Process { + return []Process{ + {PID: 101, Name: "chancellor", CPU: 3.5, Memory: 200 << 20}, + {PID: 7, Name: "archivist", CPU: 42.1, Memory: 50 << 20}, + {PID: 330, Name: "Herald", CPU: 0.2, Memory: 2 << 30}, + } +} + +func processListTester(procs []Process, s *ProcessListState, disabled bool, ended *[]int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := ProcessList(c, procs, s, ProcessListOptions{Disabled: disabled}) + r.Element.Height(300) + if r.End != nil { + *ended = append(*ended, r.End.PID) + } + }, 560, 340) +} + +func processListOrder(procs []Process, s *ProcessListState) []int { + var out []int + for _, i := range s.order { + out = append(out, procs[i].PID) + } + return out +} + +func TestProcessListSortsByCPUThenMemory(t *testing.T) { + procs := processListSample() + s := &ProcessListState{} + var ended []int + tt := processListTester(procs, s, false, &ended) + if got := processListOrder(procs, s); got[0] != 7 || got[2] != 330 { + t.Fatalf("default CPU order %v", got) + } + if err := tt.Click("Memory"); err != nil { + t.Fatal(err) + } + if got := processListOrder(procs, s); got[0] != 7 || got[2] != 330 || s.Sort.Descending { + t.Fatalf("memory ascending %v", got) + } + _ = tt.Click("Memory") + if got := processListOrder(procs, s); got[0] != 330 { + t.Fatalf("memory descending %v", got) + } + if !tt.HasText("2.0 GB") || !tt.HasText("42.1%") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestProcessListEndAsksFirst(t *testing.T) { + procs := processListSample() + s := &ProcessListState{} + var ended []int + tt := processListTester(procs, s, false, &ended) + if err := tt.Click("End Herald"); err != nil { + t.Fatal(err) + } + if len(ended) != 0 || !tt.HasText("End “Herald” (PID 330)?") { + t.Fatalf("ended %v before confirmation", ended) + } + if err := tt.Click("End process"); err != nil { + t.Fatal(err) + } + if len(ended) != 1 || ended[0] != 330 { + t.Fatalf("ended %v", ended) + } +} + +func TestProcessListKeyboardEnd(t *testing.T) { + procs := processListSample() + s := &ProcessListState{} + var ended []int + tt := processListTester(procs, s, false, &ended) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDelete) + if !tt.HasText("End “archivist” (PID 7)?") { + t.Fatalf("Delete did not ask for the chosen row: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEscape) + if len(ended) != 0 { + t.Fatal("Escape ended the process") + } +} + +func TestProcessListDisabledAndEmpty(t *testing.T) { + var ended []int + tt := processListTester(processListSample(), &ProcessListState{}, true, &ended) + _ = tt.Click("End Herald") + if tt.HasText("End process") { + t.Fatal("disabled list asked to end a process") + } + tt = processListTester(nil, &ProcessListState{}, false, &ended) + if !tt.HasText("No processes") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestProcessListBadSortPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unknown sort column did not panic") + } + }() + processListSort(processListSample(), &ProcessListState{Sort: ui.SortOrder{Column: "owner"}}) +} + +func TestProcessListSnapshot(t *testing.T) { + snapshot(t, "process_list", 560, 220, func(c *ui.Context) { + s := ui.Local(c.Root(), "snap", func() *ProcessListState { return &ProcessListState{} }) + ui.Column(c).Padding(12).Children(func() { + ProcessList(c, processListSample(), *s, ProcessListOptions{}).Element.Height(196) + }) + }) +} diff --git a/project_knowledge_panel.go b/project_knowledge_panel.go new file mode 100644 index 0000000..4a2cce1 --- /dev/null +++ b/project_knowledge_panel.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// KnowledgeFile is a project knowledge file, sized in tokens. +type KnowledgeFile struct { + ID, Name string + Tokens int +} + +// ProjectKnowledgePanelOptions configure a ProjectKnowledgePanel. Capacity is required. +type ProjectKnowledgePanelOptions struct { + // Capacity is how many tokens the project's knowledge holds. + Capacity int + // Title replaces the built-in "Project knowledge". + Title string + Disabled bool +} + +// ProjectKnowledgePanelView is a built panel and its requests. +type ProjectKnowledgePanelView struct { + Element *ui.Element + // Add asks the caller to pick files to add. + Add bool + // Removed is the ID of a file to remove. + Removed string +} + +// ProjectKnowledgePanel lists knowledge files with add, remove and usage. +func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKnowledgePanelOptions) ProjectKnowledgePanelView { + if opts.Capacity <= 0 { + panic("mujica: ProjectKnowledgePanel needs a positive Capacity") + } + used := 0 + for _, f := range files { + if f.Tokens < 0 { + panic("mujica: KnowledgeFile with negative Tokens") + } + used += f.Tokens + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit()*2).Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Disabled(opts.Disabled) + v := ProjectKnowledgePanelView{Element: col} + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + title := opts.Title + if title == "" { + title = msg(c, "project_knowledge_panel.title") + } + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1) + v.Add = Button(c, msg(c, "project_knowledge_panel.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus"), Disabled: opts.Disabled}).Clicked() + }) + ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { + internal.RuleDiamond(p, r, 6, 5, k.Ornament) + }) + projectKnowledgeMeter(c, k, used, opts.Capacity) + if len(files) == 0 { + ui.Text(c, msg(c, "project_knowledge_panel.empty")).TextColor(k.TextMuted).Padding(d.Unit()*2, 0) + return + } + ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() { + for _, f := range files { + ui.Row(c).Key(f.ID).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(0, d.Unit()).Children(func() { + ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, f.Name).SingleLine().Grow(1).MinWidth(0) + ui.Text(c, fmt.Sprintf(msg(c, "project_knowledge_panel.tokens"), f.Tokens)). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + x := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "project_knowledge_panel.remove"), f.Name)) + if x.Clicked() && !x.IsDisabled() { + v.Removed = f.ID + } + }) + } + }) + }) + if v.Add || v.Removed != "" { + slog.Debug("mujica: project_knowledge_panel action", "add", v.Add, "remove", v.Removed) + } + return v +} + +// projectKnowledgeMeter shows usage; limits get words and icons. +func projectKnowledgeMeter(c *ui.Context, k theme.Tokens, used, capacity int) { + d := Density(c) + frac := float64(used) / float64(capacity) + ink, note, icon := k.AccentText, "", "" + switch { + case used > capacity: + ink, note, icon = k.Danger, msg(c, "project_knowledge_panel.over"), "circle-alert" + case frac >= 0.8: + ink, note, icon = k.Warning, msg(c, "project_knowledge_panel.near"), "triangle-alert" + } + pct := int(frac * 100) + label := fmt.Sprintf(msg(c, "project_knowledge_panel.usage"), used, capacity, pct) + ui.Column(c).Gap(d.Unit()).Role(ui.RoleMeter).Label(label).Children(func() { + ui.Box(c).FillWidth().Height(6).Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.Border, 3) + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(min(frac, 1)), H: r.H}, ink, 3) + }) + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() { + if icon != "" { + ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(ink) + } + t := ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + if note != "" { + t.TextColor(ink) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(ink).Role(ui.RoleStatus) + } + }) + }) +} diff --git a/project_knowledge_panel_test.go b/project_knowledge_panel_test.go new file mode 100644 index 0000000..1b6ac0f --- /dev/null +++ b/project_knowledge_panel_test.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func knowledgeFiles() []KnowledgeFile { + return []KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 120000}, {ID: "2", Name: "menu.md", Tokens: 50000}} +} + +func TestProjectKnowledgePanelAddRemove(t *testing.T) { + files := knowledgeFiles() + adds := 0 + tt := chatTester(420, 400, func(c *ui.Context) { + v := ProjectKnowledgePanel(c, files, ProjectKnowledgePanelOptions{Capacity: 400000}) + if v.Add { + adds++ + } + if v.Removed != "" { + for i, f := range files { + if f.ID == v.Removed { + files = append(files[:i:i], files[i+1:]...) + break + } + } + } + }) + if !tt.HasText("170000 of 400000 tokens (42%)") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Add files") + if adds != 1 { + t.Fatalf("adds %d", adds) + } + if err := tt.Click("Remove menu.md"); err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0].ID != "1" || !tt.HasText("120000 of 400000 tokens (30%)") { + t.Fatalf("files %v texts %q", files, tt.Texts()) + } +} + +func TestProjectKnowledgePanelCapacityStates(t *testing.T) { + for _, tc := range []struct { + capacity int + note string + }{{200000, "Nearly full"}, {150000, "Over capacity"}} { + tt := chatTester(420, 400, func(c *ui.Context) { + ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: tc.capacity}) + }) + if !tt.HasText(tc.note) { + t.Errorf("%d: texts %q", tc.capacity, tt.Texts()) + } + } + tt := chatTester(420, 400, func(c *ui.Context) { + ProjectKnowledgePanel(c, nil, ProjectKnowledgePanelOptions{Capacity: 10}) + }) + if !tt.HasText("No files yet") { + t.Fatal("empty state missing") + } +} + +func TestProjectKnowledgePanelDisabledAndKeys(t *testing.T) { + removed := "" + tt := chatTester(420, 400, func(c *ui.Context) { + if v := ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 400000, Disabled: true}); v.Removed != "" { + removed = v.Removed + } + }) + _ = tt.Click("Remove menu.md") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if removed != "" { + t.Fatalf("disabled panel removed %q", removed) + } + tt = chatTester(420, 400, func(c *ui.Context) { + if v := ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 400000}); v.Removed != "" { + removed = v.Removed + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if removed != "2" { + t.Fatalf("keys removed %q", removed) + } +} + +func TestProjectKnowledgePanelRejectsBadCapacity(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ProjectKnowledgePanel(c, nil, ProjectKnowledgePanelOptions{}) + }, 100, 100, 1) +} + +func TestProjectKnowledgePanelSnapshot(t *testing.T) { + snapshot(t, "project_knowledge_panel", 420, 240, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 200000}) + }) + }) +} diff --git a/project_list.go b/project_list.go new file mode 100644 index 0000000..6c73018 --- /dev/null +++ b/project_list.go @@ -0,0 +1,212 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ChatProject holds conversations; Folder groups projects, empty for none. +type ChatProject struct { + ID, Name, Folder string +} + +// ProjectListOptions configure a ProjectList. +type ProjectListOptions struct { + Label string + Disabled bool +} + +// ProjectListState is the chosen project and open folders and projects. +type ProjectListState struct { + // Selected is the chosen project's ID; empty for none. + Selected string + closed map[string]bool + open map[string]bool +} + +// ProjectListView is a built ProjectList and this frame's events. +type ProjectListView struct { + Element *ui.Element + // Changed reports a newly chosen project, in State.Selected. + Changed bool + // Opened is a conversation the user opened, empty for none. + Opened string + // Moved asks the caller to move MoveID into MoveTo. + Moved bool + MoveID, MoveTo string +} + +// ProjectList shows project folders and conversations; drops move them. +func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, conversations func(projectID string) []ChatConversation, opts ProjectListOptions) ProjectListView { + if s == nil || conversations == nil { + panic("mujica: ProjectList needs a state and conversations") + } + if s.closed == nil { + s.closed, s.open = map[string]bool{}, map[string]bool{} + } + d := Density(c) + col := ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Role(ui.RoleTree).Label(opts.Label).Disabled(opts.Disabled) + v := ProjectListView{Element: col} + col.Children(func() { + seen := map[string]bool{} + for _, p := range projects { + if p.Folder == "" || seen[p.Folder] { + continue + } + seen[p.Folder] = true + open := !s.closed[p.Folder] + ui.Column(c).Key("folder:" + p.Folder).Children(func() { projectListFolder(c, p.Folder, &open) }) + s.closed[p.Folder] = !open + if !open { + continue + } + for _, q := range projects { + if q.Folder == p.Folder { + projectListProject(c, s, q, projects, conversations, d.Unit()*6, &v) + } + } + } + for _, p := range projects { + if p.Folder == "" { + projectListProject(c, s, p, projects, conversations, 0, &v) + } + } + }) + if col.IsDisabled() { + v.Changed, v.Opened, v.Moved = false, "", false + } + switch { + case v.Moved: + slog.Debug("mujica: project_list move", "conversation", v.MoveID, "project", v.MoveTo) + case v.Changed: + slog.Debug("mujica: project_list select", "project", s.Selected) + } + return v +} + +// projectListFolder is a folder heading on a CollapsibleBase trigger. +func projectListFolder(c *ui.Context, title string, open *bool) *ui.Element { + k := Tokens(c) + d := Density(c) + parts := ui.CollapsibleBase(c, open) + tr := parts.Trigger.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center). + Radius(theme.ControlRadius).Label(title) + internal.Ring(tr, k.Focus, theme.ControlRadius) + if tr.Hovered() { + tr.Background(k.SurfaceHover) + } + chevron := "chevron-right" + if *open { + chevron = "chevron-down" + } + tr.Children(func() { + ui.Icon(c, icons.Must(chevron)).Size(14, 14).TextColor(k.TextMuted) + ui.Icon(c, icons.Must("folder")).Size(16, 16).TextColor(k.Ornament.Mix(k.Text, 0.3)) + ui.Text(c, title).Font(theme.SerifFont).SingleLine() + }) + return tr +} + +func projectListProject(c *ui.Context, s *ProjectListState, p ChatProject, all []ChatProject, conversations func(string) []ChatConversation, indent float32, v *ProjectListView) { + k := Tokens(c) + d := Density(c) + convs := conversations(p.ID) + chosen := s.Selected == p.ID + var b *ui.Element + line := ui.Row(c).Key("project:" + p.ID).AlignItems(ui.Center) + line.Children(func() { b = ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()) }) + b.Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent). + Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleTreeItem).Label(p.Name).Selected(chosen) + b.Background(ui.Transparent).TextColor(k.Text) + internal.Ring(b, k.Focus, theme.ControlRadius) + _, over := ui.DragOver[ConversationDrag](b) + switch { + case chosen: + b.Background(k.Selection).TextColor(k.AccentText) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + if over { + b.DrawOver(func(pt *ui.Painter, r ui.Rect) { internal.Frame(pt, r, 1, 5, k.AccentText) }) + } + if drop, ok := ui.Drop[ConversationDrag](b); ok && !b.IsDisabled() { + v.Moved, v.MoveID, v.MoveTo = true, drop.ID, p.ID + } + if b.Clicked() && !chosen { + s.Selected, v.Changed = p.ID, true + } + open := s.open[p.ID] + if !b.IsDisabled() && len(convs) > 0 { + switch { + case b.Shortcut(0, ui.KeyRight): + s.open[p.ID] = true + case b.Shortcut(0, ui.KeyLeft): + s.open[p.ID] = false + } + } + if len(convs) > 0 { + b.Description(msg(c, map[bool]string{true: "project_list.expanded", false: "project_list.collapsed"}[open])) + } + b.Children(func() { + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) { + if chosen { + pt.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText) + } + }) + ui.Text(c, p.Name).SingleLine().Grow(1).MinWidth(0) + ui.Text(c, fmt.Sprint(len(convs))).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + if len(convs) > 0 { + chevron := map[bool]string{true: "chevron-down", false: "chevron-right"}[open] + line.Children(func() { + t := iconAction(c, icons.Must(chevron), fmt.Sprintf(msg(c, map[bool]string{true: "project_list.collapse", false: "project_list.expand"}[open]), p.Name)) + if t.Clicked() && !t.IsDisabled() { + s.open[p.ID] = !open + } + }) + } + if !open { + return + } + for _, conv := range convs { + projectListConversation(c, conv, p, all, indent+d.Unit()*6, v) + } +} + +func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProject, all []ChatProject, indent float32, v *ProjectListView) { + k := Tokens(c) + d := Density(c) + ui.Row(c).Key("conv:" + conv.ID).AlignItems(ui.Center).Children(func() { + b := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent). + Justify(ui.Start).Radius(theme.ControlRadius).Label(conv.Title).Drag(ConversationDrag{ID: conv.ID}) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Dragging() { + b.Opacity(0.4) + } + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Text(c, cmp.Or(conv.Title, msg(c, "conversation_list.untitled"))).SingleLine().TextColor(k.TextMuted) + }) + if b.Clicked() { + v.Opened = conv.ID + } + conversationItemMenu(c, fmt.Sprintf(msg(c, "project_list.move_to"), conv.Title), func(m *PopupMenu) { + m.Group(msg(c, "project_list.move_group"), func() { + for _, p := range all { + if p.ID != home.ID && m.Item(p.Name, PopupMenuItemOptions{}) { + v.Moved, v.MoveID, v.MoveTo = true, conv.ID, p.ID + } + } + }) + }) + }) +} diff --git a/project_list_test.go b/project_list_test.go new file mode 100644 index 0000000..4042954 --- /dev/null +++ b/project_list_test.go @@ -0,0 +1,138 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type projectFixture struct { + s ProjectListState + projects []ChatProject + owner map[string]string + last ProjectListView + moves int +} + +func newProjectFixture() *projectFixture { + return &projectFixture{ + projects: []ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}, {ID: "p2", Name: "Gardens", Folder: "Court"}, {ID: "p3", Name: "Music"}}, + owner: map[string]string{"a": "p1", "b": "p1", "c": "p3"}, + } +} + +func (f *projectFixture) view(opts ProjectListOptions) func(c *ui.Context) { + return func(c *ui.Context) { + convs := func(id string) []ChatConversation { + var out []ChatConversation + for _, conv := range chatConversations() { + if f.owner[conv.ID] == id { + out = append(out, conv) + } + } + return out + } + v := ProjectList(c, &f.s, f.projects, convs, opts) + if v.Moved { + f.owner[v.MoveID] = v.MoveTo + f.moves++ + } + if v.Changed || v.Opened != "" || v.Moved { + f.last = v + } + } +} + +func TestProjectListSelectAndExpand(t *testing.T) { + f := newProjectFixture() + tt := chatTester(420, 500, f.view(ProjectListOptions{Label: "Projects"})) + if err := tt.Click("Gardens"); err != nil { + t.Fatal(err) + } + if f.s.Selected != "p2" || !f.last.Changed { + t.Fatalf("selected %q", f.s.Selected) + } + if tt.HasText("Court etiquette notes") { + t.Fatal("conversations shown before expanding") + } + _ = tt.Click("Show conversations in Coronation") + if !tt.HasText("Court etiquette notes") || !tt.HasText("Ballroom seating plan") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Court etiquette notes") + if f.last.Opened != "a" { + t.Fatalf("opened %q", f.last.Opened) + } + // The folder collapses its projects. + _ = tt.Click("Court") + if tt.HasText("Gardens") { + t.Fatal("folder did not collapse") + } +} + +func TestProjectListKeyboard(t *testing.T) { + f := newProjectFixture() + tt := chatTester(420, 500, f.view(ProjectListOptions{})) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Court") { + t.Fatal("Tab did not enter at the folder") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + if !tt.HasText("Court etiquette notes") { + t.Fatal("Right did not expand") + } + tt.Key(0, ui.KeyEnter) + if f.s.Selected != "p1" { + t.Fatalf("Enter chose %q", f.s.Selected) + } + tt.Key(0, ui.KeyLeft) + if tt.HasText("Court etiquette notes") { + t.Fatal("Left did not collapse") + } +} + +func TestProjectListMoveByMenuAndDrag(t *testing.T) { + f := newProjectFixture() + tt := chatTester(420, 500, f.view(ProjectListOptions{})) + _ = tt.Click("Show conversations in Coronation") + if err := tt.Click("Move Court etiquette notes"); err != nil { + t.Fatal(err) + } + // The menu opens on Gardens; Down reaches Music. + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if f.owner["a"] != "p3" || f.moves != 1 { + t.Fatalf("menu move: owner %q moves %d", f.owner["a"], f.moves) + } + from, _ := tt.Find("Ballroom seating plan") + to, _ := tt.Find("Gardens") + tt.Press(from.X+5, from.Y+from.H/2) + tt.Move(from.X+20, from.Y+from.H/2+5) + tt.Move(to.X+10, to.Y+to.H/2) + tt.Release(to.X+10, to.Y+to.H/2) + if f.owner["b"] != "p2" || f.last.MoveTo != "p2" { + t.Fatalf("drag move: owner %q", f.owner["b"]) + } +} + +func TestProjectListDisabled(t *testing.T) { + f := newProjectFixture() + tt := chatTester(420, 500, f.view(ProjectListOptions{Disabled: true})) + _ = tt.Click("Gardens") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if f.s.Selected != "" || f.moves != 0 { + t.Fatalf("disabled list acted: %q", f.s.Selected) + } +} + +func TestProjectListSnapshot(t *testing.T) { + snapshot(t, "project_list", 380, 280, func(c *ui.Context) { + f := newProjectFixture() + f.s.Selected = "p1" + f.s.open = map[string]bool{"p1": true} + f.s.closed = map[string]bool{} + ui.Column(c).Padding(16).Children(func() { f.view(ProjectListOptions{})(c) }) + }) +} diff --git a/prompt_library.go b/prompt_library.go new file mode 100644 index 0000000..738566e --- /dev/null +++ b/prompt_library.go @@ -0,0 +1,218 @@ +package mujica + +import ( + "log/slog" + "regexp" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PromptTemplate is a reusable prompt; {{name}} marks a variable. +type PromptTemplate struct { + ID, Category, Title, Body string +} + +// PromptLibraryOptions configure a PromptLibrary. +type PromptLibraryOptions struct { + Label string + Disabled bool +} + +// PromptLibraryState holds category, query, chosen template and variable values. +type PromptLibraryState struct { + // Category filters templates; empty shows all. + Category string + Query string + Selected string + Values map[string]string + shown promptLibraryShown +} + +// promptLibraryShown caches filtered templates until data, query or category change. +type promptLibraryShown struct { + src []PromptTemplate + query, category string + ids []int + cats []string + built bool +} + +// PromptLibraryView is a built PromptLibrary and inserted text. +type PromptLibraryView struct { + Element *ui.Element + // Inserted is the filled template, or empty. + Inserted string +} + +var promptLibraryVar = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`) + +// PromptLibraryVariables lists a body's {{name}} variables in order, once each. +func PromptLibraryVariables(body string) []string { + var out []string + for _, m := range promptLibraryVar.FindAllStringSubmatch(body, -1) { + if !slices.Contains(out, m[1]) { + out = append(out, m[1]) + } + } + return out +} + +// PromptLibraryFill replaces each {{name}} in body with values[name]. +func PromptLibraryFill(body string, values map[string]string) string { + return promptLibraryVar.ReplaceAllStringFunc(body, func(m string) string { + return values[promptLibraryVar.FindStringSubmatch(m)[1]] + }) +} + +// PromptLibrary browses, previews, fills and inserts prompt templates. +func PromptLibrary(c *ui.Context, s *PromptLibraryState, templates []PromptTemplate, opts PromptLibraryOptions) PromptLibraryView { + if s == nil { + panic("mujica: PromptLibrary needs a state") + } + if s.Values == nil { + s.Values = map[string]string{} + } + k := Tokens(c) + d := Density(c) + promptLibraryFilter(&s.shown, templates, s.Query, s.Category) + row := ui.Row(c).Gap(d.Unit() * 4).AlignItems(ui.Stretch).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled) + v := PromptLibraryView{Element: row} + row.Children(func() { + ui.Column(c).Width(150).Shrink(0).Gap(2).FocusGroup(ui.Vertical).Children(func() { + promptLibraryCategory(c, k, s, "", msg(c, "prompt_library.all")) + for _, cat := range s.shown.cats { + promptLibraryCategory(c, k, s, cat, cat) + } + }) + ui.Column(c).Width(240).Shrink(0).Gap(d.Unit() * 2).Children(func() { + SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "prompt_library.search"), Disabled: opts.Disabled}) + ui.Scroll(c).MaxHeight(320).Children(func() { + ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() { + if len(s.shown.ids) == 0 { + ui.Text(c, msg(c, "prompt_library.no_results")).TextColor(k.TextMuted).Padding(d.Unit() * 2) + } + for _, i := range s.shown.ids { + t := templates[i] + if selectableRow(c, k, t.ID, t.Title, s.Selected == t.ID) && s.Selected != t.ID { + s.Selected = t.ID + slog.Debug("mujica: prompt_library select", "id", t.ID) + } + } + }) + }) + }) + ui.Column(c).Grow(1).MinWidth(200).Gap(d.Unit() * 2).Children(func() { + i := slices.IndexFunc(templates, func(t PromptTemplate) bool { return t.ID == s.Selected }) + if i < 0 { + ui.Text(c, msg(c, "prompt_library.pick")).TextColor(k.TextMuted) + return + } + v.Inserted = promptLibraryPreview(c, k, s, templates[i], opts.Disabled) + }) + }) + if v.Inserted != "" { + slog.Debug("mujica: prompt_library insert", "id", s.Selected) + } + return v +} + +func promptLibraryFilter(sh *promptLibraryShown, templates []PromptTemplate, query, category string) { + same := sh.built && sh.query == query && sh.category == category && len(sh.src) == len(templates) && + (len(templates) == 0 || &sh.src[0] == &templates[0]) + if same { + return + } + *sh = promptLibraryShown{src: templates, query: query, category: category, built: true} + q := strings.ToLower(strings.TrimSpace(query)) + for i, t := range templates { + if t.ID == "" || t.Title == "" { + panic("mujica: PromptTemplate needs an ID and a Title") + } + if t.Category != "" && !slices.Contains(sh.cats, t.Category) { + sh.cats = append(sh.cats, t.Category) + } + if category != "" && t.Category != category { + continue + } + if q == "" || strings.Contains(strings.ToLower(t.Title+"\n"+t.Body), q) { + sh.ids = append(sh.ids, i) + } + } +} + +func promptLibraryCategory(c *ui.Context, k theme.Tokens, s *PromptLibraryState, cat, label string) { + if selectableRow(c, k, "cat:"+cat, label, s.Category == cat) && s.Category != cat { + s.Category = cat + } +} + +// selectableRow is a focusable row marked chosen with a diamond. +func selectableRow(c *ui.Context, k theme.Tokens, key, label string, chosen bool) bool { + d := Density(c) + b := ui.ButtonBase(c).Key(key).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).Justify(ui.Start). + Radius(theme.ControlRadius).Label(label).Selected(chosen) + b.Background(ui.Transparent).TextColor(k.Text) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case chosen: + b.Background(k.Selection).TextColor(k.AccentText) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + if chosen { + p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText) + } + }) + ui.Text(c, label).SingleLine() + }) + return b.Clicked() && !b.IsDisabled() +} + +// promptLibraryPreview shows the body, variable fields and Insert. +func promptLibraryPreview(c *ui.Context, k theme.Tokens, s *PromptLibraryState, t PromptTemplate, disabled bool) string { + d := Density(c) + vars := PromptLibraryVariables(t.Body) + ui.Text(c, t.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) + missing := false + var spans []ui.Span + at := 0 + for _, m := range promptLibraryVar.FindAllStringSubmatchIndex(t.Body, -1) { + spans = append(spans, ui.Span{Text: t.Body[at:m[0]]}) + name := t.Body[m[2]:m[3]] + if val := s.Values[name]; val != "" { + spans = append(spans, ui.Span{Text: val, Weight: 600, Color: k.AccentText}) + } else { + missing = true + spans = append(spans, ui.Span{Text: "[" + name + "]", Background: k.Selection, Color: k.AccentText, Underline: true}) + } + at = m[1] + } + spans = append(spans, ui.Span{Text: t.Body[at:]}) + ui.Column(c).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius). + Children(func() { ui.RichText(c, spans...).Selectable() }) + for _, name := range vars { + val := s.Values[name] + ui.Column(c).Key("var:" + name).Children(func() { + FormField(c, name, FormFieldOptions{Required: true}, func() *ui.Element { + r := TextInput(c, &val, TextInputOptions{Label: name, Disabled: disabled}) + if r.Changed() { + s.Values[name] = val + } + return r.Element + }) + }) + } + insert := Button(c, msg(c, "prompt_library.insert"), ButtonOptions{Icon: icons.Must("plus"), Disabled: missing || disabled}).Clicked() + if insert && !missing { + return PromptLibraryFill(t.Body, s.Values) + } + return "" +} diff --git a/prompt_library_test.go b/prompt_library_test.go new file mode 100644 index 0000000..63f56e7 --- /dev/null +++ b/prompt_library_test.go @@ -0,0 +1,105 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func promptTemplates() []PromptTemplate { + return []PromptTemplate{ + {ID: "t1", Category: "Letters", Title: "Invitation", Body: "Write an invitation to {{guest}} for the {{event}}."}, + {ID: "t2", Category: "Music", Title: "Recital program", Body: "List pieces for a {{ instrument }} recital, then thank {{guest}}."}, + {ID: "t3", Category: "Letters", Title: "Thank-you note", Body: "Thank the hosts."}, + } +} + +func TestPromptLibraryVariables(t *testing.T) { + if got := PromptLibraryVariables("{{a}} and {{ b }} and {{a}}"); !slices.Equal(got, []string{"a", "b"}) { + t.Fatalf("variables %q", got) + } + if got := PromptLibraryFill("Hi {{ name }}, {{x}}!", map[string]string{"name": "Ann"}); got != "Hi Ann, !" { + t.Fatalf("fill %q", got) + } +} + +func TestPromptLibraryBrowseFillInsert(t *testing.T) { + var s PromptLibraryState + templates := promptTemplates() + inserted := "" + tt := chatTester(940, 520, func(c *ui.Context) { + if v := PromptLibrary(c, &s, templates, PromptLibraryOptions{Label: "Prompts"}); v.Inserted != "" { + inserted = v.Inserted + } + }) + _ = tt.Click("Music") + if tt.HasText("Invitation") || !tt.HasText("Recital program") { + t.Fatalf("category filter: %q", tt.Texts()) + } + _ = tt.Click("All") + _ = tt.Click("Search prompts") + tt.Type("thank") + if tt.HasText("Invitation") || !tt.HasText("Thank-you note") || !tt.HasText("Recital program") { + t.Fatalf("search covers titles and bodies: %q", tt.Texts()) + } + _ = tt.Click("Recital program") + if !tt.HasText("[instrument]") { + t.Fatal("placeholder not previewed") + } + _ = tt.Click("Insert") + if inserted != "" { + t.Fatal("inserted with empty variables") + } + // Each variable's field sits under its label. + for _, f := range [][2]string{{"instrument", "harp"}, {"guest", "the duke"}} { + r, _ := tt.Find(f[0]) + tt.ClickAt(r.X+20, r.Y+r.H+18) + tt.Type(f[1]) + } + _ = tt.Click("Insert") + if inserted != "List pieces for a harp recital, then thank the duke." { + t.Fatalf("inserted %q", inserted) + } +} + +func TestPromptLibraryKeyboardAndDisabled(t *testing.T) { + var s PromptLibraryState + tt := chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &s, promptTemplates(), PromptLibraryOptions{}) }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if s.Category != "Letters" { + t.Fatalf("keys chose category %q", s.Category) + } + var d PromptLibraryState + tt = chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &d, promptTemplates(), PromptLibraryOptions{Disabled: true}) }) + _ = tt.Click("Invitation") + _ = tt.Click("Music") + if d.Selected != "" || d.Category != "" { + t.Fatalf("disabled library chose %q %q", d.Selected, d.Category) + } +} + +func TestPromptLibraryFiltersOnlyOnChange(t *testing.T) { + var s PromptLibraryState + templates := promptTemplates() + tt := chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &s, templates, PromptLibraryOptions{}) }) + s.shown.cats = append(s.shown.cats, "Marker") + tt.Frame() + if !slices.Contains(s.shown.cats, "Marker") { + t.Fatal("refiltered without a change") + } + s.Query = "invit" + tt.Frame() + if !slices.Equal(s.shown.ids, []int{0}) { + t.Fatalf("ids %v", s.shown.ids) + } +} + +func TestPromptLibrarySnapshot(t *testing.T) { + snapshot(t, "prompt_library", 900, 360, func(c *ui.Context) { + s := &PromptLibraryState{Selected: "t1", Values: map[string]string{"guest": "Lady Ann"}} + ui.Column(c).Padding(16).Children(func() { PromptLibrary(c, s, promptTemplates(), PromptLibraryOptions{}) }) + }) +} diff --git a/pull_request_card.go b/pull_request_card.go new file mode 100644 index 0000000..e51706b --- /dev/null +++ b/pull_request_card.go @@ -0,0 +1,179 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PullRequestState is where a pull request stands. +type PullRequestState int + +const ( + PullRequestOpen PullRequestState = iota + PullRequestDraft + PullRequestMerged + PullRequestClosed +) + +// ReviewState is a reviewer's verdict. +type ReviewState int + +const ( + ReviewPending ReviewState = iota + ReviewApproved + ReviewChangesRequested +) + +// Reviewer is a person asked to review. +type Reviewer struct { + Name string + State ReviewState +} + +// PullRequest is what a PullRequestCard shows; the caller fetches it. +type PullRequest struct { + Number int + Title string + Author string + State PullRequestState + Head, Base string + // Checks counts CI results. + ChecksPassed, ChecksFailed, ChecksPending int + Reviewers []Reviewer + Added, Deleted, Files int +} + +// PullRequestCardOptions configure a PullRequestCard. +type PullRequestCardOptions struct { + Disabled bool +} + +// PullRequestCard summarizes a pull request; Changed reports opening. +func PullRequestCard(c *ui.Context, pr PullRequest, opts PullRequestCardOptions) ChoiceResult { + if pr.Number <= 0 || pr.Title == "" { + panic("mujica: PullRequest needs a positive Number and a Title") + } + if pr.ChecksPassed < 0 || pr.ChecksFailed < 0 || pr.ChecksPending < 0 || pr.Files < 0 { + panic("mujica: PullRequest counts must not be negative") + } + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + card := ui.Column(c).AlignItems(ui.Stretch).Gap(u*2).Padding(u*4).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Disabled(opts.Disabled) + res := ChoiceResult{Element: card} + card.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + pullRequestStateBadge(c, pr.State) + title := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).Padding(u, u).Radius(theme.ControlRadius). + Role(ui.RoleLink).Label(fmt.Sprintf("#%d %s", pr.Number, pr.Title)).Shrink(1).MinWidth(0).Cursor(ui.CursorPointer) + internal.Ring(title, k.Focus, theme.ControlRadius) + if title.Hovered() { + title.Background(k.SurfaceHover) + } + title.Transition(colorFade) + title.Children(func() { + ui.Text(c, pr.Title).FontWeight(600).SingleLine().MinWidth(0) + ui.Text(c, fmt.Sprintf("#%d", pr.Number)).TextColor(k.TextMuted).FontFeatures("tnum").Shrink(0) + }) + if title.Clicked() && !title.IsDisabled() { + res.changed = true + slog.Debug("mujica: pull request card open", "number", pr.Number) + } + }) + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).FontSize(small).TextColor(k.TextMuted).Wrap().Children(func() { + ui.Text(c, pr.Author).FontWeight(600).TextColor(k.Text) + if pr.Head != "" && pr.Base != "" { + ui.Icon(c, icons.Must("git-pull-request")).Size(12, 12) + ui.Text(c, pr.Head+" → "+pr.Base).Font(theme.MonoFont).SingleLine().MaxWidth(320) + } + }) + ui.Row(c).AlignItems(ui.Center).Gap(u * 4).Wrap().FontSize(small).Children(func() { + pullRequestChecks(c, pr) + if len(pr.Reviewers) > 0 { + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + for _, r := range pr.Reviewers { + pullRequestReviewer(c, r) + } + }) + } + ui.Spacer(c) + ui.Text(c, fmt.Sprintf(msg(c, "pull_request_card.files"), pr.Files)).TextColor(k.TextMuted).FontFeatures("tnum") + DiffStat(c, pr.Added, pr.Deleted, DiffStatOptions{}) + }) + }) + return res +} + +func pullRequestStateBadge(c *ui.Context, st PullRequestState) { + k := Tokens(c) + u := Density(c).Unit() + icon, col, key := "git-pull-request", k.Success, "open" + switch st { + case PullRequestOpen: + case PullRequestDraft: + icon, col, key = "git-pull-request", k.TextMuted, "draft" + case PullRequestMerged: + icon, col, key = "git-merge", k.AccentText, "merged" + case PullRequestClosed: + icon, col, key = "circle-x", k.Danger, "closed" + default: + panic("mujica: unknown PullRequestState") + } + ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(2, u*2).Radius(theme.ControlRadius).Border(theme.BorderWidth, col). + TextColor(col).Shrink(0).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, msg(c, "pull_request_card."+key)).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine() + }) +} + +func pullRequestChecks(c *ui.Context, pr PullRequest) { + k := Tokens(c) + u := Density(c).Unit() + ui.Row(c).AlignItems(ui.Center).Gap(u * 3).Children(func() { + for _, ch := range []struct { + n int + icon, key string + col ui.Color + }{ + {pr.ChecksPassed, "circle-check", "pull_request_card.passed", k.Success}, + {pr.ChecksFailed, "circle-x", "pull_request_card.failed", k.Danger}, + {pr.ChecksPending, "clock", "pull_request_card.pending", k.Warning}, + } { + if ch.n == 0 { + continue + } + name := fmt.Sprintf(msg(c, ch.key), ch.n) + ui.Row(c).AlignItems(ui.Center).Gap(u).TextColor(ch.col).Label(name).Tooltip(name).Children(func() { + ui.Icon(c, icons.Must(ch.icon)).Size(14, 14) + ui.Text(c, fmt.Sprint(ch.n)).FontFeatures("tnum") + }) + } + }) +} + +func pullRequestReviewer(c *ui.Context, r Reviewer) { + k := Tokens(c) + key, icon, col := "pull_request_card.review_pending", "clock", k.TextMuted + switch r.State { + case ReviewPending: + case ReviewApproved: + key, icon, col = "pull_request_card.approved", "check", k.Success + case ReviewChangesRequested: + key, icon, col = "pull_request_card.changes", "circle-alert", k.Danger + default: + panic("mujica: unknown ReviewState") + } + name := r.Name + ": " + msg(c, key) + ui.Box(c).Size(28, 28).Shrink(0).Label(name).Tooltip(name).Children(func() { + Avatar(c, r.Name, AvatarOptions{Size: 24}).Absolute().Left(0).Top(0) + ui.Box(c).Size(14, 14).Radius(7).Background(k.Surface).Center().Absolute().Right(0).Bottom(0).TextColor(col). + Children(func() { ui.Icon(c, icons.Must(icon)).Size(10, 10) }) + }) +} diff --git a/pull_request_card_test.go b/pull_request_card_test.go new file mode 100644 index 0000000..caa4960 --- /dev/null +++ b/pull_request_card_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func pullRequestSample() PullRequest { + return PullRequest{Number: 318, Title: "Git components 301–320", Author: "Sakiko", State: PullRequestOpen, + Head: "feature/git", Base: "main", ChecksPassed: 12, ChecksFailed: 1, ChecksPending: 2, + Reviewers: []Reviewer{{Name: "Uika", State: ReviewApproved}, {Name: "Mutsumi", State: ReviewChangesRequested}, {Name: "Umiri"}}, + Added: 2412, Deleted: 310, Files: 46} +} + +func TestPullRequestCardShowsAndOpens(t *testing.T) { + opens := 0 + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if PullRequestCard(c, pullRequestSample(), PullRequestCardOptions{Disabled: disabled}).Changed() { + opens++ + } + }, 700, 240) + for _, name := range []string{"Open", "#318", "feature/git → main", "12 checks passed", "1 checks failed", "2 checks pending", + "Uika: approved", "Mutsumi: changes requested", "Umiri: review pending", "46 files", "2412 additions, 310 deletions"} { + if _, ok := tt.Find(name); !ok { + t.Errorf("missing %q", name) + } + } + if err := tt.Click("Git components 301–320"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyEnter) + if opens != 2 { + t.Fatalf("opens %d", opens) + } + disabled = true + tt.Frame() + _ = tt.Click("Git components 301–320") + if opens != 2 { + t.Fatal("disabled card opened") + } +} + +func TestPullRequestCardStatesAndInvalid(t *testing.T) { + for st, word := range map[PullRequestState]string{PullRequestDraft: "Draft", PullRequestMerged: "Merged", PullRequestClosed: "Closed"} { + pr := pullRequestSample() + pr.State = st + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PullRequestCard(c, pr, PullRequestCardOptions{}) + }, 700, 240) + if !tt.HasText(word) { + t.Errorf("state %d: %q", st, tt.Texts()) + } + } + for _, pr := range []PullRequest{{Title: "x"}, {Number: 1}, {Number: 1, Title: "x", Files: -1}, {Number: 1, Title: "x", State: 9}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", pr) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + PullRequestCard(c, pr, PullRequestCardOptions{}) + }, 300, 100, 1) + }() + } +} + +func TestPullRequestCardSnapshot(t *testing.T) { + snapshot(t, "pull_request_card", 640, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + PullRequestCard(c, pullRequestSample(), PullRequestCardOptions{}) + pr := pullRequestSample() + pr.Number, pr.State, pr.Title, pr.Reviewers, pr.ChecksFailed, pr.ChecksPending = 301, PullRequestMerged, "Diff viewer", nil, 0, 0 + PullRequestCard(c, pr, PullRequestCardOptions{}) + }) + }) +} diff --git a/quick_trade_buttons.go b/quick_trade_buttons.go new file mode 100644 index 0000000..d0926c1 --- /dev/null +++ b/quick_trade_buttons.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// QuickTradeButtonsOptions configure QuickTradeButtons. Symbol is required. +type QuickTradeButtonsOptions struct { + Symbol string + // Presets are the quantities offered; nil means 1, 10, 100. + Presets []float64 + // Decimals of the quantities shown. + Decimals int + // Confirm asks before sending, by the button pressed. + Confirm bool + Disabled bool +} + +// QuickTradeResult is built QuickTradeButtons and their trade. +type QuickTradeResult struct { + Element *ui.Element + side OrderSide + quantity float64 +} + +// Traded returns the side and quantity the user traded. +func (r QuickTradeResult) Traded() (OrderSide, float64, bool) { return r.side, r.quantity, r.side != 0 } + +type quickTradeState struct{ asking OrderSide } + +// QuickTradeButtons trade *quantity at market with one press. +func QuickTradeButtons(c *ui.Context, quantity *float64, opts QuickTradeButtonsOptions) QuickTradeResult { + if opts.Symbol == "" { + panic("mujica: QuickTradeButtons needs a Symbol") + } + if opts.Decimals < 0 { + panic("mujica: QuickTradeButtons with negative Decimals") + } + presets := opts.Presets + if presets == nil { + presets = []float64{1, 10, 100} + } + if *quantity == 0 { + *quantity = presets[0] + } + at := slices.Index(presets, *quantity) + if at < 0 { + panic(fmt.Sprintf("mujica: QuickTradeButtons quantity %v is not one of Presets", *quantity)) + } + u := Density(c).Unit() + var r QuickTradeResult + r.Element = ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(msg(c, "quick_trade_buttons.label")) + st := ui.Local(r.Element, "quick", func() quickTradeState { return quickTradeState{} }) + disabled := r.Element.IsDisabled() + if disabled { + st.asking = 0 + } + qty := statisticFormat(*quantity, opts.Decimals) + r.Element.Children(func() { + var segs []Segment + for _, p := range presets { + segs = append(segs, Segment{Label: statisticFormat(p, opts.Decimals)}) + } + if SegmentedControl(c, &at, segs, SegmentedControlOptions{Equal: true, Label: msg(c, "quick_trade_buttons.quantity"), Disabled: disabled}).Changed() { + *quantity = presets[at] + qty = statisticFormat(*quantity, opts.Decimals) + } + ui.Row(c).Gap(u * 2).Children(func() { + for _, side := range []OrderSide{SideBuy, SideSell} { + label := fmt.Sprintf(msg(c, fmt.Sprintf("quick_trade_buttons.side%d", side)), qty) + b := quickTradeButton(c, label, side) + if b.Clicked() && !disabled { + if opts.Confirm { + st.asking = side + } else { + r.side, r.quantity = side, *quantity + } + } + open := st.asking == side + res := Popconfirm(c, b, &open, PopconfirmOptions{ + Title: fmt.Sprintf(msg(c, fmt.Sprintf("quick_trade_buttons.confirm%d", side)), qty, opts.Symbol), + Description: msg(c, "quick_trade_buttons.market"), + ConfirmLabel: msg(c, fmt.Sprintf("order_entry.side%d", side)), + Danger: true, + }) + if res.Confirmed && !disabled { + r.side, r.quantity = side, *quantity + } + if !open && st.asking == side { + st.asking = 0 + } + } + }) + }) + if r.side != 0 { + slog.Debug("mujica: quick_trade_buttons traded", "side", r.side) + } + return r +} + +// quickTradeButton is a large button tinted by its side. +func quickTradeButton(c *ui.Context, label string, side OrderSide) *ui.Element { + k := Tokens(c) + d := Density(c) + col := trendColor(c, side.sign()) + b := ui.ButtonBase(c).Grow(1).Basis(0).MinHeight(d.ControlHeight()+d.Unit()*2).Radius(theme.ControlRadius). + Border(theme.BorderWidth*1.5, col).TextColor(col).Background(col.Alpha(0.1)).Label(label) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(col.Alpha(0.28)) + case b.Hovered(): + b.Background(col.Alpha(0.18)) + } + b.Children(func() { + ui.Icon(c, trendIcon(side.sign())).Size(16, 16) + ui.Text(c, label).FontWeight(700).SingleLine() + }) + return b +} diff --git a/quick_trade_buttons_test.go b/quick_trade_buttons_test.go new file mode 100644 index 0000000..c764999 --- /dev/null +++ b/quick_trade_buttons_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type quickTradeProbe struct { + qty float64 + opts QuickTradeButtonsOptions + trades []OrderSide + sizes []float64 +} + +func (p *quickTradeProbe) tester() *ui.Tester { + return tradingTester(GreenUp, 520, 400, func(c *ui.Context) { + ui.Box(c).Width(400).Children(func() { + if side, q, ok := QuickTradeButtons(c, &p.qty, p.opts).Traded(); ok { + p.trades, p.sizes = append(p.trades, side), append(p.sizes, q) + } + }) + }) +} + +func TestQuickTradeButtonsPresetsAndTrade(t *testing.T) { + p := &quickTradeProbe{opts: QuickTradeButtonsOptions{Symbol: "MUJC"}} + tt := p.tester() + if p.qty != 1 { + t.Fatalf("default preset %v", p.qty) + } + if err := tt.Click("10"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Sell 10"); err != nil { + t.Fatal(err) + } + if len(p.trades) != 1 || p.trades[0] != SideSell || p.sizes[0] != 10 { + t.Fatalf("trades %v %v", p.trades, p.sizes) + } +} + +func TestQuickTradeButtonsConfirmFocusesCancel(t *testing.T) { + p := &quickTradeProbe{qty: 100, opts: QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}} + tt := p.tester() + _ = tt.Click("Buy 100") + if len(p.trades) != 0 || !tt.HasText("Buy 100 MUJC?") || !tt.Focused("Cancel") { + t.Fatalf("confirm: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if len(p.trades) != 0 || tt.HasText("Buy 100 MUJC?") { + t.Fatal("Enter traded instead of canceling") + } + _ = tt.Click("Buy 100") + if err := tt.Click("Buy"); err != nil { + t.Fatal(err) + } + if len(p.trades) != 1 || p.trades[0] != SideBuy || p.sizes[0] != 100 { + t.Fatalf("trades %v", p.trades) + } +} + +func TestQuickTradeButtonsDisabledAndBadQuantity(t *testing.T) { + p := &quickTradeProbe{opts: QuickTradeButtonsOptions{Symbol: "MUJC", Disabled: true}} + tt := p.tester() + _ = tt.Click("Buy 1") + _ = tt.Click("10") + if len(p.trades) != 0 || p.qty != 1 { + t.Fatal("disabled buttons acted") + } + defer func() { + if recover() == nil { + t.Fatal("quantity outside presets accepted") + } + }() + (&quickTradeProbe{qty: 7, opts: QuickTradeButtonsOptions{Symbol: "MUJC"}}).tester() +} + +func TestQuickTradeButtonsSnapshot(t *testing.T) { + qty := 10.0 + snapshot(t, "quick_trade_buttons", 380, 130, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + QuickTradeButtons(c, &qty, QuickTradeButtonsOptions{Symbol: "MUJC"}) + }) + }) +} diff --git a/quote_card.go b/quote_card.go new file mode 100644 index 0000000..a35e886 --- /dev/null +++ b/quote_card.go @@ -0,0 +1,52 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// Quote is an instrument's price and change since the close. +type Quote struct { + Symbol, Name string + Price float64 + Change float64 + ChangePercent float64 +} + +// QuoteCardOptions configure a QuoteCard. +type QuoteCardOptions struct { + // Decimals of the price and change; zero means 2. + Decimals int + // Chart builds the mini chart slot; nil leaves it out. + Chart func() +} + +// QuoteCard shows a symbol, name, price, change and mini chart. +func QuoteCard(c *ui.Context, q Quote, opts QuoteCardOptions) *ui.Element { + if q.Symbol == "" { + panic("mujica: QuoteCard needs a Symbol") + } + dec := priceDecimals("QuoteCard", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + e := ui.Column(c).Gap(u*2).Padding(u*4).MinWidth(200).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(q.Symbol) + e.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.End).Children(func() { + ui.Text(c, q.Symbol).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine() + if q.Name != "" { + ui.Text(c, q.Name).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine(). + Ellipsis("…").MinWidth(0).Shrink(1) + } + }) + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Wrap().Children(func() { + PriceText(c, q.Price, PriceTextOptions{Decimals: dec, Size: theme.H2Size}) + PriceChangeBadge(c, q.Change, q.ChangePercent, PriceChangeBadgeOptions{Decimals: dec}) + }) + if opts.Chart != nil { + ui.Box(c).Height(40).FillWidth().Children(opts.Chart) + } + }) + return e +} diff --git a/quote_card_test.go b/quote_card_test.go new file mode 100644 index 0000000..6d53d20 --- /dev/null +++ b/quote_card_test.go @@ -0,0 +1,43 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestQuoteCardShowsQuoteAndChartSlot(t *testing.T) { + charted := false + tt := tradingTester(RedUp, 400, 240, func(c *ui.Context) { + QuoteCard(c, Quote{Symbol: "600519", Name: "Kweichow Moutai", Price: 1688.5, Change: 12.3, ChangePercent: 0.73}, + QuoteCardOptions{Chart: func() { charted = true; ui.Text(c, "chart") }}) + }) + for _, want := range []string{"600519", "Kweichow Moutai", "1,688.50", "+12.30 (+0.73%)", "chart"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + if !charted { + t.Fatal("chart slot not built") + } + defer func() { + if recover() == nil { + t.Fatal("empty symbol accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { QuoteCard(c, Quote{}, QuoteCardOptions{}) }) +} + +func TestQuoteCardSnapshot(t *testing.T) { + snapshot(t, "quote_card", 300, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + QuoteCard(c, Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35}, + QuoteCardOptions{Chart: func() { + k := Tokens(c) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + p.Line(r.X, r.Y+r.H*0.3, r.X+r.W, r.Y+r.H*0.7, 1.5, k.Info) + }) + }}) + }) + }) +} diff --git a/quote_reply.go b/quote_reply.go new file mode 100644 index 0000000..ad82de1 --- /dev/null +++ b/quote_reply.go @@ -0,0 +1,33 @@ +package mujica + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// QuoteReply shows the author and a one-line excerpt of a quoted message; Clicked jumps to it. +func QuoteReply(c *ui.Context, author, excerpt string) *ui.Element { + if author == "" { + panic("mujica: QuoteReply needs an author") + } + k := Tokens(c) + u := Density(c).Unit() + line := strings.Join(strings.Fields(excerpt), " ") + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MaxWidth(420).Gap(u*2).Padding(u, u*3, u, u*2).Justify(ui.Start). + Radius(theme.ControlRadius).Background(k.SurfaceHover).BorderWidth(0, 0, 0, 3).BorderColor(k.AccentText). + Label(fmt.Sprintf(msg(c, "quote_reply.label"), author, line)).Transition(colorFade) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.Selection) + } + b.Children(func() { + ui.Text(c, author).FontWeight(600).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Shrink(0) + ui.Text(c, line).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine().MinWidth(0).Shrink(1) + }) + return b +} diff --git a/quote_reply_test.go b/quote_reply_test.go new file mode 100644 index 0000000..b9aa5b5 --- /dev/null +++ b/quote_reply_test.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestQuoteReplyJumpsAndFlattensExcerpt(t *testing.T) { + jumps, off := 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(off).Children(func() { + if QuoteReply(c, "Marie", "Let them\n eat cake.").Clicked() { + jumps++ + } + }) + }, 400, 200) + if !tt.HasText("Let them eat cake.") { + t.Fatalf("excerpt %q", tt.Texts()) + } + _ = tt.Click("Reply to Marie: Let them eat cake.") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if jumps != 2 { + t.Fatalf("jumps %d", jumps) + } + off = true + _ = tt.Click("Reply to Marie: Let them eat cake.") + if jumps != 2 { + t.Fatal("disabled quote jumped") + } +} + +func TestQuoteReplyNeedsAuthor(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without an author") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + QuoteReply(c, "", "x") + }, 100, 100) +} + +func TestQuoteReplySnapshot(t *testing.T) { + snapshot(t, "quote_reply", 420, 70, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + QuoteReply(c, "Herald", "The ball begins at nine in the Hall of Mirrors, and every guest must arrive by the east door.") + }) + }) +} diff --git a/radar_chart.go b/radar_chart.go new file mode 100644 index 0000000..d78b869 --- /dev/null +++ b/radar_chart.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RadarChartOptions configure a RadarChart. +type RadarChartOptions struct { + Frame ChartFrame + // Max is the outer ring's value; 0 means automatic. + Max float64 + Format func(v float64) string +} + +// RadarChart draws series as polygons over three or more axes. +func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarChartOptions) ChartResult { + if n := chartCheckSeries(series); len(axes) < 3 || n != len(axes) && len(series) > 0 || opts.Max < 0 { + panic("mujica: RadarChart needs three or more axes, one value per axis and Max ≥ 0") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + keys := make([]chartKey, len(series)) + top := opts.Max + for i, s := range series { + keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + for _, v := range s.Values { + if v < 0 { + panic("mujica: RadarChart values must be ≥ 0") + } + if !ch.hidden(s.Name) && opts.Max == 0 && !math.IsNaN(v) { + top = max(top, v) + } + } + } + n := len(axes) + if top == 0 { + n = 0 + } + var step float64 + if n > 0 && opts.Max == 0 { + _, top, step = internal.Nice(0, top, 4) + } else if n > 0 { + _, _, step = internal.Nice(0, top, 4) + } + geom := func(r ui.Rect) (cx, cy, rad float32) { + return r.X + r.W/2, r.Y + r.H/2 + 4, min(r.W/2-70, r.H/2-24) + } + angle := func(i int) float64 { return 2 * math.Pi * float64(i) / float64(len(axes)) } + spec := chartSpec{n: n, keys: keys, toggle: len(series) > 1} + spec.readout = func(i int) string { + parts := []string{axes[i]} + for _, s := range series { + if !ch.hidden(s.Name) { + parts = append(parts, s.Name+" "+chartValue(c, s.Values[i], opts.Format)) + } + } + return strings.Join(parts, " · ") + } + spec.pick = func(r ui.Rect, x, y float32) int { + cx, cy, rad := geom(r) + if math.Hypot(float64(x-cx), float64(y-cy)) > float64(rad)+16 { + return -1 + } + return internal.Nearest(len(axes), func(i int) (float32, float32) { return internal.Polar(cx, cy, rad, angle(i)) }, x, y, 2*rad+32, false) + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + cx, cy, rad := geom(r) + for _, t := range internal.Ticks(step, top, step) { + var ring ui.Path + for i := range axes { + x, y := internal.Polar(cx, cy, rad*float32(t/top), angle(i)) + if i == 0 { + ring.MoveTo(x, y) + } else { + ring.LineTo(x, y) + } + } + ring.Close() + p.StrokePath(&ring, 1, k.Border) + chartText(p, internal.FormatTick(t, step), cx-4, cy-rad*float32(t/top), size, k.TextMuted, 1, 0.5) + } + for i, a := range axes { + x, y := internal.Polar(cx, cy, rad, angle(i)) + w := float32(1) + if i == active { + w = 2 + } + var spoke ui.Path + spoke.MoveTo(cx, cy).LineTo(x, y) + p.StrokePath(&spoke, w, k.ControlBorder) + lx, ly := internal.Polar(cx, cy, rad+10, angle(i)) + ax := 0.5 + 0.5*float32(math.Sin(angle(i))) + col := k.TextMuted + if i == active { + col = k.Text + } + chartText(p, a, lx, ly, size, col, 1-ax, 0.5+0.5*float32(math.Cos(angle(i)))) + } + for si, s := range series { + if ch.hidden(s.Name) { + continue + } + var poly ui.Path + pt := func(i int) (float32, float32) { + v := s.Values[i] + if math.IsNaN(v) { + v = 0 + } + return internal.Polar(cx, cy, rad*float32(v/top), angle(i)) + } + for i := range axes { + if i == 0 { + poly.MoveTo(pt(i)) + } else { + poly.LineTo(pt(i)) + } + } + poly.Close() + p.FillPath(&poly, keys[si].Color.Alpha(0.16)) + p.StrokePath(&poly, 2, keys[si].Color) + for i := range axes { + x, y := pt(i) + sz := float32(7) + if i == active { + sz = 11 + } + internal.Marker(p, si, x, y, sz, keys[si].Color) + } + } + } + res, _ := ch.build(spec) + return res +} diff --git a/radar_chart_test.go b/radar_chart_test.go new file mode 100644 index 0000000..fde7651 --- /dev/null +++ b/radar_chart_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var radarAxes = []string{"Voice", "Latin", "Chant", "Organ", "Bells"} + +func radarSeries() []ChartSeries { + return []ChartSeries{ + {Name: "Agnès", Values: []float64{8, 6, 9, 4, 7}}, + {Name: "Clare", Values: []float64{5, 9, 6, 8, 3}}, + } +} + +func TestRadarChartReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: ChartFrame{Label: "Skills"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Skills") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Latin · Agnès 6 · Clare 9" { + t.Fatalf("readout %q", got) + } + b := h.res.Element.Bounds() + cx, cy := b.X+b.W/2, b.Y+38+120+4 + tt.Key(0, ui.KeyEscape) + tt.Move(cx, cy-60) // straight up: the first axis + if got := chartReadout(tt); got != "Voice · Agnès 8 · Clare 5" { + t.Fatalf("hover %q", got) + } + if err := tt.Click("Clare"); err != nil { + t.Fatal(err) + } + tt.Move(cx, cy-60) + if got := chartReadout(tt); got != "Voice · Agnès 8" { + t.Fatalf("hidden series read: %q", got) + } +} + +func TestRadarChartBad(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "two axes": func(c *ui.Context) { + RadarChart(c, radarAxes[:2], nil, RadarChartOptions{Frame: ChartFrame{Label: "x"}}) + }, + "negative": func(c *ui.Context) { + RadarChart(c, radarAxes[:3], []ChartSeries{{Name: "a", Values: []float64{1, -1, 2}}}, RadarChartOptions{Frame: ChartFrame{Label: "x"}}) + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); build(c) }, 300, 300) + }() + } +} + +func TestRadarChartSnapshot(t *testing.T) { + snapshot(t, "radar_chart", 520, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: ChartFrame{Label: "Skills"}}) + }) + }) +} diff --git a/realtime_chart.go b/realtime_chart.go new file mode 100644 index 0000000..559f7be --- /dev/null +++ b/realtime_chart.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "log/slog" + "math" + "sort" + "time" + + "github.com/egoist/mygo/ui" +) + +// RealtimeBuffer keeps the latest samples of named series. +type RealtimeBuffer struct { + names []string + limit int + times []time.Time + vals [][]float64 +} + +// NewRealtimeBuffer keeps up to capacity samples of the named series. +func NewRealtimeBuffer(capacity int, names ...string) *RealtimeBuffer { + if capacity < 2 || len(names) == 0 { + panic("mujica: RealtimeBuffer needs a capacity of 2 or more and a series name") + } + return &RealtimeBuffer{names: names, limit: capacity, vals: make([][]float64, len(names))} +} + +// Append adds one sample per series at a non-decreasing t. +func (b *RealtimeBuffer) Append(t time.Time, values ...float64) { + if len(values) != len(b.names) || len(b.times) > 0 && t.Before(b.times[len(b.times)-1]) { + panic("mujica: RealtimeBuffer.Append needs one value per series and a time not before the last") + } + b.times = append(b.times, t) + for i, v := range values { + b.vals[i] = append(b.vals[i], v) + } + // Old samples are dropped in blocks, so appends stay cheap. + if len(b.times) >= 2*b.limit { + drop := len(b.times) - b.limit + b.times = append([]time.Time(nil), b.times[drop:]...) + for i := range b.vals { + b.vals[i] = append([]float64(nil), b.vals[i][drop:]...) + } + } +} + +// Len is how many samples the buffer keeps. +func (b *RealtimeBuffer) Len() int { return min(len(b.times), b.limit) } + +// RealtimeChartOptions configure a RealtimeChart. +type RealtimeChartOptions struct { + Frame ChartFrame + // Window is the time shown up to the newest sample. + Window time.Duration + Format func(v float64) string +} + +// RealtimeChartState holds whether the view is paused, and where. +type RealtimeChartState struct { + Paused bool + end time.Time +} + +// RealtimeChart shows a fixed window ending at the newest sample. +func RealtimeChart(c *ui.Context, s *RealtimeChartState, buf *RealtimeBuffer, opts RealtimeChartOptions) ChartResult { + if opts.Window <= 0 { + panic("mujica: RealtimeChart needs a positive Window") + } + u := Density(c).Unit() + var res ChartResult + wrap := ui.Column(c).Gap(u * 2).MinWidth(0) + wrap.Children(func() { + label := msg(c, "realtime_chart.pause") + if s.Paused { + label = msg(c, "realtime_chart.resume") + } + if b := Button(c, label, ButtonOptions{Variant: Secondary, Disabled: wrap.IsDisabled()}).AlignSelf(ui.Start); b.Clicked() && !b.IsDisabled() { + s.Paused, res.value = !s.Paused, true + slog.Debug("mujica: realtime_chart pause", "paused", s.Paused) + } + times := buf.times[max(0, len(buf.times)-buf.limit):] + last := time.Time{} + if len(times) > 0 { + last = times[len(times)-1] + } + if !s.Paused || s.end.IsZero() { + s.end = last + } + from := s.end.Add(-opts.Window) + i0 := sort.Search(len(times), func(i int) bool { return !times[i].Before(from) }) + i1 := sort.Search(len(times), func(i int) bool { return times[i].After(s.end) }) + off := len(buf.times) - len(times) + ch := newChart(c, opts.Frame) + series := make([]ChartSeries, len(buf.names)) + tops := make([][]float64, len(buf.names)) + for i, name := range buf.names { + series[i] = ChartSeries{Name: name, Values: buf.vals[i][off+i0 : off+i1]} + tops[i] = series[i].Values + } + n := i1 - i0 + var ax chartX + if n > 0 { + ax = chartMemo(ch, "x", [2]any{chartID(times[i0:i1]), s.end}, func() chartX { + a := newChartX(n, nil, times[i0:i1]) + a.d0, a.d1 = float64(from.UnixNano())/1e9, float64(s.end.UnixNano())/1e9 + if a.d1 == a.d0 { + a.d0 -= 1 + } + return a + }) + } + lo, hi, ok := chartYRange(ch, series, false, nil) + if !ok || math.IsNaN(lo) { + n = 0 + } + r := chartLines(ch, chartLineData{series: series, tops: tops, ax: ax, lo: lo, hi: hi, n: n, format: opts.Format}) + res.Point, res.changed, res.retry, res.toggled = r.Point, r.changed, r.retry, r.toggled + }) + res.Element = wrap + return res +} diff --git a/realtime_chart_test.go b/realtime_chart_test.go new file mode 100644 index 0000000..ab71e08 --- /dev/null +++ b/realtime_chart_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var rtStart = time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + +func TestRealtimeBufferTrims(t *testing.T) { + b := NewRealtimeBuffer(100, "a") + for i := range 100_000 { + b.Append(rtStart.Add(time.Duration(i)*time.Second), float64(i)) + } + if b.Len() != 100 || len(b.times) >= 200 || b.vals[0][len(b.vals[0])-1] != 99_999 { + t.Fatalf("kept %d samples, %d stored", b.Len(), len(b.times)) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a sample back in time") + } + }() + b.Append(rtStart, 1) +} + +func TestRealtimeChartWindowAndPause(t *testing.T) { + buf := NewRealtimeBuffer(1000, "Bell", "Organ") + var st RealtimeChartState + values := 0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := RealtimeChart(c, &st, buf, RealtimeChartOptions{Frame: ChartFrame{Label: "Sound"}, Window: 10 * time.Second}) + if r.ValueChanged() { + values++ + } + return r + }} + tt := ui.NewTester(h.view, 520, 440) + if !tt.HasText("No data") { + t.Fatalf("empty buffer: %q", tt.Texts()) + } + for i := range 30 { + buf.Append(rtStart.Add(time.Duration(i)*time.Second), float64(i), float64(2*i)) + } + tt.Frame() + tabTo(t, tt, "Sound") + tt.Key(0, ui.KeyHome) + if got := chartReadout(tt); got != "2026-10-05 09:00:19 · Bell 19 · Organ 38" { + t.Fatalf("window start %q", got) + } + if err := tt.Click("Pause"); err != nil || !st.Paused || values != 1 { + t.Fatalf("pause: %v %v %d", err, st.Paused, values) + } + for i := 30; i < 40; i++ { + buf.Append(rtStart.Add(time.Duration(i)*time.Second), float64(i), 0) + } + tt.Frame() + tabTo(t, tt, "Sound") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 09:00:29 ·") { + t.Fatalf("paused view moved: %q", got) + } + _ = tt.Click("Resume") + tabTo(t, tt, "Sound") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 09:00:39 ·") || st.Paused { + t.Fatalf("resumed view %q", got) + } +} + +func TestRealtimeChartSnapshot(t *testing.T) { + buf := NewRealtimeBuffer(200, "Bell", "Organ") + for i := range 60 { + x := float64(i) + buf.Append(rtStart.Add(time.Duration(i)*time.Second), 5+3*math.Sin(x/5), 4+2*math.Sin(x/3+1)) + } + snapshot(t, "realtime_chart", 520, 380, func(c *ui.Context) { + var st RealtimeChartState + ui.Column(c).Padding(16).Children(func() { + RealtimeChart(c, &st, buf, RealtimeChartOptions{Frame: ChartFrame{Label: "Sound"}, Window: 30 * time.Second}) + }) + }) +} diff --git a/recurrence_editor.go b/recurrence_editor.go new file mode 100644 index 0000000..a7bd8b7 --- /dev/null +++ b/recurrence_editor.go @@ -0,0 +1,223 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RepeatUnit is how often a rule repeats; zero never does. +type RepeatUnit int + +const ( + RepeatNone RepeatUnit = iota + RepeatDaily + RepeatWeekly + RepeatMonthly + RepeatYearly +) + +// RepeatEnd says when a recurrence stops. +type RepeatEnd int + +const ( + RepeatForever RepeatEnd = iota + RepeatCount + RepeatUntil +) + +// Recurrence repeats every Interval units, on Weekdays when weekly. +type Recurrence struct { + Unit RepeatUnit + Interval int + Weekdays []time.Weekday + End RepeatEnd + Count int + Until Date +} + +// RecurrenceEditorOptions configure a RecurrenceEditor. +type RecurrenceEditorOptions struct { + // Start is the first occurrence; Until must not precede it. + Start Date + WeekStart WeekStart + Disabled bool + Label string +} + +// RecurrenceResult is the editor, its change and its problem. +type RecurrenceResult struct { + Element *ui.Element + changed bool + problem string +} + +// Changed reports whether the user changed the rule this frame. +func (r RecurrenceResult) Changed() bool { return r.changed } + +// Problem explains why the rule cannot be saved. +func (r RecurrenceResult) Problem() string { return r.problem } + +const recurrenceCustom = 5 + +// RecurrenceEditor edits a repeat rule and when it ends. +func RecurrenceEditor(c *ui.Context, rule *Recurrence, opts RecurrenceEditorOptions) RecurrenceResult { + recurrenceCheck(*rule) + d := Density(c) + k := Tokens(c) + before := recurrenceKey(*rule) + r := RecurrenceResult{} + label := opts.Label + if label == "" { + label = msg(c, "recurrence_editor.label") + } + r.Element = ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + custom := ui.Local(r.Element, "recurrence-custom", func() bool { return rule.Interval > 1 }) + r.Element.Children(func() { + mode := int(rule.Unit) + if *custom && rule.Unit != RepeatNone { + mode = recurrenceCustom + } + modes := []SelectOption[int]{} + for i, key := range []string{"none", "daily", "weekly", "monthly", "yearly", "custom"} { + modes = append(modes, SelectOption[int]{Value: i, Label: msg(c, "recurrence_editor."+key)}) + } + if Select(c, &mode, modes, SelectOptions{Label: msg(c, "recurrence_editor.repeat"), Disabled: opts.Disabled}).Changed() { + *custom = mode == recurrenceCustom + switch { + case *custom: + rule.Unit = max(rule.Unit, RepeatDaily) + rule.Interval = max(rule.Interval, 2) + default: + rule.Unit, rule.Interval = RepeatUnit(mode), 1 + if mode == int(RepeatNone) { + *rule = Recurrence{} + } + } + if rule.Unit == RepeatWeekly && len(rule.Weekdays) == 0 && !opts.Start.IsZero() { + rule.Weekdays = []time.Weekday{opts.Start.Weekday()} + } + } + if rule.Unit == RepeatNone { + return + } + if *custom { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "recurrence_editor.every")) + ui.Box(c).Width(d.ControlHeight() * 4).Children(func() { + n := float64(rule.Interval) + if NumberInput(c, &n, NumberInputOptions{Min: 1, Max: 999, Label: msg(c, "recurrence_editor.interval"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) { + rule.Interval = int(n) + } + }) + units := []SelectOption[RepeatUnit]{} + for u := RepeatDaily; u <= RepeatYearly; u++ { + units = append(units, SelectOption[RepeatUnit]{Value: u, Label: msg(c, recurrenceUnitKeys[u])}) + } + Select(c, &rule.Unit, units, SelectOptions{Label: msg(c, "recurrence_editor.unit"), Disabled: opts.Disabled}) + }) + } + if rule.Unit == RepeatWeekly { + first := calendarFirstWeekday(c, opts.WeekStart) + items := []ToggleItem[time.Weekday]{} + for i := range 7 { + wd := time.Weekday((int(first) + i) % 7) + items = append(items, ToggleItem[time.Weekday]{Value: wd, Label: msg(c, fmt.Sprintf("calendar.weekday_%d", wd)), + Name: msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd))}) + } + ToggleGroup(c, &rule.Weekdays, items, ToggleGroupOptions{Multiple: true, AllowEmpty: true, Label: msg(c, "recurrence_editor.on_days"), Disabled: opts.Disabled}) + } + ends := []RadioItem[RepeatEnd]{{Value: RepeatForever, Label: msg(c, "recurrence_editor.forever")}, + {Value: RepeatCount, Label: msg(c, "recurrence_editor.after")}, {Value: RepeatUntil, Label: msg(c, "recurrence_editor.until")}} + ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Wrap().Children(func() { + if RadioGroup(c, &rule.End, ends, RadioGroupOptions{Horizontal: true, Label: msg(c, "recurrence_editor.ends"), Disabled: opts.Disabled}).Changed() { + if rule.End == RepeatCount && rule.Count == 0 { + rule.Count = 10 + } + if rule.End == RepeatUntil && rule.Until.IsZero() && !opts.Start.IsZero() { + rule.Until = opts.Start.AddMonths(1) + } + } + switch rule.End { + case RepeatCount: + ui.Box(c).Width(d.ControlHeight() * 4).Children(func() { + n := float64(rule.Count) + if NumberInput(c, &n, NumberInputOptions{Min: 1, Max: 999, Label: msg(c, "recurrence_editor.count"), Disabled: opts.Disabled}).Changed() && !math.IsNaN(n) { + rule.Count = int(n) + } + }) + case RepeatUntil: + DatePicker(c, &rule.Until, DatePickerOptions{Label: msg(c, "recurrence_editor.until_date"), Min: opts.Start, Disabled: opts.Disabled}) + } + }) + r.problem = recurrenceProblem(c, *rule, opts.Start) + ui.Text(c, RecurrenceSummary(c, *rule)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus) + datetimeError(c, r.problem) + }) + if after := recurrenceKey(*rule); after != before && !r.Element.IsDisabled() { + slog.Debug("mujica: recurrence_editor change", "rule", after) + r.changed = true + } + return r +} + +var recurrenceUnitKeys = [...]string{"", "recurrence_editor.days", "recurrence_editor.weeks", "recurrence_editor.months", "recurrence_editor.years"} + +func recurrenceKey(r Recurrence) string { return fmt.Sprintf("%v", r) } + +// recurrenceCheck panics on rules no editor can show. +func recurrenceCheck(r Recurrence) { + if r.Unit < RepeatNone || r.Unit > RepeatYearly || r.End < RepeatForever || r.End > RepeatUntil || r.Interval < 0 || r.Count < 0 { + panic(fmt.Sprintf("mujica: Recurrence %+v is out of range", r)) + } + if r.Unit != RepeatNone && r.Interval == 0 { + panic("mujica: Recurrence repeats with Interval 0") + } + datetimeCheckDate("Recurrence Until", r.Until) +} + +// recurrenceProblem explains why a rule cannot be saved. +func recurrenceProblem(c *ui.Context, r Recurrence, start Date) string { + switch { + case r.Unit == RepeatNone: + return "" + case r.Unit == RepeatWeekly && len(r.Weekdays) == 0: + return msg(c, "recurrence_editor.no_days") + case r.End == RepeatCount && r.Count < 1: + return msg(c, "recurrence_editor.bad_count") + case r.End == RepeatUntil && (r.Until.IsZero() || !start.IsZero() && r.Until.Compare(start) < 0): + return msg(c, "recurrence_editor.bad_until") + } + return "" +} + +// RecurrenceSummary describes a rule in the window's language. +func RecurrenceSummary(c *ui.Context, r Recurrence) string { + if r.Unit == RepeatNone { + return msg(c, "recurrence_editor.never") + } + s := fmt.Sprintf(msg(c, "recurrence_editor.every_n"), r.Interval, msg(c, recurrenceUnitKeys[r.Unit])) + if r.Interval == 1 { + s = msg(c, []string{"", "recurrence_editor.daily", "recurrence_editor.weekly", "recurrence_editor.monthly", "recurrence_editor.yearly"}[r.Unit]) + } + if r.Unit == RepeatWeekly && len(r.Weekdays) > 0 { + var days []string + for _, wd := range r.Weekdays { + days = append(days, msg(c, fmt.Sprintf("calendar.weekday_long_%d", wd))) + } + s += fmt.Sprintf(msg(c, "recurrence_editor.on"), strings.Join(days, msg(c, "recurrence_editor.list_sep"))) + } + switch r.End { + case RepeatCount: + s += calendarCount(c, "recurrence_editor.times", r.Count) + case RepeatUntil: + s += fmt.Sprintf(msg(c, "recurrence_editor.until_text"), r.Until) + } + return s +} diff --git a/recurrence_editor_test.go b/recurrence_editor_test.go new file mode 100644 index 0000000..1f93803 --- /dev/null +++ b/recurrence_editor_test.go @@ -0,0 +1,133 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type recurrenceProbe struct { + rule Recurrence + opts RecurrenceEditorOptions + changes int + problem string + summary string + disabled bool +} + +func (p *recurrenceProbe) tester() *ui.Tester { + p.opts.Start = oct(5) + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := p.opts + o.Disabled = p.disabled + r := RecurrenceEditor(c, &p.rule, o) + if r.Changed() { + p.changes++ + } + p.problem = r.Problem() + p.summary = RecurrenceSummary(c, p.rule) + }, 720, 520) +} + +func recurrencePick(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click("Repeat"); err != nil { + t.Fatal(err) + } + if err := tt.Click(label); err != nil { + t.Fatal(err) + } +} + +func TestRecurrenceEditorPresets(t *testing.T) { + p := &recurrenceProbe{} + tt := p.tester() + recurrencePick(t, tt, "Weekly") + if p.rule.Unit != RepeatWeekly || p.rule.Interval != 1 || !slices.Equal(p.rule.Weekdays, []time.Weekday{time.Monday}) || p.changes != 1 { + t.Fatalf("weekly preset %+v", p.rule) + } + _ = tt.Click("Wednesday") + if p.summary != "Weekly on Monday, Wednesday" { + t.Fatalf("summary %q", p.summary) + } + for _, c := range []struct { + label string + unit RepeatUnit + }{{"Daily", RepeatDaily}, {"Monthly", RepeatMonthly}, {"Yearly", RepeatYearly}} { + recurrencePick(t, tt, c.label) + if p.rule.Unit != c.unit || p.rule.Interval != 1 { + t.Fatalf("%s gave %+v", c.label, p.rule) + } + } + recurrencePick(t, tt, "Does not repeat") + if p.rule.Unit != RepeatNone || p.summary != "Does not repeat" { + t.Fatalf("none gave %+v", p.rule) + } +} + +func TestRecurrenceEditorCustomAndEnds(t *testing.T) { + p := &recurrenceProbe{rule: Recurrence{Unit: RepeatDaily, Interval: 1}} + tt := p.tester() + recurrencePick(t, tt, "Custom") + if p.rule.Interval != 2 || !tt.HasText("Every") { + t.Fatalf("custom %+v", p.rule) + } + _ = tt.Click("Interval") + tt.Key(0, ui.KeyUp) + _ = tt.Click("After") + if p.rule.Interval != 3 || p.rule.End != RepeatCount || p.rule.Count != 10 || p.summary != "Every 3 days, 10 times" { + t.Fatalf("custom count %+v %q", p.rule, p.summary) + } + _ = tt.Click("On date") + if p.rule.End != RepeatUntil || p.rule.Until != (Date{2026, time.November, 5}) || p.problem != "" { + t.Fatalf("until %+v %q", p.rule, p.problem) + } +} + +func TestRecurrenceEditorProblems(t *testing.T) { + p := &recurrenceProbe{rule: Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}} + tt := p.tester() + _ = tt.Click("Monday") + if len(p.rule.Weekdays) != 0 || p.problem != "Choose at least one day." || !tt.HasText("Choose at least one day.") { + t.Fatalf("no-day problem %+v %q", p.rule, p.problem) + } + p.rule = Recurrence{Unit: RepeatDaily, Interval: 1, End: RepeatUntil, Until: Date{2026, time.September, 1}} + tt.Frame() + if p.problem != "The end date must not precede the start." { + t.Fatalf("until problem %q", p.problem) + } + for _, bad := range []Recurrence{{Unit: 9}, {Unit: RepeatDaily}, {Interval: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", bad) + } + }() + q := &recurrenceProbe{rule: bad} + q.tester() + }() + } +} + +func TestRecurrenceEditorDisabled(t *testing.T) { + p := &recurrenceProbe{rule: Recurrence{Unit: RepeatWeekly, Interval: 1, Weekdays: []time.Weekday{time.Monday}}, disabled: true} + tt := p.tester() + _ = tt.Click("Repeat") + _ = tt.Click("Daily") + _ = tt.Click("Friday") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if p.rule.Unit != RepeatWeekly || len(p.rule.Weekdays) != 1 || p.changes != 0 { + t.Fatalf("disabled editor changed: %+v", p.rule) + } +} + +func TestRecurrenceEditorSnapshot(t *testing.T) { + r := Recurrence{Unit: RepeatWeekly, Interval: 2, Weekdays: []time.Weekday{time.Monday, time.Thursday}, End: RepeatCount, Count: 8} + snapshot(t, "recurrence_editor", 640, 280, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { RecurrenceEditor(c, &r, RecurrenceEditorOptions{Start: oct(5)}) }) + }) +} diff --git a/regenerate_menu.go b/regenerate_menu.go new file mode 100644 index 0000000..95fe6c4 --- /dev/null +++ b/regenerate_menu.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// RegenerateKind is what a RegenerateMenu choice asks for. +type RegenerateKind int + +const ( + // RegenerateAgain retries with the same model. + RegenerateAgain RegenerateKind = iota + // RegenerateModel retries with Choice.Model. + RegenerateModel + // RegenerateVariant retries asking for Choice.Variant, such as longer or shorter. + RegenerateVariant +) + +// RegenerateChoice is the retry the user picked. +type RegenerateChoice struct { + Kind RegenerateKind + Model string + Variant string +} + +// RegenerateMenuOptions configure a RegenerateMenu. +type RegenerateMenuOptions struct { + // Models lists models to retry with; Current is left out. + Models []string + Current string + // Variants replaces the built-in Longer, Shorter, Simpler and More detailed. + Variants []string + Disabled bool +} + +// RegenerateMenuView is a built menu and the chosen retry. +type RegenerateMenuView struct { + Element *ui.Element + Chosen bool + Choice RegenerateChoice +} + +// RegenerateMenu offers retry, another model, and rewrite variants. +func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuView { + variants := opts.Variants + if variants == nil { + for _, key := range []string{"regenerate_menu.longer", "regenerate_menu.shorter", "regenerate_menu.simpler", "regenerate_menu.detailed"} { + variants = append(variants, msg(c, key)) + } + } + var v RegenerateMenuView + pick := func(ch RegenerateChoice) { v.Chosen, v.Choice = true, ch } + v.Element = DropdownMenu(c, msg(c, "regenerate_menu.regenerate"), DropdownMenuOptions{Icon: icons.Must("refresh-cw"), Disabled: opts.Disabled}, func(m *PopupMenu) { + if m.Item(msg(c, "regenerate_menu.again"), PopupMenuItemOptions{}) { + pick(RegenerateChoice{Kind: RegenerateAgain}) + } + others := 0 + for _, model := range opts.Models { + if model != opts.Current { + others++ + } + } + m.Submenu(msg(c, "regenerate_menu.model"), PopupMenuItemOptions{Disabled: others == 0}, func(m *PopupMenu) { + for _, model := range opts.Models { + if model != opts.Current && m.Item(model, PopupMenuItemOptions{}) { + pick(RegenerateChoice{Kind: RegenerateModel, Model: model}) + } + } + }) + m.Separator() + m.Group(msg(c, "regenerate_menu.variants"), func() { + for _, variant := range variants { + if m.Item(variant, PopupMenuItemOptions{}) { + pick(RegenerateChoice{Kind: RegenerateVariant, Variant: variant}) + } + } + }) + }) + v.Element.AlignSelf(ui.Start) + if v.Element.IsDisabled() { + v.Chosen = false + } + if v.Chosen { + slog.Debug("mujica: regenerate_menu choose", "kind", v.Choice.Kind, "model", v.Choice.Model, "variant", v.Choice.Variant) + } + return v +} diff --git a/regenerate_menu_test.go b/regenerate_menu_test.go new file mode 100644 index 0000000..1f44c46 --- /dev/null +++ b/regenerate_menu_test.go @@ -0,0 +1,70 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func regenerateTester(opts RegenerateMenuOptions, got *[]RegenerateChoice) *ui.Tester { + return chatTester(500, 420, func(c *ui.Context) { + if v := RegenerateMenu(c, opts); v.Chosen { + *got = append(*got, v.Choice) + } + }) +} + +func TestRegenerateMenuChoices(t *testing.T) { + var got []RegenerateChoice + opts := RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"} + tt := regenerateTester(opts, &got) + _ = tt.Click("Regenerate") + _ = tt.Click("Try again") + _ = tt.Click("Regenerate") + _ = tt.Click("Shorter") + if len(got) != 2 || got[0].Kind != RegenerateAgain || got[1] != (RegenerateChoice{Kind: RegenerateVariant, Variant: "Shorter"}) { + t.Fatalf("choices %+v", got) + } + _ = tt.Click("Regenerate") + _ = tt.Click("Try another model") + if tt.HasText("Opus") || !tt.HasText("Sonnet") { + t.Fatalf("model submenu %q", tt.Texts()) + } + _ = tt.Click("Sonnet") + if len(got) != 3 || got[2] != (RegenerateChoice{Kind: RegenerateModel, Model: "Sonnet"}) { + t.Fatalf("choices %+v", got) + } +} + +func TestRegenerateMenuKeyboardAndVariants(t *testing.T) { + var got []RegenerateChoice + tt := regenerateTester(RegenerateMenuOptions{Variants: []string{"In verse"}}, &got) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(got) != 1 || got[0].Kind != RegenerateAgain { + t.Fatalf("keys chose %+v", got) + } + _ = tt.Click("Regenerate") + if !tt.HasText("In verse") || tt.HasText("Longer") { + t.Fatalf("variants %q", tt.Texts()) + } +} + +func TestRegenerateMenuDisabled(t *testing.T) { + var got []RegenerateChoice + tt := regenerateTester(RegenerateMenuOptions{Disabled: true}, &got) + _ = tt.Click("Regenerate") + _ = tt.Click("Try again") + if len(got) != 0 || tt.HasText("Try again") { + t.Fatalf("disabled menu chose %+v", got) + } +} + +func TestRegenerateMenuSnapshot(t *testing.T) { + snapshotAfter(t, "regenerate_menu", 360, 330, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + RegenerateMenu(c, RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}) + }) + }, func(tt *ui.Tester) { _ = tt.Click("Regenerate") }) +} diff --git a/relative_time.go b/relative_time.go new file mode 100644 index 0000000..eb91cfa --- /dev/null +++ b/relative_time.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" +) + +// RelativeTimeOptions configure a RelativeTime. +type RelativeTimeOptions struct { + // Location is the tooltip's zone; required. + Location *time.Location + // Now fixes the clock and stops updates. + Now time.Time + Hour12 bool +} + +// RelativeTime shows t as "3 minutes ago", refreshing while shown. +func RelativeTime(c *ui.Context, t time.Time, opts RelativeTimeOptions) *ui.Element { + loc := eventLocation("RelativeTime", opts.Location) + now := opts.Now + if now.IsZero() { + now = c.Now() + c.After(relativeTimeNext(now.Sub(t))) + } + text := RelativeTimeText(c, now.Sub(t)) + w := t.In(loc) + exact := DateOf(w).String() + " " + timeText(c, TimeOfDay{w.Hour(), w.Minute(), 0}, timeConfig{hour12: opts.Hour12}) + " " + w.Format("MST") + return ui.Text(c, text).Label(text + ", " + exact).Tooltip(exact).FontFeatures("tnum") +} + +// RelativeTimeText words d ago (positive) or ahead (negative). +func RelativeTimeText(c *ui.Context, d time.Duration) string { + past := d >= 0 + if !past { + d = -d + } + var key string + var n int + switch { + case d < 45*time.Second: + return msg(c, "relative_time.now") + case d < 45*time.Minute: + key, n = "minutes", max(1, int(d/time.Minute)) + case d < 22*time.Hour: + key, n = "hours", max(1, int(d/time.Hour)) + case d < 26*24*time.Hour: + key, n = "days", max(1, int(d/(24*time.Hour))) + case d < 320*24*time.Hour: + key, n = "months", max(1, int(d/(30*24*time.Hour))) + default: + key, n = "years", max(1, int(d/(365*24*time.Hour))) + } + unit := calendarCount(c, "relative_time."+key, n) + if past { + return fmt.Sprintf(msg(c, "relative_time.ago"), unit) + } + return fmt.Sprintf(msg(c, "relative_time.in"), unit) +} + +// relativeTimeNext is how long until the wording may change. +func relativeTimeNext(d time.Duration) time.Duration { + if d < 0 { + d = -d + } + step := time.Hour + switch { + case d < 2*time.Minute: + step = time.Second + case d < 2*time.Hour: + step = time.Minute + } + return step - d%step +} diff --git a/relative_time_test.go b/relative_time_test.go new file mode 100644 index 0000000..8501255 --- /dev/null +++ b/relative_time_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestRelativeTimeWording(t *testing.T) { + var en, zh []string + cases := []time.Duration{10 * time.Second, -90 * time.Second, 3 * time.Minute, 5 * time.Hour, -25 * time.Hour, 40 * 24 * time.Hour, 800 * 24 * time.Hour} + for _, loc := range []Locale{EnUS, ZhCN} { + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: loc}) + var out []string + for _, d := range cases { + out = append(out, RelativeTimeText(c, d)) + } + if loc == EnUS { + en = out + } else { + zh = out + } + }, 10, 10) + } + wantEn := []string{"just now", "in 1 minute", "3 minutes ago", "5 hours ago", "in 1 day", "1 month ago", "2 years ago"} + wantZh := []string{"刚刚", "1 分钟后", "3 分钟前", "5 小时前", "1 天后", "1 个月前", "2 年前"} + for i := range cases { + if en[i] != wantEn[i] || zh[i] != wantZh[i] { + t.Fatalf("%v: %q %q", cases[i], en[i], zh[i]) + } + } +} + +func TestRelativeTimeRefreshFollowsDistance(t *testing.T) { + for _, c := range []struct{ d, next time.Duration }{ + {10*time.Second + 300*time.Millisecond, 700 * time.Millisecond}, + {5*time.Minute + 20*time.Second, 40 * time.Second}, + {3*time.Hour + 15*time.Minute, 45 * time.Minute}, + {-(90 * time.Second), 1 * time.Second}, + } { + if got := relativeTimeNext(c.d); got != c.next { + t.Errorf("%v: next %v, want %v", c.d, got, c.next) + } + } +} + +func TestRelativeTimeShowsExactTimeAndStopsWhenHidden(t *testing.T) { + at := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + shown := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if shown { + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(3 * time.Hour)}) + } + }, 300, 100) + if !tt.HasText("3 hours ago, 2026-10-05 18:00 JST") { + t.Fatalf("texts %v", tt.Texts()) + } + shown = false + tt.Frame() + if tt.HasText("ago") { + t.Fatal("hidden text remains") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic without Location") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); RelativeTime(c, at, RelativeTimeOptions{}) }, 10, 10) +} + +func TestRelativeTimeSnapshot(t *testing.T) { + at := time.Date(2026, time.October, 5, 9, 0, 0, 0, calTokyo) + snapshot(t, "relative_time", 260, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(20 * time.Second)}) + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(-2 * time.Hour)}) + RelativeTime(c, at, RelativeTimeOptions{Location: calTokyo, Now: at.Add(72 * time.Hour)}) + }) + }) +} diff --git a/reminder_picker.go b/reminder_picker.go new file mode 100644 index 0000000..9d7c761 --- /dev/null +++ b/reminder_picker.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" +) + +// ReminderPickerOptions configure a ReminderPicker. +type ReminderPickerOptions struct { + // Choices are the lead times offered; empty uses defaults. + Choices []time.Duration + // Max limits how many are set; zero means any. + Max int + Disabled bool + Label string +} + +var reminderDefaults = []time.Duration{0, 5 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour, 24 * time.Hour, 7 * 24 * time.Hour} + +// ReminderPicker toggles reminder lead times, kept in choice order. +func ReminderPicker(c *ui.Context, value *[]time.Duration, opts ReminderPickerOptions) ChoiceResult { + choices := opts.Choices + if len(choices) == 0 { + choices = reminderDefaults + } + for i, d := range choices { + if d < 0 || d%time.Minute != 0 || slices.Contains(choices[:i], d) { + panic(fmt.Sprintf("mujica: ReminderPicker choice %v is negative, sub-minute or repeated", d)) + } + } + for _, d := range *value { + if !slices.Contains(choices, d) { + panic(fmt.Sprintf("mujica: ReminderPicker value %v is not a choice", d)) + } + } + if opts.Max < 0 || opts.Max > 0 && len(*value) > opts.Max { + panic(fmt.Sprintf("mujica: ReminderPicker Max %d is negative or below the value", opts.Max)) + } + full := opts.Max > 0 && len(*value) >= opts.Max + items := make([]ToggleItem[time.Duration], len(choices)) + for i, d := range choices { + items[i] = ToggleItem[time.Duration]{Value: d, Label: ReminderText(c, d), Disabled: full && !slices.Contains(*value, d)} + } + label := opts.Label + if label == "" { + label = msg(c, "reminder_picker.label") + } + r := ToggleGroup(c, value, items, ToggleGroupOptions{Multiple: true, AllowEmpty: true, Vertical: true, Label: label, Disabled: opts.Disabled}) + if r.Changed() { + slices.SortFunc(*value, func(a, b time.Duration) int { return slices.Index(choices, a) - slices.Index(choices, b) }) + slog.Debug("mujica: reminder_picker change", "value", *value) + } + return r +} + +// ReminderText words a lead time, as "15 minutes before". +func ReminderText(c *ui.Context, d time.Duration) string { + switch { + case d == 0: + return msg(c, "reminder_picker.at_start") + case d%(7*24*time.Hour) == 0: + return calendarCount(c, "reminder_picker.weeks", int(d/(7*24*time.Hour))) + case d%(24*time.Hour) == 0: + return calendarCount(c, "reminder_picker.days", int(d/(24*time.Hour))) + case d%time.Hour == 0: + return calendarCount(c, "reminder_picker.hours", int(d/time.Hour)) + } + return calendarCount(c, "reminder_picker.minutes", int(d/time.Minute)) +} diff --git a/reminder_picker_test.go b/reminder_picker_test.go new file mode 100644 index 0000000..69f1abf --- /dev/null +++ b/reminder_picker_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func reminderTester(v *[]time.Duration, opts ReminderPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ReminderPicker(c, v, opts).Changed() { + *n++ + } + }, 640, 200) +} + +func TestReminderPickerTogglesInChoiceOrder(t *testing.T) { + v, n := []time.Duration{}, 0 + tt := reminderTester(&v, ReminderPickerOptions{}, &n) + _ = tt.Click("1 day before") + _ = tt.Click("15 minutes before") + _ = tt.Click("At start") + if !slices.Equal(v, []time.Duration{0, 15 * time.Minute, 24 * time.Hour}) || n != 3 { + t.Fatalf("value %v (%d)", v, n) + } + _ = tt.Click("15 minutes before") + if !slices.Equal(v, []time.Duration{0, 24 * time.Hour}) { + t.Fatalf("untoggle left %v", v) + } +} + +func TestReminderPickerMaxKeyboardDisabled(t *testing.T) { + v, n := []time.Duration{time.Hour}, 0 + tt := reminderTester(&v, ReminderPickerOptions{Max: 2}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + _ = tt.Click("1 week before") + _ = tt.Click("5 minutes before") + if !slices.Equal(v, []time.Duration{0, time.Hour}) { + t.Fatalf("Max ignored: %v", v) + } + d := []time.Duration{} + tt = reminderTester(&d, ReminderPickerOptions{Disabled: true}, &n) + _ = tt.Click("At start") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if len(d) != 0 { + t.Fatal("disabled picker changed") + } +} + +func TestReminderTextAndInvalid(t *testing.T) { + var got []string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + got = []string{ReminderText(c, time.Minute), ReminderText(c, 2*time.Hour), ReminderText(c, 14*24*time.Hour)} + }, 10, 10) + if !slices.Equal(got, []string{"提前 1 分钟", "提前 2 小时", "提前 2 周"}) { + t.Fatalf("texts %q", got) + } + for _, opts := range []ReminderPickerOptions{{Choices: []time.Duration{30 * time.Second}}, {Choices: []time.Duration{0, 0}}, {Max: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: expected panic", opts) + } + }() + v := []time.Duration{} + reminderTester(&v, opts, new(int)) + }() + } +} + +func TestReminderPickerSnapshot(t *testing.T) { + v := []time.Duration{15 * time.Minute, 24 * time.Hour} + snapshot(t, "reminder_picker", 300, 330, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { ReminderPicker(c, &v, ReminderPickerOptions{}) }) + }) +} diff --git a/review_comment.go b/review_comment.go new file mode 100644 index 0000000..5731307 --- /dev/null +++ b/review_comment.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ReviewMessage is one comment of a review thread. +type ReviewMessage struct { + Author string + When time.Time + Body string +} + +// ReviewThread is a line-level review discussion. +type ReviewThread struct { + File string + Line int + Resolved bool + Messages []ReviewMessage +} + +// ReviewCommentOptions configure a ReviewComment. +type ReviewCommentOptions struct { + // TimeFormat lays out message times; empty means "2006-01-02 15:04". + TimeFormat string + Disabled bool +} + +// ReviewCommentResult is a built ReviewComment and its events. +type ReviewCommentResult struct { + Element *ui.Element + reply string + resolved, reopened bool +} + +// Replied returns the reply sent this frame. +func (r ReviewCommentResult) Replied() (string, bool) { return r.reply, r.reply != "" } + +// Resolved reports the Resolve button this frame. +func (r ReviewCommentResult) Resolved() bool { return r.resolved } + +// Reopened reports the Reopen button this frame. +func (r ReviewCommentResult) Reopened() bool { return r.reopened } + +// ReviewComment shows a line's review thread; *draft is the reply. +func ReviewComment(c *ui.Context, thread ReviewThread, draft *string, opts ReviewCommentOptions) ReviewCommentResult { + if thread.File == "" || thread.Line <= 0 || len(thread.Messages) == 0 { + panic("mujica: ReviewThread needs a File, a positive Line and a message") + } + k := Tokens(c) + u := Density(c).Unit() + small := FontSize(c, theme.CaptionSize) + layout := gitTimeLayout(opts.TimeFormat) + card := ui.Column(c).AlignItems(ui.Stretch).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Disabled(opts.Disabled).Clip() + res := ReviewCommentResult{Element: card} + card.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*2, u*3).Background(k.Background).FontSize(small).Children(func() { + ui.Icon(c, icons.Must("message-square")).Size(14, 14).TextColor(k.TextMuted) + ui.Text(c, fmt.Sprintf("%s:%d", thread.File, thread.Line)).Font(theme.MonoFont).SingleLine().Grow(1).MinWidth(0) + if thread.Resolved { + ui.Row(c).AlignItems(ui.Center).Gap(u).TextColor(k.Success).Children(func() { + ui.Icon(c, icons.Must("circle-check")).Size(14, 14) + ui.Text(c, msg(c, "review_comment.resolved")).FontWeight(600) + }) + } + }) + for i, m := range thread.Messages { + ui.Column(c).Gap(u).Padding(u*3, u*4).Children(func() { + if i > 0 { + ui.Box(c).Height(1).Background(k.Border).Margin(0, 0, u*2, 0).Role(ui.RoleNone) + } + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + Avatar(c, m.Author, AvatarOptions{Size: 20}) + ui.Text(c, m.Author).FontWeight(600).SingleLine() + ui.Text(c, m.When.Format(layout)).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum") + }) + ui.Text(c, m.Body) + }) + } + ui.Box(c).Height(1).Background(k.Border).Role(ui.RoleNone) + ui.Column(c).Gap(u * 2).Padding(u * 3).AlignItems(ui.Stretch).Children(func() { + if !thread.Resolved { + in := TextArea(c, draft, TextAreaOptions{Placeholder: msg(c, "review_comment.placeholder"), Label: msg(c, "review_comment.field"), + MinHeight: 56, MaxHeight: 160, Disabled: opts.Disabled}) + send := in.Submitted() + ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + if Button(c, msg(c, "review_comment.resolve"), ButtonOptions{Variant: Secondary, Icon: icons.Must("check"), Disabled: opts.Disabled}).Clicked() { + res.resolved = true + } + if Button(c, msg(c, "review_comment.reply"), ButtonOptions{Icon: icons.Must("reply"), Disabled: opts.Disabled || strings.TrimSpace(*draft) == ""}).Clicked() { + send = true + } + }) + if send && !card.IsDisabled() && strings.TrimSpace(*draft) != "" { + res.reply, *draft = strings.TrimSpace(*draft), "" + } + return + } + ui.Row(c).Justify(ui.End).Children(func() { + if Button(c, msg(c, "review_comment.reopen"), ButtonOptions{Variant: Secondary, Icon: icons.Must("rotate-ccw"), Disabled: opts.Disabled}).Clicked() { + res.reopened = true + } + }) + }) + }) + if card.IsDisabled() { + res.resolved, res.reopened = false, false + } + switch { + case res.reply != "": + slog.Debug("mujica: review comment reply", "file", thread.File, "line", thread.Line) + case res.resolved: + slog.Debug("mujica: review comment resolve", "file", thread.File, "line", thread.Line) + case res.reopened: + slog.Debug("mujica: review comment reopen", "file", thread.File, "line", thread.Line) + } + return res +} diff --git a/review_comment_test.go b/review_comment_test.go new file mode 100644 index 0000000..f2b8711 --- /dev/null +++ b/review_comment_test.go @@ -0,0 +1,97 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func reviewSample() ReviewThread { + return ReviewThread{File: "commit_graph.go", Line: 88, Messages: []ReviewMessage{ + {Author: "Uika", When: gitTestTime, Body: "Should merge lanes reuse the parent's color?"}, + {Author: "Sakiko", When: gitTestTime, Body: "Yes, the first parent keeps it."}, + }} +} + +func TestReviewCommentReplyResolveReopen(t *testing.T) { + thread := reviewSample() + draft := "" + var replies []string + resolves, reopens := 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := ReviewComment(c, thread, &draft, ReviewCommentOptions{}) + if s, ok := r.Replied(); ok { + replies = append(replies, s) + } + if r.Resolved() { + resolves++ + thread.Resolved = true + } + if r.Reopened() { + reopens++ + thread.Resolved = false + } + }, 600, 500) + if !tt.HasText("commit_graph.go:88") || !tt.HasText("Yes, the first parent keeps it.") { + t.Fatalf("thread: %q", tt.Texts()) + } + _ = tt.Click("Reply") + if len(replies) != 0 { + t.Fatal("empty reply sent") + } + if err := tt.Click("Reply text"); err != nil { + t.Fatal(err) + } + tt.Type(" Agreed. ") + tt.Key(ui.Cmd, ui.KeyEnter) + if len(replies) != 1 || replies[0] != "Agreed." || draft != "" { + t.Fatalf("replies %q draft %q", replies, draft) + } + _ = tt.Click("Resolve") + if resolves != 1 || !tt.HasText("Resolved") || tt.HasText("Reply text") { + t.Fatalf("resolve: %d %q", resolves, tt.Texts()) + } + _ = tt.Click("Reopen") + if reopens != 1 || !tt.HasText("Reply") { + t.Fatalf("reopen: %d", reopens) + } +} + +func TestReviewCommentDisabledAndInvalid(t *testing.T) { + draft := "Text" + acted := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := ReviewComment(c, reviewSample(), &draft, ReviewCommentOptions{Disabled: true}) + _, replied := r.Replied() + acted = acted || replied || r.Resolved() + }, 600, 500) + _ = tt.Click("Resolve") + _ = tt.Click("Reply") + if acted || draft != "Text" { + t.Fatal("disabled thread acted") + } + for _, th := range []ReviewThread{{Line: 1, Messages: reviewSample().Messages}, {File: "a", Messages: reviewSample().Messages}, {File: "a", Line: 1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", th) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ReviewComment(c, th, &draft, ReviewCommentOptions{}) + }, 300, 100, 1) + }() + } +} + +func TestReviewCommentSnapshot(t *testing.T) { + snapshot(t, "review_comment", 560, 420, func(c *ui.Context) { + draft := ui.Local(c.Root(), "draft", func() string { return "" }) + ui.Column(c).Padding(16).Children(func() { + ReviewComment(c, reviewSample(), draft, ReviewCommentOptions{}) + }) + }) +} diff --git a/risk_meter.go b/risk_meter.go new file mode 100644 index 0000000..d0a5685 --- /dev/null +++ b/risk_meter.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RiskLevel is one step of a RiskMeter's scale. +type RiskLevel struct { + Label string + Description string +} + +// RiskMeterOptions configure a RiskMeter. +type RiskMeterOptions struct { + // Levels rise in risk; nil uses five built-in levels. + Levels []RiskLevel + // Label names the meter; empty uses the built-in "Risk level". + Label string +} + +// RiskMeter shows level, from 1, on a named graded scale. +func RiskMeter(c *ui.Context, level int, opts RiskMeterOptions) *ui.Element { + levels := opts.Levels + if levels == nil { + for i := range 5 { + levels = append(levels, RiskLevel{Label: msg(c, fmt.Sprintf("risk_meter.level%d", i+1))}) + } + } + if len(levels) < 2 || level < 1 || level > len(levels) { + panic(fmt.Sprintf("mujica: RiskMeter level %d outside 1..%d", level, len(levels))) + } + k := Tokens(c) + u := Density(c).Unit() + label := opts.Label + if label == "" { + label = msg(c, "risk_meter.label") + } + cur := levels[level-1] + spoken := fmt.Sprintf(msg(c, "risk_meter.value"), level, len(levels), cur.Label) + col := riskMeterColor(k, level, len(levels)) + e := ui.Column(c).Gap(u * 2).MinWidth(200).Role(ui.RoleMeter).Label(label).Description(spoken + " " + cur.Description) + e.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, cur.Label).TextColor(col).FontWeight(600).SingleLine() + ui.Text(c, fmt.Sprintf("%d / %d", level, len(levels))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + ui.Box(c).Height(18).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) { + n := len(levels) + const gap = 3 + w := (r.W - gap*float32(n-1)) / float32(n) + for i := range n { + seg := ui.Rect{X: r.X + float32(i)*(w+gap), Y: r.Y + 8, W: w, H: 8} + if i < level { + p.Fill(seg, riskMeterColor(k, i+1, n), theme.ControlRadius) + } else { + p.Stroke(seg, k.ControlBorder, theme.ControlRadius, theme.BorderWidth) + } + if i == level-1 { + p.FillPath(internal.Diamond(seg.X+seg.W/2, r.Y+3, 7), k.Text) + } + } + }) + if cur.Description != "" { + ui.Text(c, cur.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + } + }) + return e +} + +// riskMeterColor grades from success through warning to danger. +func riskMeterColor(k theme.Tokens, level, n int) ui.Color { + switch f := float64(level-1) / float64(n-1); { + case f < 0.34: + return k.Success + case f < 0.67: + return k.Warning + } + return k.Danger +} diff --git a/risk_meter_test.go b/risk_meter_test.go new file mode 100644 index 0000000..83058ed --- /dev/null +++ b/risk_meter_test.go @@ -0,0 +1,42 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestRiskMeterLevelsAndText(t *testing.T) { + level := 4 + levels := []RiskLevel{{Label: "Conservative", Description: "Capital first."}, {Label: "Balanced"}, {Label: "Aggressive", Description: "Losses can exceed half."}} + custom := false + tt := tradingTester(GreenUp, 400, 160, func(c *ui.Context) { + if custom { + RiskMeter(c, level, RiskMeterOptions{Levels: levels}) + } else { + RiskMeter(c, level, RiskMeterOptions{}) + } + }) + if !tt.HasText("Moderately high") || !tt.HasText("4 / 5") { + t.Fatalf("default: %q", tt.Texts()) + } + custom, level = true, 3 + tt.Frame() + if !tt.HasText("Aggressive") || !tt.HasText("Losses can exceed half.") || !tt.HasText("3 / 3") { + t.Fatalf("custom: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("level 0 accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { RiskMeter(c, 0, RiskMeterOptions{}) }) +} + +func TestRiskMeterSnapshot(t *testing.T) { + snapshot(t, "risk_meter", 320, 110, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + RiskMeter(c, 4, RiskMeterOptions{Levels: []RiskLevel{{Label: "Low"}, {Label: "Medium"}, {Label: "Elevated"}, {Label: "High", Description: "Suited to experienced traders."}, {Label: "Extreme"}}}) + }) + }) +} diff --git a/sandbox_status.go b/sandbox_status.go new file mode 100644 index 0000000..e9390bd --- /dev/null +++ b/sandbox_status.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SandboxState is a sandbox's lifecycle. The zero value is SandboxStarting. +type SandboxState int + +const ( + SandboxStarting SandboxState = iota + SandboxRunning + SandboxStopped + SandboxFailed +) + +// SandboxResource is one metered resource, as CPU or memory. +type SandboxResource struct { + Name string + Used, Limit float64 + // Unit follows the numbers, as "GB" or "cores". + Unit string +} + +// SandboxInfo is the sandbox the caller reports. +type SandboxInfo struct { + Name string + State SandboxState + Resources []SandboxResource + // Expires is the shutdown time; zero means none. + Expires time.Time +} + +// SandboxStatusOptions configure a SandboxStatus. +type SandboxStatusOptions struct { + Still bool +} + +// SandboxStatus shows sandbox state, resources and time left. +func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element { + agent := map[SandboxState]AgentState{SandboxStarting: AgentPlanning, SandboxRunning: AgentRunning, SandboxStopped: AgentSkipped, SandboxFailed: AgentFailed} + state, ok := agent[info.State] + if !ok { + panic(fmt.Sprintf("mujica: unknown SandboxState %d", int(info.State))) + } + for _, r := range info.Resources { + if r.Name == "" || !(r.Limit > 0) || r.Used < 0 || math.IsNaN(r.Used) || r.Used > r.Limit { + panic(fmt.Sprintf("mujica: SandboxStatus resource %q needs a name and 0 <= Used <= Limit", r.Name)) + } + } + k := Tokens(c) + u := Density(c).Unit() + word := msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State]) + card := agentCard(c).Label(info.Name + ", " + word) + card.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, info.Name).FontWeight(600).SingleLine().Grow(1).Shrink(1) + AgentStatus(c, state, AgentStatusOptions{Label: word, Still: opts.Still}) + }) + for _, r := range info.Resources { + frac := r.Used / r.Limit + ui.Column(c).Gap(u).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + agentCaption(c, r.Name).Grow(1) + if frac >= 0.9 { + ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(msg(c, "sandbox_status.high")) + } + ui.Text(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Limit), r.Unit)). + FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text) + }) + agentBar(c, frac, fmt.Sprintf("%s %.0f%%", r.Name, frac*100)) + }) + } + if !info.Expires.IsZero() && info.State != SandboxStopped && info.State != SandboxFailed { + left := info.Expires.Sub(c.Now()) + text := msg(c, "sandbox_status.expired") + if left > 0 { + text = fmt.Sprintf(msg(c, "sandbox_status.left"), agentDuration(left.Truncate(time.Second))) + wait := left % time.Second + if wait == 0 { + wait = time.Second + } + c.After(wait) + } + ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() { + ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(k.TextMuted) + agentCaption(c, text) + }) + } + }) + return card +} + +// sandboxNum prints a resource amount with at most one decimal. +func sandboxNum(v float64) string { + if v == math.Trunc(v) { + return fmt.Sprintf("%.0f", v) + } + return fmt.Sprintf("%.1f", v) +} diff --git a/sandbox_status_test.go b/sandbox_status_test.go new file mode 100644 index 0000000..2a8815b --- /dev/null +++ b/sandbox_status_test.go @@ -0,0 +1,68 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func sandboxSample(expires time.Time) SandboxInfo { + return SandboxInfo{Name: "sandbox-7", State: SandboxRunning, Expires: expires, Resources: []SandboxResource{ + {Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}, + {Name: "Memory", Used: 3.8, Limit: 4, Unit: "GB"}, + }} +} + +func TestSandboxStatusResourcesAndCountdown(t *testing.T) { + info := sandboxSample(time.Now().Add(90 * time.Second)) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SandboxStatus(c, info, SandboxStatusOptions{Still: true}) + }, 480, 300) + for _, s := range []string{"sandbox-7", "Running", "1.5 / 2 cores", "3.8 / 4 GB", "Near the limit"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + left := "" + for _, s := range tt.Texts() { + if strings.HasSuffix(s, " left") { + left = s + } + } + if !strings.HasPrefix(left, "1 m ") { + t.Fatalf("countdown %q", left) + } + info.Expires = time.Now().Add(-time.Second) + tt.Frame() + if !tt.HasText("Time is up") { + t.Fatalf("expired: %q", tt.Texts()) + } + info.State = SandboxStopped + tt.Frame() + if !tt.HasText("Stopped") || tt.HasText("Time is up") { + t.Fatalf("stopped: %q", tt.Texts()) + } +} + +func TestSandboxStatusRejectsBadResource(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SandboxStatus(c, SandboxInfo{Name: "x", Resources: []SandboxResource{{Name: "CPU", Used: 3, Limit: 2}}}, SandboxStatusOptions{}) + }, 200, 200) +} + +func TestSandboxStatusSnapshot(t *testing.T) { + snapshot(t, "sandbox_status", 440, 230, func(c *ui.Context) { + info := sandboxSample(time.Time{}) + info.Resources = append(info.Resources, SandboxResource{Name: "Disk", Used: 12, Limit: 40, Unit: "GB"}) + ui.Column(c).Padding(16).Children(func() { SandboxStatus(c, info, SandboxStatusOptions{Still: true}) }) + }) +} diff --git a/sankey_chart.go b/sankey_chart.go new file mode 100644 index 0000000..edfe561 --- /dev/null +++ b/sankey_chart.go @@ -0,0 +1,235 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SankeyNode is a stage that flow passes through. +type SankeyNode struct{ ID, Label string } + +// SankeyLink carries Value from node From to node To. +type SankeyLink struct { + From, To string + Value float64 +} + +// SankeyChartOptions configure a SankeyChart. +type SankeyChartOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// sankeyLayout puts nodes in columns by longest path. +type sankeyLayout struct { + col []int + in, out []float64 + cols int + from, to []int +} + +func sankeyPlace(nodes []SankeyNode, links []SankeyLink) sankeyLayout { + at := map[string]int{} + for i, n := range nodes { + if n.ID == "" || n.Label == "" { + panic("mujica: SankeyChart nodes need an ID and a Label") + } + if _, dup := at[n.ID]; dup { + panic("mujica: SankeyChart has two nodes " + n.ID) + } + at[n.ID] = i + } + l := sankeyLayout{col: make([]int, len(nodes)), in: make([]float64, len(nodes)), out: make([]float64, len(nodes))} + for _, e := range links { + f, okf := at[e.From] + t, okt := at[e.To] + if !okf || !okt || f == t || !(e.Value > 0) || math.IsInf(e.Value, 0) { + panic("mujica: SankeyChart links need known ends and a positive value") + } + l.from, l.to = append(l.from, f), append(l.to, t) + l.out[f] += e.Value + l.in[t] += e.Value + } + // Relax columns; more passes than nodes means a cycle. + for pass := 0; ; pass++ { + if pass > len(nodes) { + panic("mujica: SankeyChart links form a cycle") + } + moved := false + for i := range links { + if l.col[l.to[i]] < l.col[l.from[i]]+1 { + l.col[l.to[i]], moved = l.col[l.from[i]]+1, true + } + } + if !moved { + break + } + } + for i := range nodes { + l.cols = max(l.cols, l.col[i]+1) + } + return l +} + +// SankeyChart draws flows; reading a mark highlights its paths. +func SankeyChart(c *ui.Context, nodes []SankeyNode, links []SankeyLink, opts SankeyChartOptions) ChartResult { + ch := newChart(c, opts.Frame) + l := chartMemo(ch, "layout", [2]string{chartID(nodes), chartID(links)}, func() sankeyLayout { return sankeyPlace(nodes, links) }) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + val := func(v float64) string { return chartValue(c, v, opts.Format) } + size1 := func(i int) float64 { return max(l.in[i], l.out[i]) } + // geom returns node boxes and link ends. + geom := func(r ui.Rect) (boxes []ui.Rect, y0, y1 []float32, scale float32) { + const gap, w = 10, 12 + plot := ui.Rect{X: r.X + 8, Y: r.Y + 6, W: r.W - 16, H: r.H - 12} + most, count := 0.0, 0 + for col := range l.cols { + sum, n := 0.0, 0 + for i := range nodes { + if l.col[i] == col { + sum, n = sum+size1(i), n+1 + } + } + if sum > most { + most, count = sum, n + } + } + scale = (plot.H - gap*float32(max(count-1, 0))) / float32(max(most, 1e-9)) + boxes = make([]ui.Rect, len(nodes)) + step := (plot.W - w) / float32(max(l.cols-1, 1)) + next := make([]float32, l.cols) + for i := range nodes { + h := max(float32(size1(i))*scale, 2) + boxes[i] = ui.Rect{X: plot.X + step*float32(l.col[i]), Y: plot.Y + next[l.col[i]], W: w, H: h} + next[l.col[i]] += h + gap + } + outAt, inAt := make([]float32, len(nodes)), make([]float32, len(nodes)) + for i := range links { + y0, y1 = append(y0, boxes[l.from[i]].Y+outAt[l.from[i]]), append(y1, boxes[l.to[i]].Y+inAt[l.to[i]]) + outAt[l.from[i]] += float32(links[i].Value) * scale + inAt[l.to[i]] += float32(links[i].Value) * scale + } + return + } + // ribbon returns link i's top edge at t and thickness. + ribbon := func(boxes []ui.Rect, y0, y1 []float32, scale float32, i int, t float32) (x, y, h float32) { + a, b := boxes[l.from[i]], boxes[l.to[i]] + s := t * t * (3 - 2*t) + return a.X + a.W + (b.X-a.X-a.W)*t, y0[i] + (y1[i]-y0[i])*s, float32(links[i].Value) * scale + } + n := len(nodes) + len(links) + if len(links) == 0 { + n = 0 + } + spec := chartSpec{n: n} + spec.readout = func(i int) string { + if i < len(nodes) { + return nodes[i].Label + " · " + msg(c, "sankey_chart.in") + " " + val(l.in[i]) + " · " + msg(c, "sankey_chart.out") + " " + val(l.out[i]) + } + e := links[i-len(nodes)] + return nodes[l.from[i-len(nodes)]].Label + " → " + nodes[l.to[i-len(nodes)]].Label + " · " + val(e.Value) + } + spec.pick = func(r ui.Rect, x, y float32) int { + boxes, y0, y1, scale := geom(r) + for i, b := range boxes { + if (ui.Rect{X: b.X - 4, Y: b.Y, W: b.W + 8, H: b.H}).Contains(x, y) { + return i + } + } + for i := range links { + a, b := boxes[l.from[i]], boxes[l.to[i]] + if x < a.X+a.W || x > b.X { + continue + } + _, top, h := ribbon(boxes, y0, y1, scale, i, (x-a.X-a.W)/(b.X-a.X-a.W)) + if y >= top && y <= top+max(h, 4) { + return len(nodes) + i + } + } + return -1 + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + boxes, y0, y1, scale := geom(r) + lit := sankeyLit(l, len(nodes), active) + for i := range links { + var band ui.Path + const steps = 24 + for s := 0; s <= steps; s++ { + x, y, _ := ribbon(boxes, y0, y1, scale, i, float32(s)/steps) + if s == 0 { + band.MoveTo(x, y) + } else { + band.LineTo(x, y) + } + } + for s := steps; s >= 0; s-- { + x, y, h := ribbon(boxes, y0, y1, scale, i, float32(s)/steps) + band.LineTo(x, y+h) + } + band.Close() + alpha := float32(0.4) + if lit != nil && !lit[len(nodes)+i] { + alpha = 0.12 + } + p.FillPath(&band, pal[l.from[i]%len(pal)].Alpha(alpha)) + if active == len(nodes)+i { + p.StrokePath(&band, 1.5, k.Text) + } + } + for i, b := range boxes { + col := pal[i%len(pal)] + if lit != nil && !lit[i] { + col = col.Mix(k.Surface, 0.6) + } + 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) + } + if l.col[i] == l.cols-1 { + chartText(p, nodes[i].Label, b.X-6, b.Y+b.H/2, size, k.Text, 1, 0.5) + } else { + chartText(p, nodes[i].Label, b.X+b.W+6, b.Y+b.H/2, size, k.Text, 0, 0.5) + } + } + } + res, _ := ch.build(spec) + return res +} + +// sankeyLit marks everything on paths through active; nil means all. +func sankeyLit(l sankeyLayout, nodes, active int) []bool { + if active < 0 { + return nil + } + lit := make([]bool, nodes+len(l.from)) + var walk func(node int, down bool) + walk = func(node int, down bool) { + lit[node] = true + for i := range l.from { + if down && l.from[i] == node && !lit[nodes+i] { + lit[nodes+i] = true + walk(l.to[i], true) + } + if !down && l.to[i] == node && !lit[nodes+i] { + lit[nodes+i] = true + walk(l.from[i], false) + } + } + } + if active < nodes { + walk(active, true) + walk(active, false) + return lit + } + i := active - nodes + lit[active] = true + walk(l.to[i], true) + walk(l.from[i], false) + return lit +} diff --git a/sankey_chart_test.go b/sankey_chart_test.go new file mode 100644 index 0000000..056c1b5 --- /dev/null +++ b/sankey_chart_test.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var sankeyNodes = []SankeyNode{{"tithe", "Tithes"}, {"alms", "Alms"}, {"fund", "Fund"}, {"roof", "Roof"}, {"bread", "Bread"}, {"bells", "Bells"}} +var sankeyLinks = []SankeyLink{ + {"tithe", "fund", 60}, {"alms", "fund", 30}, {"alms", "bread", 10}, + {"fund", "roof", 50}, {"fund", "bells", 40}, +} + +func TestSankeyPlaceStable(t *testing.T) { + l := sankeyPlace(sankeyNodes, sankeyLinks) + if fmt.Sprint(l.col) != "[0 0 1 2 1 2]" || l.cols != 3 || l.in[2] != 90 || l.out[1] != 40 { + t.Fatalf("layout %+v", l) + } + lit := sankeyLit(l, len(sankeyNodes), 0) // Tithes: its fund, roof and bells, not alms + if !lit[2] || !lit[3] || !lit[5] || lit[1] || lit[4] || !lit[6] || lit[7] { + t.Fatalf("lit from Tithes %v", lit) + } + lit = sankeyLit(l, len(sankeyNodes), 6+3) // the fund → roof link + if !lit[0] || !lit[1] || !lit[3] || lit[5] || lit[6+4] { + t.Fatalf("lit from fund → roof %v", lit) + } +} + +func TestSankeyChartReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: ChartFrame{Label: "Money"}}) + }} + tt := ui.NewTester(h.view, 560, 440) + tabTo(t, tt, "Money") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Fund · in 90 · out 90" { + t.Fatalf("node %q", got) + } + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Fund → Bells · 40" { + t.Fatalf("link %q", got) + } + tt.Key(0, ui.KeyEscape) + b := h.res.Element.Bounds() + tt.Move(b.X+8+4, b.Y+10) // the first node, at the top left + if got := chartReadout(tt); got != "Tithes · in 0 · out 60" { + t.Fatalf("hover %q", got) + } +} + +func TestSankeyChartBad(t *testing.T) { + for name, links := range map[string][]SankeyLink{ + "cycle": {{"tithe", "fund", 1}, {"fund", "tithe", 1}}, + "unknown": {{"tithe", "nowhere", 1}}, + "zero": {{"tithe", "fund", 0}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SankeyChart(c, sankeyNodes, links, SankeyChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) + }() + } +} + +func TestSankeyChartSnapshot(t *testing.T) { + snapshot(t, "sankey_chart", 560, 320, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: ChartFrame{Label: "Money"}}) + }) + }) +} diff --git a/scatter_chart.go b/scatter_chart.go new file mode 100644 index 0000000..1703011 --- /dev/null +++ b/scatter_chart.go @@ -0,0 +1,160 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ScatterPoint is one dot; Size sets a bubble's area. +type ScatterPoint struct{ X, Y, Size float64 } + +// ScatterGroup is named points sharing color and shape. +type ScatterGroup struct { + Name string + Points []ScatterPoint +} + +// ScatterKey names point Point of group Group. +type ScatterKey struct{ Group, Point int } + +// ScatterChartOptions configure a ScatterChart. +type ScatterChartOptions struct { + Frame ChartFrame + // Bubbles maps Size to dot area, 4–18 DIPs across. + Bubbles bool + Format func(v float64) string +} + +type scatterDrag struct { + on bool + x0, y0, x1, y1 float32 +} + +// ScatterChart plots groups; box drag, Enter or Space selects. +func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, opts ScatterChartOptions) ChartResult { + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + keys := make([]chartKey, len(groups)) + var marks []ScatterKey + b := [6]float64{math.Inf(1), math.Inf(-1), math.Inf(1), math.Inf(-1), math.Inf(1), math.Inf(-1)} + for gi, g := range groups { + keys[gi] = chartKey{Name: g.Name, Color: pal[gi%len(pal)], Shape: gi} + if g.Name == "" || ch.hidden(g.Name) { + continue + } + for pi, pt := range g.Points { + if math.IsNaN(pt.X) || math.IsNaN(pt.Y) || pt.Size < 0 { + panic("mujica: ScatterChart points need finite X, Y and Size ≥ 0") + } + marks = append(marks, ScatterKey{gi, pi}) + b = [6]float64{min(b[0], pt.X), max(b[1], pt.X), min(b[2], pt.Y), max(b[3], pt.Y), min(b[4], pt.Size), max(b[5], pt.Size)} + } + } + var xs, ys [3]float64 + if len(marks) > 0 { + // A margin keeps dots off the plot edges. + px, py := (b[1]-b[0])*0.06, (b[3]-b[2])*0.06 + xs[0], xs[1], xs[2] = internal.Nice(b[0]-px, b[1]+px, 4) + ys[0], ys[1], ys[2] = internal.Nice(b[2]-py, b[3]+py, 4) + } + pt := func(m ScatterKey) ScatterPoint { return groups[m.Group].Points[m.Point] } + geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { + plot := chartInset(r, 52, 26) + return plot, internal.Linear{D0: xs[0], D1: xs[1], R0: float64(plot.X), R1: float64(plot.X + plot.W)}, + internal.Linear{D0: ys[0], D1: ys[1], R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + } + radius := func(m ScatterKey) float32 { + if !opts.Bubbles || b[5] <= 0 { + return 4 + } + return 2 + 7*float32(math.Sqrt(pt(m).Size/b[5])) + } + toggle := func(m ScatterKey) { + if i := slices.Index(*selected, m); i >= 0 { + *selected = slices.Delete(slices.Clone(*selected), i, i+1) + } else { + *selected = append(slices.Clone(*selected), m) + } + ch.res.value = true + slog.Debug("mujica: scatter_chart select", "count", len(*selected)) + } + spec := chartSpec{n: len(marks), keys: keys, toggle: len(groups) > 1} + spec.readout = func(i int) string { + m, p := marks[i], pt(marks[i]) + s := fmt.Sprintf("%s · x %s · y %s", groups[m.Group].Name, chartValue(c, p.X, opts.Format), chartValue(c, p.Y, opts.Format)) + if opts.Bubbles { + s += " · " + msg(c, "scatter_chart.size") + " " + chartValue(c, p.Size, opts.Format) + } + if slices.Contains(*selected, m) { + s += " · " + msg(c, "scatter_chart.selected") + } + return s + } + spec.pick = func(r ui.Rect, x, y float32) int { + _, sx, sy := geom(r) + return internal.Nearest(len(marks), func(i int) (float32, float32) { return sx.Map(pt(marks[i]).X), sy.Map(pt(marks[i]).Y) }, x, y, 14, false) + } + spec.activate = func(i int) { toggle(marks[i]) } + drag := ui.Local(ch.res.Element, "mujica.scatter.drag", func() scatterDrag { return scatterDrag{} }) + box := *drag + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, sx, sy := geom(r) + internal.AxisY(p, plot, sy, internal.Ticks(ys[0], ys[1], ys[2]), func(v float64) string { return internal.FormatTick(v, ys[2]) }, size, k) + var tx []float32 + var tl []string + for _, t := range internal.Ticks(xs[0], xs[1], xs[2]) { + tx, tl = append(tx, sx.Map(t)), append(tl, internal.FormatTick(t, xs[2])) + } + internal.AxisX(p, plot, tx, tl, size, k) + for i, m := range marks { + x, y, rad := sx.Map(pt(m).X), sy.Map(pt(m).Y), radius(m) + internal.Marker(p, m.Group, x, y, 2*rad, keys[m.Group].Color.Alpha(0.85)) + if slices.Contains(*selected, m) || i == active { + var ring ui.Path + ring.Circle(internal.Snap(p, x), internal.Snap(p, y), rad+3+2*float32(chartBool(i == active))) + p.StrokePath(&ring, 1.5, k.Text) + } + } + if box.on { + p.StrokeDashed(scatterRect(box, r.X, r.Y), k.Text, 0, 1) + } + } + res, plot := ch.build(spec) + if plot != nil && !plot.IsDisabled() { + x, y, _ := plot.PointerPosition() + if _, _, held := plot.Dragged(); held { + if !drag.on { + *drag = scatterDrag{on: true, x0: x, y0: y} + } + drag.x1, drag.y1 = x, y + } else if drag.on { + if pb := plot.Bounds(); max(abs32(drag.x1-drag.x0), abs32(drag.y1-drag.y0)) > 4 { + _, sx, sy := geom(ui.Rect{W: pb.W, H: pb.H}) + in := scatterRect(*drag, 0, 0) + var picked []ScatterKey + for _, m := range marks { + if in.Contains(sx.Map(pt(m).X), sy.Map(pt(m).Y)) { + picked = append(picked, m) + } + } + *selected, res.value = picked, true + slog.Debug("mujica: scatter_chart box", "count", len(picked)) + } + *drag = scatterDrag{} + } + } + return res +} + +func scatterRect(d scatterDrag, ox, oy float32) ui.Rect { + return ui.Rect{X: ox + min(d.x0, d.x1), Y: oy + min(d.y0, d.y1), W: abs32(d.x1 - d.x0), H: abs32(d.y1 - d.y0)} +} diff --git a/scatter_chart_test.go b/scatter_chart_test.go new file mode 100644 index 0000000..713b57c --- /dev/null +++ b/scatter_chart_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var scatterGroups = []ScatterGroup{ + {Name: "Monks", Points: []ScatterPoint{{1, 2, 4}, {3, 5, 9}, {6, 4, 1}}}, + {Name: "Nuns", Points: []ScatterPoint{{2, 7, 16}, {8, 9, 2}}}, +} + +func TestScatterChartKeyboardSelect(t *testing.T) { + var sel []ScatterKey + values := 0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}, Bubbles: true}) + if r.ValueChanged() { + values++ + } + return r + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Choir") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if fmt.Sprint(sel) != "[{0 1}]" || values != 1 { + t.Fatalf("Space selected %v (%d events)", sel, values) + } + if got := chartReadout(tt); got != "Monks · x 3 · y 5 · size 9 · selected" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyEnter) + if len(sel) != 0 { + t.Fatalf("Enter did not unselect: %v", sel) + } +} + +func TestScatterChartBoxSelect(t *testing.T) { + var sel []ScatterKey + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + top := b.Y + 38 + // Nice domains: x 0..10 over 52..W-12; y 0..10 over top+8..top+214. + x := func(v float32) float32 { return b.X + 52 + (b.W-64)*v/10 } + y := func(v float32) float32 { return top + 8 + 206*(1-v/10) } + tt.Press(x(0.5), y(6)) + tt.Move(x(4), y(4)) + tt.Move(x(6.5), y(1.5)) + tt.Release(x(6.5), y(1.5)) + if fmt.Sprint(sel) != "[{0 0} {0 1} {0 2}]" { + t.Fatalf("box chose %v", sel) + } +} + +func TestScatterChartBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for negative size") + } + }() + var sel []ScatterKey + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ScatterChart(c, &sel, []ScatterGroup{{Name: "a", Points: []ScatterPoint{{1, 1, -1}}}}, ScatterChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestScatterChartSnapshot(t *testing.T) { + snapshot(t, "scatter_chart", 520, 340, func(c *ui.Context) { + sel := []ScatterKey{{1, 0}} + ui.Column(c).Padding(16).Children(func() { + ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}, Bubbles: true}) + }) + }) +} diff --git a/screen_recorder_controls.go b/screen_recorder_controls.go new file mode 100644 index 0000000..d23ce6f --- /dev/null +++ b/screen_recorder_controls.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RecorderStatus is where a recording is; zero is idle. +type RecorderStatus int + +const ( + RecorderIdle RecorderStatus = iota + RecorderRecording + RecorderPaused +) + +// RecorderState is what the caller's recorder reports. +type RecorderState struct { + Status RecorderStatus + Elapsed time.Duration +} + +// ScreenRecorderControlsOptions are the callbacks; every one is required. +type ScreenRecorderControlsOptions struct { + OnStart, OnPause, OnResume, OnStop func() + Disabled bool +} + +// ScreenRecorderControls start, pause, resume and stop a recording. +func ScreenRecorderControls(c *ui.Context, st RecorderState, opts ScreenRecorderControlsOptions) *ui.Element { + if opts.OnStart == nil || opts.OnPause == nil || opts.OnResume == nil || opts.OnStop == nil { + panic("mujica: ScreenRecorderControls needs every callback") + } + if st.Status < RecorderIdle || st.Status > RecorderPaused || st.Elapsed < 0 { + panic(fmt.Sprintf("mujica: bad RecorderState %+v", st)) + } + k := Tokens(c) + u := Density(c).Unit() + row := ui.Row(c).Gap(u*2).Padding(u, u*2).AlignItems(ui.Center).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleToolbar). + Label(msg(c, "screen_recorder_controls.label")).Disabled(opts.Disabled) + act := func(icon *ui.SVG, key string, variant ButtonVariant, fn func()) { + if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icon}).Clicked() && !row.IsDisabled() { + slog.Debug("mujica: recorder " + key) + fn() + } + } + row.Children(func() { + status := []string{"screen_recorder_controls.idle", "screen_recorder_controls.recording", "screen_recorder_controls.paused"}[st.Status] + ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(msg(c, status) + " " + mediaClock(st.Elapsed)).Children(func() { + dot := k.TextMuted + if st.Status == RecorderRecording { + dot = k.Danger + } + ui.Box(c).Size(10, 10).Radius(5).Background(dot) + ui.Text(c, msg(c, status)).TextColor(k.TextMuted).SingleLine() + ui.Text(c, mediaClock(st.Elapsed)).FontFeatures("tnum").SingleLine().MinWidth(48) + }) + switch st.Status { + case RecorderIdle: + act(mediaRecord, "screen_recorder_controls.start", Danger, opts.OnStart) + case RecorderRecording: + act(mediaPause, "screen_recorder_controls.pause", Secondary, opts.OnPause) + act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop) + case RecorderPaused: + act(mediaRecord, "screen_recorder_controls.resume", Secondary, opts.OnResume) + act(mediaStop, "screen_recorder_controls.stop", Danger, opts.OnStop) + } + }) + return row +} diff --git a/screen_recorder_controls_test.go b/screen_recorder_controls_test.go new file mode 100644 index 0000000..f83ba6d --- /dev/null +++ b/screen_recorder_controls_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type recorderCalls struct{ start, pause, resume, stop int } + +func (r *recorderCalls) opts() ScreenRecorderControlsOptions { + return ScreenRecorderControlsOptions{OnStart: func() { r.start++ }, OnPause: func() { r.pause++ }, + OnResume: func() { r.resume++ }, OnStop: func() { r.stop++ }} +} + +func TestScreenRecorderControlsFlow(t *testing.T) { + st := RecorderState{} + var r recorderCalls + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { ScreenRecorderControls(c, st, r.opts()) }) + }, 500, 120) + if !tt.HasText("Ready") || tt.HasText("Stop") { + t.Fatalf("idle shows %v", tt.Texts()) + } + if err := tt.Click("Record"); err != nil { + t.Fatal(err) + } + st = RecorderState{Status: RecorderRecording, Elapsed: 65 * time.Second} + tt.Frame() + if !tt.HasText("1:05") || !tt.HasText("Recording") { + t.Fatalf("recording shows %v", tt.Texts()) + } + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + st.Status = RecorderPaused + tt.Frame() + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if r != (recorderCalls{1, 1, 1, 1}) { + t.Fatalf("calls %+v", r) + } +} + +func TestScreenRecorderControlsDisabledAndBad(t *testing.T) { + var r recorderCalls + o := r.opts() + o.Disabled = true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ScreenRecorderControls(c, RecorderState{}, o) + }, 500, 120) + _ = tt.Click("Record") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if r.start != 0 { + t.Fatal("disabled recorder started") + } + mediaPanics(t, "no callbacks", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{}, ScreenRecorderControlsOptions{}) }) + mediaPanics(t, "bad status", func(c *ui.Context) { ScreenRecorderControls(c, RecorderState{Status: 9}, r.opts()) }) +} + +func TestScreenRecorderControlsSnapshot(t *testing.T) { + var r recorderCalls + snapshot(t, "screen_recorder_controls", 420, 140, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + ScreenRecorderControls(c, RecorderState{}, r.opts()) + ScreenRecorderControls(c, RecorderState{Status: RecorderRecording, Elapsed: 3*time.Minute + 7*time.Second}, r.opts()) + }) + }) +} diff --git a/screenshot_stream.go b/screenshot_stream.go new file mode 100644 index 0000000..86df6d9 --- /dev/null +++ b/screenshot_stream.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ScreenshotFrame is one caller-supplied frame of the agent's screen. +type ScreenshotFrame struct { + Image ui.ImageSource + Caption string + // At is the time since the run started. + At time.Duration +} + +// ScreenshotStreamState is the shown frame; unpaused, it follows the newest. +type ScreenshotStreamState struct { + Index int + Paused bool +} + +// ScreenshotStreamOptions configure a ScreenshotStream. +type ScreenshotStreamOptions struct { + // Height of the viewport; 280 DIPs when zero. + Height float32 + Disabled bool +} + +// ScreenshotStream shows screen frames live, paused or stepped. +func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult { + if st == nil { + panic("mujica: ScreenshotStream needs a state") + } + if opts.Height < 0 { + panic("mujica: ScreenshotStream Height is negative") + } + for i, f := range frames { + if f.Image == nil { + panic(fmt.Sprintf("mujica: ScreenshotStream frame %d has no Image", i)) + } + } + h := opts.Height + if h == 0 { + h = 280 + } + k := Tokens(c) + u := Density(c).Unit() + n := len(frames) + if st.Index < 0 { + panic(fmt.Sprintf("mujica: ScreenshotStream Index %d is negative", st.Index)) + } + if !st.Paused || st.Index >= n { // frames may be dropped while paused + + st.Index = max(0, n-1) + } + var r ChoiceResult + r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "screenshot_stream.label")) + locked := r.Element.IsDisabled() + step := func(d int) { + if n == 0 || locked { + return + } + st.Index = max(0, min(n-1, st.Index+d)) + st.Paused, r.changed = true, true + } + r.Element.Children(func() { + view := ui.Box(c).FillWidth().Height(h).Center().Clip().Radius(theme.ControlRadius). + Background(k.Background).Border(theme.BorderWidth, k.Border).Focusable(). + Label(msg(c, "screenshot_stream.label")) + internal.Ring(view, k.Focus, theme.ControlRadius) + view.Children(func() { + if n == 0 { + agentCaption(c, msg(c, "screenshot_stream.waiting")) + return + } + ui.Image(c, frames[st.Index].Image).Fill().Fit(ui.Contain).Label(frames[st.Index].Caption) + }) + if !locked { + switch { + case view.Shortcut(0, ui.KeyLeft): + step(-1) + case view.Shortcut(0, ui.KeyRight): + step(1) + case view.Shortcut(0, ui.KeySpace): + st.Paused, r.changed = !st.Paused, true + } + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + if iconAction(c, icons.Must("chevron-left"), msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() { + step(-1) + } + pause := msg(c, "screenshot_stream.pause") + if st.Paused { + pause = msg(c, "screenshot_stream.live") + } + if Button(c, pause, ButtonOptions{Variant: Secondary, Disabled: locked || n == 0}).Clicked() && !locked { + st.Paused, r.changed = !st.Paused, true + } + if iconAction(c, icons.Must("chevron-right"), msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() { + step(1) + } + counter := fmt.Sprintf(msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n) + agentCaption(c, counter).Role(ui.RoleStatus) + if n > 0 { + f := frames[st.Index] + ui.Text(c, f.Caption).SingleLine().Grow(1).Shrink(1) + agentCaption(c, agentDuration(f.At)) + } + if !st.Paused && n > 0 { + AgentStatus(c, AgentRunning, AgentStatusOptions{Label: msg(c, "screenshot_stream.following"), Still: true}) + } + }) + }) + if r.changed { + slog.Debug("mujica: screenshot_stream move", "index", st.Index, "paused", st.Paused) + } + return r +} diff --git a/screenshot_stream_test.go b/screenshot_stream_test.go new file mode 100644 index 0000000..bd52c20 --- /dev/null +++ b/screenshot_stream_test.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "fmt" + "image" + "image/color" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// screenshotFrames makes n solid frames of shifting colors. +func screenshotFrames(n int) []ScreenshotFrame { + out := make([]ScreenshotFrame, n) + for i := range out { + img := image.NewRGBA(image.Rect(0, 0, 160, 90)) + for p := 0; p < len(img.Pix); p += 4 { + img.Pix[p], img.Pix[p+1], img.Pix[p+2], img.Pix[p+3] = uint8(60+i*40), 90, uint8(160-i*20), 255 + } + img.Set(10, 10, color.White) + out[i] = ScreenshotFrame{Image: ui.NewBitmap(img), Caption: fmt.Sprintf("Step %d", i+1), At: time.Duration(i) * time.Second} + } + return out +} + +func screenshotTester(st *ScreenshotStreamState, frames *[]ScreenshotFrame, opts ScreenshotStreamOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ScreenshotStream(c, st, *frames, opts).Changed() { + *changes++ + } + }, 600, 420) +} + +func TestScreenshotStreamFollowsPausesAndSteps(t *testing.T) { + var st ScreenshotStreamState + frames := screenshotFrames(3) + changes := 0 + tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &changes) + if st.Index != 2 || !tt.HasText("Frame 3 of 3") || !tt.HasText("Live") { + t.Fatalf("live: %+v %q", st, tt.Texts()) + } + frames = append(frames, screenshotFrames(4)[3]) + tt.Frame() + if st.Index != 3 || !tt.HasText("Step 4") { + t.Fatalf("new frame not followed: %+v", st) + } + _ = tt.Click("Previous frame") + if !st.Paused || st.Index != 2 || changes != 1 || !tt.HasText("Step 3") { + t.Fatalf("step back: %+v", st) + } + frames = append(frames, screenshotFrames(5)[4]) + tt.Frame() + if st.Index != 2 { + t.Fatal("paused viewer moved") + } + _ = tt.Click("Go live") + if st.Paused || st.Index != 4 { + t.Fatalf("go live: %+v", st) + } +} + +func TestScreenshotStreamKeyboard(t *testing.T) { + var st ScreenshotStreamState + frames := screenshotFrames(3) + n := 0 + tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &n) + tt.Key(0, ui.KeyTab) // the viewport + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) // stops at the first + if st.Index != 0 || !st.Paused { + t.Fatalf("left: %+v", st) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if st.Paused || st.Index != 2 { + t.Fatalf("space: %+v", st) + } +} + +func TestScreenshotStreamEmptyAndDisabled(t *testing.T) { + var st ScreenshotStreamState + var frames []ScreenshotFrame + n := 0 + tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &n) + if !tt.HasText("Waiting for frames") || !tt.HasText("Frame 0 of 0") { + t.Fatalf("empty: %q", tt.Texts()) + } + frames = screenshotFrames(3) + st = ScreenshotStreamState{} + tt = screenshotTester(&st, &frames, ScreenshotStreamOptions{Disabled: true}, &n) + _ = tt.Click("Previous frame") + _ = tt.Click("Pause") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyLeft) + if n != 0 || st.Paused || st.Index != 2 { + t.Fatalf("disabled moved: %+v", st) + } +} + +func TestScreenshotStreamSnapshot(t *testing.T) { + snapshot(t, "screenshot_stream", 520, 330, func(c *ui.Context) { + st := ScreenshotStreamState{Index: 1, Paused: true} + ui.Column(c).Padding(16).Children(func() { + ScreenshotStream(c, &st, screenshotFrames(3), ScreenshotStreamOptions{Height: 200}) + }) + }) +} diff --git a/scroll_to_bottom_button.go b/scroll_to_bottom_button.go new file mode 100644 index 0000000..19ac548 --- /dev/null +++ b/scroll_to_bottom_button.go @@ -0,0 +1,47 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ScrollToBottomButton shows while away is true, with the unread count; click it to return to the end. +// While hidden it returns an empty element whose Clicked is false. +func ScrollToBottomButton(c *ui.Context, away bool, unread int) *ui.Element { + if unread < 0 { + panic("mujica: ScrollToBottomButton unread must not be negative") + } + if !away { + return ui.Box(c).Absolute().Invisible() + } + k := Tokens(c) + u := Density(c).Unit() + label := msg(c, "scroll_to_bottom_button.label") + if unread > 0 { + label = fmt.Sprintf(msg(c, "scroll_to_bottom_button.unread"), unread) + } + wrap := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 3).Justify(ui.Center).PassThrough() + var b *ui.Element + wrap.Children(func() { + b = ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*3).Gap(u*2). + Radius(theme.MinHitSize/2).Label(label).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder). + TextColor(k.Text).Shadow(0, 2, 8, 0, ui.RGBA(0, 0, 0, 0.14)).Transition(colorFade) + b.Opacity(overlayEnter(c, b)) + internal.Ring(b, k.Focus, theme.MinHitSize/2) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Icon(c, icons.Must("arrow-down")).Size(16, 16) + if unread > 0 { + ui.Text(c, fmt.Sprint(unread)).FontFeatures("tnum").FontWeight(600).TextColor(k.AccentText).SingleLine() + } + }) + }) + return b +} diff --git a/scroll_to_bottom_button_test.go b/scroll_to_bottom_button_test.go new file mode 100644 index 0000000..2ea4271 --- /dev/null +++ b/scroll_to_bottom_button_test.go @@ -0,0 +1,51 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestScrollToBottomButtonShowsWhenAway(t *testing.T) { + away, unread, clicks := false, 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Box(c).Fill().Children(func() { + if ScrollToBottomButton(c, away, unread).Clicked() { + clicks++ + } + }) + }, 300, 200) + if _, ok := tt.Find("Scroll to latest"); ok { + t.Fatal("shown while at the bottom") + } + away, unread = true, 4 + tt.Frame() + if !tt.HasText("4") { + t.Fatalf("unread count missing: %q", tt.Texts()) + } + _ = tt.Click("Scroll to latest, 4 new") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if clicks != 2 { + t.Fatalf("clicks %d", clicks) + } +} + +func TestScrollToBottomButtonNegativeUnread(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for negative unread") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ScrollToBottomButton(c, true, -1) + }, 100, 100) +} + +func TestScrollToBottomButtonSnapshot(t *testing.T) { + snapshot(t, "scroll_to_bottom_button", 240, 80, func(c *ui.Context) { + ui.Box(c).Fill().Children(func() { ScrollToBottomButton(c, true, 12) }) + }) +} diff --git a/search_panel.go b/search_panel.go new file mode 100644 index 0000000..6e14471 --- /dev/null +++ b/search_panel.go @@ -0,0 +1,202 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// SearchMatch is a hit; Start and End index Text. +type SearchMatch struct { + Line int + Text string + Start, End int +} + +// SearchFileResult is a file and its hits. +type SearchFileResult struct { + Path string + Matches []SearchMatch +} + +// SearchPanelState is a SearchPanel's query, patterns, folded files and place. +type SearchPanelState struct { + Query, Include, Exclude string + // Collapsed holds the paths of folded files. + Collapsed ui.Selection[string] + rows ui.ListState + selected int + flat []searchRow + data *SearchFileResult + n, folded int + started bool +} + +type searchRow struct{ file, match int } + +// SearchPanelOptions configure a SearchPanel. +type SearchPanelOptions struct { + // Status shows a search in progress or failed. + Status DataStatus + Error string + Disabled bool +} + +// SearchOpen is a hit the user opened. +type SearchOpen struct { + Path string + Line int +} + +// SearchPanelResult is a SearchPanel and what happened in this frame. +type SearchPanelResult struct { + Element *ui.Element + // Changed asks the caller to search again. + Changed bool + Opened *SearchOpen +} + +// SearchPanel shows search results grouped by file. +func SearchPanel(c *ui.Context, results []SearchFileResult, s *SearchPanelState, opts SearchPanelOptions) SearchPanelResult { + if s == nil { + panic("mujica: SearchPanel needs a state") + } + if !s.started { + s.started, s.selected = true, -1 + } + d := Density(c) + var res SearchPanelResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + res.Element.Children(func() { + ui.Column(c).Shrink(0).Padding(d.Unit() * 2).Gap(d.Unit() * 2).Children(func() { + res.Changed = SearchInput(c, &s.Query, SearchInputOptions{Disabled: opts.Disabled}).Changed() + ui.Row(c).Gap(d.Unit() * 2).Children(func() { + for _, f := range []struct { + v *string + key string + }{{&s.Include, "search_panel.include"}, {&s.Exclude, "search_panel.exclude"}} { + r := TextInput(c, f.v, TextInputOptions{Placeholder: msg(c, f.key), Label: msg(c, f.key), Disabled: opts.Disabled}) + r.Element.Grow(1).MinWidth(0) + res.Changed = res.Changed || r.Changed() + } + }) + }) + if res.Changed { + slog.Debug("mujica: search_panel query", "query", s.Query, "include", s.Include, "exclude", s.Exclude) + } + searchPanelFlatten(results, s) + if len(results) == 0 || opts.Status != DataReady { + text := msg(c, "search_panel.idle") + if s.Query != "" { + text = msg(c, "search_panel.none") + } + res.Changed = dataPlaceholder(c, opts.Status, opts.Error, text, text) || res.Changed + return + } + total := 0 + for _, f := range results { + total += len(f.Matches) + } + codeCaption(c, fmt.Sprintf(msg(c, "search_panel.summary"), total, len(results))).Padding(0, d.Unit()*3, d.Unit(), d.Unit()*3) + s.rows.Selected = &s.selected + s.rows.Key = func(i int) any { return s.flat[i] } + s.rows.Label = func(i int) string { return searchPanelLabel(results, s.flat[i]) } + list := codeRows(c, &s.rows, len(s.flat), func(i int) { searchPanelRow(c, results, s, s.flat[i], disabled) }) + if list.Submitted() && !disabled && s.selected >= 0 { + r := s.flat[s.selected] + f := results[r.file] + if r.match < 0 { + searchPanelFold(s, f.Path, !s.Collapsed.Has(f.Path)) + } else { + res.Opened = &SearchOpen{Path: f.Path, Line: f.Matches[r.match].Line} + slog.Debug("mujica: search_panel open", "line", res.Opened.Line) + } + } + if !disabled && s.selected >= 0 && s.flat[s.selected].match < 0 { + path := results[s.flat[s.selected].file].Path + switch { + case list.Shortcut(0, ui.KeyLeft): + searchPanelFold(s, path, true) + case list.Shortcut(0, ui.KeyRight): + searchPanelFold(s, path, false) + } + } + }) + return res +} + +func searchPanelFold(s *SearchPanelState, path string, fold bool) { + if fold { + s.Collapsed.Add(path) + } else { + s.Collapsed.Remove(path) + } + s.folded = -1 + slog.Debug("mujica: search_panel fold", "folded", fold) +} + +// searchPanelFlatten relists rows when results or folds change. +func searchPanelFlatten(results []SearchFileResult, s *SearchPanelState) { + var first *SearchFileResult + if len(results) > 0 { + first = &results[0] + } + if s.data == first && s.n == len(results) && s.folded == s.Collapsed.Len() { + return + } + s.data, s.n, s.folded = first, len(results), s.Collapsed.Len() + s.flat = s.flat[:0] + for fi, f := range results { + s.flat = append(s.flat, searchRow{fi, -1}) + if !s.Collapsed.Has(f.Path) { + for mi := range f.Matches { + s.flat = append(s.flat, searchRow{fi, mi}) + } + } + } + s.selected = min(s.selected, len(s.flat)-1) +} + +func searchPanelLabel(results []SearchFileResult, r searchRow) string { + f := results[r.file] + if r.match < 0 { + return f.Path + } + m := f.Matches[r.match] + return fmt.Sprintf("%d %s", m.Line, strings.TrimSpace(m.Text)) +} + +func searchPanelRow(c *ui.Context, results []SearchFileResult, s *SearchPanelState, r searchRow, disabled bool) { + k := Tokens(c) + f := results[r.file] + if r.match < 0 { + chev, key := "chevron-down", "search_panel.fold" + if s.Collapsed.Has(f.Path) { + chev, key = "chevron-right", "search_panel.unfold" + } + if iconAction(c, icons.Must(chev), fmt.Sprintf(msg(c, key), f.Path)).Clicked() && !disabled { + searchPanelFold(s, f.Path, !s.Collapsed.Has(f.Path)) + } + ui.Icon(c, icons.Must("file-text")).Size(16, 16).Shrink(0).TextColor(k.TextMuted) + ui.Text(c, f.Path).FontWeight(600).SingleLine().Grow(1).MinWidth(0) + codeCaption(c, fmt.Sprint(len(f.Matches))).Shrink(0) + return + } + m := f.Matches[r.match] + if m.Start < 0 || m.End > len(m.Text) || m.Start > m.End { + panic("mujica: SearchMatch range outside its Text") + } + codeMono(c, fmt.Sprint(m.Line)).Width(codeTextWidth(c, "99999")).TextColor(k.TextMuted).Shrink(0).Margin(0, 0, 0, 28) + from := max(0, m.Start-40) + before := strings.TrimLeft(m.Text[from:m.Start], " \t") + if from > 0 { + before = "…" + before + } + spans := []ui.Span{{Text: before}, {Text: m.Text[m.Start:m.End], Background: k.Selection, Underline: true, DecorationColor: k.AccentText, Weight: 600}, {Text: m.Text[m.End:]}} + ui.RichText(c, spans...).SingleLine().Grow(1).MinWidth(0) +} diff --git a/search_panel_test.go b/search_panel_test.go new file mode 100644 index 0000000..23e9102 --- /dev/null +++ b/search_panel_test.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func searchPanelSample() []SearchFileResult { + return []SearchFileResult{ + {Path: "court/seal.go", Matches: []SearchMatch{{Line: 12, Text: "func Seal(d Decree) {", Start: 5, End: 9}, {Line: 30, Text: "\treturn Seal(next)", Start: 8, End: 12}}}, + {Path: "docs/ledger.md", Matches: []SearchMatch{{Line: 4, Text: "The seal is kept by the chancellor.", Start: 4, End: 8}}}, + } +} + +type searchPanelProbe struct { + results []SearchFileResult + s SearchPanelState + opts SearchPanelOptions + changed int + opened []SearchOpen +} + +func (p *searchPanelProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := SearchPanel(c, p.results, &p.s, p.opts) + r.Element.Height(400) + if r.Changed { + p.changed++ + } + if r.Opened != nil { + p.opened = append(p.opened, *r.Opened) + } +} + +func TestSearchPanelGroupsAndOpens(t *testing.T) { + p := &searchPanelProbe{results: searchPanelSample()} + tt := ui.NewTester(p.view, 560, 420) + if !tt.HasText("3 results in 2 files") || !tt.HasText("court/seal.go") { + t.Fatalf("texts %q", tt.Texts()) + } + r, _ := tt.Find("12") + tt.ClickAt(r.X+60, r.Y+r.H/2) + tt.ClickAt(r.X+60, r.Y+r.H/2) + if len(p.opened) != 1 || p.opened[0] != (SearchOpen{"court/seal.go", 12}) { + t.Fatalf("opened %v", p.opened) + } + if err := tt.Click("Fold court/seal.go"); err != nil { + t.Fatal(err) + } + if tt.HasText("12") || !tt.HasText("docs/ledger.md") { + t.Fatalf("fold kept hits: %q", tt.Texts()) + } + _ = tt.Click("Unfold court/seal.go") + if !tt.HasText("30") { + t.Fatal("unfold did not show the hits") + } +} + +func TestSearchPanelQueryAndPatternsReport(t *testing.T) { + p := &searchPanelProbe{} + tt := ui.NewTester(p.view, 560, 420) + if !tt.HasText("Type to search in files") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Type("seal") + tt.Key(0, ui.KeyTab) + tt.Type("*.go") + if p.changed != 2 || p.s.Query != "seal" || p.s.Include != "*.go" { + t.Fatalf("changed %d query %q include %q", p.changed, p.s.Query, p.s.Include) + } + if !tt.HasText("No results") { + t.Fatal("empty search with a query says nothing") + } +} + +func TestSearchPanelKeyboard(t *testing.T) { + p := &searchPanelProbe{results: searchPanelSample()} + tt := ui.NewTester(p.view, 560, 420) + for range 5 { + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyLeft) + if !p.s.Collapsed.Has("court/seal.go") { + t.Fatal("Left on a file did not fold it") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(p.opened) != 1 || p.opened[0].Line != 30 { + t.Fatalf("Enter opened %v", p.opened) + } +} + +func TestSearchPanelStatusAndDisabled(t *testing.T) { + p := &searchPanelProbe{opts: SearchPanelOptions{Status: DataFailed, Error: "index unavailable"}} + tt := ui.NewTester(p.view, 560, 420) + if !tt.HasText("index unavailable") { + t.Fatalf("texts %q", tt.Texts()) + } + p = &searchPanelProbe{results: searchPanelSample(), opts: SearchPanelOptions{Disabled: true}} + tt = ui.NewTester(p.view, 560, 420) + _ = tt.Click("Fold court/seal.go") + if p.s.Collapsed.Len() != 0 { + t.Fatal("disabled panel folded") + } +} + +func TestSearchPanelBadRangePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("match outside its text did not panic") + } + }() + p := &searchPanelProbe{results: []SearchFileResult{{Path: "a", Matches: []SearchMatch{{Text: "ab", Start: 1, End: 9}}}}} + ui.NewTester(p.view, 560, 420) +} + +func TestSearchPanelSnapshot(t *testing.T) { + snapshot(t, "search_panel", 520, 300, func(c *ui.Context) { + s := ui.Local(c.Root(), "snap", func() *SearchPanelState { return &SearchPanelState{Query: "seal", Include: "*.go, *.md"} }) + ui.Column(c).Padding(12).Children(func() { + SearchPanel(c, searchPanelSample(), *s, SearchPanelOptions{}).Element.Height(276) + }) + }) +} diff --git a/shared_conversation_view.go b/shared_conversation_view.go new file mode 100644 index 0000000..cda049f --- /dev/null +++ b/shared_conversation_view.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SessionRole is who wrote a message; zero is SessionUser. +type SessionRole int + +const ( + SessionUser SessionRole = iota + SessionAssistant + SessionSystem +) + +// SessionMessage is one saved message, as plain text. +type SessionMessage struct { + Role SessionRole + // Author names the writer; empty uses the role's name. + Author string + Text string + Time time.Time +} + +// SharedConversationViewOptions configure a SharedConversationView. +type SharedConversationViewOptions struct { + Title string + SharedBy string + SharedAt time.Time + Disabled bool +} + +// SharedConversationView shows a shared conversation read-only with Copy link. +func SharedConversationView(c *ui.Context, messages []SessionMessage, opts SharedConversationViewOptions) ChoiceResult { + if opts.Title == "" { + panic("mujica: SharedConversationView needs a Title") + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 3).Disabled(opts.Disabled) + rows := *ui.Local(col, sharedConversationKey{}, func() *ui.ListState { return &ui.ListState{} }) + copied := false + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text) + by := fmt.Sprintf(msg(c, "shared_conversation_view.shared_by"), cmp.Or(opts.SharedBy, msg(c, "shared_conversation_view.someone"))) + if !opts.SharedAt.IsZero() { + by += " · " + dateSeparatorLabel(c, DateOf(opts.SharedAt), DateOf(c.Now())) + } + ui.Text(c, by).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("lock")).Size(14, 14).TextColor(k.TextMuted) + ui.Text(c, msg(c, "shared_conversation_view.read_only")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + copied = Button(c, msg(c, "shared_conversation_view.copy_link"), ButtonOptions{Variant: Secondary, Icon: icons.Must("copy"), + Disabled: opts.Disabled}).Clicked() + }) + ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { internal.RuleDiamond(p, r, 6, 5, k.Ornament) }) + rows.Key = func(i int) any { return i } + ui.List(c, rows, len(messages), func(i int) { sessionMessageView(c, k, messages[i]) }).Grow(1).MinHeight(0).Gap(d.Unit() * 3) + }) + if copied { + slog.Debug("mujica: shared_conversation_view copy_link") + } + return ChoiceResult{Element: col, changed: copied} +} + +type sharedConversationKey struct{} + +// sessionMessageView shows one read-only message with author and time. +func sessionMessageView(c *ui.Context, k theme.Tokens, m SessionMessage) { + d := Density(c) + if m.Role < SessionUser || m.Role > SessionSystem { + panic("mujica: SessionMessage Role out of range") + } + author := cmp.Or(m.Author, msg(c, sessionRoleKeys[m.Role])) + box := ui.Column(c).Gap(d.Unit()).Padding(d.Unit()*2, d.Unit()*3).Radius(theme.CardRadius) + if m.Role == SessionUser { + box.Background(k.Background).Border(theme.BorderWidth, k.Border) + } + box.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, author).Bold().TextColor(k.Text) + if !m.Time.IsZero() { + ui.Text(c, m.Time.Format("15:04")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + // Plain text: Markdown is not rendered here. + Text(c, m.Text, TextOptions{Selectable: true}) + }) +} + +var sessionRoleKeys = map[SessionRole]string{ + SessionUser: "shared_conversation_view.user", SessionAssistant: "shared_conversation_view.assistant", + SessionSystem: "shared_conversation_view.system", +} diff --git a/shared_conversation_view_test.go b/shared_conversation_view_test.go new file mode 100644 index 0000000..43fae0e --- /dev/null +++ b/shared_conversation_view_test.go @@ -0,0 +1,70 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSharedConversationViewShowsAndCopies(t *testing.T) { + copies := 0 + tt := chatTester(560, 460, func(c *ui.Context) { + if SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "Banquet seating", SharedBy: "Lady Ann", SharedAt: chatNow}).Changed() { + copies++ + } + }) + for _, s := range []string{"Banquet seating", "Shared by Lady Ann", "Read only", "You", "Assistant", "Lady Ann", "And the musicians?"} { + if !tt.HasText(s) { + t.Errorf("%q missing from %q", s, tt.Texts()) + } + } + _ = tt.Click("Copy link") + tt.Key(0, ui.KeyEnter) + if copies != 2 { + t.Fatalf("copies %d", copies) + } +} + +func TestSharedConversationViewVirtualizes(t *testing.T) { + msgs := make([]SessionMessage, 100000) + for i := range msgs { + msgs[i] = SessionMessage{Role: SessionRole(i % 2), Text: fmt.Sprintf("Message %d", i)} + } + tt := chatTester(560, 460, func(c *ui.Context) { + SharedConversationView(c, msgs, SharedConversationViewOptions{Title: "Long"}) + }) + if n := len(tt.Texts()); n > 80 { + t.Fatalf("built %d texts for 100000 messages", n) + } +} + +func TestSharedConversationViewDisabledAndChecks(t *testing.T) { + copies := 0 + tt := chatTester(560, 460, func(c *ui.Context) { + if SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "T", Disabled: true}).Changed() { + copies++ + } + }) + _ = tt.Click("Copy link") + if copies != 0 || !tt.HasText("Shared by someone") { + t.Fatalf("copies %d texts %q", copies, tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a missing title") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + SharedConversationView(c, nil, SharedConversationViewOptions{}) + }, 100, 100, 1) +} + +func TestSharedConversationViewSnapshot(t *testing.T) { + snapshot(t, "shared_conversation_view", 560, 380, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(380).Children(func() { + SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "Banquet seating", SharedBy: "Lady Ann", SharedAt: chatNow}) + }) + }) +} diff --git a/sparkline.go b/sparkline.go new file mode 100644 index 0000000..7048ebe --- /dev/null +++ b/sparkline.go @@ -0,0 +1,170 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SparklineKind is how a Sparkline draws its values. +type SparklineKind int + +const ( + SparklineLine SparklineKind = iota + SparklineBar + SparklineArea +) + +// SparklineOptions configure a Sparkline; Frame.Height 0 means 28 DIPs. +type SparklineOptions struct { + Frame ChartFrame + Kind SparklineKind + // Width is the plot width; 0 means 96 DIPs. + Width float32 + Format func(v float64) string +} + +// Sparkline is an inline chart without axes. +func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResult { + f := opts.Frame + if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind > SparklineArea { + panic("mujica: Sparkline needs a Label, sizes ≥ 0 and a known Kind") + } + if f.Height == 0 { + f.Height = 28 + } + w := opts.Width + if w == 0 { + w = 96 + } + k := Tokens(c) + u := Density(c).Unit() + col := internal.Palette(k)[0] + size := FontSize(c, theme.CaptionSize) + lo, hi := math.Inf(1), math.Inf(-1) + last := -1 + for i, v := range values { + if !math.IsNaN(v) { + lo, hi, last = min(lo, v), max(hi, v), i + } + } + res := ChartResult{Point: -1} + res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center) + st := ui.Local(res.Element, "mujica.sparkline", func() chartState { return chartState{focus: -1, last: -1} }) + res.Element.Children(func() { + if f.Status == DataLoading || f.Status == DataFailed || last < 0 { + box := ui.Row(c).Size(w, f.Height).AlignItems(ui.Center).Gap(u) + box.Children(func() { + switch f.Status { + case DataLoading: + Spinner(c, SpinnerOptions{Size: 14, Label: msg(c, "list.loading")}) + case DataFailed: + ui.Text(c, dataError(c, f.Error)).FontSize(size).TextColor(k.Danger).SingleLine().Grow(1).MinWidth(0) + res.retry = iconAction(c, icons.Must("refresh-cw"), msg(c, "list.retry")).Clicked() + default: + empty := f.Empty + if empty == "" { + empty = msg(c, "chart.empty") + } + ui.Text(c, empty).FontSize(size).TextColor(k.TextMuted).SingleLine() + } + }) + st.focus = -1 + return + } + if lo == hi { + lo, hi = lo-1, hi+1 + } + if opts.Kind == SparklineBar { + lo, hi = min(lo, 0), max(hi, 0) + } + plot := ui.Box(c).Size(w, f.Height).Shrink(0).Focusable().Label(f.Label).Role(ui.RoleGroup) + internal.Ring(plot, k.Focus, theme.ControlRadius) + n := len(values) + x := func(r ui.Rect, i int) float32 { return r.X + 3 + (r.W-6)*float32(i)/float32(max(n-1, 1)) } + ys := func(r ui.Rect) internal.Linear { + return internal.Linear{D0: lo, D1: hi, R0: float64(r.Y + r.H - 3), R1: float64(r.Y + 3)} + } + if !plot.IsDisabled() { + to := st.focus + switch { + case plot.Shortcut(0, ui.KeyLeft): + to = max(st.focus-1, 0) + case plot.Shortcut(0, ui.KeyRight): + to = min(st.focus+1, n-1) + case plot.Shortcut(0, ui.KeyHome): + to = 0 + case plot.Shortcut(0, ui.KeyEnd): + to = n - 1 + case plot.Shortcut(0, ui.KeyEscape): + to = -1 + } + if to != st.focus { + st.focus = to + if to >= 0 { + c.Announce(chartValue(c, values[to], opts.Format)) + } + slog.Debug("mujica: sparkline focus", "label", f.Label, "mark", to) + } + } + active := -1 + if px, _, over := plot.PointerPosition(); over && !plot.IsDisabled() { + active = max(0, min(n-1, int(math.Round(float64((px-3)/(w-6)*float32(max(n-1, 1))))))) + } else if plot.Focused() { + active = st.focus + } + res.Point = active + plot.Draw(func(p *ui.Painter, r ui.Rect) { + s := ys(r) + if opts.Kind == SparklineBar { + bw := max((r.W-6)/float32(n)-1, 1) + for i, v := range values { + if math.IsNaN(v) { + continue + } + a, b := s.Map(0), s.Map(v) + cx := x(r, i) + p.Fill(ui.Rect{X: cx - bw/2, Y: min(a, b), W: bw, H: max(abs32(a-b), 1)}, col, 0) + } + } else { + chartRuns(values, func(i int) float32 { return x(r, i) }, func(idx []int) { + var line ui.Path + for j, i := range idx { + px, py := internal.Snap(p, x(r, i)), internal.Snap(p, s.Map(values[i])) + if j == 0 { + line.MoveTo(px, py) + } else { + line.LineTo(px, py) + } + } + if opts.Kind == SparklineArea { + fill := line + fill.LineTo(internal.Snap(p, x(r, idx[len(idx)-1])), r.Y+r.H-3).LineTo(internal.Snap(p, x(r, idx[0])), r.Y+r.H-3).Close() + p.FillPath(&fill, col.Alpha(0.22)) + } + p.StrokePath(&line, 1.5, col) + }) + } + // Diamond: last value. Ring: the value being read. + p.FillPath(internal.Diamond(internal.Snap(p, x(r, last)), internal.Snap(p, s.Map(values[last])), 7), k.Text) + if active >= 0 && !math.IsNaN(values[active]) { + var ring ui.Path + ring.Circle(internal.Snap(p, x(r, active)), internal.Snap(p, s.Map(values[active])), 4) + p.StrokePath(&ring, 1.5, k.Text) + } + }) + shown := last + if active >= 0 { + shown = active + } + ui.Text(c, chartValue(c, values[shown], opts.Format)).FontSize(size).TextColor(k.Text).SingleLine().Role(ui.RoleStatus) + }) + res.changed = res.Point != st.last + st.last = res.Point + return res +} diff --git a/sparkline_test.go b/sparkline_test.go new file mode 100644 index 0000000..b8bdbbd --- /dev/null +++ b/sparkline_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var sparkValues = []float64{3, 5, 4, math.NaN(), 7, 6, 9} + +func TestSparklineEndValueAndKeys(t *testing.T) { + kind := SparklineLine + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return Sparkline(c, sparkValues, SparklineOptions{Frame: ChartFrame{Label: "Bells"}, Kind: kind}) + }} + tt := ui.NewTester(h.view, 300, 200) + if got := chartReadout(tt); got != "9" { + t.Fatalf("end value %q", got) + } + r, _ := tt.Find("Bells") + if r.H < 28 || r.W != 96 { + t.Fatalf("sparkline box %v", r) + } + tabTo(t, tt, "Bells") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "5" || h.res.Point != 1 { + t.Fatalf("read %q at %d", got, h.res.Point) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "no value" { + t.Fatalf("missing %q", got) + } + for _, kind = range []SparklineKind{SparklineBar, SparklineArea} { + tt.Frame() + tt.Move(r.X+r.W-2, r.Y+r.H/2) + if got := chartReadout(tt); got != "9" || h.res.Point != 6 { + t.Fatalf("kind %d hover %q", kind, got) + } + } +} + +func TestSparklineStates(t *testing.T) { + status := DataLoading + var vals []float64 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return Sparkline(c, vals, SparklineOptions{Frame: ChartFrame{Label: "Bells", Status: status}}) + }} + tt := ui.NewTester(h.view, 300, 200) + status = DataFailed + tt.Frame() + if err := tt.Click("Retry"); err != nil { + t.Fatal(err) + } + status = DataReady + tt.Frame() + if !tt.HasText("No data") { + t.Fatalf("empty %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic without a label") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Sparkline(c, vals, SparklineOptions{}) }, 100, 100) +} + +func TestSparklineSnapshot(t *testing.T) { + snapshot(t, "sparkline", 420, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + for _, k := range []SparklineKind{SparklineLine, SparklineBar, SparklineArea} { + Sparkline(c, sparkValues, SparklineOptions{Frame: ChartFrame{Label: "Bells"}, Kind: k}) + } + }) + }) +} diff --git a/spread_indicator.go b/spread_indicator.go new file mode 100644 index 0000000..be7c7fa --- /dev/null +++ b/spread_indicator.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SpreadIndicatorOptions configure a SpreadIndicator. +type SpreadIndicatorOptions struct { + // Decimals of the spread; zero means 2. + Decimals int + // WarnPercent flags spreads this wide, in percent; zero never. + WarnPercent float64 +} + +// SpreadIndicator shows ask minus bid and its percent. +func SpreadIndicator(c *ui.Context, bid, ask float64, opts SpreadIndicatorOptions) *ui.Element { + if opts.WarnPercent < 0 { + panic("mujica: SpreadIndicator with negative WarnPercent") + } + dec := priceDecimals("SpreadIndicator", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + spread := ask - bid + pct := 0.0 + if mid := (ask + bid) / 2; mid > 0 { + pct = spread / mid * 100 + } + text := fmt.Sprintf(msg(c, "spread_indicator.value"), statisticFormat(spread, dec), statisticFormat(pct, 3)) + note, col, icon := "", k.TextMuted, "" + switch { + case spread < 0: + note, col, icon = msg(c, "spread_indicator.crossed"), k.Danger, "circle-alert" + case opts.WarnPercent > 0 && pct >= opts.WarnPercent: + note, col, icon = msg(c, "spread_indicator.wide"), k.Warning, "triangle-alert" + } + e := ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Shrink(0).Role(ui.RoleStatus).Label(text + " " + note) + e.Children(func() { + ui.Text(c, msg(c, "spread_indicator.title")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + ui.Text(c, text).FontFeatures("tnum").FontWeight(500).SingleLine() + if note != "" { + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine() + }) + } + }) + return e +} diff --git a/spread_indicator_test.go b/spread_indicator_test.go new file mode 100644 index 0000000..c9835e4 --- /dev/null +++ b/spread_indicator_test.go @@ -0,0 +1,42 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSpreadIndicatorValueAndThreshold(t *testing.T) { + bid, ask := 99.95, 100.05 + tt := tradingTester(GreenUp, 400, 100, func(c *ui.Context) { + SpreadIndicator(c, bid, ask, SpreadIndicatorOptions{WarnPercent: 0.5}) + }) + if !tt.HasText("0.10 (0.100%)") || tt.HasText("Wide spread") { + t.Fatalf("narrow: %q", tt.Texts()) + } + ask = 101 + tt.Frame() + if !tt.HasText("Wide spread") { + t.Fatalf("wide: %q", tt.Texts()) + } + ask = 99 + tt.Frame() + if !tt.HasText("Crossed market") { + t.Fatalf("crossed: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative threshold accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { SpreadIndicator(c, 1, 2, SpreadIndicatorOptions{WarnPercent: -1}) }) +} + +func TestSpreadIndicatorSnapshot(t *testing.T) { + snapshot(t, "spread_indicator", 380, 100, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(10).Children(func() { + SpreadIndicator(c, 99.95, 100.05, SpreadIndicatorOptions{WarnPercent: 0.5}) + SpreadIndicator(c, 99, 101, SpreadIndicatorOptions{WarnPercent: 0.5}) + }) + }) +} diff --git a/stash_list.go b/stash_list.go new file mode 100644 index 0000000..f505ca0 --- /dev/null +++ b/stash_list.go @@ -0,0 +1,58 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Stash is one stash entry. +type Stash struct { + // Ref names the entry, as stash@{0}. + Ref string + Message string + Branch string +} + +// StashListOptions configure a StashList. +type StashListOptions struct { + // Status and Error show the load state. + Status DataStatus + Error string +} + +// StashList shows stashes with apply, pop and drop actions. +func StashList(c *ui.Context, s *ListState[string], stashes []Stash, opts StashListOptions) GitListResult { + var r GitListResult + k := Tokens(c) + actions := []GitAction{ + {ID: "apply", Label: msg(c, "stash_list.apply"), Icon: "check"}, + {ID: "pop", Label: msg(c, "stash_list.pop"), Icon: "upload"}, + {ID: "drop", Label: msg(c, "stash_list.drop"), Icon: "trash-2"}, + } + v := List(c, s, len(stashes), ListOptions[string]{ + Key: func(i int) string { return stashListKey(stashes[i]) }, + Label: func(i int) string { return stashes[i].Message }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "stash_list.empty"), + }, func(i int) { + st := stashes[i] + small := FontSize(c, theme.CaptionSize) + ui.Icon(c, icons.Must("archive")).Size(14, 14).Shrink(0).TextColor(k.TextMuted) + ui.Text(c, st.Ref).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0) + ui.Text(c, st.Message).SingleLine().Grow(1).MinWidth(0) + if st.Branch != "" { + ui.Text(c, st.Branch).FontSize(small).TextColor(k.TextMuted).SingleLine().MaxWidth(140).Shrink(0) + } + gitRowActions(c, actions, st.Ref, &r) + }) + r.Element, r.changed, r.submitted = v.Element, v.Changed(), v.Submitted() + return r +} + +func stashListKey(s Stash) string { + if s.Ref == "" { + panic("mujica: Stash needs a Ref") + } + return s.Ref +} diff --git a/stash_list_test.go b/stash_list_test.go new file mode 100644 index 0000000..fd76dd0 --- /dev/null +++ b/stash_list_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func stashSample() []Stash { + return []Stash{ + {Ref: "stash@{0}", Message: "WIP on main: graph lanes", Branch: "main"}, + {Ref: "stash@{1}", Message: "Experiment with blame colors", Branch: "fix/blame"}, + } +} + +func TestStashListActions(t *testing.T) { + var s ListState[string] + var acted [][2]string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := StashList(c, &s, stashSample(), StashListOptions{}) + r.Element.Height(200) + if a, item, ok := r.Action(); ok { + acted = append(acted, [2]string{a, item}) + } + }, 700, 240) + r, _ := tt.Find("Experiment with blame colors") + y := r.Y + r.H/2 + // Apply, Pop and Drop sit at the right end, 28 DIPs each. + for _, x := range []float32{700 - 90, 700 - 60, 700 - 30} { + tt.ClickAt(x, y) + } + want := [][2]string{{"apply", "stash@{1}"}, {"pop", "stash@{1}"}, {"drop", "stash@{1}"}} + if len(acted) != 3 || acted[0] != want[0] || acted[1] != want[1] || acted[2] != want[2] { + t.Fatalf("actions %q", acted) + } + for _, name := range []string{"Apply", "Pop", "Drop"} { + if _, ok := tt.Find(name); !ok { + t.Errorf("no %s button", name) + } + } +} + +func TestStashListEmptyAndInvalid(t *testing.T) { + var s ListState[string] + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + StashList(c, &s, nil, StashListOptions{}) + }, 400, 200) + if !tt.HasText("没有贮藏") { + t.Fatalf("empty: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + var s ListState[string] + StashList(c, &s, []Stash{{Message: "x"}}, StashListOptions{}).Element.Height(100) + }, 300, 120, 1) +} + +func TestStashListSnapshot(t *testing.T) { + snapshot(t, "stash_list", 640, 140, func(c *ui.Context) { + s := ui.Local(c.Root(), "list", func() *ListState[string] { return &ListState[string]{} }) + ui.Column(c).Padding(16).Children(func() { + StashList(c, *s, stashSample(), StashListOptions{}).Element.Height(100) + }) + }) +} diff --git a/stop_generating_button.go b/stop_generating_button.go new file mode 100644 index 0000000..0d5fe29 --- /dev/null +++ b/stop_generating_button.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "log/slog" + "runtime" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// StopGeneratingButtonOptions configure a StopGeneratingButton. +type StopGeneratingButtonOptions struct { + // Generating shows the button; it hides once generation ends. + Generating bool + Disabled bool +} + +// StopGeneratingButton asks to stop generating; Mod+. does too. +func StopGeneratingButton(c *ui.Context, opts StopGeneratingButtonOptions) ChoiceResult { + if !opts.Generating { + return ChoiceResult{} + } + k := Tokens(c) + d := Density(c) + mods := ui.Ctrl + if runtime.GOOS == "darwin" { + mods = ui.Cmd + } + var b *ui.Element + wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + b = ui.ButtonBase(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).Gap(d.Unit()*2). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "stop_generating_button.stop")) + }) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Transition(colorFade) + b.Children(func() { + ui.Box(c).Size(10, 10).Shrink(0).Background(k.Text).Radius(1) + ui.Text(c, msg(c, "stop_generating_button.stop")).SingleLine() + Kbd(c, "Mod", ".") + }) + stop := b.Clicked() + if c.Shortcut(mods, ui.KeyPeriod) { + stop = true + } + stop = stop && !b.IsDisabled() + if stop { + slog.Debug("mujica: stop_generating_button stop") + } + return ChoiceResult{Element: b, changed: stop} +} diff --git a/stop_generating_button_test.go b/stop_generating_button_test.go new file mode 100644 index 0000000..c6663b4 --- /dev/null +++ b/stop_generating_button_test.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "runtime" + "testing" + + "github.com/egoist/mygo/ui" +) + +func chatMod() ui.Modifiers { + if runtime.GOOS == "darwin" { + return ui.Cmd + } + return ui.Ctrl +} + +func TestStopGeneratingButtonClickAndShortcut(t *testing.T) { + generating, stops := true, 0 + tt := chatTester(400, 120, func(c *ui.Context) { + if StopGeneratingButton(c, StopGeneratingButtonOptions{Generating: generating}).Changed() { + stops++ + generating = false + } + }) + if err := tt.Click("Stop generating"); err != nil { + t.Fatal(err) + } + if stops != 1 || tt.HasText("Stop generating") { + t.Fatalf("stops %d, still shown %v", stops, tt.HasText("Stop generating")) + } + generating = true + tt.Frame() + tt.Key(chatMod(), ui.KeyPeriod) + if stops != 2 { + t.Fatalf("shortcut stops %d", stops) + } + tt.Key(chatMod(), ui.KeyPeriod) + if stops != 2 { + t.Fatal("shortcut acted while hidden") + } +} + +func TestStopGeneratingButtonDisabled(t *testing.T) { + stops := 0 + tt := chatTester(400, 120, func(c *ui.Context) { + if StopGeneratingButton(c, StopGeneratingButtonOptions{Generating: true, Disabled: true}).Changed() { + stops++ + } + }) + _ = tt.Click("Stop generating") + tt.Key(chatMod(), ui.KeyPeriod) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if stops != 0 { + t.Fatalf("disabled button stopped %d times", stops) + } +} + +func TestStopGeneratingButtonSnapshot(t *testing.T) { + snapshot(t, "stop_generating_button", 300, 70, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + StopGeneratingButton(c, StopGeneratingButtonOptions{Generating: true}) + }) + }) +} diff --git a/stopwatch.go b/stopwatch.go new file mode 100644 index 0000000..9fc6e24 --- /dev/null +++ b/stopwatch.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// StopwatchState is the caller's run and laps. +type StopwatchState struct { + running bool + since time.Time + base time.Duration + // Laps are the elapsed times at each lap, oldest first. + Laps []time.Duration +} + +// Elapsed returns the time measured at now. +func (s *StopwatchState) Elapsed(now time.Time) time.Duration { + if s.running { + return s.base + now.Sub(s.since) + } + return s.base +} + +// Running reports whether the stopwatch is counting. +func (s *StopwatchState) Running() bool { return s.running } + +// StopwatchOptions configure a Stopwatch. +type StopwatchOptions struct { + // Now fixes the clock and stops ticking. + Now time.Time + Disabled bool + Label string +} + +// Stopwatch measures time with laps, ticking while running and shown. +func Stopwatch(c *ui.Context, s *StopwatchState, opts StopwatchOptions) ChoiceResult { + k := Tokens(c) + d := Density(c) + now := opts.Now + live := now.IsZero() + if live { + now = c.Now() + } + label := opts.Label + if label == "" { + label = msg(c, "stopwatch.label") + } + changed := false + col := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + act := func(what string, fn func()) { + if col.IsDisabled() { + return + } + fn() + changed = true + slog.Debug("mujica: stopwatch "+what, "elapsed", s.Elapsed(now)) + } + col.Children(func() { + el := s.Elapsed(now) + ui.Text(c, StopwatchText(el)).Font(theme.MonoFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum"). + Label(label + ", " + StopwatchText(el)) + ui.Row(c).Gap(d.Unit() * 2).Children(func() { + start, variant := "stopwatch.start", Primary + if s.running { + start, variant = "stopwatch.pause", Secondary + } else if el > 0 { + start = "stopwatch.resume" + } + if Button(c, msg(c, start), ButtonOptions{Variant: variant}).Clicked() { + act("toggle", func() { + if s.running { + s.base, s.running = s.Elapsed(now), false + } else { + s.since, s.running = now, true + } + }) + } + if Button(c, msg(c, "stopwatch.lap"), ButtonOptions{Variant: Secondary, Disabled: !s.running}).Clicked() { + act("lap", func() { s.Laps = append(s.Laps, s.Elapsed(now)) }) + } + if Button(c, msg(c, "stopwatch.reset"), ButtonOptions{Variant: Ghost, Disabled: s.running || el == 0 && len(s.Laps) == 0}).Clicked() { + act("reset", func() { *s = StopwatchState{} }) + } + }) + if len(s.Laps) > 0 { + ui.Column(c).Role(ui.RoleList).Children(func() { + for i := len(s.Laps) - 1; i >= 0; i-- { + split := s.Laps[i] + if i > 0 { + split -= s.Laps[i-1] + } + row := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit() * 4).Role(ui.RoleListItem) + row.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Rule(p, r.X, r.X+r.W, r.Y+r.H-1, k.Border) }) + row.Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "stopwatch.lap_n"), i+1)).Width(d.ControlHeight() * 2).TextColor(k.TextMuted) + ui.Text(c, StopwatchText(split)).Font(theme.MonoFont).FontFeatures("tnum").TextColor(k.Text).Grow(1) + ui.Text(c, StopwatchText(s.Laps[i])).Font(theme.MonoFont).FontFeatures("tnum").TextColor(k.TextMuted) + }) + } + }) + } + }) + if s.running && live { + c.After(100*time.Millisecond - s.Elapsed(now)%(100*time.Millisecond)) + } + return ChoiceResult{Element: col, changed: changed} +} + +// StopwatchText formats elapsed time as 01:02.3, with hours when needed. +func StopwatchText(d time.Duration) string { + t := int(d / (100 * time.Millisecond)) + h, m, s, tenth := t/36000, t%36000/600, t%600/10, t%10 + if h > 0 { + return fmt.Sprintf("%d:%02d:%02d.%d", h, m, s, tenth) + } + return fmt.Sprintf("%02d:%02d.%d", m, s, tenth) +} diff --git a/stopwatch_test.go b/stopwatch_test.go new file mode 100644 index 0000000..8709593 --- /dev/null +++ b/stopwatch_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestStopwatchStartLapPauseReset(t *testing.T) { + var s StopwatchState + now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + n, disabled := 0, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Stopwatch(c, &s, StopwatchOptions{Now: now, Disabled: disabled}).Changed() { + n++ + } + }, 500, 500) + _ = tt.Click("Start") + now = now.Add(12*time.Second + 340*time.Millisecond) + tt.Frame() + if !tt.HasText("00:12.3") || !s.Running() { + t.Fatalf("running %v", tt.Texts()) + } + _ = tt.Click("Lap") + now = now.Add(5 * time.Second) + _ = tt.Click("Lap") + if len(s.Laps) != 2 || !tt.HasText("Lap 2") || !tt.HasText("00:05.0") { + t.Fatalf("laps %v %v", s.Laps, tt.Texts()) + } + _ = tt.Click("Pause") + now = now.Add(time.Minute) + tt.Frame() + if s.Running() || s.Elapsed(now) != 17340*time.Millisecond || !tt.HasText("Resume") { + t.Fatalf("paused at %v", s.Elapsed(now)) + } + tt.Key(0, ui.KeyEnter) + now = now.Add(time.Hour) + tt.Frame() + if !tt.HasText("1:00:17.3") { + t.Fatalf("resumed by keyboard: %v", tt.Texts()) + } + _ = tt.Click("Pause") + _ = tt.Click("Reset") + if s.Elapsed(now) != 0 || len(s.Laps) != 0 || n != 7 { + t.Fatalf("reset %v laps %v changes %d", s.Elapsed(now), s.Laps, n) + } + disabled = true + tt.Frame() + _ = tt.Click("Start") + if s.Running() { + t.Fatal("disabled stopwatch started") + } +} + +func TestStopwatchSnapshot(t *testing.T) { + now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) + s := StopwatchState{base: 83*time.Second + 400*time.Millisecond, Laps: []time.Duration{31 * time.Second, 62*time.Second + 500*time.Millisecond}} + snapshot(t, "stopwatch", 420, 260, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { Stopwatch(c, &s, StopwatchOptions{Now: now}) }) + }) +} diff --git a/streaming_text.go b/streaming_text.go new file mode 100644 index 0000000..8c658f4 --- /dev/null +++ b/streaming_text.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "strings" + "time" + "unicode/utf8" + + "github.com/egoist/mygo/ui" +) + +// StreamingTextOptions configure a StreamingText. +type StreamingTextOptions struct { + // Streaming shows the cursor after the text while more is coming. + Streaming bool + // Smooth reveals new text gradually instead of at once. + Smooth bool + // Rate is the smooth reveal speed in characters per second; zero means 80. + Rate float64 +} + +// streamingState is how much of the text shows, and since when. +type streamingState struct { + text string + shown float64 + at time.Time +} + +// StreamingText shows text that grows as it streams; reduced motion shows it at once. +func StreamingText(c *ui.Context, text string, opts StreamingTextOptions) *ui.Element { + if opts.Rate < 0 { + panic("mujica: StreamingText Rate must not be negative") + } + rate := opts.Rate + if rate == 0 { + rate = 80 + } + k := Tokens(c) + e := ui.Box(c).Label(text) + st := ui.Local(e, "stream", func() streamingState { + return streamingState{text: text, shown: float64(utf8.RuneCountInString(text)), at: c.Now()} + }) + total := float64(utf8.RuneCountInString(text)) + if !strings.HasPrefix(text, st.text) { + // Replaced text starts over from what it shares. + st.shown = min(st.shown, float64(utf8.RuneCountInString(streamingCommon(st.text, text)))) + } + st.text = text + now := c.Now() + if !opts.Smooth || Motion(c, time.Second) == 0 { + st.shown = total + } else if st.shown < total { + st.shown = min(total, st.shown+now.Sub(st.at).Seconds()*rate) + c.AnimationFrame() + } + st.at = now + shown := text + if n := int(st.shown); n < int(total) { + shown = string([]rune(text)[:n]) + } + spans := []ui.Span{{Text: shown}} + if opts.Streaming { + spans = append(spans, ui.Span{Text: "▍", Color: k.AccentText}) + } + return e.Children(func() { ui.RichText(c, spans...) }) +} + +// streamingCommon returns the longest common prefix of a and b, on rune boundaries. +func streamingCommon(a, b string) string { + n := 0 + for n < len(a) && n < len(b) && a[n] == b[n] { + n++ + } + for n > 0 && n < len(a) && !utf8.RuneStart(a[n]) { + n-- + } + return a[:n] +} diff --git a/streaming_text_test.go b/streaming_text_test.go new file mode 100644 index 0000000..a8499d3 --- /dev/null +++ b/streaming_text_test.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func streamingTester(text *string, opts StreamingTextOptions, reduce bool) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StreamingText(c, *text, opts) + }, 400, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) + tt.Frame() + return tt +} + +// streamingShown returns the visible text of the stream. +func streamingShown(tt *ui.Tester) string { + texts := tt.Texts() + return texts[len(texts)-1] +} + +func TestStreamingTextSmoothReveal(t *testing.T) { + text := "Hello" + tt := streamingTester(&text, StreamingTextOptions{Smooth: true, Streaming: true, Rate: 1}, false) + if !strings.HasPrefix(streamingShown(tt), "Hello") || !strings.HasSuffix(streamingShown(tt), "▍") { + t.Fatalf("first text %q", streamingShown(tt)) + } + text += ", court" + tt.Frame() + if s := streamingShown(tt); strings.Contains(s, "court") { + t.Fatalf("1 char/s revealed %q at once", s) + } +} + +func TestStreamingTextInstantAndReducedMotion(t *testing.T) { + for _, tc := range []struct { + opts StreamingTextOptions + reduce bool + }{{StreamingTextOptions{}, false}, {StreamingTextOptions{Smooth: true, Rate: 1}, true}} { + text := "Hello" + tt := streamingTester(&text, tc.opts, tc.reduce) + text += ", court" + tt.Frame() + if s := streamingShown(tt); s != "Hello, court" { + t.Errorf("%+v reduce %v: shows %q", tc.opts, tc.reduce, s) + } + } +} + +func TestStreamingTextReplacedRestarts(t *testing.T) { + text := "abcdef" + tt := streamingTester(&text, StreamingTextOptions{Smooth: true, Rate: 1}, false) + text = "abXYZ" + tt.Frame() + if s := streamingShown(tt); s != "ab" { + t.Fatalf("replaced text shows %q, want the shared prefix", s) + } +} + +func TestStreamingTextNegativeRate(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a negative rate") + } + }() + text := "" + streamingTester(&text, StreamingTextOptions{Rate: -1}, false) +} + +func TestStreamingTextSnapshot(t *testing.T) { + snapshot(t, "streaming_text", 360, 90, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + StreamingText(c, "The herald reads the decree, word by word", StreamingTextOptions{Streaming: true, Smooth: true}) + }) + }) +} diff --git a/sub_agent_tree.go b/sub_agent_tree.go new file mode 100644 index 0000000..c142006 --- /dev/null +++ b/sub_agent_tree.go @@ -0,0 +1,104 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SubAgent is one agent in a hierarchy. +type SubAgent struct { + ID string + Name string + Task string + State AgentState + Children []SubAgent +} + +// SubAgentTreeOptions configure a SubAgentTree. +type SubAgentTreeOptions struct { + Disabled bool + Still bool +} + +// SubAgentTree shows nested agents and their states. +func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult { + seen := map[string]bool{} + var check func([]SubAgent) + check = func(list []SubAgent) { + for _, a := range list { + if a.ID == "" || a.Name == "" || seen[a.ID] { + panic(fmt.Sprintf("mujica: SubAgentTree agent %q needs a unique ID and a Name", a.ID)) + } + seen[a.ID] = true + check(a.Children) + } + } + check(roots) + var r ChoiceResult + r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled) + open := ui.Local(r.Element, "mujica.subagents.open", func() map[string]bool { return map[string]bool{} }) + r.Element.Children(func() { + var rows func([]SubAgent, int) + rows = func(list []SubAgent, depth int) { + for _, a := range list { + if subAgentRow(c, a, depth, (*open)[a.ID], opts.Still) { + (*open)[a.ID] = !(*open)[a.ID] + r.changed = true + slog.Debug("mujica: sub_agent_tree toggle", "id", a.ID, "open", (*open)[a.ID]) + } + if (*open)[a.ID] { + rows(a.Children, depth+1) + } + } + } + rows(roots, 0) + }) + for id := range *open { + if !seen[id] { + delete(*open, id) + } + } + return r +} + +// subAgentRow builds one row, reporting an expand or collapse. +func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool { + k := Tokens(c) + d := Density(c) + u := d.Unit() + parent := len(a.Children) > 0 + row := ui.ButtonBase(c).Key(a.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()). + Padding(0, u*2, 0, u*2+float32(depth)*u*5).Radius(theme.ControlRadius). + Label(a.Name + ", " + agentWord(c, a.State)) + internal.Ring(row, k.Focus, theme.ControlRadius) + if row.Hovered() { + row.Background(k.SurfaceHover) + } + row.Children(func() { + chev := ui.Box(c).Size(14, 14).Shrink(0) + if parent { + name := "chevron-right" + if open { + name = "chevron-down" + } + chev.Children(func() { ui.Icon(c, icons.Must(name)).Size(14, 14).TextColor(k.TextMuted) }) + } + agentMark(c, a.State, 16, still) + ui.Text(c, a.Name).FontWeight(600).SingleLine().Shrink(0) + ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1) + agentCaption(c, agentWord(c, a.State)) + if parent { + agentCaption(c, fmt.Sprintf(msg(c, "sub_agent_tree.children"), len(a.Children))) + } + }) + if !parent || row.IsDisabled() { + return false + } + return row.Clicked() || !open && row.Shortcut(0, ui.KeyRight) || open && row.Shortcut(0, ui.KeyLeft) +} diff --git a/sub_agent_tree_test.go b/sub_agent_tree_test.go new file mode 100644 index 0000000..8961923 --- /dev/null +++ b/sub_agent_tree_test.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var subAgentSample = []SubAgent{ + {ID: "lead", Name: "Steward", Task: "Prepare the ball", State: AgentRunning, Children: []SubAgent{ + {ID: "music", Name: "Musician", Task: "Hire the quartet", State: AgentDone}, + {ID: "kitchen", Name: "Cook", Task: "Plan the menu", State: AgentWaiting, Children: []SubAgent{ + {ID: "wine", Name: "Sommelier", Task: "Choose the wine", State: AgentFailed}, + }}, + }}, +} + +func subAgentTester(opts SubAgentTreeOptions, changes *int) *ui.Tester { + opts.Still = true + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if SubAgentTree(c, subAgentSample, opts).Changed() { + *changes++ + } + }, 560, 300) +} + +func TestSubAgentTreeExpandByPointerAndKeys(t *testing.T) { + changes := 0 + tt := subAgentTester(SubAgentTreeOptions{}, &changes) + if tt.HasText("Musician") || !tt.HasText("Running") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Steward") + if changes != 1 || !tt.HasText("Musician") || !tt.HasText("Waiting") || tt.HasText("Sommelier") { + t.Fatalf("click: changes %d texts %q", changes, tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) // Cook + tt.Key(0, ui.KeyRight) + if !tt.HasText("Sommelier") || !tt.HasText("Failed") { + t.Fatal("Right did not expand Cook") + } + tt.Key(0, ui.KeyRight) // already open: nothing + tt.Key(0, ui.KeyLeft) + if tt.HasText("Sommelier") || changes != 3 { + t.Fatalf("Left: changes %d", changes) + } +} + +func TestSubAgentTreeDisabled(t *testing.T) { + changes := 0 + tt := subAgentTester(SubAgentTreeOptions{Disabled: true}, &changes) + _ = tt.Click("Steward") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if changes != 0 || tt.HasText("Musician") { + t.Fatal("disabled tree expanded") + } +} + +func TestSubAgentTreeRejectsDuplicateIDs(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SubAgentTree(c, []SubAgent{{ID: "a", Name: "x", Children: []SubAgent{{ID: "a", Name: "y"}}}}, SubAgentTreeOptions{}) + }, 200, 200) +} + +func TestSubAgentTreeSnapshot(t *testing.T) { + snapshotAfter(t, "sub_agent_tree", 540, 200, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { SubAgentTree(c, subAgentSample, SubAgentTreeOptions{Still: true}) }) + }, func(tt *ui.Tester) { + _ = tt.Click("Steward") + _ = tt.Click("Cook") + }) +} diff --git a/subtitle_editor.go b/subtitle_editor.go new file mode 100644 index 0000000..8a1a5fa --- /dev/null +++ b/subtitle_editor.go @@ -0,0 +1,244 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SubtitleCue is one subtitle: shown from Start until End. +type SubtitleCue struct { + Start, End time.Duration + Text string +} + +// SubtitleIssueKind is what is wrong with a cue. +type SubtitleIssueKind int + +const ( + // SubtitleEndBeforeStart: End is not after Start. + SubtitleEndBeforeStart SubtitleIssueKind = iota + // SubtitleOverlap: Start is before the previous cue's End. + SubtitleOverlap +) + +// SubtitleIssue is a problem with cue Index. +type SubtitleIssue struct { + Index int + Kind SubtitleIssueKind +} + +// CheckSubtitles lists misordered cues and cues overlapping the previous. +func CheckSubtitles(cues []SubtitleCue) []SubtitleIssue { + var out []SubtitleIssue + for i, q := range cues { + if q.End <= q.Start { + out = append(out, SubtitleIssue{i, SubtitleEndBeforeStart}) + } + if i > 0 && q.Start < cues[i-1].End { + out = append(out, SubtitleIssue{i, SubtitleOverlap}) + } + } + return out +} + +// SubtitleEditorOptions configure a SubtitleEditor. +type SubtitleEditorOptions struct { + // Height is the visible list's height; zero means 360. + Height float32 + Disabled bool +} + +type subtitleState struct { + scroll ui.ScrollState + drafts map[subtitleField]string +} + +type subtitleField struct{ row, part int } + +type subtitleKey struct{} + +// SubtitleEditor edits cue times and text, building visible rows. +func SubtitleEditor(c *ui.Context, cues *[]SubtitleCue, opts SubtitleEditorOptions) ChoiceResult { + if opts.Height < 0 { + panic("mujica: SubtitleEditor Height must not be negative") + } + h := opts.Height + if h == 0 { + h = 360 + } + k := Tokens(c) + u := Density(c).Unit() + rowH := Density(c).ControlHeight() + FontSize(c, theme.CaptionSize)*theme.LineHeight + u*3 + col := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + st := ui.Local(col, subtitleKey{}, func() *subtitleState { return &subtitleState{drafts: map[subtitleField]string{}} }) + s := *st + issues := map[int]string{} + for _, is := range CheckSubtitles(*cues) { + if is.Kind == SubtitleOverlap { + issues[is.Index] = fmt.Sprintf(msg(c, "subtitle_editor.overlap"), is.Index) + } else { + issues[is.Index] = msg(c, "subtitle_editor.order") + } + } + changed := false + col.Children(func() { + list := ui.Scroll(c).Height(h).TrackScroll(&s.scroll).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius) + list.Children(func() { + n := len(*cues) + // The container keeps Y inside the content; match it. + y := max(0, min(s.scroll.Y, float32(n)*rowH-h)) + first := max(0, int(y/rowH)-1) + last := min(n, int((y+h)/rowH)+2) + ui.Box(c).Height(float32(n) * rowH).Children(func() { + for i := first; i < last; i++ { + before := len(*cues) + if subtitleRow(c, k, cues, s, i, rowH, issues[i]) { + changed = true + } + if len(*cues) != before { + // A removal shifts rows below; the next frame builds them. + c.AnimationFrame() + return + } + } + }) + if n == 0 { + ui.Text(c, msg(c, "subtitle_editor.empty")).TextColor(k.TextMuted).Padding(u * 3) + } + }) + if Button(c, msg(c, "subtitle_editor.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}).Clicked() && !col.IsDisabled() { + var at time.Duration + if n := len(*cues); n > 0 { + at = (*cues)[n-1].End + } + *cues = append(*cues, SubtitleCue{Start: at, End: at + 2*time.Second}) + s.scroll.Y = float32(len(*cues)) * rowH + changed = true + } + }) + if changed { + slog.Debug("mujica: subtitle_editor changed", "cues", len(*cues)) + } + return ChoiceResult{Element: col, changed: changed} +} + +// subtitleRow edits cue i; true means changed or removed. +func subtitleRow(c *ui.Context, k theme.Tokens, cues *[]SubtitleCue, s *subtitleState, i int, rowH float32, issue string) bool { + u := Density(c).Unit() + q := &(*cues)[i] + changed, removed := false, false + row := ui.Column(c).Key(i).Absolute().Left(0).Right(0).Top(float32(i)*rowH).Height(rowH).Padding(u*1.5, u*2).Gap(2). + BorderWidth(0, 0, 1, 0).BorderColor(k.Border) + row.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Textf(c, "%d", i+1).Width(28).TextColor(k.TextMuted).FontFeatures("tnum") + for part, t := range []*time.Duration{&q.Start, &q.End} { + f := subtitleField{i, part} + text, editing := s.drafts[f] + if !editing { + text = subtitleClock(*t) + } + name := msg(c, []string{"subtitle_editor.start", "subtitle_editor.end"}[part]) + in := TextInput(c, &text, TextInputOptions{Label: fmt.Sprintf("%s %d", name, i+1)}) + in.Element.Width(112) + bad := false + if in.Changed() { + if v, ok := subtitleParse(text); ok { + *t, changed = v, true + delete(s.drafts, f) + } else { + s.drafts[f], bad = text, true + } + } else if editing { + _, ok := subtitleParse(text) + bad = !ok + } + if bad { + in.Input.Error(msg(c, "subtitle_editor.badTime")) + } else if issue != "" { + in.Input.Error(issue) + } + } + txt := TextInput(c, &q.Text, TextInputOptions{Label: fmt.Sprintf("%s %d", msg(c, "subtitle_editor.text"), i+1)}) + txt.Element.Grow(1) + if txt.Changed() { + changed = true + } + if b := iconAction(c, icons.Must("trash-2"), fmt.Sprintf("%s %d", msg(c, "subtitle_editor.remove"), i+1)); b.Clicked() && !b.IsDisabled() { + removed = true + } + }) + ui.Row(c).Gap(u).PaddingX(28 + u*2).Children(func() { + note := issue + for part := range 2 { + if d, ok := s.drafts[subtitleField{i, part}]; ok { + if _, valid := subtitleParse(d); !valid { + note = msg(c, "subtitle_editor.badTime") + } + } + } + if note != "" { + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine() + } + }) + }) + if removed { + *cues = slices.Delete(*cues, i, i+1) + clear(s.drafts) + return true + } + return changed +} + +// subtitleClock writes d as m:ss.mmm. +func subtitleClock(d time.Duration) string { + ms := d.Milliseconds() + return fmt.Sprintf("%d:%02d.%03d", ms/60000, ms/1000%60, ms%1000) +} + +// subtitleParse reads m:ss.mmm or h:mm:ss.mmm; the fraction is optional. +func subtitleParse(s string) (time.Duration, bool) { + parts := strings.Split(strings.TrimSpace(s), ":") + if len(parts) < 2 || len(parts) > 3 { + return 0, false + } + acc := 0 + for i, p := range parts[:len(parts)-1] { + n, err := strconv.Atoi(p) + if err != nil || !subtitleDigits(p) || (i > 0 && n > 59) { + return 0, false + } + acc = acc*60 + n + } + sec := parts[len(parts)-1] + whole, frac, _ := strings.Cut(sec, ".") + s0, err := strconv.Atoi(whole) + if err != nil || len(whole) != 2 || s0 < 0 || s0 > 59 || len(frac) > 3 || !subtitleDigits(whole+frac) { + return 0, false + } + ms := 0 + if frac != "" { + ms, _ = strconv.Atoi(frac + strings.Repeat("0", 3-len(frac))) + } + return time.Duration(acc*60+s0)*time.Second + time.Duration(ms)*time.Millisecond, true +} + +// subtitleDigits reports whether s is ASCII digits only. +func subtitleDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/subtitle_editor_test.go b/subtitle_editor_test.go new file mode 100644 index 0000000..9c2b4c7 --- /dev/null +++ b/subtitle_editor_test.go @@ -0,0 +1,131 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func subtitleTester(cues *[]SubtitleCue, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(620).Children(func() { + if SubtitleEditor(c, cues, SubtitleEditorOptions{Height: 260, Disabled: disabled}).Changed() { + *n++ + } + }) + }, 700, 400) +} + +func TestCheckSubtitles(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}, {time.Second, 3 * time.Second, "b"}, {4 * time.Second, 4 * time.Second, "c"}} + want := []SubtitleIssue{{1, SubtitleOverlap}, {2, SubtitleEndBeforeStart}} + if got := CheckSubtitles(cues); !slices.Equal(got, want) { + t.Fatalf("issues %v", got) + } +} + +func TestSubtitleParse(t *testing.T) { + for s, want := range map[string]time.Duration{"0:01.5": 1500 * time.Millisecond, "1:05.250": 65250 * time.Millisecond, "1:00:00": time.Hour, "2:03": 123 * time.Second} { + if got, ok := subtitleParse(s); !ok || got != want { + t.Errorf("%q: %v %v", s, got, ok) + } + } + for _, s := range []string{"", "5", "1:60", "1:5", "a:00", "1:00.1234", "1:-1", "+1:00"} { + if _, ok := subtitleParse(s); ok { + t.Errorf("%q parsed", s) + } + } +} + +func TestSubtitleEditorEditTimesAndText(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "Welcome"}, {3 * time.Second, 5 * time.Second, "to the court"}} + n := 0 + tt := subtitleTester(&cues, false, &n) + if err := tt.Click("End 1"); err != nil { + t.Fatal(err) + } + tt.Command("selectAll") + tt.Type("0:04") + if cues[0].End != 4*time.Second || !tt.HasText("Overlaps cue 1") { + t.Fatalf("end edit %+v, texts %v", cues[0], tt.Texts()) + } + tt.Command("selectAll") + tt.Type("zz") + if cues[0].End != 4*time.Second || !tt.HasText("Use m:ss.mmm, as 1:05.250") { + t.Fatalf("bad time kept? %+v", cues[0]) + } + if err := tt.Click("Text 2"); err != nil { + t.Fatal(err) + } + tt.Type("!") + if cues[1].Text != "to the court!" || n < 2 { + t.Fatalf("text %q changes %d", cues[1].Text, n) + } +} + +func TestSubtitleEditorAddRemove(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}} + n := 0 + tt := subtitleTester(&cues, false, &n) + if err := tt.Click("Add cue"); err != nil { + t.Fatal(err) + } + if len(cues) != 2 || cues[1].Start != 2*time.Second || cues[1].End != 4*time.Second { + t.Fatalf("added %+v", cues) + } + if err := tt.Click("Remove cue 1"); err != nil { + t.Fatal(err) + } + if len(cues) != 1 || cues[0].Start != 2*time.Second || n != 2 { + t.Fatalf("removed %+v, changes %d", cues, n) + } +} + +func TestSubtitleEditorBuildsOnlyVisible(t *testing.T) { + cues := make([]SubtitleCue, 100000) + for i := range cues { + cues[i] = SubtitleCue{time.Duration(i) * time.Second, time.Duration(i)*time.Second + 900*time.Millisecond, "line"} + } + n := 0 + tt := subtitleTester(&cues, false, &n) + tt.Frame() + built := 0 + for _, x := range tt.Texts() { + if strings.HasPrefix(x, "Remove cue ") { + built++ + } + } + if built == 0 || built > 20 { + t.Fatalf("built %d rows of 100000", built) + } +} + +func TestSubtitleEditorDisabled(t *testing.T) { + cues := []SubtitleCue{{0, 2 * time.Second, "a"}} + n := 0 + tt := subtitleTester(&cues, true, &n) + _ = tt.Click("Add cue") + _ = tt.Click("Remove cue 1") + _ = tt.Click("Text 1") + tt.Type("x") + if len(cues) != 1 || cues[0].Text != "a" || n != 0 { + t.Fatalf("disabled editor changed %+v", cues) + } +} + +func TestSubtitleEditorSnapshot(t *testing.T) { + cues := []SubtitleCue{ + {0, 2500 * time.Millisecond, "The curtain rises."}, + {2 * time.Second, 5 * time.Second, "Welcome to the masquerade."}, + {6 * time.Second, 9 * time.Second, "Ave Mujica."}, + } + snapshot(t, "subtitle_editor", 640, 300, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(600).Children(func() { + SubtitleEditor(c, &cues, SubtitleEditorOptions{Height: 200}) + }) + }) +} diff --git a/suggestion_chips.go b/suggestion_chips.go new file mode 100644 index 0000000..c11de04 --- /dev/null +++ b/suggestion_chips.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SuggestionChipsOptions configure SuggestionChips. +type SuggestionChipsOptions struct { + // Send makes a click send instead of filling the input. + Send bool + // Label names the group, such as "Follow-up questions". + Label string + Disabled bool +} + +// SuggestionChipsView is built chips and the suggestion used this frame. +type SuggestionChipsView struct { + Element *ui.Element + // Chosen is the suggestion clicked, empty for none. + Chosen string + // Sent asks to send Chosen now instead of filling. + Sent bool +} + +// SuggestionChips offers prompts that fill *input or ask to send. +func SuggestionChips(c *ui.Context, input *string, suggestions []string, opts SuggestionChipsOptions) SuggestionChipsView { + if input == nil { + panic("mujica: SuggestionChips needs an input") + } + k := Tokens(c) + d := Density(c) + row := ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled) + v := SuggestionChipsView{Element: row} + icon := icons.Must("chevron-right") + if opts.Send { + icon = icons.Must("arrow-up") + } + row.Children(func() { + for i, s := range suggestions { + if s == "" { + panic("mujica: SuggestionChips with an empty suggestion") + } + b := ui.ButtonBase(c).Key(i).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). + Gap(d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(s) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Transition(colorFade) + b.Children(func() { + ui.Text(c, s).SingleLine() + ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted) + }) + if b.Clicked() && !b.IsDisabled() { + v.Chosen = s + } + } + }) + if v.Chosen != "" { + v.Sent = opts.Send + if !opts.Send { + *input = v.Chosen + } + slog.Debug("mujica: suggestion_chips choose", "send", v.Sent) + } + return v +} diff --git a/suggestion_chips_test.go b/suggestion_chips_test.go new file mode 100644 index 0000000..87eb259 --- /dev/null +++ b/suggestion_chips_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var chipTexts = []string{"Plan a ball", "Write a sonnet", "Explain the minuet"} + +func TestSuggestionChipsFill(t *testing.T) { + input := "draft" + var last SuggestionChipsView + tt := chatTester(500, 200, func(c *ui.Context) { + if v := SuggestionChips(c, &input, chipTexts, SuggestionChipsOptions{Label: "Suggestions"}); v.Chosen != "" { + last = v + } + }) + _ = tt.Click("Write a sonnet") + if input != "Write a sonnet" || last.Sent { + t.Fatalf("input %q sent %v", input, last.Sent) + } +} + +func TestSuggestionChipsSendAndKeys(t *testing.T) { + input := "draft" + var last SuggestionChipsView + tt := chatTester(500, 200, func(c *ui.Context) { + if v := SuggestionChips(c, &input, chipTexts, SuggestionChipsOptions{Send: true}); v.Chosen != "" { + last = v + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if last.Chosen != "Explain the minuet" || !last.Sent || input != "draft" { + t.Fatalf("chose %+v input %q", last, input) + } +} + +func TestSuggestionChipsDisabled(t *testing.T) { + input := "" + tt := chatTester(500, 200, func(c *ui.Context) { + SuggestionChips(c, &input, chipTexts, SuggestionChipsOptions{Disabled: true}) + }) + _ = tt.Click("Plan a ball") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if input != "" { + t.Fatalf("disabled chips filled %q", input) + } +} + +func TestSuggestionChipsRejectEmpty(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + in := "" + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + SuggestionChips(c, &in, []string{""}, SuggestionChipsOptions{}) + }, 100, 100, 1) +} + +func TestSuggestionChipsSnapshot(t *testing.T) { + snapshot(t, "suggestion_chips", 520, 120, func(c *ui.Context) { + in := "" + ui.Column(c).Padding(16).Gap(8).Children(func() { + SuggestionChips(c, &in, chipTexts, SuggestionChipsOptions{}) + SuggestionChips(c, &in, chipTexts[:2], SuggestionChipsOptions{Send: true, Disabled: true}) + }) + }) +} diff --git a/sunburst.go b/sunburst.go new file mode 100644 index 0000000..6ee6712 --- /dev/null +++ b/sunburst.go @@ -0,0 +1,155 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SunburstOptions configure a Sunburst. +type SunburstOptions struct { + Frame ChartFrame + // Depth is the number of rings; 0 means 3. + Depth int + Format func(v float64) string +} + +// sunArc is one drawn segment of a ring. +type sunArc struct { + path []string + node ChartNode + ring int + a0, a1 float64 + color int +} + +// Sunburst draws descendants of *path as rings; branches drill in. +func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOptions) ChartResult { + if opts.Depth < 0 { + panic("mujica: Sunburst Depth must be ≥ 0") + } + depth := opts.Depth + if depth == 0 { + depth = 3 + } + chartCheckTree(root) + chartDescend(root, *path) + k := Tokens(c) + u := Density(c).Unit() + size := FontSize(c, theme.CaptionSize) + pal := internal.Palette(k) + var res ChartResult + wrap := ui.Column(c).Gap(u * 2).MinWidth(0) + wrap.Children(func() { + res.value = chartPath(c, root.Name, path, wrap.IsDisabled()) + node := chartDescend(root, *path) + sum := node.total() + var arcs []sunArc + var walk func(n ChartNode, at []string, ring int, a0, a1 float64, color int) + walk = func(n ChartNode, at []string, ring int, a0, a1 float64, color int) { + total, width := n.total(), a1-a0 + for i, ch := range n.Children { + v := ch.total() + if v <= 0 { + continue + } + span := width * v / total + col := color + if ring == 0 { + col = i + } + arc := sunArc{path: append(slices.Clone(at), ch.Name), node: ch, ring: ring, a0: a0, a1: a0 + span, color: col} + arcs = append(arcs, arc) + if ring+1 < depth { + walk(ch, arc.path, ring+1, arc.a0, arc.a1, col) + } + a0 += span + } + } + if sum > 0 { + walk(node, nil, 0, 0, 2*math.Pi, 0) + } + geom := func(r ui.Rect) (cx, cy, hole, ring float32) { + rad := min(r.W, r.H)/2 - 6 + hole = rad * 0.3 + return r.X + r.W/2, r.Y + r.H/2, hole, (rad - hole) / float32(depth) + } + ch := newChart(c, opts.Frame) + spec := chartSpec{n: len(arcs)} + spec.readout = func(i int) string { + a := arcs[i] + s := fmt.Sprintf("%s · %s · %.1f%%", strings.Join(a.path, " › "), chartValue(c, a.node.total(), opts.Format), 100*a.node.total()/sum) + if len(a.node.Children) > 0 { + s += " · " + msg(c, "chart.opens") + } + return s + } + spec.pick = func(r ui.Rect, x, y float32) int { + cx, cy, hole, ring := geom(r) + d := float32(math.Hypot(float64(x-cx), float64(y-cy))) + a := math.Atan2(float64(x-cx), float64(cy-y)) + if a < 0 { + a += 2 * math.Pi + } + for i, arc := range arcs { + r0 := hole + ring*float32(arc.ring) + if d >= r0 && d < r0+ring && a >= arc.a0 && a < arc.a1 { + return i + } + } + return -1 + } + spec.activate = func(i int) { + if len(arcs[i].node.Children) > 0 { + *path = append(slices.Clone(*path), arcs[i].path...) + res.value = true + slog.Debug("mujica: sunburst drill", "path", strings.Join(*path, "/")) + } + } + // color fades outer rings, keeping 3:1 against Surface. + color := func(a sunArc) ui.Color { + col := pal[a.color%len(pal)] + if faded := col.Mix(k.Surface, 0.18*float32(a.ring)); theme.Contrast(faded, k.Surface) >= 3 { + return faded + } + return col + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + cx, cy, hole, ring := geom(r) + for i, a := range arcs { + r0 := hole + ring*float32(a.ring) + col := color(a) + p.FillPath(internal.Sector(cx, cy, r0, r0+ring, a.a0, a.a1), col) + p.StrokePath(internal.Sector(cx, cy, r0, r0+ring, a.a0, a.a1), 1.5, k.Surface) + if i == active { + p.StrokePath(internal.Sector(cx, cy, r0+2, r0+ring-2, a.a0, a.a1), 2, chartInk(k, col)) + } + } + // Labels go on top where arcs are long enough. + for _, a := range arcs { + r0 := hole + ring*float32(a.ring) + col := color(a) + if mid := float64(r0+ring/2) * (a.a1 - a.a0); mid > 44 { + x, y := internal.Polar(cx, cy, r0+ring/2, (a.a0+a.a1)/2) + chartText(p, chartFit(p, a.node.Name, min(float32(mid)*0.8, ring*1.25), size), x, y, size, chartInk(k, col), 0.5, 0.5) + } + } + chartText(p, chartFit(p, node.Name, hole*2-8, size), cx, cy, size, k.TextMuted, 0.5, 1) + chartText(p, chartValue(c, sum, opts.Format), cx, cy+2, FontSize(c, theme.BodySize), k.Text, 0.5, 0) + } + r, plot := ch.build(spec) + if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { + *path, res.value = (*path)[:len(*path)-1], true + } + res.Point, res.changed, res.retry = r.Point, r.changed, r.retry + }) + res.Element = wrap + return res +} diff --git a/sunburst_test.go b/sunburst_test.go new file mode 100644 index 0000000..e65d9c4 --- /dev/null +++ b/sunburst_test.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSunburstReadoutAndDrill(t *testing.T) { + var path []string + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return Sunburst(c, &path, chartTree(), SunburstOptions{Frame: ChartFrame{Label: "Rooms"}}) + }} + tt := ui.NewTester(h.view, 560, 440) + tabTo(t, tt, "Rooms") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Library › Psalters · 30 · 34.1%" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + if fmt.Sprint(path) != "[Library]" || !tt.HasText("Abbey") { + t.Fatalf("drill %v", path) + } + if err := tt.Click("Abbey"); err != nil || len(path) != 0 { + t.Fatalf("breadcrumb back: %v %v", err, path) + } + b := h.res.Element.Bounds() + plotTop := b.Y + b.H - 20 - 8 - 240 + cx, cy := b.X+b.W/2, plotTop+120 + tt.Key(0, ui.KeyEscape) + tt.Move(cx+50, cy-30) // inner ring, upper right: Library + if got := chartReadout(tt); got != "Library · 40 · 45.5% · Enter opens" { + t.Fatalf("hover %q", got) + } + tt.Move(cx-48*0.866, cy-48*0.5) // inner ring at 300°: Garden spans 266°–348° + if got := chartReadout(tt); got != "Garden · 20 · 22.7% · Enter opens" { + t.Fatalf("hover at 300° %q", got) + } +} + +func TestSunburstBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for duplicate children") + } + }() + var path []string + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Sunburst(c, &path, ChartNode{Name: "r", Children: []ChartNode{{Name: "a", Value: 1}, {Name: "a", Value: 2}}}, SunburstOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestSunburstSnapshot(t *testing.T) { + snapshot(t, "sunburst", 560, 360, func(c *ui.Context) { + var path []string + ui.Column(c).Padding(16).Children(func() { + Sunburst(c, &path, chartTree(), SunburstOptions{Frame: ChartFrame{Label: "Rooms", Height: 260}}) + }) + }) +} diff --git a/symbol_outline.go b/symbol_outline.go new file mode 100644 index 0000000..e82bba1 --- /dev/null +++ b/symbol_outline.go @@ -0,0 +1,235 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SymbolKind is what a Symbol declares. +type SymbolKind int + +const ( + SymbolFunction SymbolKind = iota + SymbolMethod + SymbolType + SymbolInterface + SymbolField + SymbolVariable + SymbolConstant + SymbolModule +) + +// Symbol is a declaration spanning Line to EndLine, from 1. +type Symbol struct { + Name, Detail string + Kind SymbolKind + Line, EndLine int + Children []Symbol +} + +// SymbolOutlineState is a SymbolOutline's filter, open symbols and choice. +type SymbolOutlineState struct { + Query string + outline ui.OutlineState[int] + cursor int + nodes []symbolNode + roots []int + data *Symbol + n int + line int + started bool +} + +type symbolNode struct { + sym *Symbol + parent int + kids []int +} + +// SymbolOutlineOptions configure a SymbolOutline. +type SymbolOutlineOptions struct { + // CursorLine chooses its symbol when it changes; 0 is off. + CursorLine int + Disabled bool +} + +// SymbolOutlineResult carries the symbol to jump to. +type SymbolOutlineResult struct { + Element *ui.Element + Jumped *Symbol +} + +var symbolBadges = [...]string{"f", "m", "T", "I", "p", "v", "c", "M"} + +// SymbolOutline shows a filterable symbol tree. +func SymbolOutline(c *ui.Context, symbols []Symbol, s *SymbolOutlineState, opts SymbolOutlineOptions) SymbolOutlineResult { + if s == nil { + panic("mujica: SymbolOutline needs a state") + } + if !s.started { + s.started, s.cursor = true, -1 + } + symbolIndex(symbols, s) + k := Tokens(c) + d := Density(c) + var res SymbolOutlineResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + res.Element.Children(func() { + codeHeader(c, func() { + SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "symbol_outline.filter"), Disabled: opts.Disabled}).Element.Grow(1) + }) + q := strings.ToLower(strings.TrimSpace(s.Query)) + keep := symbolFilter(s, q) + roots := symbolKept(s.roots, keep) + if len(roots) == 0 { + codeCaption(c, msg(c, "symbol_outline.empty")).Padding(d.Unit() * 4) + return + } + follow := opts.CursorLine != s.line + s.line = opts.CursorLine + if follow { + symbolFollow(s, opts.CursorLine, keep) + } + s.outline.List.Selected = &s.cursor + s.outline.List.Label = func(i int) string { return s.nodes[s.outline.Item(i)].sym.Name } + children := func(i int) []int { + if len(s.nodes[i].kids) == 0 { + return nil + } + if q != "" { + s.outline.Open.Add(i) + } + return symbolKept(s.nodes[i].kids, keep) + } + restore := dataSelectTheme(c, 0) + e := ui.Outline(c, &s.outline, roots, children, func(i int) { + sym := s.nodes[i].sym + if sym.Kind < SymbolFunction || sym.Kind > SymbolModule { + panic("mujica: Symbol kind out of range") + } + ui.Row(c).MinHeight(d.ControlHeight() - 2*c.Theme().Space(1)).Gap(d.Unit() * 2).AlignItems(ui.Center).Grow(1).MinWidth(0).Children(func() { + codeBadge(c, symbolBadges[sym.Kind], [...]ui.Color{k.AccentText, k.AccentText, k.Info, k.Info, k.TextMuted, k.TextMuted, k.Warning, k.Success}[sym.Kind]) + ui.RichText(c, codeMatchSpans(c, sym.Name, q)...).SingleLine().MinWidth(0) + if sym.Detail != "" { + codeCaption(c, sym.Detail).MinWidth(0) + } + ui.Spacer(c) + codeSelectMark(c, s.cursor >= 0 && s.cursor < s.outline.Rows() && s.outline.Item(s.cursor) == i) + }) + }) + restore() + e.Grow(1).MinHeight(d.ControlHeight() * 3) + internal.Ring(e, k.Focus, theme.ControlRadius) + if follow && s.cursor >= 0 { + s.outline.List.ScrollIntoView(s.cursor) + } + if !disabled && s.cursor >= 0 && s.cursor < s.outline.Rows() && e.Submitted() { + sym := *s.nodes[s.outline.Item(s.cursor)].sym + res.Jumped = &sym + slog.Debug("mujica: symbol_outline jump", "line", sym.Line) + } + }) + return res +} + +// symbolIndex numbers the symbols depth first when the tree changes. +func symbolIndex(symbols []Symbol, s *SymbolOutlineState) { + var first *Symbol + if len(symbols) > 0 { + first = &symbols[0] + } + if s.data == first && s.n == len(symbols) { + return + } + s.data, s.n, s.nodes, s.roots = first, len(symbols), s.nodes[:0], s.roots[:0] + var walk func(list []Symbol, parent int) []int + walk = func(list []Symbol, parent int) []int { + var ids []int + for i := range list { + id := len(s.nodes) + s.nodes = append(s.nodes, symbolNode{sym: &list[i], parent: parent}) + s.nodes[id].kids = walk(list[i].Children, id) + ids = append(ids, id) + } + return ids + } + s.roots = walk(symbols, -1) +} + +// symbolFilter marks matches and ancestors; nil keeps all. +func symbolFilter(s *SymbolOutlineState, q string) []bool { + if q == "" { + return nil + } + keep := make([]bool, len(s.nodes)) + for i, n := range s.nodes { + if strings.Contains(strings.ToLower(n.sym.Name), q) { + for j := i; j >= 0 && !keep[j]; j = s.nodes[j].parent { + keep[j] = true + } + } + } + return keep +} + +func symbolKept(ids []int, keep []bool) []int { + if keep == nil { + return ids + } + var out []int + for _, id := range ids { + if keep[id] { + out = append(out, id) + } + } + return out +} + +// symbolFollow opens and chooses the innermost symbol spanning line. +func symbolFollow(s *SymbolOutlineState, line int, keep []bool) { + best := -1 + for i, n := range s.nodes { + if keep != nil && !keep[i] { + continue + } + if n.sym.Line <= line && line <= max(n.sym.EndLine, n.sym.Line) { + best = i + } + } + if best < 0 { + return + } + for p := s.nodes[best].parent; p >= 0; p = s.nodes[p].parent { + s.outline.Open.Add(p) + } + s.cursor = symbolRow(s, best, keep) + slog.Debug("mujica: symbol_outline follow", "line", line) +} + +// symbolRow returns the row the shown node id is at. +func symbolRow(s *SymbolOutlineState, id int, keep []bool) int { + row := 0 + var walk func(ids []int) bool + walk = func(ids []int) bool { + for _, n := range symbolKept(ids, keep) { + if n == id { + return true + } + row++ + if s.outline.Open.Has(n) && walk(s.nodes[n].kids) { + return true + } + } + return false + } + if walk(s.roots) { + return row + } + return -1 +} diff --git a/symbol_outline_test.go b/symbol_outline_test.go new file mode 100644 index 0000000..106bb33 --- /dev/null +++ b/symbol_outline_test.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func symbolSample() []Symbol { + return []Symbol{ + {Name: "Court", Kind: SymbolType, Line: 3, EndLine: 40, Detail: "struct", Children: []Symbol{ + {Name: "Seal", Kind: SymbolMethod, Line: 10, EndLine: 20, Detail: "(d Decree) error"}, + {Name: "ledger", Kind: SymbolField, Line: 5, EndLine: 5}, + }}, + {Name: "Herald", Kind: SymbolFunction, Line: 44, EndLine: 60}, + {Name: "MaxSeals", Kind: SymbolConstant, Line: 1, EndLine: 1}, + } +} + +type symbolProbe struct { + symbols []Symbol + s SymbolOutlineState + opts SymbolOutlineOptions + jumped []string +} + +func (p *symbolProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := SymbolOutline(c, p.symbols, &p.s, p.opts) + r.Element.Height(320) + if r.Jumped != nil { + p.jumped = append(p.jumped, r.Jumped.Name) + } +} + +func TestSymbolOutlineFollowsCursor(t *testing.T) { + p := &symbolProbe{symbols: symbolSample(), opts: SymbolOutlineOptions{CursorLine: 12}} + tt := ui.NewTester(p.view, 400, 340) + if !tt.HasText("Seal") || p.s.cursor < 0 || p.s.nodes[p.s.outline.Item(p.s.cursor)].sym.Name != "Seal" { + t.Fatalf("cursor at line 12 did not choose Seal: %q", tt.Texts()) + } + p.opts.CursorLine = 50 + tt.Frame() + if name := p.s.nodes[p.s.outline.Item(p.s.cursor)].sym.Name; name != "Herald" { + t.Fatalf("line 50 chose %q", name) + } +} + +func TestSymbolOutlineFilter(t *testing.T) { + p := &symbolProbe{symbols: symbolSample()} + tt := ui.NewTester(p.view, 400, 340) + tt.Key(0, ui.KeyTab) + tt.Type("led") + if !tt.HasText("Court") || !tt.HasText("ledger") || tt.HasText("Herald") || tt.HasText("Seal") { + t.Fatalf("filter shows %q", tt.Texts()) + } + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("zz") + if !tt.HasText("No symbols") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestSymbolOutlineJumpByKeyboard(t *testing.T) { + p := &symbolProbe{symbols: symbolSample()} + tt := ui.NewTester(p.view, 400, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(p.jumped) != 1 || p.jumped[0] != "Herald" { + t.Fatalf("jumped %v", p.jumped) + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyRight) + if !tt.HasText("Seal") { + t.Fatal("Right did not open Court") + } +} + +func TestSymbolOutlineDisabledAndBadKind(t *testing.T) { + p := &symbolProbe{symbols: symbolSample(), opts: SymbolOutlineOptions{Disabled: true}} + tt := ui.NewTester(p.view, 400, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(p.jumped) != 0 { + t.Fatal("disabled outline jumped") + } + defer func() { + if recover() == nil { + t.Fatal("bad kind did not panic") + } + }() + ui.NewTester((&symbolProbe{symbols: []Symbol{{Name: "x", Kind: 99}}}).view, 400, 340) +} + +func TestSymbolOutlineSnapshot(t *testing.T) { + snapshot(t, "symbol_outline", 360, 260, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *symbolProbe { + return &symbolProbe{symbols: symbolSample(), opts: SymbolOutlineOptions{CursorLine: 12}} + }) + ui.Column(c).Padding(12).Children(func() { + SymbolOutline(c, (*p).symbols, &(*p).s, (*p).opts).Element.Height(236) + }) + }) +} diff --git a/system_prompt_editor.go b/system_prompt_editor.go new file mode 100644 index 0000000..ffb0792 --- /dev/null +++ b/system_prompt_editor.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "log/slog" + "runtime" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SystemPromptEditorOptions configure a SystemPromptEditor. +type SystemPromptEditorOptions struct { + // Default is what Restore default puts back. + Default string + // Saved is the text last saved; differences show as unsaved. + Saved string + // MaxLength limits characters, blocking Save beyond it; zero is none. + MaxLength int + // Saving shows the caller's save in progress and blocks editing. + Saving bool + Disabled bool +} + +// SystemPromptEditorView is a built editor and its requests. +type SystemPromptEditorView struct { + Element *ui.Element + // Save asks to save *value; Restored reports the default returned. + Save, Restored bool +} + +// SystemPromptEditor edits a system prompt with count, restore and save. +func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOptions) SystemPromptEditorView { + if value == nil { + panic("mujica: SystemPromptEditor needs a value") + } + if opts.MaxLength < 0 { + panic("mujica: SystemPromptEditor MaxLength is negative") + } + k := Tokens(c) + d := Density(c) + blocked := opts.Disabled || opts.Saving + col := ui.Column(c).Gap(d.Unit() * 2).Disabled(blocked) + v := SystemPromptEditorView{Element: col} + dirty := *value != opts.Saved + over := opts.MaxLength > 0 && utf8.RuneCountInString(*value) > opts.MaxLength + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "system_prompt_editor.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1) + if dirty { + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() { + ui.Box(c).Size(8, 8).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.Warning) + }) + ui.Text(c, msg(c, "system_prompt_editor.unsaved")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Warning) + }) + } + }) + TextArea(c, value, TextAreaOptions{Label: msg(c, "system_prompt_editor.title"), MinHeight: 120, MaxHeight: 320, + MaxLength: opts.MaxLength, Disabled: blocked}) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + // With MaxLength the TextArea shows "n / max" itself. + count := "" + if opts.MaxLength == 0 { + count = fmt.Sprintf(msg(c, "system_prompt_editor.count"), utf8.RuneCountInString(*value)) + } + ui.Text(c, count).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1) + if Button(c, msg(c, "system_prompt_editor.restore"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"), + Disabled: blocked || *value == opts.Default}).Clicked() && !col.IsDisabled() { + *value, v.Restored = opts.Default, true + } + v.Save = Button(c, msg(c, "system_prompt_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: opts.Disabled || !dirty || over}).Clicked() + }) + }) + mods := ui.Ctrl + if runtime.GOOS == "darwin" { + mods = ui.Cmd + } + if col.Shortcut(mods, ui.KeyS) && dirty && !over { + v.Save = true + } + if col.IsDisabled() { + v.Save = false + } + switch { + case v.Save: + slog.Debug("mujica: system_prompt_editor save", "len", len(*value)) + case v.Restored: + slog.Debug("mujica: system_prompt_editor restore") + } + return v +} diff --git a/system_prompt_editor_test.go b/system_prompt_editor_test.go new file mode 100644 index 0000000..ace4f4b --- /dev/null +++ b/system_prompt_editor_test.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSystemPromptEditorEditSaveRestore(t *testing.T) { + value, saved := "Be brief.", "Be brief." + saves, restores := 0, 0 + tt := chatTester(520, 360, func(c *ui.Context) { + v := SystemPromptEditor(c, &value, SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}) + if v.Save { + saves++ + saved = value + } + if v.Restored { + restores++ + } + }) + if tt.HasText("Unsaved changes") || !tt.HasText("9 characters") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + tt.Type(" Be kind.") + if !tt.HasText("Unsaved changes") || !tt.HasText("18 characters") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(chatMod(), ui.KeyS) + if saves != 1 || saved != "Be brief. Be kind." || tt.HasText("Unsaved changes") { + t.Fatalf("saves %d saved %q", saves, saved) + } + _ = tt.Click("Restore default") + if value != "You are helpful." || restores != 1 || !tt.HasText("Unsaved changes") { + t.Fatalf("restore gave %q", value) + } + _ = tt.Click("Save") + if saves != 2 { + t.Fatalf("saves %d", saves) + } +} + +func TestSystemPromptEditorLimitAndDisabled(t *testing.T) { + value, saves := "abc", 0 + tt := chatTester(520, 360, func(c *ui.Context) { + if SystemPromptEditor(c, &value, SystemPromptEditorOptions{MaxLength: 5}).Save { + saves++ + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + tt.Type("defgh") + tt.Key(chatMod(), ui.KeyS) + _ = tt.Click("Save") + if saves != 0 || !tt.HasText("3 characters over the limit") { + t.Fatalf("over the limit: saves %d, texts %q", saves, tt.Texts()) + } + value = "abcde" + tt = chatTester(520, 360, func(c *ui.Context) { + if SystemPromptEditor(c, &value, SystemPromptEditorOptions{Saved: "x", Disabled: true}).Save { + saves++ + } + }) + _ = tt.Click("Save") + tt.Key(chatMod(), ui.KeyS) + _ = tt.Click("Restore default") + if saves != 0 || value != "abcde" { + t.Fatalf("disabled editor acted: %d %q", saves, value) + } +} + +func TestSystemPromptEditorSnapshot(t *testing.T) { + snapshot(t, "system_prompt_editor", 520, 280, func(c *ui.Context) { + v := "You are a courtly assistant." + ui.Column(c).Padding(16).Children(func() { + SystemPromptEditor(c, &v, SystemPromptEditorOptions{Default: "You are helpful.", Saved: "x", MaxLength: 2000}) + }) + }) +} diff --git a/table_block.go b/table_block.go new file mode 100644 index 0000000..55ccfc7 --- /dev/null +++ b/table_block.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TableBlockOptions configure a TableBlock. +type TableBlockOptions struct { + // Align sets each column's alignment: ui.Start, ui.Center or ui.End; missing columns start. + Align []ui.Align +} + +// TableBlock shows a small table inside a message; it scrolls sideways and copies as TSV. +func TableBlock(c *ui.Context, header []string, rows [][]string, opts TableBlockOptions) CopyResult { + n := len(header) + if n == 0 { + panic("mujica: TableBlock needs a header") + } + if len(opts.Align) > n { + panic("mujica: TableBlock has more alignments than columns") + } + for _, row := range rows { + if len(row) != n { + panic("mujica: TableBlock row width differs from the header") + } + } + 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) + copiedAt := ui.Local(r.Element, "copied", func() time.Time { return time.Time{} }) + r.Element.Children(func() { + ui.Row(c).Padding(0, u, 0, u*3).AlignItems(ui.Center).Background(k.SurfaceHover).Children(func() { + ui.Text(c, msg(c, "table_block.title")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1) + icon, name := "copy", msg(c, "table_block.copy") + if left := codeBlockCopiedFor - c.Now().Sub(*copiedAt); left > 0 { + icon, name = "check", msg(c, "code_block.copied") + c.After(left) + } + if iconAction(c, icons.Must(icon), name).Clicked() && !r.Element.IsDisabled() { + c.WriteClipboard(tableBlockTSV(header, rows)) + *copiedAt, r.copied = c.Now(), true + c.Announce(msg(c, "code_block.copied")) + slog.Debug("mujica: table_block copy", "rows", len(rows)) + } + }) + tracks := make([]ui.Track, n) + for i := range tracks { + tracks[i] = ui.FitContent() + } + sc := ui.ScrollHorizontal(c).AlignItems(ui.Start) + // Rows span the block: the grid is at least last frame's scroll width. + w := sc.Bounds().W + internal.Remeasure(c, sc, w, sc.Bounds().H) + sc.Children(func() { + ui.Grid(c).ColumnTracks(tracks...).MinWidth(w).Children(func() { + for i, h := range header { + tableBlockCell(c, h, tableBlockAlign(opts.Align, i), true, false) + } + for ri, row := range rows { + for i, cell := range row { + tableBlockCell(c, cell, tableBlockAlign(opts.Align, i), false, ri == len(rows)-1) + } + } + }) + }) + }) + return r +} + +func tableBlockAlign(a []ui.Align, i int) ui.Align { + if i < len(a) { + return a[i] + } + return ui.Start +} + +func tableBlockCell(c *ui.Context, s string, align ui.Align, head, last bool) { + k := Tokens(c) + u := Density(c).Unit() + cell := ui.Box(c).Padding(u*2, u*3).Role(ui.RoleCell) + if !last { + cell.BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) + } + if head { + cell.Role(ui.RoleColumnHeader) + } + cell.Children(func() { + t := ui.Text(c, tableBlockClean.Replace(s)).NoWrap().TextAlign(align) + if head { + t.FontWeight(600) + } + }) +} + +// tableBlockClean turns a cell's tabs and newlines into spaces. +var tableBlockClean = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ") + +// tableBlockTSV joins cells with tabs and rows with newlines. +func tableBlockTSV(header []string, rows [][]string) string { + clean := tableBlockClean + var b strings.Builder + line := func(cells []string) { + for i, s := range cells { + if i > 0 { + b.WriteByte('\t') + } + b.WriteString(clean.Replace(s)) + } + b.WriteByte('\n') + } + line(header) + for _, row := range rows { + line(row) + } + return b.String() +} diff --git a/table_block_test.go b/table_block_test.go new file mode 100644 index 0000000..c5bc804 --- /dev/null +++ b/table_block_test.go @@ -0,0 +1,58 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var ( + tableBlockHeader = []string{"Guest", "Title", "Seat"} + tableBlockRows = [][]string{{"Marie", "Queen", "1"}, {"Louis", "King", "2"}, {"Axel", "Count\tof Fersen", "14"}} +) + +func TestTableBlockCopiesTSVAndScrolls(t *testing.T) { + copies := 0 + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(160).Children(func() { + r := TableBlock(c, tableBlockHeader, tableBlockRows, TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}}) + w = r.Element.Bounds().W + if r.Copied() { + copies++ + } + }) + }, 400, 300) + tt.Frame() + if w > 160 { + t.Fatalf("table widened its message to %v", w) + } + if err := tt.Click("Copy as TSV"); err != nil { + t.Fatal(err) + } + want := "Guest\tTitle\tSeat\nMarie\tQueen\t1\nLouis\tKing\t2\nAxel\tCount of Fersen\t14\n" + if tt.Clipboard() != want || copies != 1 { + t.Fatalf("copies %d clipboard %q", copies, tt.Clipboard()) + } +} + +func TestTableBlockBadRows(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a short row") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TableBlock(c, []string{"a", "b"}, [][]string{{"1"}}, TableBlockOptions{}) + }, 100, 100) +} + +func TestTableBlockSnapshot(t *testing.T) { + snapshot(t, "table_block", 400, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + TableBlock(c, tableBlockHeader, tableBlockRows, TableBlockOptions{Align: []ui.Align{ui.Start, ui.Start, ui.End}}) + }) + }) +} diff --git a/tag_list.go b/tag_list.go new file mode 100644 index 0000000..7e14756 --- /dev/null +++ b/tag_list.go @@ -0,0 +1,50 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// GitTag is a tag and the commit it names. +type GitTag struct { + Name string + Target string + // Annotation is the tag message; empty for a lightweight tag. + Annotation string +} + +// TagListOptions configure a TagList. +type TagListOptions struct { + Status DataStatus + Error string +} + +// TagList shows tags; Action "jump" carries the target hash. +func TagList(c *ui.Context, s *ListState[string], tags []GitTag, opts TagListOptions) GitListResult { + var r GitListResult + k := Tokens(c) + v := List(c, s, len(tags), ListOptions[string]{ + Key: func(i int) string { return tagListKey(tags[i]) }, + Label: func(i int) string { return tags[i].Name }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "tag_list.empty"), + }, func(i int) { + t := tags[i] + small := FontSize(c, theme.CaptionSize) + ui.Icon(c, icons.Must("tag")).Size(14, 14).Shrink(0).TextColor(k.TextMuted) + ui.Text(c, t.Name).FontWeight(600).SingleLine().Shrink(0).MaxWidth(200) + ui.Text(c, t.Annotation).FontSize(small).TextColor(k.TextMuted).SingleLine().Grow(1).MinWidth(0) + ui.Text(c, gitShortHash(t.Target)).Font(theme.MonoFont).FontSize(small).TextColor(k.AccentText).Shrink(0) + gitRowActions(c, []GitAction{{ID: "jump", Label: msg(c, "tag_list.jump"), Icon: "git-commit-horizontal"}}, t.Target, &r) + }) + r.Element, r.changed, r.submitted = v.Element, v.Changed(), v.Submitted() + return r +} + +func tagListKey(t GitTag) string { + if t.Name == "" || t.Target == "" { + panic("mujica: GitTag needs a Name and a Target") + } + return t.Name +} diff --git a/tag_list_test.go b/tag_list_test.go new file mode 100644 index 0000000..5f4ada8 --- /dev/null +++ b/tag_list_test.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func tagSample() []GitTag { + return []GitTag{ + {Name: "v1.1.0", Target: gitTestHash(42), Annotation: "Five hundred components"}, + {Name: "v1.0.0", Target: gitTestHash(7)}, + } +} + +func TestTagListJumpAndSelect(t *testing.T) { + var s ListState[string] + jump := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := TagList(c, &s, tagSample(), TagListOptions{}) + r.Element.Height(200) + if a, item, ok := r.Action(); ok && a == "jump" { + jump = item + } + }, 600, 240) + if !tt.HasText("Five hundred components") || !tt.HasText(gitShortHash(gitTestHash(42))) { + t.Fatalf("row: %q", tt.Texts()) + } + if err := tt.Click("v1.0.0"); err != nil { + t.Fatal(err) + } + if key, _ := s.Selected(); key != "v1.0.0" { + t.Fatalf("selected %q", key) + } + if err := tt.Click("Go to commit"); err != nil { + t.Fatal(err) + } + if jump != gitTestHash(42) { + t.Fatalf("jump %q", jump) + } +} + +func TestTagListInvalid(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] + TagList(c, &s, []GitTag{{Name: "v1"}}, TagListOptions{}).Element.Height(100) + }, 300, 120, 1) +} + +func TestTagListSnapshot(t *testing.T) { + snapshot(t, "tag_list", 600, 140, func(c *ui.Context) { + s := ui.Local(c.Root(), "list", func() *ListState[string] { return &ListState[string]{} }) + ui.Column(c).Padding(16).Children(func() { + TagList(c, *s, tagSample(), TagListOptions{}).Element.Height(100) + }) + }) +} diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..deee353 --- /dev/null +++ b/terminal.go @@ -0,0 +1,308 @@ +package mujica + +import ( + "log/slog" + "math" + "runtime" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TerminalOptions configure a Terminal. +type TerminalOptions struct { + // Input receives what the user types, encoded for the PTY. + Input func(data string) + // Resized reports a new grid size for the PTY. + Resized func(cols, rows int) + // Scrollback caps lines kept above; zero keeps 1000. + Scrollback int + Label string + Disabled bool +} + +// terminalPad is the space around the grid in DIPs. +const terminalPad float32 = 6 + +// terminalCtrl maps letter keys to their Ctrl codes. +var terminalCtrl = map[ui.Key]byte{ui.KeyA: 1, ui.KeyB: 2, ui.KeyC: 3, ui.KeyD: 4, ui.KeyE: 5, ui.KeyF: 6, ui.KeyG: 7, + ui.KeyH: 8, ui.KeyI: 9, ui.KeyJ: 10, ui.KeyK: 11, ui.KeyL: 12, ui.KeyM: 13, ui.KeyN: 14, ui.KeyO: 15, ui.KeyP: 16, + ui.KeyQ: 17, ui.KeyR: 18, ui.KeyS: 19, ui.KeyT: 20, ui.KeyU: 21, ui.KeyV: 22, ui.KeyW: 23, ui.KeyX: 24, ui.KeyY: 25, ui.KeyZ: 26} + +// terminalKeys are the sequences of keys that type no text. +var terminalKeys = map[ui.Key]string{ui.KeyEnter: "\r", ui.KeyBackspace: "\x7f", ui.KeyTab: "\t", ui.KeyEscape: "\x1b", + ui.KeyUp: "\x1b[A", ui.KeyDown: "\x1b[B", ui.KeyRight: "\x1b[C", ui.KeyLeft: "\x1b[D", ui.KeyHome: "\x1b[H", ui.KeyEnd: "\x1b[F", + ui.KeyDelete: "\x1b[3~", ui.KeyPageUp: "\x1b[5~", ui.KeyPageDown: "\x1b[6~"} + +// terminalCell size and baseline for the code font. +func terminalMetrics(f ui.Font) (cw, ch, base float32) { + m := f.Metrics() + g := ui.Shape("M", f) + cw = f.Size * 0.6 + if len(g) > 0 { + cw = g[0].Advance + } + ch = float32(math.Ceil(float64((m.Ascent + m.Descent + m.LineGap) * 1.15))) + return cw, ch, (ch-m.Ascent-m.Descent)/2 + m.Ascent +} + +// Terminal draws a TerminalState and sends input to the PTY. +func Terminal(c *ui.Context, s *TerminalState, opts TerminalOptions) *ui.Element { + if s == nil { + panic("mujica: Terminal needs a TerminalState") + } + if opts.Scrollback < 0 { + panic("mujica: Terminal Scrollback is negative") + } + s.init() + if opts.Scrollback > 0 { + s.limit = opts.Scrollback + } + k := Tokens(c) + font := codeFont(c) + cw, ch, base := terminalMetrics(font) + label := opts.Label + if label == "" { + label = msg(c, "terminal.label") + } + e := ui.Box(c).Focusable().Cursor(ui.CursorText).Clip().Label(label).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Disabled(opts.Disabled).MinHeight(ch * 4) + b := e.Bounds() + if b.W > 0 { + cols := max(int((b.W-2*terminalPad)/cw), 1) + rows := max(int((b.H-2*terminalPad)/ch), 1) + if cols != s.cols || rows != s.rows { + s.Resize(cols, rows) + slog.Debug("mujica: terminal resize", "cols", cols, "rows", rows) + if opts.Resized != nil { + opts.Resized(cols, rows) + } + } + } + send := func(data string) { + s.offset, s.selOn, s.preedit = 0, false, "" + if opts.Input != nil { + opts.Input(data) + } + } + cell := func(x, y float32) TerminalPos { + col := max(0, min(int((x-terminalPad)/cw), s.cols-1)) + row := max(0, min(int((y-terminalPad)/ch), s.rows-1)) + return TerminalPos{Line: len(s.back) - s.offset + row, Col: col} + } + anchor := ui.Local(e, "terminal.anchor", func() TerminalPos { return TerminalPos{} }) + e.HandleInput(func(ev ui.InputEvent) bool { + if e.IsDisabled() { + return false + } + switch ev.Kind { + case ui.InputKeyDown: + return terminalKey(c, s, ev, send) + case ui.InputText: + send(ev.Text) + return true + case ui.InputCompose: + s.preedit = ev.Text + return true + case ui.InputScroll: + s.scrolled -= ev.DY / ch + n := int(s.scrolled) + s.scrolled -= float32(n) + s.ScrollBack(n) + return true + case ui.InputPointerDown: + if ev.Button != 0 { + return false + } + e.Focus() + *anchor = cell(ev.X, ev.Y) + s.selOn = false + return true + case ui.InputPointerMove: + if e.Pressed() { + s.Select(*anchor, cell(ev.X, ev.Y)) + } + return true + } + return false + }) + cy := float32(s.cy+s.offset) * ch + e.TextCaret(ui.Rect{X: terminalPad + float32(s.cx)*cw, Y: terminalPad + cy, W: cw, H: ch}) + focused := e.Focused() + internal.Ring(e, k.Focus, theme.ControlRadius) + e.Draw(func(p *ui.Painter, r ui.Rect) { + if r.W != b.W || r.H != b.H { + // Laid out at another size: rebuild to fit the grid. + c.Invalidate() + } + terminalPaint(p, r, s, k, font, cw, ch, base, focused) + }) + return e +} + +// terminalKey encodes a key, handling copy, paste and scrollback. +func terminalKey(c *ui.Context, s *TerminalState, ev ui.InputEvent, send func(string)) bool { + clip := ui.Super + if runtime.GOOS != "darwin" { + clip = ui.Ctrl | ui.Shift + } + switch { + case ev.Mods == clip && ev.Key == ui.KeyC: + if t := s.Selection(); t != "" { + c.WriteClipboard(t) + slog.Debug("mujica: terminal copy", "runes", len([]rune(t))) + } + return true + case ev.Mods == clip && ev.Key == ui.KeyV: + if t := c.ReadClipboard(); t != "" { + send(t) + } + return true + case ev.Mods == ui.Shift && (ev.Key == ui.KeyPageUp || ev.Key == ui.KeyPageDown): + n := s.rows - 1 + if ev.Key == ui.KeyPageDown { + n = -n + } + s.ScrollBack(n) + return true + case ev.Mods == ui.Ctrl && terminalCtrl[ev.Key] != 0: + send(string([]byte{terminalCtrl[ev.Key]})) + return true + } + if seq, ok := terminalKeys[ev.Key]; ok && ev.Mods&^ui.Shift == 0 { + if ev.Key == ui.KeyTab && ev.Mods == ui.Shift { + seq = "\x1b[Z" + } + send(seq) + return true + } + return false +} + +// terminalPaint draws the visible rows, marks and cursor. +func terminalPaint(p *ui.Painter, r ui.Rect, s *TerminalState, k theme.Tokens, font ui.Font, cw, ch, base float32, focused bool) { + ox, oy := r.X+terminalPad, r.Y+terminalPad + top := len(s.back) - s.offset + for row := range s.rows { + li := top + row + cells := s.line(li) + y := oy + float32(row)*ch + for col := 0; col < len(cells); { + st := cells[col].st + end := col + 1 + for end < len(cells) && cells[end].st == st { + end++ + } + fg, bg := codeAnsiColor(k, st.FG), codeAnsiColor(k, st.BG) + if fg.A == 0 { + fg = k.Text + } + if st.Inverse { + if bg.A == 0 { + bg = k.Surface + } + fg, bg = bg, fg + } + if st.Dim { + fg = fg.Alpha(0.6) + } + x0 := ox + float32(col)*cw + if bg.A > 0 { + p.Fill(ui.Rect{X: x0, Y: y, W: float32(end-col) * cw, H: ch}, bg, 0) + } + for i := col; i < end; i++ { + if s.selected(TerminalPos{li, i}) { + p.Fill(ui.Rect{X: ox + float32(i)*cw, Y: y, W: cw, H: ch}, k.Selection, 0) + } + } + terminalRun(p, s, cells[col:end], st, font, x0, y+base, cw, fg) + if st.Underline || st.Strike { + ly := y + base + 2 + if st.Strike { + ly = y + ch/2 + } + codeRule(p, x0, x0+float32(end-col)*cw, ly, fg) + } + col = end + } + for mi, m := range s.matches { + if m.pos.Line != li { + continue + } + mr := ui.Rect{X: ox + float32(m.pos.Col)*cw, Y: y, W: float32(m.n) * cw, H: ch} + codeRule(p, mr.X, mr.X+mr.W, y+ch-1, k.AccentText) + if mi == s.current { + p.Stroke(mr, k.AccentText, 1, 1.5) + } + } + } + if s.offset > 0 || s.cy >= s.rows { + return + } + cr := ui.Rect{X: ox + float32(s.cx)*cw, Y: oy + float32(s.cy)*ch, W: cw, H: ch} + if s.cx >= s.cols { + cr.X = ox + float32(s.cols-1)*cw + } + if s.preedit != "" { + g := terminalShape(s, s.preedit, font) + p.Glyphs(g, cr.X, cr.Y+base, k.AccentText) + codeRule(p, cr.X, cr.X+float32(len([]rune(s.preedit)))*cw, cr.Y+ch-1, k.AccentText) + return + } + if !focused { + p.Stroke(cr, k.AccentText, 0, 1) + return + } + p.Fill(cr, k.AccentText, 0) + if c := min(s.cx, s.cols-1); s.screen[s.cy][c].r > ' ' { + p.Glyphs(terminalShape(s, string(s.screen[s.cy][c].r), font), cr.X, cr.Y+base, k.Surface) + } +} + +// terminalRun draws same-style cells, one glyph per cell. +func terminalRun(p *ui.Painter, s *TerminalState, cells []terminalCell, st internal.AnsiStyle, font ui.Font, x, y, cw float32, fg ui.Color) { + runes := make([]rune, len(cells)) + blank := true + for i, cell := range cells { + runes[i] = cell.r + if cell.r == 0 { + runes[i] = ' ' + } else if cell.r != ' ' { + blank = false + } + } + if blank { + return + } + if st.Bold { + font.Weight = 700 + } + font.Italic = st.Italic + p.Glyphs(terminalShape(s, string(runes), font), x, y, fg) +} + +// terminalShape shapes text into cells, cached across frames. +func terminalShape(s *TerminalState, text string, f ui.Font) []ui.Glyph { + key := string(rune(f.Weight)) + string(rune(int(f.Size*4))) + text + if f.Italic { + key = "i" + key + } + if g, ok := s.shaped[key]; ok { + return g + } + if s.shaped == nil || len(s.shaped) > 4096 { + s.shaped = map[string][]ui.Glyph{} + } + g := ui.Shape(text, f) + cw := f.Size * 0.6 + if m := ui.Shape("M", ui.Font{Family: f.Family, Size: f.Size}); len(m) > 0 { + cw = m[0].Advance + } + for i := range g { + g[i].X = float32(g[i].Cluster) * cw + } + s.shaped[key] = g + return g +} diff --git a/terminal_search.go b/terminal_search.go new file mode 100644 index 0000000..c7bba0a --- /dev/null +++ b/terminal_search.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" +) + +// TerminalSearchState is a search through a terminal's scrollback and screen. +type TerminalSearchState struct { + Query string + // Current is the chosen match, from 0. + Current int + query string + version int + ran bool +} + +// TerminalSearchOptions configure a TerminalSearch. +type TerminalSearchOptions struct { + Disabled bool +} + +// TerminalSearch finds Query in term and shows the current match. +func TerminalSearch(c *ui.Context, term *TerminalState, s *TerminalSearchState, opts TerminalSearchOptions) ChoiceResult { + if term == nil || s == nil { + panic("mujica: TerminalSearch needs a terminal and a state") + } + term.init() + fresh := terminalSearchRun(term, s) + r := SearchInput(c, &s.Query, SearchInputOptions{Find: true, Matches: len(term.matches), Current: &s.Current, + Disabled: opts.Disabled, Label: msg(c, "terminal_search.label")}) + var res ChoiceResult + res.Element = r.Element + if r.Changed() { + s.Current = 0 + terminalSearchRun(term, s) + res.changed = true + } + if r.Moved() { + res.changed = true + } + if res.changed || fresh { + term.current = s.Current + if s.Current < len(term.matches) { + term.reveal(term.matches[s.Current].pos.Line) + } + slog.Debug("mujica: terminal_search move", "matches", len(term.matches), "current", s.Current) + } + return res +} + +// terminalSearchRun refinds matches; it reports a new query. +func terminalSearchRun(term *TerminalState, s *TerminalSearchState) bool { + if s.ran && s.query == s.Query && s.version == term.version { + return false + } + fresh := !s.ran || s.query != s.Query + s.ran, s.query, s.version = true, s.Query, term.version + term.matches = term.matches[:0] + q := []rune(strings.ToLower(s.Query)) + if len(q) > 0 { + for i := range term.Lines() { + line := []rune(strings.ToLower(term.LineText(i))) + for col := 0; col+len(q) <= len(line); col++ { + if string(line[col:col+len(q)]) == string(q) { + term.matches = append(term.matches, terminalMatch{TerminalPos{i, col}, len(q)}) + col += len(q) - 1 + } + } + } + } + s.Current = max(0, min(s.Current, len(term.matches)-1)) + term.current = s.Current + return fresh +} diff --git a/terminal_search_test.go b/terminal_search_test.go new file mode 100644 index 0000000..584e293 --- /dev/null +++ b/terminal_search_test.go @@ -0,0 +1,105 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func terminalSearchTester(term *TerminalState, s *TerminalSearchState, opts TerminalSearchOptions, moves *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + if TerminalSearch(c, term, s, opts).Changed() { + *moves++ + } + Terminal(c, term, TerminalOptions{}).Size(400, 120) + }) + }, 420, 220) +} + +func terminalSearchFill() *TerminalState { + term := &TerminalState{} + term.Resize(40, 5) + for i := range 30 { + if i%10 == 3 { + term.Write(fmt.Sprintf("step %d: Error found\r\n", i)) + } else { + term.Write(fmt.Sprintf("step %d: ok\r\n", i)) + } + } + return term +} + +func TestTerminalSearchCountsAndJumps(t *testing.T) { + term := terminalSearchFill() + s := &TerminalSearchState{} + moves := 0 + tt := terminalSearchTester(term, s, TerminalSearchOptions{}, &moves) + tt.Key(0, ui.KeyTab) + tt.Type("error") + if len(term.matches) != 3 || !tt.HasText("1 of 3") { + t.Fatalf("matches %d, texts %q", len(term.matches), tt.Texts()) + } + first := term.matches[0].pos.Line + top := len(term.back) - term.Offset() + if first < top || first >= top+5 { + t.Fatalf("match line %d not on screen from %d", first, top) + } + tt.Key(0, ui.KeyEnter) + if s.Current != 1 || !tt.HasText("2 of 3") { + t.Fatalf("Enter moved to %d", s.Current) + } + tt.Key(ui.Shift, ui.KeyEnter) + tt.Key(ui.Shift, ui.KeyEnter) + if s.Current != 2 { + t.Fatalf("Shift+Enter twice from 1 gave %d, want 2 (wraps)", s.Current) + } + if line := term.matches[2].pos.Line; line < len(term.back)-term.Offset() { + t.Fatalf("current match line %d above the view", line) + } + if moves < 4 { + t.Fatalf("Changed reported %d times", moves) + } +} + +func TestTerminalSearchFollowsNewOutput(t *testing.T) { + term := terminalSearchFill() + s := &TerminalSearchState{Query: "error"} + moves := 0 + tt := terminalSearchTester(term, s, TerminalSearchOptions{}, &moves) + term.Write("late error\r\n") + tt.Frame() + if len(term.matches) != 4 { + t.Fatalf("%d matches after new output, want 4", len(term.matches)) + } +} + +func TestTerminalSearchNoMatchAndDisabled(t *testing.T) { + term := terminalSearchFill() + s := &TerminalSearchState{Query: "zebra"} + moves := 0 + tt := terminalSearchTester(term, s, TerminalSearchOptions{Disabled: true}, &moves) + if !tt.HasText("No matches") || len(term.matches) != 0 { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Type("ok") + if s.Query != "zebra" || moves != 0 { + t.Fatalf("disabled search changed to %q", s.Query) + } +} + +func TestTerminalSearchSnapshot(t *testing.T) { + snapshot(t, "terminal_search", 420, 170, func(c *ui.Context) { + st := ui.Local(c.Root(), "snap", func() [2]any { + return [2]any{terminalSearchFill(), &TerminalSearchState{Query: "error", Current: 1}} + }) + term, s := (*st)[0].(*TerminalState), (*st)[1].(*TerminalSearchState) + ui.Column(c).Padding(12).Gap(8).Children(func() { + TerminalSearch(c, term, s, TerminalSearchOptions{}) + Terminal(c, term, TerminalOptions{}).Height(96) + }) + }) +} diff --git a/terminal_state.go b/terminal_state.go new file mode 100644 index 0000000..e3930fb --- /dev/null +++ b/terminal_state.go @@ -0,0 +1,361 @@ +package mujica + +import ( + "log/slog" + "strconv" + "strings" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +// terminalCell is one grid character; zero is blank. +type terminalCell struct { + r rune + st internal.AnsiStyle +} + +// TerminalPos is a cell; Line counts from the oldest line. +type TerminalPos struct{ Line, Col int } + +func (a TerminalPos) before(b TerminalPos) bool { + return a.Line < b.Line || a.Line == b.Line && a.Col < b.Col +} + +// TerminalState is the screen; zero is an empty 80×24 grid. +type TerminalState struct { + cols, rows int + screen [][]terminalCell + back [][]terminalCell + cx, cy int + style internal.AnsiStyle + pending string + limit int + // offset is how far the view scrolled back. + offset int + scrolled float32 + sel [2]TerminalPos + selOn bool + matches []terminalMatch + current int + version int + shaped map[string][]ui.Glyph + preedit string +} + +type terminalMatch struct { + pos TerminalPos + n int +} + +func (s *TerminalState) init() { + if s.screen == nil { + s.Resize(80, 24) + } + if s.limit == 0 { + s.limit = 1000 + } +} + +// Size returns the grid's columns and rows. +func (s *TerminalState) Size() (cols, rows int) { + s.init() + return s.cols, s.rows +} + +// Cursor returns the cursor's column and row on the screen. +func (s *TerminalState) Cursor() (col, row int) { return s.cx, s.cy } + +// Resize sets the grid size, keeping the cursor in view. +func (s *TerminalState) Resize(cols, rows int) { + if cols < 1 || rows < 1 { + panic("mujica: TerminalState size must be at least 1×1") + } + if s.limit == 0 { + s.limit = 1000 + } + old := s.screen + // Lines above a falling-off cursor go to the scrollback. + for drop := s.cy - (rows - 1); drop > 0 && len(old) > 0; drop-- { + s.pushBack(old[0]) + old = old[1:] + s.cy-- + if s.offset > 0 { + s.offset++ + } + } + s.screen = make([][]terminalCell, rows) + for y := range rows { + s.screen[y] = make([]terminalCell, cols) + if y < len(old) { + copy(s.screen[y], old[y]) + } + } + s.cols, s.rows = cols, rows + s.cx, s.cy = min(s.cx, cols-1), min(max(s.cy, 0), rows-1) + s.version++ +} + +// Write feeds PTY output; call it on the UI thread. +func (s *TerminalState) Write(data string) { + s.init() + data = s.pending + data + s.pending = "" + for i := 0; i < len(data); { + b := data[i] + switch { + case b == 0x1b: + n, ok := s.escape(data[i:]) + if !ok { + s.pending = data[i:] + s.version++ + return + } + i += n + case b == '\r': + s.cx = 0 + i++ + case b == '\n': + s.lineFeed() + i++ + case b == '\b': + s.cx = max(s.cx-1, 0) + i++ + case b == '\t': + s.cx = min((s.cx/8+1)*8, s.cols-1) + i++ + case b < 0x20 || b == 0x7f: + i++ + default: + r, n := utf8.DecodeRuneInString(data[i:]) + s.put(r) + i += n + } + } + s.version++ +} + +func (s *TerminalState) put(r rune) { + if s.cx >= s.cols { + s.cx = 0 + s.lineFeed() + } + s.screen[s.cy][s.cx] = terminalCell{r, s.style} + s.cx++ +} + +func (s *TerminalState) lineFeed() { + if s.cy < s.rows-1 { + s.cy++ + return + } + s.pushBack(s.screen[0]) + copy(s.screen, s.screen[1:]) + s.screen[s.rows-1] = make([]terminalCell, s.cols) + if s.offset > 0 { + s.offset = min(s.offset+1, len(s.back)) + } +} + +func (s *TerminalState) pushBack(line []terminalCell) { + s.back = append(s.back, line) + if over := len(s.back) - s.limit; over > 0 { + s.back = s.back[over:] + s.offset = min(s.offset, len(s.back)) + s.selOn = false + } +} + +// escape runs the sequence at d; false means incomplete. +func (s *TerminalState) escape(d string) (int, bool) { + if len(d) < 2 { + return 0, false + } + switch d[1] { + case '[': + case ']': + // OSC ends with BEL or ST; nothing to draw. + if end := strings.IndexAny(d, "\a"); end >= 0 { + return end + 1, true + } + if end := strings.Index(d, "\x1b\\"); end >= 0 { + return end + 2, true + } + return 0, false + default: + return 2, true + } + j := 2 + for j < len(d) && (d[j] < 0x40 || d[j] > 0x7e) { + j++ + } + if j >= len(d) { + return 0, false + } + params, final := d[2:j], d[j] + if final == 'm' { + _, s.style = internal.ParseAnsi(d[:j+1], s.style) + return j + 1, true + } + s.control(params, final) + return j + 1, true +} + +// control applies a CSI cursor or erase command. +func (s *TerminalState) control(params string, final byte) { + args := strings.Split(params, ";") + arg := func(i, def int) int { + if i >= len(args) || args[i] == "" { + return def + } + v, err := strconv.Atoi(args[i]) + if err != nil { + return def + } + return v + } + switch final { + case 'A': + s.cy = max(s.cy-max(arg(0, 1), 1), 0) + case 'B': + s.cy = min(s.cy+max(arg(0, 1), 1), s.rows-1) + case 'C': + s.cx = min(s.cx+max(arg(0, 1), 1), s.cols-1) + case 'D': + s.cx = max(min(s.cx, s.cols-1)-max(arg(0, 1), 1), 0) + case 'G': + s.cx = min(max(arg(0, 1), 1), s.cols) - 1 + case 'H', 'f': + s.cy = min(max(arg(0, 1), 1), s.rows) - 1 + s.cx = min(max(arg(1, 1), 1), s.cols) - 1 + case 'J': + s.eraseDisplay(arg(0, 0)) + case 'K': + s.eraseLine(s.cy, arg(0, 0)) + default: + slog.Debug("mujica: terminal unsupported sequence", "params", params, "final", string(final)) + } +} + +func (s *TerminalState) eraseLine(y, mode int) { + line := s.screen[y] + from, to := 0, s.cols + switch mode { + case 0: + from = min(s.cx, s.cols) + case 1: + to = min(s.cx+1, s.cols) + } + clear(line[from:to]) +} + +func (s *TerminalState) eraseDisplay(mode int) { + switch mode { + case 0: + s.eraseLine(s.cy, 0) + for y := s.cy + 1; y < s.rows; y++ { + clear(s.screen[y]) + } + case 1: + s.eraseLine(s.cy, 1) + for y := range s.cy { + clear(s.screen[y]) + } + default: + for y := range s.rows { + clear(s.screen[y]) + } + if mode == 3 { + s.back, s.offset = nil, 0 + } + } +} + +// Lines counts scrollback and screen lines. +func (s *TerminalState) Lines() int { + s.init() + return len(s.back) + s.rows +} + +func (s *TerminalState) line(i int) []terminalCell { + if i < len(s.back) { + return s.back[i] + } + return s.screen[i-len(s.back)] +} + +// LineText returns line i without trailing blanks. +func (s *TerminalState) LineText(i int) string { + var b strings.Builder + for _, cell := range s.line(i) { + r := cell.r + if r == 0 { + r = ' ' + } + b.WriteRune(r) + } + return strings.TrimRight(b.String(), " ") +} + +// Select marks the cells from a to b. +func (s *TerminalState) Select(a, b TerminalPos) { + if b.before(a) { + a, b = b, a + } + s.sel, s.selOn = [2]TerminalPos{a, b}, true +} + +// Selection returns the selected text, lines joined by newlines. +func (s *TerminalState) Selection() string { + if !s.selOn { + return "" + } + a, b := s.sel[0], s.sel[1] + var out []string + for y := a.Line; y <= b.Line && y < s.Lines(); y++ { + cells := s.line(y) + from, to := 0, len(cells) + if y == a.Line { + from = min(a.Col, len(cells)) + } + if y == b.Line { + to = min(b.Col+1, len(cells)) + } + var sb strings.Builder + for _, cell := range cells[from:max(to, from)] { + r := cell.r + if r == 0 { + r = ' ' + } + sb.WriteRune(r) + } + out = append(out, strings.TrimRight(sb.String(), " ")) + } + return strings.Join(out, "\n") +} + +func (s *TerminalState) selected(p TerminalPos) bool { + return s.selOn && !p.before(s.sel[0]) && !s.sel[1].before(p) +} + +// ScrollBack moves the view n lines up; negative goes down. +func (s *TerminalState) ScrollBack(n int) { + s.init() + s.offset = max(0, min(s.offset+n, len(s.back))) +} + +// Offset returns how many lines the view is scrolled back. +func (s *TerminalState) Offset() int { return s.offset } + +// reveal scrolls so that line shows on screen. +func (s *TerminalState) reveal(line int) { + top := len(s.back) - s.offset + switch { + case line < top: + s.offset = len(s.back) - line + case line >= top+s.rows: + s.offset = max(0, len(s.back)-(line-s.rows+1)) + } +} diff --git a/terminal_tabs.go b/terminal_tabs.go new file mode 100644 index 0000000..fbc4c87 --- /dev/null +++ b/terminal_tabs.go @@ -0,0 +1,181 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TerminalTab is one terminal of a TerminalTabs. +type TerminalTab struct { + ID, Title string + // Busy asks for confirmation before closing. + Busy bool +} + +// TerminalTabsOptions configure TerminalTabs. +type TerminalTabsOptions struct { + Disabled bool +} + +// TerminalTabsResult is TerminalTabs and what happened in this frame. +type TerminalTabsResult struct { + Element *ui.Element + // Renamed and Closed hold a tab ID. + Changed bool + Renamed string + Closed string +} + +type terminalTabsState struct { + editing, draft, closing string + confirm bool +} + +// TerminalTabs shows renamable, closable terminal tabs. +func TerminalTabs(c *ui.Context, tabs *[]TerminalTab, active *string, opts TerminalTabsOptions, content func(id string)) TerminalTabsResult { + if tabs == nil || active == nil || content == nil { + panic("mujica: TerminalTabs needs tabs, active and content") + } + at := slices.IndexFunc(*tabs, func(t TerminalTab) bool { return t.ID == *active }) + if len(*tabs) > 0 && at < 0 { + panic("mujica: TerminalTabs active is not a tab ID") + } + k := Tokens(c) + u := Density(c).Unit() + var res TerminalTabsResult + res.Element = ui.Column(c).MinHeight(0).Disabled(opts.Disabled) + st := ui.Local(res.Element, "terminal_tabs", func() terminalTabsState { return terminalTabsState{} }) + disabled := res.Element.IsDisabled() + if disabled { + st.editing, st.confirm = "", false + } + remove := func(id string) { + i := slices.IndexFunc(*tabs, func(t TerminalTab) bool { return t.ID == id }) + if i < 0 { + return + } + *tabs = slices.Delete(*tabs, i, i+1) + if *active == id && len(*tabs) > 0 { + *active = (*tabs)[min(i, len(*tabs)-1)].ID + res.Changed = true + } + res.Closed = id + slog.Debug("mujica: terminal_tabs close", "id", id) + } + askClose := func(t TerminalTab) { + if t.Busy { + st.closing, st.confirm = t.ID, true + return + } + remove(t.ID) + } + res.Element.Children(func() { + strip := ui.ScrollHorizontal(c).Shrink(0).MinWidth(0).Padding(theme.FocusGap+theme.FocusWidth, theme.FocusGap+theme.FocusWidth, 0, theme.FocusGap+theme.FocusWidth) + strip.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0) }) + strip.Children(func() { + idx := max(at, 0) + base := ui.TabsBase(c, &idx, len(*tabs)) + base.List.Gap(u).Label(msg(c, "terminal_tabs.label")) + base.List.Children(func() { + for i, t := range *tabs { + rename := func(title string) { + (*tabs)[i].Title = title + res.Renamed = t.ID + slog.Debug("mujica: terminal_tabs rename", "id", t.ID) + } + terminalTabsTab(c, base.Tab(i), t, i == at, st, rename, askClose, disabled) + } + }) + if base.List.Changed() && !disabled { + *active = (*tabs)[idx].ID + res.Changed = true + slog.Debug("mujica: terminal_tabs select", "id", *active) + } + }) + if len(*tabs) == 0 { + codeCaption(c, msg(c, "terminal_tabs.empty")).Padding(u * 4) + return + } + ui.Column(c).Grow(1).MinHeight(0).Padding(u*2, 0, 0, 0).Children(func() { content(*active) }) + }) + if st.confirm { + title := "" + for _, t := range *tabs { + if t.ID == st.closing { + title = t.Title + } + } + r := AlertDialog(c, &st.confirm, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "terminal_tabs.confirm"), title), + Description: msg(c, "terminal_tabs.busy"), ConfirmLabel: msg(c, "terminal_tabs.close")}) + if r.Confirmed && !disabled { + remove(st.closing) + } + } + return res +} + +// terminalTabsTab builds one tab with title and close. +func terminalTabsTab(c *ui.Context, tab *ui.Element, t TerminalTab, on bool, st *terminalTabsState, rename func(string), askClose func(TerminalTab), disabled bool) { + k := Tokens(c) + d := Density(c) + u := d.Unit() + tab.MinHeight(d.ControlHeight()).Padding(0, u, 0, u*3).Gap(u).AlignItems(ui.Center).Radius(theme.ControlRadius, theme.ControlRadius, 0, 0).Label(t.Title) + if t.Busy { + tab.Description(msg(c, "terminal_tabs.running")) + } + fg := k.TextMuted + switch { + case on: + tab.Background(k.Selection).FontWeight(600) + fg = k.Text + case tab.Hovered(): + tab.Background(k.SurfaceHover) + fg = k.Text + } + internal.Ease(tab, Motion(c, theme.StateDuration), fg) + tab.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { + if on { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, k.AccentText, 0) + } + if tab.FocusVisible() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + if !disabled && st.editing == "" && (tab.DoubleClicked() || tab.Shortcut(0, ui.KeyF2)) { + st.editing, st.draft = t.ID, t.Title + } + if !disabled && tab.Shortcut(0, ui.KeyDelete) { + askClose(t) + } + tab.Children(func() { + if st.editing == t.ID { + in := TextInput(c, &st.draft, TextInputOptions{Label: msg(c, "terminal_tabs.rename")}) + in.Element.Width(u * 36) + in.Input.AutoFocus() + switch { + case in.Input.Shortcut(0, ui.KeyEscape): + st.editing = "" + case in.Submitted() && strings.TrimSpace(st.draft) != "": + rename(strings.TrimSpace(st.draft)) + st.editing = "" + tab.Focus() + } + return + } + ui.Text(c, t.Title).SingleLine() + if t.Busy { + ui.Box(c).Size(6, 6).Shrink(0).Role(ui.RoleNone).Background(k.AccentText).Radius(3) + } + if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "terminal_tabs.closeTab"), t.Title)).Clicked() && !disabled { + askClose(t) + } + }) +} diff --git a/terminal_tabs_test.go b/terminal_tabs_test.go new file mode 100644 index 0000000..e3964c9 --- /dev/null +++ b/terminal_tabs_test.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type terminalTabsProbe struct { + tabs []TerminalTab + active string + disabled bool + changed int + renamed, closed []string +} + +func (p *terminalTabsProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := TerminalTabs(c, &p.tabs, &p.active, TerminalTabsOptions{Disabled: p.disabled}, func(id string) { + ui.Text(c, "Content of "+id) + }) + if r.Changed { + p.changed++ + } + if r.Renamed != "" { + p.renamed = append(p.renamed, r.Renamed) + } + if r.Closed != "" { + p.closed = append(p.closed, r.Closed) + } +} + +func newTerminalTabsProbe() *terminalTabsProbe { + return &terminalTabsProbe{tabs: []TerminalTab{{ID: "a", Title: "zsh"}, {ID: "b", Title: "build", Busy: true}, {ID: "c", Title: "logs"}}, active: "a"} +} + +func TestTerminalTabsSwitch(t *testing.T) { + p := newTerminalTabsProbe() + tt := ui.NewTester(p.view, 600, 400) + if err := tt.Click("logs"); err != nil { + t.Fatal(err) + } + if p.active != "c" || !tt.HasText("Content of c") || p.changed != 1 { + t.Fatalf("active %q changed %d", p.active, p.changed) + } + tt.Key(0, ui.KeyLeft) + if p.active != "b" { + t.Fatalf("Left gave %q", p.active) + } +} + +func TestTerminalTabsRename(t *testing.T) { + p := newTerminalTabsProbe() + tt := ui.NewTester(p.view, 600, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyF2) + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("server") + tt.Key(0, ui.KeyEnter) + if p.tabs[0].Title != "server" || len(p.renamed) != 1 || p.renamed[0] != "a" { + t.Fatalf("title %q renamed %v", p.tabs[0].Title, p.renamed) + } + // Escape cancels a rename. + tt.Key(0, ui.KeyF2) + tt.Type("x") + tt.Key(0, ui.KeyEscape) + if p.tabs[0].Title != "server" || !tt.HasText("server") { + t.Fatalf("Escape kept %q", p.tabs[0].Title) + } +} + +func TestTerminalTabsCloseIdleAndBusy(t *testing.T) { + p := newTerminalTabsProbe() + tt := ui.NewTester(p.view, 600, 400) + if err := tt.Click("Close zsh"); err != nil { + t.Fatal(err) + } + if len(p.tabs) != 2 || p.active != "b" || p.closed[0] != "a" { + t.Fatalf("after closing idle: tabs %v active %q", p.tabs, p.active) + } + if err := tt.Click("Close build"); err != nil { + t.Fatal(err) + } + if len(p.tabs) != 2 || !tt.HasText("Close “build”?") { + t.Fatalf("busy tab closed without asking: %v", p.tabs) + } + if err := tt.Click("Cancel"); err != nil { + t.Fatal(err) + } + if len(p.tabs) != 2 { + t.Fatal("Cancel closed the tab") + } + _ = tt.Click("Close build") + if err := tt.Click("Close"); err != nil { + t.Fatal(err) + } + if len(p.tabs) != 1 || p.active != "c" || len(p.closed) != 2 { + t.Fatalf("after confirm: tabs %v active %q closed %v", p.tabs, p.active, p.closed) + } +} + +func TestTerminalTabsDisabled(t *testing.T) { + p := newTerminalTabsProbe() + p.disabled = true + tt := ui.NewTester(p.view, 600, 400) + _ = tt.Click("logs") + _ = tt.Click("Close zsh") + if p.active != "a" || len(p.tabs) != 3 { + t.Fatalf("disabled tabs changed: active %q tabs %d", p.active, len(p.tabs)) + } +} + +func TestTerminalTabsSnapshot(t *testing.T) { + snapshot(t, "terminal_tabs", 460, 120, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", newTerminalTabsProbe) + ui.Column(c).Padding(12).Children(func() { + TerminalTabs(c, &(*p).tabs, &(*p).active, TerminalTabsOptions{}, func(id string) { + codeMono(c, "~/court $ make seal") + }) + }) + }) +} diff --git a/terminal_test.go b/terminal_test.go new file mode 100644 index 0000000..88cad6b --- /dev/null +++ b/terminal_test.go @@ -0,0 +1,192 @@ +package mujica + +import ( + "runtime" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTerminalStateText(t *testing.T) { + var s TerminalState + s.Resize(10, 3) + s.Write("hello\r\nwor") + s.Write("ld\x1b[31m!\x1b[0m") + if got := s.LineText(0); got != "hello" { + t.Fatalf("line 0 %q", got) + } + if got := s.LineText(1); got != "world!" { + t.Fatalf("line 1 %q", got) + } + if st := s.screen[1][5].st; st.FG.Index != 1 || st.FG.Kind == 0 { + t.Fatalf("'!' style %+v, want red", st) + } + if st := s.screen[1][4].st; st.FG.Kind != 0 { + t.Fatalf("'d' style %+v, want default", st) + } +} + +func TestTerminalStateCursorAndErase(t *testing.T) { + var s TerminalState + s.Resize(10, 3) + s.Write("abcdef\x1b[3D") + if x, _ := s.Cursor(); x != 3 { + t.Fatalf("cursor after 3D at %d, want 3", x) + } + s.Write("\x1b[K") + if got := s.LineText(0); got != "abc" { + t.Fatalf("after erase-to-end %q", got) + } + s.Write("\x1b[2;4HX") + if got := s.LineText(1); got != " X" { + t.Fatalf("after CUP %q", got) + } + s.Write("\x1b[2J") + if s.LineText(0) != "" || s.LineText(1) != "" { + t.Fatal("2J left text") + } + // A sequence split across writes completes on the next one. + s.Write("\x1b[") + s.Write("1;1Hz") + if got := s.LineText(0); got != "z" { + t.Fatalf("split CUP gave %q", got) + } +} + +func TestTerminalStateScrollback(t *testing.T) { + var s TerminalState + s.Resize(8, 2) + for _, l := range []string{"one", "two", "three", "four"} { + s.Write(l + "\r\n") + } + if s.Lines() != 5 || s.LineText(0) != "one" || s.LineText(3) != "four" { + t.Fatalf("lines %d: %q %q", s.Lines(), s.LineText(0), s.LineText(3)) + } + s.ScrollBack(10) + if s.Offset() != 3 { + t.Fatalf("offset %d, want the 3 scrollback lines", s.Offset()) + } + s.ScrollBack(-1) + if s.Offset() != 2 { + t.Fatalf("offset %d after scrolling down one", s.Offset()) + } +} + +func TestTerminalStateWrapsLongLines(t *testing.T) { + var s TerminalState + s.Resize(4, 3) + s.Write("abcdefg") + if s.LineText(0) != "abcd" || s.LineText(1) != "efg" { + t.Fatalf("wrapped %q %q", s.LineText(0), s.LineText(1)) + } +} + +func terminalTester(s *TerminalState, opts *TerminalOptions, typed *[]string) *ui.Tester { + opts.Input = func(d string) { *typed = append(*typed, d) } + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Terminal(c, s, *opts).Size(400, 200) + }, 420, 220) +} + +func TestTerminalInputAndResize(t *testing.T) { + var s TerminalState + var typed []string + var size [2]int + opts := TerminalOptions{Resized: func(cols, rows int) { size = [2]int{cols, rows} }} + tt := terminalTester(&s, &opts, &typed) + if size[0] < 30 || size[1] < 5 { + t.Fatalf("resize reported %v for a 400×200 view", size) + } + if cols, rows := s.Size(); cols != size[0] || rows != size[1] { + t.Fatalf("state %d×%d, reported %v", cols, rows, size) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Terminal") { + t.Fatal("Tab did not focus the terminal") + } + tt.Type("ls") + tt.Key(0, ui.KeyEnter) + tt.Key(ui.Ctrl, ui.KeyC) + tt.Key(0, ui.KeyUp) + want := []string{"ls", "\r", "\x03", "\x1b[A"} + if strings.Join(typed, "|") != strings.Join(want, "|") { + t.Fatalf("typed %q, want %q", typed, want) + } +} + +func TestTerminalSelectCopyPaste(t *testing.T) { + var s TerminalState + var typed []string + tt := terminalTester(&s, &TerminalOptions{}, &typed) + s.Write("alpha beta") + tt.Frame() + cw, _, _ := terminalMetrics(ui.Font{Family: "monospace", Size: 13}) + y := float32(terminalPad + 5) + tt.Press(terminalPad+1+cw*0.5, y) + tt.Move(terminalPad+1+cw*4.5, y) + tt.Release(terminalPad+1+cw*4.5, y) + if got := s.Selection(); got != "alpha" { + t.Fatalf("selection %q, want alpha", got) + } + mod := ui.Super + if runtime.GOOS != "darwin" { + mod = ui.Ctrl | ui.Shift + } + tt.Key(mod, ui.KeyC) + if tt.Clipboard() != "alpha" { + t.Fatalf("clipboard %q", tt.Clipboard()) + } + tt.SetClipboard("echo hi") + tt.Key(mod, ui.KeyV) + if len(typed) != 1 || typed[0] != "echo hi" { + t.Fatalf("paste sent %q", typed) + } +} + +func TestTerminalDisabledSendsNothing(t *testing.T) { + var s TerminalState + var typed []string + tt := terminalTester(&s, &TerminalOptions{Disabled: true}, &typed) + tt.Key(0, ui.KeyTab) + tt.Type("x") + tt.Key(0, ui.KeyEnter) + if len(typed) != 0 { + t.Fatalf("disabled terminal sent %q", typed) + } +} + +func TestTerminalScrollbackKeys(t *testing.T) { + var s TerminalState + var typed []string + tt := terminalTester(&s, &TerminalOptions{}, &typed) + for range 40 { + s.Write("line\r\n") + } + tt.Frame() + tt.Key(0, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyPageUp) + if s.Offset() == 0 { + t.Fatal("Shift+PageUp did not scroll back") + } + tt.Type("q") + if s.Offset() != 0 { + t.Fatal("typing did not return to the bottom") + } +} + +func TestTerminalSnapshot(t *testing.T) { + snapshot(t, "terminal", 420, 160, func(c *ui.Context) { + s := ui.Local(c.Root(), "snap", func() *TerminalState { + s := &TerminalState{} + s.Resize(50, 7) + s.Write("\x1b[1m~/court\x1b[0m $ ls\r\n\x1b[34mchronicles\x1b[0m decrees.txt \x1b[32mrun.sh\x1b[0m\r\n") + s.Write("\x1b[33mwarning:\x1b[0m ledger is \x1b[7mlocked\x1b[0m\r\n\x1b[31merror\x1b[0m: missing seal\r\n\x1b[1m~/court\x1b[0m $ ") + return s + }) + ui.Column(c).Padding(12).Children(func() { + Terminal(c, *s, TerminalOptions{}).Height(136) + }) + }) +} diff --git a/testdata/snapshots/darwin/agenda_view-dark.png b/testdata/snapshots/darwin/agenda_view-dark.png new file mode 100644 index 0000000..4cba669 Binary files /dev/null and b/testdata/snapshots/darwin/agenda_view-dark.png differ diff --git a/testdata/snapshots/darwin/agenda_view-light.png b/testdata/snapshots/darwin/agenda_view-light.png new file mode 100644 index 0000000..6d5ab80 Binary files /dev/null and b/testdata/snapshots/darwin/agenda_view-light.png differ diff --git a/testdata/snapshots/darwin/agent_plan-dark.png b/testdata/snapshots/darwin/agent_plan-dark.png new file mode 100644 index 0000000..726c0f6 Binary files /dev/null and b/testdata/snapshots/darwin/agent_plan-dark.png differ diff --git a/testdata/snapshots/darwin/agent_plan-light.png b/testdata/snapshots/darwin/agent_plan-light.png new file mode 100644 index 0000000..79d287e Binary files /dev/null and b/testdata/snapshots/darwin/agent_plan-light.png differ diff --git a/testdata/snapshots/darwin/agent_progress-dark.png b/testdata/snapshots/darwin/agent_progress-dark.png new file mode 100644 index 0000000..32136e9 Binary files /dev/null and b/testdata/snapshots/darwin/agent_progress-dark.png differ diff --git a/testdata/snapshots/darwin/agent_progress-light.png b/testdata/snapshots/darwin/agent_progress-light.png new file mode 100644 index 0000000..c0d2aa4 Binary files /dev/null and b/testdata/snapshots/darwin/agent_progress-light.png differ diff --git a/testdata/snapshots/darwin/agent_status-dark.png b/testdata/snapshots/darwin/agent_status-dark.png new file mode 100644 index 0000000..aac7f1c Binary files /dev/null and b/testdata/snapshots/darwin/agent_status-dark.png differ diff --git a/testdata/snapshots/darwin/agent_status-light.png b/testdata/snapshots/darwin/agent_status-light.png new file mode 100644 index 0000000..753bb61 Binary files /dev/null and b/testdata/snapshots/darwin/agent_status-light.png differ diff --git a/testdata/snapshots/darwin/agent_step_list-dark.png b/testdata/snapshots/darwin/agent_step_list-dark.png new file mode 100644 index 0000000..6c0fbe6 Binary files /dev/null and b/testdata/snapshots/darwin/agent_step_list-dark.png differ diff --git a/testdata/snapshots/darwin/agent_step_list-light.png b/testdata/snapshots/darwin/agent_step_list-light.png new file mode 100644 index 0000000..e4f70fa Binary files /dev/null and b/testdata/snapshots/darwin/agent_step_list-light.png differ diff --git a/testdata/snapshots/darwin/ansi_text-dark.png b/testdata/snapshots/darwin/ansi_text-dark.png new file mode 100644 index 0000000..0f36ef0 Binary files /dev/null and b/testdata/snapshots/darwin/ansi_text-dark.png differ diff --git a/testdata/snapshots/darwin/ansi_text-light.png b/testdata/snapshots/darwin/ansi_text-light.png new file mode 100644 index 0000000..a6c5d94 Binary files /dev/null and b/testdata/snapshots/darwin/ansi_text-light.png differ diff --git a/testdata/snapshots/darwin/area_chart-dark.png b/testdata/snapshots/darwin/area_chart-dark.png new file mode 100644 index 0000000..26128f5 Binary files /dev/null and b/testdata/snapshots/darwin/area_chart-dark.png differ diff --git a/testdata/snapshots/darwin/area_chart-light.png b/testdata/snapshots/darwin/area_chart-light.png new file mode 100644 index 0000000..101392a Binary files /dev/null and b/testdata/snapshots/darwin/area_chart-light.png differ diff --git a/testdata/snapshots/darwin/artifact_card-dark.png b/testdata/snapshots/darwin/artifact_card-dark.png new file mode 100644 index 0000000..375162b Binary files /dev/null and b/testdata/snapshots/darwin/artifact_card-dark.png differ diff --git a/testdata/snapshots/darwin/artifact_card-light.png b/testdata/snapshots/darwin/artifact_card-light.png new file mode 100644 index 0000000..693b537 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_card-light.png differ diff --git a/testdata/snapshots/darwin/artifact_panel-dark.png b/testdata/snapshots/darwin/artifact_panel-dark.png new file mode 100644 index 0000000..5f0cd25 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_panel-dark.png differ diff --git a/testdata/snapshots/darwin/artifact_panel-light.png b/testdata/snapshots/darwin/artifact_panel-light.png new file mode 100644 index 0000000..1b1f06b Binary files /dev/null and b/testdata/snapshots/darwin/artifact_panel-light.png differ diff --git a/testdata/snapshots/darwin/artifact_version_switcher-dark.png b/testdata/snapshots/darwin/artifact_version_switcher-dark.png new file mode 100644 index 0000000..2e1db43 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_version_switcher-dark.png differ diff --git a/testdata/snapshots/darwin/artifact_version_switcher-light.png b/testdata/snapshots/darwin/artifact_version_switcher-light.png new file mode 100644 index 0000000..ecdbb71 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_version_switcher-light.png differ diff --git a/testdata/snapshots/darwin/attendee_list-dark.png b/testdata/snapshots/darwin/attendee_list-dark.png new file mode 100644 index 0000000..6f044e5 Binary files /dev/null and b/testdata/snapshots/darwin/attendee_list-dark.png differ diff --git a/testdata/snapshots/darwin/attendee_list-light.png b/testdata/snapshots/darwin/attendee_list-light.png new file mode 100644 index 0000000..ff96bc2 Binary files /dev/null and b/testdata/snapshots/darwin/attendee_list-light.png differ diff --git a/testdata/snapshots/darwin/audio_message-dark.png b/testdata/snapshots/darwin/audio_message-dark.png new file mode 100644 index 0000000..f987222 Binary files /dev/null and b/testdata/snapshots/darwin/audio_message-dark.png differ diff --git a/testdata/snapshots/darwin/audio_message-light.png b/testdata/snapshots/darwin/audio_message-light.png new file mode 100644 index 0000000..8c92e8e Binary files /dev/null and b/testdata/snapshots/darwin/audio_message-light.png differ diff --git a/testdata/snapshots/darwin/audio_player-dark.png b/testdata/snapshots/darwin/audio_player-dark.png new file mode 100644 index 0000000..a19fc33 Binary files /dev/null and b/testdata/snapshots/darwin/audio_player-dark.png differ diff --git a/testdata/snapshots/darwin/audio_player-light.png b/testdata/snapshots/darwin/audio_player-light.png new file mode 100644 index 0000000..6d5dba4 Binary files /dev/null and b/testdata/snapshots/darwin/audio_player-light.png differ diff --git a/testdata/snapshots/darwin/audio_spectrum-dark.png b/testdata/snapshots/darwin/audio_spectrum-dark.png new file mode 100644 index 0000000..8c621f0 Binary files /dev/null and b/testdata/snapshots/darwin/audio_spectrum-dark.png differ diff --git a/testdata/snapshots/darwin/audio_spectrum-light.png b/testdata/snapshots/darwin/audio_spectrum-light.png new file mode 100644 index 0000000..0d0b852 Binary files /dev/null and b/testdata/snapshots/darwin/audio_spectrum-light.png differ diff --git a/testdata/snapshots/darwin/audio_waveform-dark.png b/testdata/snapshots/darwin/audio_waveform-dark.png new file mode 100644 index 0000000..e6a424c Binary files /dev/null and b/testdata/snapshots/darwin/audio_waveform-dark.png differ diff --git a/testdata/snapshots/darwin/audio_waveform-light.png b/testdata/snapshots/darwin/audio_waveform-light.png new file mode 100644 index 0000000..0440cbf Binary files /dev/null and b/testdata/snapshots/darwin/audio_waveform-light.png differ diff --git a/testdata/snapshots/darwin/availability_picker-dark.png b/testdata/snapshots/darwin/availability_picker-dark.png new file mode 100644 index 0000000..2e92e34 Binary files /dev/null and b/testdata/snapshots/darwin/availability_picker-dark.png differ diff --git a/testdata/snapshots/darwin/availability_picker-light.png b/testdata/snapshots/darwin/availability_picker-light.png new file mode 100644 index 0000000..1708736 Binary files /dev/null and b/testdata/snapshots/darwin/availability_picker-light.png differ diff --git a/testdata/snapshots/darwin/bar_chart-dark.png b/testdata/snapshots/darwin/bar_chart-dark.png new file mode 100644 index 0000000..118d393 Binary files /dev/null and b/testdata/snapshots/darwin/bar_chart-dark.png differ diff --git a/testdata/snapshots/darwin/bar_chart-light.png b/testdata/snapshots/darwin/bar_chart-light.png new file mode 100644 index 0000000..17580fc Binary files /dev/null and b/testdata/snapshots/darwin/bar_chart-light.png differ diff --git a/testdata/snapshots/darwin/bid_ask_bar-dark.png b/testdata/snapshots/darwin/bid_ask_bar-dark.png new file mode 100644 index 0000000..16712d6 Binary files /dev/null and b/testdata/snapshots/darwin/bid_ask_bar-dark.png differ diff --git a/testdata/snapshots/darwin/bid_ask_bar-light.png b/testdata/snapshots/darwin/bid_ask_bar-light.png new file mode 100644 index 0000000..a2c9c78 Binary files /dev/null and b/testdata/snapshots/darwin/bid_ask_bar-light.png differ diff --git a/testdata/snapshots/darwin/blame_view-dark.png b/testdata/snapshots/darwin/blame_view-dark.png new file mode 100644 index 0000000..70f6090 Binary files /dev/null and b/testdata/snapshots/darwin/blame_view-dark.png differ diff --git a/testdata/snapshots/darwin/blame_view-light.png b/testdata/snapshots/darwin/blame_view-light.png new file mode 100644 index 0000000..5c64377 Binary files /dev/null and b/testdata/snapshots/darwin/blame_view-light.png differ diff --git a/testdata/snapshots/darwin/box_plot-dark.png b/testdata/snapshots/darwin/box_plot-dark.png new file mode 100644 index 0000000..a6f9639 Binary files /dev/null and b/testdata/snapshots/darwin/box_plot-dark.png differ diff --git a/testdata/snapshots/darwin/box_plot-light.png b/testdata/snapshots/darwin/box_plot-light.png new file mode 100644 index 0000000..f6433c3 Binary files /dev/null and b/testdata/snapshots/darwin/box_plot-light.png differ diff --git a/testdata/snapshots/darwin/branch_list-dark.png b/testdata/snapshots/darwin/branch_list-dark.png new file mode 100644 index 0000000..3b39735 Binary files /dev/null and b/testdata/snapshots/darwin/branch_list-dark.png differ diff --git a/testdata/snapshots/darwin/branch_list-light.png b/testdata/snapshots/darwin/branch_list-light.png new file mode 100644 index 0000000..3019690 Binary files /dev/null and b/testdata/snapshots/darwin/branch_list-light.png differ diff --git a/testdata/snapshots/darwin/branch_navigator-dark.png b/testdata/snapshots/darwin/branch_navigator-dark.png new file mode 100644 index 0000000..a2341da Binary files /dev/null and b/testdata/snapshots/darwin/branch_navigator-dark.png differ diff --git a/testdata/snapshots/darwin/branch_navigator-light.png b/testdata/snapshots/darwin/branch_navigator-light.png new file mode 100644 index 0000000..9179751 Binary files /dev/null and b/testdata/snapshots/darwin/branch_navigator-light.png differ diff --git a/testdata/snapshots/darwin/branch_selector-dark.png b/testdata/snapshots/darwin/branch_selector-dark.png new file mode 100644 index 0000000..46a00c6 Binary files /dev/null and b/testdata/snapshots/darwin/branch_selector-dark.png differ diff --git a/testdata/snapshots/darwin/branch_selector-light.png b/testdata/snapshots/darwin/branch_selector-light.png new file mode 100644 index 0000000..bd8ae7c Binary files /dev/null and b/testdata/snapshots/darwin/branch_selector-light.png differ diff --git a/testdata/snapshots/darwin/breakpoint_list-dark.png b/testdata/snapshots/darwin/breakpoint_list-dark.png new file mode 100644 index 0000000..e11b0a0 Binary files /dev/null and b/testdata/snapshots/darwin/breakpoint_list-dark.png differ diff --git a/testdata/snapshots/darwin/breakpoint_list-light.png b/testdata/snapshots/darwin/breakpoint_list-light.png new file mode 100644 index 0000000..43ac81e Binary files /dev/null and b/testdata/snapshots/darwin/breakpoint_list-light.png differ diff --git a/testdata/snapshots/darwin/bullet_chart-dark.png b/testdata/snapshots/darwin/bullet_chart-dark.png new file mode 100644 index 0000000..82f62d5 Binary files /dev/null and b/testdata/snapshots/darwin/bullet_chart-dark.png differ diff --git a/testdata/snapshots/darwin/bullet_chart-light.png b/testdata/snapshots/darwin/bullet_chart-light.png new file mode 100644 index 0000000..45de0d3 Binary files /dev/null and b/testdata/snapshots/darwin/bullet_chart-light.png differ diff --git a/testdata/snapshots/darwin/calendar_day_view-dark.png b/testdata/snapshots/darwin/calendar_day_view-dark.png new file mode 100644 index 0000000..e3c891e Binary files /dev/null and b/testdata/snapshots/darwin/calendar_day_view-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_day_view-light.png b/testdata/snapshots/darwin/calendar_day_view-light.png new file mode 100644 index 0000000..6e4983d Binary files /dev/null and b/testdata/snapshots/darwin/calendar_day_view-light.png differ diff --git a/testdata/snapshots/darwin/calendar_heatmap-dark.png b/testdata/snapshots/darwin/calendar_heatmap-dark.png new file mode 100644 index 0000000..75a10e3 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_heatmap-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_heatmap-light.png b/testdata/snapshots/darwin/calendar_heatmap-light.png new file mode 100644 index 0000000..cb21e9b Binary files /dev/null and b/testdata/snapshots/darwin/calendar_heatmap-light.png differ diff --git a/testdata/snapshots/darwin/calendar_month_view-dark.png b/testdata/snapshots/darwin/calendar_month_view-dark.png new file mode 100644 index 0000000..5819be7 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_month_view-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_month_view-light.png b/testdata/snapshots/darwin/calendar_month_view-light.png new file mode 100644 index 0000000..e9fb1e8 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_month_view-light.png differ diff --git a/testdata/snapshots/darwin/calendar_week_view-dark.png b/testdata/snapshots/darwin/calendar_week_view-dark.png new file mode 100644 index 0000000..6be02c2 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_week_view-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_week_view-light.png b/testdata/snapshots/darwin/calendar_week_view-light.png new file mode 100644 index 0000000..65583a1 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_week_view-light.png differ diff --git a/testdata/snapshots/darwin/call_stack-dark.png b/testdata/snapshots/darwin/call_stack-dark.png new file mode 100644 index 0000000..167b17e Binary files /dev/null and b/testdata/snapshots/darwin/call_stack-dark.png differ diff --git a/testdata/snapshots/darwin/call_stack-light.png b/testdata/snapshots/darwin/call_stack-light.png new file mode 100644 index 0000000..e0aa6b8 Binary files /dev/null and b/testdata/snapshots/darwin/call_stack-light.png differ diff --git a/testdata/snapshots/darwin/camera_preview-dark.png b/testdata/snapshots/darwin/camera_preview-dark.png new file mode 100644 index 0000000..e9d7a4a Binary files /dev/null and b/testdata/snapshots/darwin/camera_preview-dark.png differ diff --git a/testdata/snapshots/darwin/camera_preview-light.png b/testdata/snapshots/darwin/camera_preview-light.png new file mode 100644 index 0000000..142ea8c Binary files /dev/null and b/testdata/snapshots/darwin/camera_preview-light.png differ diff --git a/testdata/snapshots/darwin/capability_cards-dark.png b/testdata/snapshots/darwin/capability_cards-dark.png new file mode 100644 index 0000000..3d1e014 Binary files /dev/null and b/testdata/snapshots/darwin/capability_cards-dark.png differ diff --git a/testdata/snapshots/darwin/capability_cards-light.png b/testdata/snapshots/darwin/capability_cards-light.png new file mode 100644 index 0000000..35b9a97 Binary files /dev/null and b/testdata/snapshots/darwin/capability_cards-light.png differ diff --git a/testdata/snapshots/darwin/changes_list-dark.png b/testdata/snapshots/darwin/changes_list-dark.png new file mode 100644 index 0000000..4225ab6 Binary files /dev/null and b/testdata/snapshots/darwin/changes_list-dark.png differ diff --git a/testdata/snapshots/darwin/changes_list-light.png b/testdata/snapshots/darwin/changes_list-light.png new file mode 100644 index 0000000..e796485 Binary files /dev/null and b/testdata/snapshots/darwin/changes_list-light.png differ diff --git a/testdata/snapshots/darwin/chat_container-dark.png b/testdata/snapshots/darwin/chat_container-dark.png new file mode 100644 index 0000000..850e4c2 Binary files /dev/null and b/testdata/snapshots/darwin/chat_container-dark.png differ diff --git a/testdata/snapshots/darwin/chat_container-light.png b/testdata/snapshots/darwin/chat_container-light.png new file mode 100644 index 0000000..503f7a3 Binary files /dev/null and b/testdata/snapshots/darwin/chat_container-light.png differ diff --git a/testdata/snapshots/darwin/chat_empty_state-dark.png b/testdata/snapshots/darwin/chat_empty_state-dark.png new file mode 100644 index 0000000..aababdc Binary files /dev/null and b/testdata/snapshots/darwin/chat_empty_state-dark.png differ diff --git a/testdata/snapshots/darwin/chat_empty_state-light.png b/testdata/snapshots/darwin/chat_empty_state-light.png new file mode 100644 index 0000000..8368eef Binary files /dev/null and b/testdata/snapshots/darwin/chat_empty_state-light.png differ diff --git a/testdata/snapshots/darwin/checkpoint_list-dark.png b/testdata/snapshots/darwin/checkpoint_list-dark.png new file mode 100644 index 0000000..a31d523 Binary files /dev/null and b/testdata/snapshots/darwin/checkpoint_list-dark.png differ diff --git a/testdata/snapshots/darwin/checkpoint_list-light.png b/testdata/snapshots/darwin/checkpoint_list-light.png new file mode 100644 index 0000000..9496405 Binary files /dev/null and b/testdata/snapshots/darwin/checkpoint_list-light.png differ diff --git a/testdata/snapshots/darwin/code_block-dark.png b/testdata/snapshots/darwin/code_block-dark.png new file mode 100644 index 0000000..6c41a35 Binary files /dev/null and b/testdata/snapshots/darwin/code_block-dark.png differ diff --git a/testdata/snapshots/darwin/code_block-light.png b/testdata/snapshots/darwin/code_block-light.png new file mode 100644 index 0000000..7c3b9cf Binary files /dev/null and b/testdata/snapshots/darwin/code_block-light.png differ diff --git a/testdata/snapshots/darwin/code_viewer-dark.png b/testdata/snapshots/darwin/code_viewer-dark.png new file mode 100644 index 0000000..16404df Binary files /dev/null and b/testdata/snapshots/darwin/code_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/code_viewer-light.png b/testdata/snapshots/darwin/code_viewer-light.png new file mode 100644 index 0000000..c40631d Binary files /dev/null and b/testdata/snapshots/darwin/code_viewer-light.png differ diff --git a/testdata/snapshots/darwin/command_block-dark.png b/testdata/snapshots/darwin/command_block-dark.png new file mode 100644 index 0000000..892e54a Binary files /dev/null and b/testdata/snapshots/darwin/command_block-dark.png differ diff --git a/testdata/snapshots/darwin/command_block-light.png b/testdata/snapshots/darwin/command_block-light.png new file mode 100644 index 0000000..028584b Binary files /dev/null and b/testdata/snapshots/darwin/command_block-light.png differ diff --git a/testdata/snapshots/darwin/command_execution_card-dark.png b/testdata/snapshots/darwin/command_execution_card-dark.png new file mode 100644 index 0000000..5dc61aa Binary files /dev/null and b/testdata/snapshots/darwin/command_execution_card-dark.png differ diff --git a/testdata/snapshots/darwin/command_execution_card-light.png b/testdata/snapshots/darwin/command_execution_card-light.png new file mode 100644 index 0000000..fac8d11 Binary files /dev/null and b/testdata/snapshots/darwin/command_execution_card-light.png differ diff --git a/testdata/snapshots/darwin/command_history-dark.png b/testdata/snapshots/darwin/command_history-dark.png new file mode 100644 index 0000000..e8de3fc Binary files /dev/null and b/testdata/snapshots/darwin/command_history-dark.png differ diff --git a/testdata/snapshots/darwin/command_history-light.png b/testdata/snapshots/darwin/command_history-light.png new file mode 100644 index 0000000..4012a1d Binary files /dev/null and b/testdata/snapshots/darwin/command_history-light.png differ diff --git a/testdata/snapshots/darwin/commit_graph-dark.png b/testdata/snapshots/darwin/commit_graph-dark.png new file mode 100644 index 0000000..902eb32 Binary files /dev/null and b/testdata/snapshots/darwin/commit_graph-dark.png differ diff --git a/testdata/snapshots/darwin/commit_graph-light.png b/testdata/snapshots/darwin/commit_graph-light.png new file mode 100644 index 0000000..6344bcc Binary files /dev/null and b/testdata/snapshots/darwin/commit_graph-light.png differ diff --git a/testdata/snapshots/darwin/commit_input-dark.png b/testdata/snapshots/darwin/commit_input-dark.png new file mode 100644 index 0000000..a83bb54 Binary files /dev/null and b/testdata/snapshots/darwin/commit_input-dark.png differ diff --git a/testdata/snapshots/darwin/commit_input-light.png b/testdata/snapshots/darwin/commit_input-light.png new file mode 100644 index 0000000..99b1d49 Binary files /dev/null and b/testdata/snapshots/darwin/commit_input-light.png differ diff --git a/testdata/snapshots/darwin/commit_list-dark.png b/testdata/snapshots/darwin/commit_list-dark.png new file mode 100644 index 0000000..08d404f Binary files /dev/null and b/testdata/snapshots/darwin/commit_list-dark.png differ diff --git a/testdata/snapshots/darwin/commit_list-light.png b/testdata/snapshots/darwin/commit_list-light.png new file mode 100644 index 0000000..78f01a3 Binary files /dev/null and b/testdata/snapshots/darwin/commit_list-light.png differ diff --git a/testdata/snapshots/darwin/completion_menu-dark.png b/testdata/snapshots/darwin/completion_menu-dark.png new file mode 100644 index 0000000..d1dcdfe Binary files /dev/null and b/testdata/snapshots/darwin/completion_menu-dark.png differ diff --git a/testdata/snapshots/darwin/completion_menu-light.png b/testdata/snapshots/darwin/completion_menu-light.png new file mode 100644 index 0000000..0d0b9e9 Binary files /dev/null and b/testdata/snapshots/darwin/completion_menu-light.png differ diff --git a/testdata/snapshots/darwin/conflict_resolver-dark.png b/testdata/snapshots/darwin/conflict_resolver-dark.png new file mode 100644 index 0000000..704d851 Binary files /dev/null and b/testdata/snapshots/darwin/conflict_resolver-dark.png differ diff --git a/testdata/snapshots/darwin/conflict_resolver-light.png b/testdata/snapshots/darwin/conflict_resolver-light.png new file mode 100644 index 0000000..271f745 Binary files /dev/null and b/testdata/snapshots/darwin/conflict_resolver-light.png differ diff --git a/testdata/snapshots/darwin/conversation_export-dark.png b/testdata/snapshots/darwin/conversation_export-dark.png new file mode 100644 index 0000000..2dec7db Binary files /dev/null and b/testdata/snapshots/darwin/conversation_export-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_export-light.png b/testdata/snapshots/darwin/conversation_export-light.png new file mode 100644 index 0000000..cf9085c Binary files /dev/null and b/testdata/snapshots/darwin/conversation_export-light.png differ diff --git a/testdata/snapshots/darwin/conversation_item-dark.png b/testdata/snapshots/darwin/conversation_item-dark.png new file mode 100644 index 0000000..577f3a3 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_item-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_item-light.png b/testdata/snapshots/darwin/conversation_item-light.png new file mode 100644 index 0000000..cb33c94 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_item-light.png differ diff --git a/testdata/snapshots/darwin/conversation_list-dark.png b/testdata/snapshots/darwin/conversation_list-dark.png new file mode 100644 index 0000000..775de36 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_list-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_list-light.png b/testdata/snapshots/darwin/conversation_list-light.png new file mode 100644 index 0000000..ba51165 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_list-light.png differ diff --git a/testdata/snapshots/darwin/conversation_search-dark.png b/testdata/snapshots/darwin/conversation_search-dark.png new file mode 100644 index 0000000..c97673f Binary files /dev/null and b/testdata/snapshots/darwin/conversation_search-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_search-light.png b/testdata/snapshots/darwin/conversation_search-light.png new file mode 100644 index 0000000..8e4136f Binary files /dev/null and b/testdata/snapshots/darwin/conversation_search-light.png differ diff --git a/testdata/snapshots/darwin/cost_estimator-dark.png b/testdata/snapshots/darwin/cost_estimator-dark.png new file mode 100644 index 0000000..43d1f41 Binary files /dev/null and b/testdata/snapshots/darwin/cost_estimator-dark.png differ diff --git a/testdata/snapshots/darwin/cost_estimator-light.png b/testdata/snapshots/darwin/cost_estimator-light.png new file mode 100644 index 0000000..8aa5c2c Binary files /dev/null and b/testdata/snapshots/darwin/cost_estimator-light.png differ diff --git a/testdata/snapshots/darwin/countdown-dark.png b/testdata/snapshots/darwin/countdown-dark.png new file mode 100644 index 0000000..e8b146a Binary files /dev/null and b/testdata/snapshots/darwin/countdown-dark.png differ diff --git a/testdata/snapshots/darwin/countdown-light.png b/testdata/snapshots/darwin/countdown-light.png new file mode 100644 index 0000000..daf8755 Binary files /dev/null and b/testdata/snapshots/darwin/countdown-light.png differ diff --git a/testdata/snapshots/darwin/cron_editor-dark.png b/testdata/snapshots/darwin/cron_editor-dark.png new file mode 100644 index 0000000..433762c Binary files /dev/null and b/testdata/snapshots/darwin/cron_editor-dark.png differ diff --git a/testdata/snapshots/darwin/cron_editor-light.png b/testdata/snapshots/darwin/cron_editor-light.png new file mode 100644 index 0000000..e8b898b Binary files /dev/null and b/testdata/snapshots/darwin/cron_editor-light.png differ diff --git a/testdata/snapshots/darwin/current_time_indicator-dark.png b/testdata/snapshots/darwin/current_time_indicator-dark.png new file mode 100644 index 0000000..892631a Binary files /dev/null and b/testdata/snapshots/darwin/current_time_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/current_time_indicator-light.png b/testdata/snapshots/darwin/current_time_indicator-light.png new file mode 100644 index 0000000..56daacd Binary files /dev/null and b/testdata/snapshots/darwin/current_time_indicator-light.png differ diff --git a/testdata/snapshots/darwin/date_separator-dark.png b/testdata/snapshots/darwin/date_separator-dark.png new file mode 100644 index 0000000..907f647 Binary files /dev/null and b/testdata/snapshots/darwin/date_separator-dark.png differ diff --git a/testdata/snapshots/darwin/date_separator-light.png b/testdata/snapshots/darwin/date_separator-light.png new file mode 100644 index 0000000..ab2d287 Binary files /dev/null and b/testdata/snapshots/darwin/date_separator-light.png differ diff --git a/testdata/snapshots/darwin/debug_toolbar-dark.png b/testdata/snapshots/darwin/debug_toolbar-dark.png new file mode 100644 index 0000000..0090c93 Binary files /dev/null and b/testdata/snapshots/darwin/debug_toolbar-dark.png differ diff --git a/testdata/snapshots/darwin/debug_toolbar-light.png b/testdata/snapshots/darwin/debug_toolbar-light.png new file mode 100644 index 0000000..77eaa79 Binary files /dev/null and b/testdata/snapshots/darwin/debug_toolbar-light.png differ diff --git a/testdata/snapshots/darwin/depth_ladder-dark.png b/testdata/snapshots/darwin/depth_ladder-dark.png new file mode 100644 index 0000000..a4c3a5b Binary files /dev/null and b/testdata/snapshots/darwin/depth_ladder-dark.png differ diff --git a/testdata/snapshots/darwin/depth_ladder-light.png b/testdata/snapshots/darwin/depth_ladder-light.png new file mode 100644 index 0000000..5055483 Binary files /dev/null and b/testdata/snapshots/darwin/depth_ladder-light.png differ diff --git a/testdata/snapshots/darwin/device_selector-dark.png b/testdata/snapshots/darwin/device_selector-dark.png new file mode 100644 index 0000000..645f377 Binary files /dev/null and b/testdata/snapshots/darwin/device_selector-dark.png differ diff --git a/testdata/snapshots/darwin/device_selector-light.png b/testdata/snapshots/darwin/device_selector-light.png new file mode 100644 index 0000000..a9d6d44 Binary files /dev/null and b/testdata/snapshots/darwin/device_selector-light.png differ diff --git a/testdata/snapshots/darwin/diff_stat-dark.png b/testdata/snapshots/darwin/diff_stat-dark.png new file mode 100644 index 0000000..0b39984 Binary files /dev/null and b/testdata/snapshots/darwin/diff_stat-dark.png differ diff --git a/testdata/snapshots/darwin/diff_stat-light.png b/testdata/snapshots/darwin/diff_stat-light.png new file mode 100644 index 0000000..93e0b93 Binary files /dev/null and b/testdata/snapshots/darwin/diff_stat-light.png differ diff --git a/testdata/snapshots/darwin/diff_viewer-dark.png b/testdata/snapshots/darwin/diff_viewer-dark.png new file mode 100644 index 0000000..96b226d Binary files /dev/null and b/testdata/snapshots/darwin/diff_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/diff_viewer-light.png b/testdata/snapshots/darwin/diff_viewer-light.png new file mode 100644 index 0000000..23ecef1 Binary files /dev/null and b/testdata/snapshots/darwin/diff_viewer-light.png differ diff --git a/testdata/snapshots/darwin/duration_picker-dark.png b/testdata/snapshots/darwin/duration_picker-dark.png new file mode 100644 index 0000000..18263ae Binary files /dev/null and b/testdata/snapshots/darwin/duration_picker-dark.png differ diff --git a/testdata/snapshots/darwin/duration_picker-light.png b/testdata/snapshots/darwin/duration_picker-light.png new file mode 100644 index 0000000..84a22db Binary files /dev/null and b/testdata/snapshots/darwin/duration_picker-light.png differ diff --git a/testdata/snapshots/darwin/error_message-dark.png b/testdata/snapshots/darwin/error_message-dark.png new file mode 100644 index 0000000..ba389db Binary files /dev/null and b/testdata/snapshots/darwin/error_message-dark.png differ diff --git a/testdata/snapshots/darwin/error_message-light.png b/testdata/snapshots/darwin/error_message-light.png new file mode 100644 index 0000000..04a1344 Binary files /dev/null and b/testdata/snapshots/darwin/error_message-light.png differ diff --git a/testdata/snapshots/darwin/event_chip-dark.png b/testdata/snapshots/darwin/event_chip-dark.png new file mode 100644 index 0000000..d19dfbc Binary files /dev/null and b/testdata/snapshots/darwin/event_chip-dark.png differ diff --git a/testdata/snapshots/darwin/event_chip-light.png b/testdata/snapshots/darwin/event_chip-light.png new file mode 100644 index 0000000..50ac79d Binary files /dev/null and b/testdata/snapshots/darwin/event_chip-light.png differ diff --git a/testdata/snapshots/darwin/event_editor-dark.png b/testdata/snapshots/darwin/event_editor-dark.png new file mode 100644 index 0000000..5678325 Binary files /dev/null and b/testdata/snapshots/darwin/event_editor-dark.png differ diff --git a/testdata/snapshots/darwin/event_editor-light.png b/testdata/snapshots/darwin/event_editor-light.png new file mode 100644 index 0000000..ac2de16 Binary files /dev/null and b/testdata/snapshots/darwin/event_editor-light.png differ diff --git a/testdata/snapshots/darwin/event_popover-dark.png b/testdata/snapshots/darwin/event_popover-dark.png new file mode 100644 index 0000000..625cd6c Binary files /dev/null and b/testdata/snapshots/darwin/event_popover-dark.png differ diff --git a/testdata/snapshots/darwin/event_popover-light.png b/testdata/snapshots/darwin/event_popover-light.png new file mode 100644 index 0000000..67ff2e4 Binary files /dev/null and b/testdata/snapshots/darwin/event_popover-light.png differ diff --git a/testdata/snapshots/darwin/feedback_form-dark.png b/testdata/snapshots/darwin/feedback_form-dark.png new file mode 100644 index 0000000..a14bcd5 Binary files /dev/null and b/testdata/snapshots/darwin/feedback_form-dark.png differ diff --git a/testdata/snapshots/darwin/feedback_form-light.png b/testdata/snapshots/darwin/feedback_form-light.png new file mode 100644 index 0000000..14dbb88 Binary files /dev/null and b/testdata/snapshots/darwin/feedback_form-light.png differ diff --git a/testdata/snapshots/darwin/file_change_card-dark.png b/testdata/snapshots/darwin/file_change_card-dark.png new file mode 100644 index 0000000..3840158 Binary files /dev/null and b/testdata/snapshots/darwin/file_change_card-dark.png differ diff --git a/testdata/snapshots/darwin/file_change_card-light.png b/testdata/snapshots/darwin/file_change_card-light.png new file mode 100644 index 0000000..ba001c4 Binary files /dev/null and b/testdata/snapshots/darwin/file_change_card-light.png differ diff --git a/testdata/snapshots/darwin/file_history-dark.png b/testdata/snapshots/darwin/file_history-dark.png new file mode 100644 index 0000000..6ad872e Binary files /dev/null and b/testdata/snapshots/darwin/file_history-dark.png differ diff --git a/testdata/snapshots/darwin/file_history-light.png b/testdata/snapshots/darwin/file_history-light.png new file mode 100644 index 0000000..733704f Binary files /dev/null and b/testdata/snapshots/darwin/file_history-light.png differ diff --git a/testdata/snapshots/darwin/file_message-dark.png b/testdata/snapshots/darwin/file_message-dark.png new file mode 100644 index 0000000..f3438b9 Binary files /dev/null and b/testdata/snapshots/darwin/file_message-dark.png differ diff --git a/testdata/snapshots/darwin/file_message-light.png b/testdata/snapshots/darwin/file_message-light.png new file mode 100644 index 0000000..f0b5f9c Binary files /dev/null and b/testdata/snapshots/darwin/file_message-light.png differ diff --git a/testdata/snapshots/darwin/find_widget-dark.png b/testdata/snapshots/darwin/find_widget-dark.png new file mode 100644 index 0000000..4dc8da5 Binary files /dev/null and b/testdata/snapshots/darwin/find_widget-dark.png differ diff --git a/testdata/snapshots/darwin/find_widget-light.png b/testdata/snapshots/darwin/find_widget-light.png new file mode 100644 index 0000000..7f8bfa4 Binary files /dev/null and b/testdata/snapshots/darwin/find_widget-light.png differ diff --git a/testdata/snapshots/darwin/flamegraph-dark.png b/testdata/snapshots/darwin/flamegraph-dark.png new file mode 100644 index 0000000..9da9b69 Binary files /dev/null and b/testdata/snapshots/darwin/flamegraph-dark.png differ diff --git a/testdata/snapshots/darwin/flamegraph-light.png b/testdata/snapshots/darwin/flamegraph-light.png new file mode 100644 index 0000000..6a9f5c8 Binary files /dev/null and b/testdata/snapshots/darwin/flamegraph-light.png differ diff --git a/testdata/snapshots/darwin/funnel_chart-dark.png b/testdata/snapshots/darwin/funnel_chart-dark.png new file mode 100644 index 0000000..0457c86 Binary files /dev/null and b/testdata/snapshots/darwin/funnel_chart-dark.png differ diff --git a/testdata/snapshots/darwin/funnel_chart-light.png b/testdata/snapshots/darwin/funnel_chart-light.png new file mode 100644 index 0000000..d13fb80 Binary files /dev/null and b/testdata/snapshots/darwin/funnel_chart-light.png differ diff --git a/testdata/snapshots/darwin/gauge-dark.png b/testdata/snapshots/darwin/gauge-dark.png new file mode 100644 index 0000000..3abc39e Binary files /dev/null and b/testdata/snapshots/darwin/gauge-dark.png differ diff --git a/testdata/snapshots/darwin/gauge-light.png b/testdata/snapshots/darwin/gauge-light.png new file mode 100644 index 0000000..fb55f17 Binary files /dev/null and b/testdata/snapshots/darwin/gauge-light.png differ diff --git a/testdata/snapshots/darwin/git_status_badge-dark.png b/testdata/snapshots/darwin/git_status_badge-dark.png new file mode 100644 index 0000000..7ef1ce6 Binary files /dev/null and b/testdata/snapshots/darwin/git_status_badge-dark.png differ diff --git a/testdata/snapshots/darwin/git_status_badge-light.png b/testdata/snapshots/darwin/git_status_badge-light.png new file mode 100644 index 0000000..6ecbc24 Binary files /dev/null and b/testdata/snapshots/darwin/git_status_badge-light.png differ diff --git a/testdata/snapshots/darwin/heatmap_chart-dark.png b/testdata/snapshots/darwin/heatmap_chart-dark.png new file mode 100644 index 0000000..1bd7b9e Binary files /dev/null and b/testdata/snapshots/darwin/heatmap_chart-dark.png differ diff --git a/testdata/snapshots/darwin/heatmap_chart-light.png b/testdata/snapshots/darwin/heatmap_chart-light.png new file mode 100644 index 0000000..3547f65 Binary files /dev/null and b/testdata/snapshots/darwin/heatmap_chart-light.png differ diff --git a/testdata/snapshots/darwin/hex_viewer-dark.png b/testdata/snapshots/darwin/hex_viewer-dark.png new file mode 100644 index 0000000..3296b92 Binary files /dev/null and b/testdata/snapshots/darwin/hex_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/hex_viewer-light.png b/testdata/snapshots/darwin/hex_viewer-light.png new file mode 100644 index 0000000..d01870b Binary files /dev/null and b/testdata/snapshots/darwin/hex_viewer-light.png differ diff --git a/testdata/snapshots/darwin/histogram-dark.png b/testdata/snapshots/darwin/histogram-dark.png new file mode 100644 index 0000000..66cc3ad Binary files /dev/null and b/testdata/snapshots/darwin/histogram-dark.png differ diff --git a/testdata/snapshots/darwin/histogram-light.png b/testdata/snapshots/darwin/histogram-light.png new file mode 100644 index 0000000..c803403 Binary files /dev/null and b/testdata/snapshots/darwin/histogram-light.png differ diff --git a/testdata/snapshots/darwin/human_input_request-dark.png b/testdata/snapshots/darwin/human_input_request-dark.png new file mode 100644 index 0000000..f9d9968 Binary files /dev/null and b/testdata/snapshots/darwin/human_input_request-dark.png differ diff --git a/testdata/snapshots/darwin/human_input_request-light.png b/testdata/snapshots/darwin/human_input_request-light.png new file mode 100644 index 0000000..b9355f2 Binary files /dev/null and b/testdata/snapshots/darwin/human_input_request-light.png differ diff --git a/testdata/snapshots/darwin/image_annotator-dark.png b/testdata/snapshots/darwin/image_annotator-dark.png new file mode 100644 index 0000000..70d803d Binary files /dev/null and b/testdata/snapshots/darwin/image_annotator-dark.png differ diff --git a/testdata/snapshots/darwin/image_annotator-light.png b/testdata/snapshots/darwin/image_annotator-light.png new file mode 100644 index 0000000..001ebe8 Binary files /dev/null and b/testdata/snapshots/darwin/image_annotator-light.png differ diff --git a/testdata/snapshots/darwin/image_compare-dark.png b/testdata/snapshots/darwin/image_compare-dark.png new file mode 100644 index 0000000..a1d42b6 Binary files /dev/null and b/testdata/snapshots/darwin/image_compare-dark.png differ diff --git a/testdata/snapshots/darwin/image_compare-light.png b/testdata/snapshots/darwin/image_compare-light.png new file mode 100644 index 0000000..53eb412 Binary files /dev/null and b/testdata/snapshots/darwin/image_compare-light.png differ diff --git a/testdata/snapshots/darwin/image_cropper-dark.png b/testdata/snapshots/darwin/image_cropper-dark.png new file mode 100644 index 0000000..e7e23fe Binary files /dev/null and b/testdata/snapshots/darwin/image_cropper-dark.png differ diff --git a/testdata/snapshots/darwin/image_cropper-light.png b/testdata/snapshots/darwin/image_cropper-light.png new file mode 100644 index 0000000..d4273bb Binary files /dev/null and b/testdata/snapshots/darwin/image_cropper-light.png differ diff --git a/testdata/snapshots/darwin/image_grid-dark.png b/testdata/snapshots/darwin/image_grid-dark.png new file mode 100644 index 0000000..c28e23f Binary files /dev/null and b/testdata/snapshots/darwin/image_grid-dark.png differ diff --git a/testdata/snapshots/darwin/image_grid-light.png b/testdata/snapshots/darwin/image_grid-light.png new file mode 100644 index 0000000..a622891 Binary files /dev/null and b/testdata/snapshots/darwin/image_grid-light.png differ diff --git a/testdata/snapshots/darwin/image_thumbnail-dark.png b/testdata/snapshots/darwin/image_thumbnail-dark.png new file mode 100644 index 0000000..a4d396f Binary files /dev/null and b/testdata/snapshots/darwin/image_thumbnail-dark.png differ diff --git a/testdata/snapshots/darwin/image_thumbnail-light.png b/testdata/snapshots/darwin/image_thumbnail-light.png new file mode 100644 index 0000000..73026ac Binary files /dev/null and b/testdata/snapshots/darwin/image_thumbnail-light.png differ diff --git a/testdata/snapshots/darwin/inline_diff-dark.png b/testdata/snapshots/darwin/inline_diff-dark.png new file mode 100644 index 0000000..da868df Binary files /dev/null and b/testdata/snapshots/darwin/inline_diff-dark.png differ diff --git a/testdata/snapshots/darwin/inline_diff-light.png b/testdata/snapshots/darwin/inline_diff-light.png new file mode 100644 index 0000000..ca6f3a2 Binary files /dev/null and b/testdata/snapshots/darwin/inline_diff-light.png differ diff --git a/testdata/snapshots/darwin/level2_quotes-dark.png b/testdata/snapshots/darwin/level2_quotes-dark.png new file mode 100644 index 0000000..021ce64 Binary files /dev/null and b/testdata/snapshots/darwin/level2_quotes-dark.png differ diff --git a/testdata/snapshots/darwin/level2_quotes-light.png b/testdata/snapshots/darwin/level2_quotes-light.png new file mode 100644 index 0000000..6a9a591 Binary files /dev/null and b/testdata/snapshots/darwin/level2_quotes-light.png differ diff --git a/testdata/snapshots/darwin/leverage_slider-dark.png b/testdata/snapshots/darwin/leverage_slider-dark.png new file mode 100644 index 0000000..4657808 Binary files /dev/null and b/testdata/snapshots/darwin/leverage_slider-dark.png differ diff --git a/testdata/snapshots/darwin/leverage_slider-light.png b/testdata/snapshots/darwin/leverage_slider-light.png new file mode 100644 index 0000000..155c312 Binary files /dev/null and b/testdata/snapshots/darwin/leverage_slider-light.png differ diff --git a/testdata/snapshots/darwin/lightbox-dark.png b/testdata/snapshots/darwin/lightbox-dark.png new file mode 100644 index 0000000..05e9033 Binary files /dev/null and b/testdata/snapshots/darwin/lightbox-dark.png differ diff --git a/testdata/snapshots/darwin/lightbox-light.png b/testdata/snapshots/darwin/lightbox-light.png new file mode 100644 index 0000000..284b8e6 Binary files /dev/null and b/testdata/snapshots/darwin/lightbox-light.png differ diff --git a/testdata/snapshots/darwin/line_chart-dark.png b/testdata/snapshots/darwin/line_chart-dark.png new file mode 100644 index 0000000..92043c4 Binary files /dev/null and b/testdata/snapshots/darwin/line_chart-dark.png differ diff --git a/testdata/snapshots/darwin/line_chart-light.png b/testdata/snapshots/darwin/line_chart-light.png new file mode 100644 index 0000000..4de7657 Binary files /dev/null and b/testdata/snapshots/darwin/line_chart-light.png differ diff --git a/testdata/snapshots/darwin/link_preview_card-dark.png b/testdata/snapshots/darwin/link_preview_card-dark.png new file mode 100644 index 0000000..904852b Binary files /dev/null and b/testdata/snapshots/darwin/link_preview_card-dark.png differ diff --git a/testdata/snapshots/darwin/link_preview_card-light.png b/testdata/snapshots/darwin/link_preview_card-light.png new file mode 100644 index 0000000..b1e45ba Binary files /dev/null and b/testdata/snapshots/darwin/link_preview_card-light.png differ diff --git a/testdata/snapshots/darwin/log_viewer-dark.png b/testdata/snapshots/darwin/log_viewer-dark.png new file mode 100644 index 0000000..613b6d0 Binary files /dev/null and b/testdata/snapshots/darwin/log_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/log_viewer-light.png b/testdata/snapshots/darwin/log_viewer-light.png new file mode 100644 index 0000000..7cdbd50 Binary files /dev/null and b/testdata/snapshots/darwin/log_viewer-light.png differ diff --git a/testdata/snapshots/darwin/margin_indicator-dark.png b/testdata/snapshots/darwin/margin_indicator-dark.png new file mode 100644 index 0000000..f77e26e Binary files /dev/null and b/testdata/snapshots/darwin/margin_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/margin_indicator-light.png b/testdata/snapshots/darwin/margin_indicator-light.png new file mode 100644 index 0000000..d6f4140 Binary files /dev/null and b/testdata/snapshots/darwin/margin_indicator-light.png differ diff --git a/testdata/snapshots/darwin/markdown_view-dark.png b/testdata/snapshots/darwin/markdown_view-dark.png new file mode 100644 index 0000000..a4d43d3 Binary files /dev/null and b/testdata/snapshots/darwin/markdown_view-dark.png differ diff --git a/testdata/snapshots/darwin/markdown_view-light.png b/testdata/snapshots/darwin/markdown_view-light.png new file mode 100644 index 0000000..0550c31 Binary files /dev/null and b/testdata/snapshots/darwin/markdown_view-light.png differ diff --git a/testdata/snapshots/darwin/mcp_server_list-dark.png b/testdata/snapshots/darwin/mcp_server_list-dark.png new file mode 100644 index 0000000..a8a5a4d Binary files /dev/null and b/testdata/snapshots/darwin/mcp_server_list-dark.png differ diff --git a/testdata/snapshots/darwin/mcp_server_list-light.png b/testdata/snapshots/darwin/mcp_server_list-light.png new file mode 100644 index 0000000..75ca50a Binary files /dev/null and b/testdata/snapshots/darwin/mcp_server_list-light.png differ diff --git a/testdata/snapshots/darwin/media_controls-dark.png b/testdata/snapshots/darwin/media_controls-dark.png new file mode 100644 index 0000000..5c9d223 Binary files /dev/null and b/testdata/snapshots/darwin/media_controls-dark.png differ diff --git a/testdata/snapshots/darwin/media_controls-light.png b/testdata/snapshots/darwin/media_controls-light.png new file mode 100644 index 0000000..52aca9c Binary files /dev/null and b/testdata/snapshots/darwin/media_controls-light.png differ diff --git a/testdata/snapshots/darwin/memory_panel-dark.png b/testdata/snapshots/darwin/memory_panel-dark.png new file mode 100644 index 0000000..c5f6834 Binary files /dev/null and b/testdata/snapshots/darwin/memory_panel-dark.png differ diff --git a/testdata/snapshots/darwin/memory_panel-light.png b/testdata/snapshots/darwin/memory_panel-light.png new file mode 100644 index 0000000..849c3f1 Binary files /dev/null and b/testdata/snapshots/darwin/memory_panel-light.png differ diff --git a/testdata/snapshots/darwin/message_actions-dark.png b/testdata/snapshots/darwin/message_actions-dark.png new file mode 100644 index 0000000..878585a Binary files /dev/null and b/testdata/snapshots/darwin/message_actions-dark.png differ diff --git a/testdata/snapshots/darwin/message_actions-light.png b/testdata/snapshots/darwin/message_actions-light.png new file mode 100644 index 0000000..f31f9f0 Binary files /dev/null and b/testdata/snapshots/darwin/message_actions-light.png differ diff --git a/testdata/snapshots/darwin/message_bubble-dark.png b/testdata/snapshots/darwin/message_bubble-dark.png new file mode 100644 index 0000000..71d3f9a Binary files /dev/null and b/testdata/snapshots/darwin/message_bubble-dark.png differ diff --git a/testdata/snapshots/darwin/message_bubble-light.png b/testdata/snapshots/darwin/message_bubble-light.png new file mode 100644 index 0000000..fd189cc Binary files /dev/null and b/testdata/snapshots/darwin/message_bubble-light.png differ diff --git a/testdata/snapshots/darwin/message_editor-dark.png b/testdata/snapshots/darwin/message_editor-dark.png new file mode 100644 index 0000000..b61c1ac Binary files /dev/null and b/testdata/snapshots/darwin/message_editor-dark.png differ diff --git a/testdata/snapshots/darwin/message_editor-light.png b/testdata/snapshots/darwin/message_editor-light.png new file mode 100644 index 0000000..e609072 Binary files /dev/null and b/testdata/snapshots/darwin/message_editor-light.png differ diff --git a/testdata/snapshots/darwin/message_header-dark.png b/testdata/snapshots/darwin/message_header-dark.png new file mode 100644 index 0000000..547af06 Binary files /dev/null and b/testdata/snapshots/darwin/message_header-dark.png differ diff --git a/testdata/snapshots/darwin/message_header-light.png b/testdata/snapshots/darwin/message_header-light.png new file mode 100644 index 0000000..cc7c41b Binary files /dev/null and b/testdata/snapshots/darwin/message_header-light.png differ diff --git a/testdata/snapshots/darwin/message_list-dark.png b/testdata/snapshots/darwin/message_list-dark.png new file mode 100644 index 0000000..0850170 Binary files /dev/null and b/testdata/snapshots/darwin/message_list-dark.png differ diff --git a/testdata/snapshots/darwin/message_list-light.png b/testdata/snapshots/darwin/message_list-light.png new file mode 100644 index 0000000..1853340 Binary files /dev/null and b/testdata/snapshots/darwin/message_list-light.png differ diff --git a/testdata/snapshots/darwin/mic_level_meter-dark.png b/testdata/snapshots/darwin/mic_level_meter-dark.png new file mode 100644 index 0000000..0c478e1 Binary files /dev/null and b/testdata/snapshots/darwin/mic_level_meter-dark.png differ diff --git a/testdata/snapshots/darwin/mic_level_meter-light.png b/testdata/snapshots/darwin/mic_level_meter-light.png new file mode 100644 index 0000000..5e2c50d Binary files /dev/null and b/testdata/snapshots/darwin/mic_level_meter-light.png differ diff --git a/testdata/snapshots/darwin/minimap-dark.png b/testdata/snapshots/darwin/minimap-dark.png new file mode 100644 index 0000000..f9dfd84 Binary files /dev/null and b/testdata/snapshots/darwin/minimap-dark.png differ diff --git a/testdata/snapshots/darwin/minimap-light.png b/testdata/snapshots/darwin/minimap-light.png new file mode 100644 index 0000000..e061ab9 Binary files /dev/null and b/testdata/snapshots/darwin/minimap-light.png differ diff --git a/testdata/snapshots/darwin/multi_file_diff_review-dark.png b/testdata/snapshots/darwin/multi_file_diff_review-dark.png new file mode 100644 index 0000000..5b3c55b Binary files /dev/null and b/testdata/snapshots/darwin/multi_file_diff_review-dark.png differ diff --git a/testdata/snapshots/darwin/multi_file_diff_review-light.png b/testdata/snapshots/darwin/multi_file_diff_review-light.png new file mode 100644 index 0000000..6ba5027 Binary files /dev/null and b/testdata/snapshots/darwin/multi_file_diff_review-light.png differ diff --git a/testdata/snapshots/darwin/network_graph-dark.png b/testdata/snapshots/darwin/network_graph-dark.png new file mode 100644 index 0000000..f874117 Binary files /dev/null and b/testdata/snapshots/darwin/network_graph-dark.png differ diff --git a/testdata/snapshots/darwin/network_graph-light.png b/testdata/snapshots/darwin/network_graph-light.png new file mode 100644 index 0000000..90c1640 Binary files /dev/null and b/testdata/snapshots/darwin/network_graph-light.png differ diff --git a/testdata/snapshots/darwin/order_book-dark.png b/testdata/snapshots/darwin/order_book-dark.png new file mode 100644 index 0000000..a3cbd72 Binary files /dev/null and b/testdata/snapshots/darwin/order_book-dark.png differ diff --git a/testdata/snapshots/darwin/order_book-light.png b/testdata/snapshots/darwin/order_book-light.png new file mode 100644 index 0000000..4c132d4 Binary files /dev/null and b/testdata/snapshots/darwin/order_book-light.png differ diff --git a/testdata/snapshots/darwin/order_confirm_dialog-dark.png b/testdata/snapshots/darwin/order_confirm_dialog-dark.png new file mode 100644 index 0000000..e36eea5 Binary files /dev/null and b/testdata/snapshots/darwin/order_confirm_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/order_confirm_dialog-light.png b/testdata/snapshots/darwin/order_confirm_dialog-light.png new file mode 100644 index 0000000..4d91420 Binary files /dev/null and b/testdata/snapshots/darwin/order_confirm_dialog-light.png differ diff --git a/testdata/snapshots/darwin/order_entry-dark.png b/testdata/snapshots/darwin/order_entry-dark.png new file mode 100644 index 0000000..45148b8 Binary files /dev/null and b/testdata/snapshots/darwin/order_entry-dark.png differ diff --git a/testdata/snapshots/darwin/order_entry-light.png b/testdata/snapshots/darwin/order_entry-light.png new file mode 100644 index 0000000..c8505f3 Binary files /dev/null and b/testdata/snapshots/darwin/order_entry-light.png differ diff --git a/testdata/snapshots/darwin/order_table-dark.png b/testdata/snapshots/darwin/order_table-dark.png new file mode 100644 index 0000000..4279f9b Binary files /dev/null and b/testdata/snapshots/darwin/order_table-dark.png differ diff --git a/testdata/snapshots/darwin/order_table-light.png b/testdata/snapshots/darwin/order_table-light.png new file mode 100644 index 0000000..442feec Binary files /dev/null and b/testdata/snapshots/darwin/order_table-light.png differ diff --git a/testdata/snapshots/darwin/output_panel-dark.png b/testdata/snapshots/darwin/output_panel-dark.png new file mode 100644 index 0000000..09d8585 Binary files /dev/null and b/testdata/snapshots/darwin/output_panel-dark.png differ diff --git a/testdata/snapshots/darwin/output_panel-light.png b/testdata/snapshots/darwin/output_panel-light.png new file mode 100644 index 0000000..c95ef87 Binary files /dev/null and b/testdata/snapshots/darwin/output_panel-light.png differ diff --git a/testdata/snapshots/darwin/permission_prompt-dark.png b/testdata/snapshots/darwin/permission_prompt-dark.png new file mode 100644 index 0000000..d1bd8d5 Binary files /dev/null and b/testdata/snapshots/darwin/permission_prompt-dark.png differ diff --git a/testdata/snapshots/darwin/permission_prompt-light.png b/testdata/snapshots/darwin/permission_prompt-light.png new file mode 100644 index 0000000..bce80df Binary files /dev/null and b/testdata/snapshots/darwin/permission_prompt-light.png differ diff --git a/testdata/snapshots/darwin/pie_chart-dark.png b/testdata/snapshots/darwin/pie_chart-dark.png new file mode 100644 index 0000000..8629a99 Binary files /dev/null and b/testdata/snapshots/darwin/pie_chart-dark.png differ diff --git a/testdata/snapshots/darwin/pie_chart-light.png b/testdata/snapshots/darwin/pie_chart-light.png new file mode 100644 index 0000000..8237cec Binary files /dev/null and b/testdata/snapshots/darwin/pie_chart-light.png differ diff --git a/testdata/snapshots/darwin/playback_speed_control-dark.png b/testdata/snapshots/darwin/playback_speed_control-dark.png new file mode 100644 index 0000000..13c0077 Binary files /dev/null and b/testdata/snapshots/darwin/playback_speed_control-dark.png differ diff --git a/testdata/snapshots/darwin/playback_speed_control-light.png b/testdata/snapshots/darwin/playback_speed_control-light.png new file mode 100644 index 0000000..0db4687 Binary files /dev/null and b/testdata/snapshots/darwin/playback_speed_control-light.png differ diff --git a/testdata/snapshots/darwin/playlist-dark.png b/testdata/snapshots/darwin/playlist-dark.png new file mode 100644 index 0000000..ac2b501 Binary files /dev/null and b/testdata/snapshots/darwin/playlist-dark.png differ diff --git a/testdata/snapshots/darwin/playlist-light.png b/testdata/snapshots/darwin/playlist-light.png new file mode 100644 index 0000000..7fc92aa Binary files /dev/null and b/testdata/snapshots/darwin/playlist-light.png differ diff --git a/testdata/snapshots/darwin/pnl_display-dark.png b/testdata/snapshots/darwin/pnl_display-dark.png new file mode 100644 index 0000000..e700740 Binary files /dev/null and b/testdata/snapshots/darwin/pnl_display-dark.png differ diff --git a/testdata/snapshots/darwin/pnl_display-light.png b/testdata/snapshots/darwin/pnl_display-light.png new file mode 100644 index 0000000..5652165 Binary files /dev/null and b/testdata/snapshots/darwin/pnl_display-light.png differ diff --git a/testdata/snapshots/darwin/position_table-dark.png b/testdata/snapshots/darwin/position_table-dark.png new file mode 100644 index 0000000..4c1ad60 Binary files /dev/null and b/testdata/snapshots/darwin/position_table-dark.png differ diff --git a/testdata/snapshots/darwin/position_table-light.png b/testdata/snapshots/darwin/position_table-light.png new file mode 100644 index 0000000..860ce9f Binary files /dev/null and b/testdata/snapshots/darwin/position_table-light.png differ diff --git a/testdata/snapshots/darwin/price_change_badge-dark.png b/testdata/snapshots/darwin/price_change_badge-dark.png new file mode 100644 index 0000000..735c548 Binary files /dev/null and b/testdata/snapshots/darwin/price_change_badge-dark.png differ diff --git a/testdata/snapshots/darwin/price_change_badge-light.png b/testdata/snapshots/darwin/price_change_badge-light.png new file mode 100644 index 0000000..2008a6c Binary files /dev/null and b/testdata/snapshots/darwin/price_change_badge-light.png differ diff --git a/testdata/snapshots/darwin/price_text-dark.png b/testdata/snapshots/darwin/price_text-dark.png new file mode 100644 index 0000000..9c9c101 Binary files /dev/null and b/testdata/snapshots/darwin/price_text-dark.png differ diff --git a/testdata/snapshots/darwin/price_text-light.png b/testdata/snapshots/darwin/price_text-light.png new file mode 100644 index 0000000..c891bbf Binary files /dev/null and b/testdata/snapshots/darwin/price_text-light.png differ diff --git a/testdata/snapshots/darwin/problems_panel-dark.png b/testdata/snapshots/darwin/problems_panel-dark.png new file mode 100644 index 0000000..d19e9ae Binary files /dev/null and b/testdata/snapshots/darwin/problems_panel-dark.png differ diff --git a/testdata/snapshots/darwin/problems_panel-light.png b/testdata/snapshots/darwin/problems_panel-light.png new file mode 100644 index 0000000..17355b8 Binary files /dev/null and b/testdata/snapshots/darwin/problems_panel-light.png differ diff --git a/testdata/snapshots/darwin/process_list-dark.png b/testdata/snapshots/darwin/process_list-dark.png new file mode 100644 index 0000000..a784437 Binary files /dev/null and b/testdata/snapshots/darwin/process_list-dark.png differ diff --git a/testdata/snapshots/darwin/process_list-light.png b/testdata/snapshots/darwin/process_list-light.png new file mode 100644 index 0000000..79711b4 Binary files /dev/null and b/testdata/snapshots/darwin/process_list-light.png differ diff --git a/testdata/snapshots/darwin/project_knowledge_panel-dark.png b/testdata/snapshots/darwin/project_knowledge_panel-dark.png new file mode 100644 index 0000000..eb1eca7 Binary files /dev/null and b/testdata/snapshots/darwin/project_knowledge_panel-dark.png differ diff --git a/testdata/snapshots/darwin/project_knowledge_panel-light.png b/testdata/snapshots/darwin/project_knowledge_panel-light.png new file mode 100644 index 0000000..6efc4e7 Binary files /dev/null and b/testdata/snapshots/darwin/project_knowledge_panel-light.png differ diff --git a/testdata/snapshots/darwin/project_list-dark.png b/testdata/snapshots/darwin/project_list-dark.png new file mode 100644 index 0000000..5a3ec14 Binary files /dev/null and b/testdata/snapshots/darwin/project_list-dark.png differ diff --git a/testdata/snapshots/darwin/project_list-light.png b/testdata/snapshots/darwin/project_list-light.png new file mode 100644 index 0000000..2fc92d8 Binary files /dev/null and b/testdata/snapshots/darwin/project_list-light.png differ diff --git a/testdata/snapshots/darwin/prompt_library-dark.png b/testdata/snapshots/darwin/prompt_library-dark.png new file mode 100644 index 0000000..6205353 Binary files /dev/null and b/testdata/snapshots/darwin/prompt_library-dark.png differ diff --git a/testdata/snapshots/darwin/prompt_library-light.png b/testdata/snapshots/darwin/prompt_library-light.png new file mode 100644 index 0000000..1c2ad29 Binary files /dev/null and b/testdata/snapshots/darwin/prompt_library-light.png differ diff --git a/testdata/snapshots/darwin/pull_request_card-dark.png b/testdata/snapshots/darwin/pull_request_card-dark.png new file mode 100644 index 0000000..353a57b Binary files /dev/null and b/testdata/snapshots/darwin/pull_request_card-dark.png differ diff --git a/testdata/snapshots/darwin/pull_request_card-light.png b/testdata/snapshots/darwin/pull_request_card-light.png new file mode 100644 index 0000000..987ba23 Binary files /dev/null and b/testdata/snapshots/darwin/pull_request_card-light.png differ diff --git a/testdata/snapshots/darwin/quick_trade_buttons-dark.png b/testdata/snapshots/darwin/quick_trade_buttons-dark.png new file mode 100644 index 0000000..b6a619e Binary files /dev/null and b/testdata/snapshots/darwin/quick_trade_buttons-dark.png differ diff --git a/testdata/snapshots/darwin/quick_trade_buttons-light.png b/testdata/snapshots/darwin/quick_trade_buttons-light.png new file mode 100644 index 0000000..34f5b42 Binary files /dev/null and b/testdata/snapshots/darwin/quick_trade_buttons-light.png differ diff --git a/testdata/snapshots/darwin/quote_card-dark.png b/testdata/snapshots/darwin/quote_card-dark.png new file mode 100644 index 0000000..4d553ac Binary files /dev/null and b/testdata/snapshots/darwin/quote_card-dark.png differ diff --git a/testdata/snapshots/darwin/quote_card-light.png b/testdata/snapshots/darwin/quote_card-light.png new file mode 100644 index 0000000..85c9d4a Binary files /dev/null and b/testdata/snapshots/darwin/quote_card-light.png differ diff --git a/testdata/snapshots/darwin/quote_reply-dark.png b/testdata/snapshots/darwin/quote_reply-dark.png new file mode 100644 index 0000000..dae6d61 Binary files /dev/null and b/testdata/snapshots/darwin/quote_reply-dark.png differ diff --git a/testdata/snapshots/darwin/quote_reply-light.png b/testdata/snapshots/darwin/quote_reply-light.png new file mode 100644 index 0000000..9c4e543 Binary files /dev/null and b/testdata/snapshots/darwin/quote_reply-light.png differ diff --git a/testdata/snapshots/darwin/radar_chart-dark.png b/testdata/snapshots/darwin/radar_chart-dark.png new file mode 100644 index 0000000..aa910f8 Binary files /dev/null and b/testdata/snapshots/darwin/radar_chart-dark.png differ diff --git a/testdata/snapshots/darwin/radar_chart-light.png b/testdata/snapshots/darwin/radar_chart-light.png new file mode 100644 index 0000000..3ce2f97 Binary files /dev/null and b/testdata/snapshots/darwin/radar_chart-light.png differ diff --git a/testdata/snapshots/darwin/realtime_chart-dark.png b/testdata/snapshots/darwin/realtime_chart-dark.png new file mode 100644 index 0000000..08f61cf Binary files /dev/null and b/testdata/snapshots/darwin/realtime_chart-dark.png differ diff --git a/testdata/snapshots/darwin/realtime_chart-light.png b/testdata/snapshots/darwin/realtime_chart-light.png new file mode 100644 index 0000000..acfc8c4 Binary files /dev/null and b/testdata/snapshots/darwin/realtime_chart-light.png differ diff --git a/testdata/snapshots/darwin/recurrence_editor-dark.png b/testdata/snapshots/darwin/recurrence_editor-dark.png new file mode 100644 index 0000000..fc99a37 Binary files /dev/null and b/testdata/snapshots/darwin/recurrence_editor-dark.png differ diff --git a/testdata/snapshots/darwin/recurrence_editor-light.png b/testdata/snapshots/darwin/recurrence_editor-light.png new file mode 100644 index 0000000..200edd4 Binary files /dev/null and b/testdata/snapshots/darwin/recurrence_editor-light.png differ diff --git a/testdata/snapshots/darwin/regenerate_menu-dark.png b/testdata/snapshots/darwin/regenerate_menu-dark.png new file mode 100644 index 0000000..5d4356e Binary files /dev/null and b/testdata/snapshots/darwin/regenerate_menu-dark.png differ diff --git a/testdata/snapshots/darwin/regenerate_menu-light.png b/testdata/snapshots/darwin/regenerate_menu-light.png new file mode 100644 index 0000000..9cea599 Binary files /dev/null and b/testdata/snapshots/darwin/regenerate_menu-light.png differ diff --git a/testdata/snapshots/darwin/relative_time-dark.png b/testdata/snapshots/darwin/relative_time-dark.png new file mode 100644 index 0000000..a915c4b Binary files /dev/null and b/testdata/snapshots/darwin/relative_time-dark.png differ diff --git a/testdata/snapshots/darwin/relative_time-light.png b/testdata/snapshots/darwin/relative_time-light.png new file mode 100644 index 0000000..ed172f4 Binary files /dev/null and b/testdata/snapshots/darwin/relative_time-light.png differ diff --git a/testdata/snapshots/darwin/reminder_picker-dark.png b/testdata/snapshots/darwin/reminder_picker-dark.png new file mode 100644 index 0000000..9941062 Binary files /dev/null and b/testdata/snapshots/darwin/reminder_picker-dark.png differ diff --git a/testdata/snapshots/darwin/reminder_picker-light.png b/testdata/snapshots/darwin/reminder_picker-light.png new file mode 100644 index 0000000..b69a82b Binary files /dev/null and b/testdata/snapshots/darwin/reminder_picker-light.png differ diff --git a/testdata/snapshots/darwin/review_comment-dark.png b/testdata/snapshots/darwin/review_comment-dark.png new file mode 100644 index 0000000..508238e Binary files /dev/null and b/testdata/snapshots/darwin/review_comment-dark.png differ diff --git a/testdata/snapshots/darwin/review_comment-light.png b/testdata/snapshots/darwin/review_comment-light.png new file mode 100644 index 0000000..67db2b5 Binary files /dev/null and b/testdata/snapshots/darwin/review_comment-light.png differ diff --git a/testdata/snapshots/darwin/risk_meter-dark.png b/testdata/snapshots/darwin/risk_meter-dark.png new file mode 100644 index 0000000..805964c Binary files /dev/null and b/testdata/snapshots/darwin/risk_meter-dark.png differ diff --git a/testdata/snapshots/darwin/risk_meter-light.png b/testdata/snapshots/darwin/risk_meter-light.png new file mode 100644 index 0000000..2ac5c3f Binary files /dev/null and b/testdata/snapshots/darwin/risk_meter-light.png differ diff --git a/testdata/snapshots/darwin/sandbox_status-dark.png b/testdata/snapshots/darwin/sandbox_status-dark.png new file mode 100644 index 0000000..8b02d36 Binary files /dev/null and b/testdata/snapshots/darwin/sandbox_status-dark.png differ diff --git a/testdata/snapshots/darwin/sandbox_status-light.png b/testdata/snapshots/darwin/sandbox_status-light.png new file mode 100644 index 0000000..6a92c06 Binary files /dev/null and b/testdata/snapshots/darwin/sandbox_status-light.png differ diff --git a/testdata/snapshots/darwin/sankey_chart-dark.png b/testdata/snapshots/darwin/sankey_chart-dark.png new file mode 100644 index 0000000..29ad59c Binary files /dev/null and b/testdata/snapshots/darwin/sankey_chart-dark.png differ diff --git a/testdata/snapshots/darwin/sankey_chart-light.png b/testdata/snapshots/darwin/sankey_chart-light.png new file mode 100644 index 0000000..706817a Binary files /dev/null and b/testdata/snapshots/darwin/sankey_chart-light.png differ diff --git a/testdata/snapshots/darwin/scatter_chart-dark.png b/testdata/snapshots/darwin/scatter_chart-dark.png new file mode 100644 index 0000000..35cea09 Binary files /dev/null and b/testdata/snapshots/darwin/scatter_chart-dark.png differ diff --git a/testdata/snapshots/darwin/scatter_chart-light.png b/testdata/snapshots/darwin/scatter_chart-light.png new file mode 100644 index 0000000..2838b97 Binary files /dev/null and b/testdata/snapshots/darwin/scatter_chart-light.png differ diff --git a/testdata/snapshots/darwin/screen_recorder_controls-dark.png b/testdata/snapshots/darwin/screen_recorder_controls-dark.png new file mode 100644 index 0000000..925cdf8 Binary files /dev/null and b/testdata/snapshots/darwin/screen_recorder_controls-dark.png differ diff --git a/testdata/snapshots/darwin/screen_recorder_controls-light.png b/testdata/snapshots/darwin/screen_recorder_controls-light.png new file mode 100644 index 0000000..4e60fdf Binary files /dev/null and b/testdata/snapshots/darwin/screen_recorder_controls-light.png differ diff --git a/testdata/snapshots/darwin/screenshot_stream-dark.png b/testdata/snapshots/darwin/screenshot_stream-dark.png new file mode 100644 index 0000000..833f426 Binary files /dev/null and b/testdata/snapshots/darwin/screenshot_stream-dark.png differ diff --git a/testdata/snapshots/darwin/screenshot_stream-light.png b/testdata/snapshots/darwin/screenshot_stream-light.png new file mode 100644 index 0000000..408ca79 Binary files /dev/null and b/testdata/snapshots/darwin/screenshot_stream-light.png differ diff --git a/testdata/snapshots/darwin/scroll_to_bottom_button-dark.png b/testdata/snapshots/darwin/scroll_to_bottom_button-dark.png new file mode 100644 index 0000000..2213efe Binary files /dev/null and b/testdata/snapshots/darwin/scroll_to_bottom_button-dark.png differ diff --git a/testdata/snapshots/darwin/scroll_to_bottom_button-light.png b/testdata/snapshots/darwin/scroll_to_bottom_button-light.png new file mode 100644 index 0000000..31fd1d7 Binary files /dev/null and b/testdata/snapshots/darwin/scroll_to_bottom_button-light.png differ diff --git a/testdata/snapshots/darwin/search_panel-dark.png b/testdata/snapshots/darwin/search_panel-dark.png new file mode 100644 index 0000000..16ff38e Binary files /dev/null and b/testdata/snapshots/darwin/search_panel-dark.png differ diff --git a/testdata/snapshots/darwin/search_panel-light.png b/testdata/snapshots/darwin/search_panel-light.png new file mode 100644 index 0000000..ac992e5 Binary files /dev/null and b/testdata/snapshots/darwin/search_panel-light.png differ diff --git a/testdata/snapshots/darwin/shared_conversation_view-dark.png b/testdata/snapshots/darwin/shared_conversation_view-dark.png new file mode 100644 index 0000000..212d179 Binary files /dev/null and b/testdata/snapshots/darwin/shared_conversation_view-dark.png differ diff --git a/testdata/snapshots/darwin/shared_conversation_view-light.png b/testdata/snapshots/darwin/shared_conversation_view-light.png new file mode 100644 index 0000000..09fa005 Binary files /dev/null and b/testdata/snapshots/darwin/shared_conversation_view-light.png differ diff --git a/testdata/snapshots/darwin/sparkline-dark.png b/testdata/snapshots/darwin/sparkline-dark.png new file mode 100644 index 0000000..f5e0358 Binary files /dev/null and b/testdata/snapshots/darwin/sparkline-dark.png differ diff --git a/testdata/snapshots/darwin/sparkline-light.png b/testdata/snapshots/darwin/sparkline-light.png new file mode 100644 index 0000000..e17c629 Binary files /dev/null and b/testdata/snapshots/darwin/sparkline-light.png differ diff --git a/testdata/snapshots/darwin/spread_indicator-dark.png b/testdata/snapshots/darwin/spread_indicator-dark.png new file mode 100644 index 0000000..df230e2 Binary files /dev/null and b/testdata/snapshots/darwin/spread_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/spread_indicator-light.png b/testdata/snapshots/darwin/spread_indicator-light.png new file mode 100644 index 0000000..d0a9759 Binary files /dev/null and b/testdata/snapshots/darwin/spread_indicator-light.png differ diff --git a/testdata/snapshots/darwin/stash_list-dark.png b/testdata/snapshots/darwin/stash_list-dark.png new file mode 100644 index 0000000..4a36fdd Binary files /dev/null and b/testdata/snapshots/darwin/stash_list-dark.png differ diff --git a/testdata/snapshots/darwin/stash_list-light.png b/testdata/snapshots/darwin/stash_list-light.png new file mode 100644 index 0000000..6bb21d6 Binary files /dev/null and b/testdata/snapshots/darwin/stash_list-light.png differ diff --git a/testdata/snapshots/darwin/stop_generating_button-dark.png b/testdata/snapshots/darwin/stop_generating_button-dark.png new file mode 100644 index 0000000..f0a9cd3 Binary files /dev/null and b/testdata/snapshots/darwin/stop_generating_button-dark.png differ diff --git a/testdata/snapshots/darwin/stop_generating_button-light.png b/testdata/snapshots/darwin/stop_generating_button-light.png new file mode 100644 index 0000000..974880f Binary files /dev/null and b/testdata/snapshots/darwin/stop_generating_button-light.png differ diff --git a/testdata/snapshots/darwin/stopwatch-dark.png b/testdata/snapshots/darwin/stopwatch-dark.png new file mode 100644 index 0000000..0808f83 Binary files /dev/null and b/testdata/snapshots/darwin/stopwatch-dark.png differ diff --git a/testdata/snapshots/darwin/stopwatch-light.png b/testdata/snapshots/darwin/stopwatch-light.png new file mode 100644 index 0000000..b513e4c Binary files /dev/null and b/testdata/snapshots/darwin/stopwatch-light.png differ diff --git a/testdata/snapshots/darwin/streaming_text-dark.png b/testdata/snapshots/darwin/streaming_text-dark.png new file mode 100644 index 0000000..4e3abd4 Binary files /dev/null and b/testdata/snapshots/darwin/streaming_text-dark.png differ diff --git a/testdata/snapshots/darwin/streaming_text-light.png b/testdata/snapshots/darwin/streaming_text-light.png new file mode 100644 index 0000000..cadc899 Binary files /dev/null and b/testdata/snapshots/darwin/streaming_text-light.png differ diff --git a/testdata/snapshots/darwin/sub_agent_tree-dark.png b/testdata/snapshots/darwin/sub_agent_tree-dark.png new file mode 100644 index 0000000..ab686fe Binary files /dev/null and b/testdata/snapshots/darwin/sub_agent_tree-dark.png differ diff --git a/testdata/snapshots/darwin/sub_agent_tree-light.png b/testdata/snapshots/darwin/sub_agent_tree-light.png new file mode 100644 index 0000000..6309770 Binary files /dev/null and b/testdata/snapshots/darwin/sub_agent_tree-light.png differ diff --git a/testdata/snapshots/darwin/subtitle_editor-dark.png b/testdata/snapshots/darwin/subtitle_editor-dark.png new file mode 100644 index 0000000..00ed599 Binary files /dev/null and b/testdata/snapshots/darwin/subtitle_editor-dark.png differ diff --git a/testdata/snapshots/darwin/subtitle_editor-light.png b/testdata/snapshots/darwin/subtitle_editor-light.png new file mode 100644 index 0000000..f0f3564 Binary files /dev/null and b/testdata/snapshots/darwin/subtitle_editor-light.png differ diff --git a/testdata/snapshots/darwin/suggestion_chips-dark.png b/testdata/snapshots/darwin/suggestion_chips-dark.png new file mode 100644 index 0000000..8d62552 Binary files /dev/null and b/testdata/snapshots/darwin/suggestion_chips-dark.png differ diff --git a/testdata/snapshots/darwin/suggestion_chips-light.png b/testdata/snapshots/darwin/suggestion_chips-light.png new file mode 100644 index 0000000..5d1b6fe Binary files /dev/null and b/testdata/snapshots/darwin/suggestion_chips-light.png differ diff --git a/testdata/snapshots/darwin/sunburst-dark.png b/testdata/snapshots/darwin/sunburst-dark.png new file mode 100644 index 0000000..4c8c9f6 Binary files /dev/null and b/testdata/snapshots/darwin/sunburst-dark.png differ diff --git a/testdata/snapshots/darwin/sunburst-light.png b/testdata/snapshots/darwin/sunburst-light.png new file mode 100644 index 0000000..297c871 Binary files /dev/null and b/testdata/snapshots/darwin/sunburst-light.png differ diff --git a/testdata/snapshots/darwin/symbol_outline-dark.png b/testdata/snapshots/darwin/symbol_outline-dark.png new file mode 100644 index 0000000..b68cba4 Binary files /dev/null and b/testdata/snapshots/darwin/symbol_outline-dark.png differ diff --git a/testdata/snapshots/darwin/symbol_outline-light.png b/testdata/snapshots/darwin/symbol_outline-light.png new file mode 100644 index 0000000..d46e177 Binary files /dev/null and b/testdata/snapshots/darwin/symbol_outline-light.png differ diff --git a/testdata/snapshots/darwin/system_prompt_editor-dark.png b/testdata/snapshots/darwin/system_prompt_editor-dark.png new file mode 100644 index 0000000..2444177 Binary files /dev/null and b/testdata/snapshots/darwin/system_prompt_editor-dark.png differ diff --git a/testdata/snapshots/darwin/system_prompt_editor-light.png b/testdata/snapshots/darwin/system_prompt_editor-light.png new file mode 100644 index 0000000..6dced57 Binary files /dev/null and b/testdata/snapshots/darwin/system_prompt_editor-light.png differ diff --git a/testdata/snapshots/darwin/table_block-dark.png b/testdata/snapshots/darwin/table_block-dark.png new file mode 100644 index 0000000..cc38f31 Binary files /dev/null and b/testdata/snapshots/darwin/table_block-dark.png differ diff --git a/testdata/snapshots/darwin/table_block-light.png b/testdata/snapshots/darwin/table_block-light.png new file mode 100644 index 0000000..54fdc1d Binary files /dev/null and b/testdata/snapshots/darwin/table_block-light.png differ diff --git a/testdata/snapshots/darwin/tag_list-dark.png b/testdata/snapshots/darwin/tag_list-dark.png new file mode 100644 index 0000000..1a9535c Binary files /dev/null and b/testdata/snapshots/darwin/tag_list-dark.png differ diff --git a/testdata/snapshots/darwin/tag_list-light.png b/testdata/snapshots/darwin/tag_list-light.png new file mode 100644 index 0000000..da5953d Binary files /dev/null and b/testdata/snapshots/darwin/tag_list-light.png differ diff --git a/testdata/snapshots/darwin/terminal-dark.png b/testdata/snapshots/darwin/terminal-dark.png new file mode 100644 index 0000000..33d98ea Binary files /dev/null and b/testdata/snapshots/darwin/terminal-dark.png differ diff --git a/testdata/snapshots/darwin/terminal-light.png b/testdata/snapshots/darwin/terminal-light.png new file mode 100644 index 0000000..31dc614 Binary files /dev/null and b/testdata/snapshots/darwin/terminal-light.png differ diff --git a/testdata/snapshots/darwin/terminal_search-dark.png b/testdata/snapshots/darwin/terminal_search-dark.png new file mode 100644 index 0000000..50f4000 Binary files /dev/null and b/testdata/snapshots/darwin/terminal_search-dark.png differ diff --git a/testdata/snapshots/darwin/terminal_search-light.png b/testdata/snapshots/darwin/terminal_search-light.png new file mode 100644 index 0000000..0af80e0 Binary files /dev/null and b/testdata/snapshots/darwin/terminal_search-light.png differ diff --git a/testdata/snapshots/darwin/terminal_tabs-dark.png b/testdata/snapshots/darwin/terminal_tabs-dark.png new file mode 100644 index 0000000..ce68ef6 Binary files /dev/null and b/testdata/snapshots/darwin/terminal_tabs-dark.png differ diff --git a/testdata/snapshots/darwin/terminal_tabs-light.png b/testdata/snapshots/darwin/terminal_tabs-light.png new file mode 100644 index 0000000..9ef9a6c Binary files /dev/null and b/testdata/snapshots/darwin/terminal_tabs-light.png differ diff --git a/testdata/snapshots/darwin/thinking_block-dark.png b/testdata/snapshots/darwin/thinking_block-dark.png new file mode 100644 index 0000000..a33397b Binary files /dev/null and b/testdata/snapshots/darwin/thinking_block-dark.png differ diff --git a/testdata/snapshots/darwin/thinking_block-light.png b/testdata/snapshots/darwin/thinking_block-light.png new file mode 100644 index 0000000..4f62d55 Binary files /dev/null and b/testdata/snapshots/darwin/thinking_block-light.png differ diff --git a/testdata/snapshots/darwin/thinking_indicator-dark.png b/testdata/snapshots/darwin/thinking_indicator-dark.png new file mode 100644 index 0000000..36e4f45 Binary files /dev/null and b/testdata/snapshots/darwin/thinking_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/thinking_indicator-light.png b/testdata/snapshots/darwin/thinking_indicator-light.png new file mode 100644 index 0000000..d913285 Binary files /dev/null and b/testdata/snapshots/darwin/thinking_indicator-light.png differ diff --git a/testdata/snapshots/darwin/three_way_merge-dark.png b/testdata/snapshots/darwin/three_way_merge-dark.png new file mode 100644 index 0000000..a18d990 Binary files /dev/null and b/testdata/snapshots/darwin/three_way_merge-dark.png differ diff --git a/testdata/snapshots/darwin/three_way_merge-light.png b/testdata/snapshots/darwin/three_way_merge-light.png new file mode 100644 index 0000000..8dfcc51 Binary files /dev/null and b/testdata/snapshots/darwin/three_way_merge-light.png differ diff --git a/testdata/snapshots/darwin/ticker_tape-dark.png b/testdata/snapshots/darwin/ticker_tape-dark.png new file mode 100644 index 0000000..d749ec0 Binary files /dev/null and b/testdata/snapshots/darwin/ticker_tape-dark.png differ diff --git a/testdata/snapshots/darwin/ticker_tape-light.png b/testdata/snapshots/darwin/ticker_tape-light.png new file mode 100644 index 0000000..f3b79a8 Binary files /dev/null and b/testdata/snapshots/darwin/ticker_tape-light.png differ diff --git a/testdata/snapshots/darwin/time_and_sales-dark.png b/testdata/snapshots/darwin/time_and_sales-dark.png new file mode 100644 index 0000000..5b8a1e8 Binary files /dev/null and b/testdata/snapshots/darwin/time_and_sales-dark.png differ diff --git a/testdata/snapshots/darwin/time_and_sales-light.png b/testdata/snapshots/darwin/time_and_sales-light.png new file mode 100644 index 0000000..813e949 Binary files /dev/null and b/testdata/snapshots/darwin/time_and_sales-light.png differ diff --git a/testdata/snapshots/darwin/timezone_select-dark.png b/testdata/snapshots/darwin/timezone_select-dark.png new file mode 100644 index 0000000..f226c09 Binary files /dev/null and b/testdata/snapshots/darwin/timezone_select-dark.png differ diff --git a/testdata/snapshots/darwin/timezone_select-light.png b/testdata/snapshots/darwin/timezone_select-light.png new file mode 100644 index 0000000..30762f9 Binary files /dev/null and b/testdata/snapshots/darwin/timezone_select-light.png differ diff --git a/testdata/snapshots/darwin/tool_approval_dialog-dark.png b/testdata/snapshots/darwin/tool_approval_dialog-dark.png new file mode 100644 index 0000000..4dab692 Binary files /dev/null and b/testdata/snapshots/darwin/tool_approval_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/tool_approval_dialog-light.png b/testdata/snapshots/darwin/tool_approval_dialog-light.png new file mode 100644 index 0000000..77c70a6 Binary files /dev/null and b/testdata/snapshots/darwin/tool_approval_dialog-light.png differ diff --git a/testdata/snapshots/darwin/tool_call_card-dark.png b/testdata/snapshots/darwin/tool_call_card-dark.png new file mode 100644 index 0000000..3424c0b Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_card-dark.png differ diff --git a/testdata/snapshots/darwin/tool_call_card-light.png b/testdata/snapshots/darwin/tool_call_card-light.png new file mode 100644 index 0000000..68ca034 Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_card-light.png differ diff --git a/testdata/snapshots/darwin/tool_call_group-dark.png b/testdata/snapshots/darwin/tool_call_group-dark.png new file mode 100644 index 0000000..22cce78 Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_group-dark.png differ diff --git a/testdata/snapshots/darwin/tool_call_group-light.png b/testdata/snapshots/darwin/tool_call_group-light.png new file mode 100644 index 0000000..f7acfa4 Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_group-light.png differ diff --git a/testdata/snapshots/darwin/trade_history_table-dark.png b/testdata/snapshots/darwin/trade_history_table-dark.png new file mode 100644 index 0000000..900b54e Binary files /dev/null and b/testdata/snapshots/darwin/trade_history_table-dark.png differ diff --git a/testdata/snapshots/darwin/trade_history_table-light.png b/testdata/snapshots/darwin/trade_history_table-light.png new file mode 100644 index 0000000..1b2f118 Binary files /dev/null and b/testdata/snapshots/darwin/trade_history_table-light.png differ diff --git a/testdata/snapshots/darwin/treemap-dark.png b/testdata/snapshots/darwin/treemap-dark.png new file mode 100644 index 0000000..18f2d90 Binary files /dev/null and b/testdata/snapshots/darwin/treemap-dark.png differ diff --git a/testdata/snapshots/darwin/treemap-light.png b/testdata/snapshots/darwin/treemap-light.png new file mode 100644 index 0000000..4e9d7a7 Binary files /dev/null and b/testdata/snapshots/darwin/treemap-light.png differ diff --git a/testdata/snapshots/darwin/typing_indicator-dark.png b/testdata/snapshots/darwin/typing_indicator-dark.png new file mode 100644 index 0000000..50032a9 Binary files /dev/null and b/testdata/snapshots/darwin/typing_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/typing_indicator-light.png b/testdata/snapshots/darwin/typing_indicator-light.png new file mode 100644 index 0000000..d9aa0ce Binary files /dev/null and b/testdata/snapshots/darwin/typing_indicator-light.png differ diff --git a/testdata/snapshots/darwin/variables_panel-dark.png b/testdata/snapshots/darwin/variables_panel-dark.png new file mode 100644 index 0000000..ef72e74 Binary files /dev/null and b/testdata/snapshots/darwin/variables_panel-dark.png differ diff --git a/testdata/snapshots/darwin/variables_panel-light.png b/testdata/snapshots/darwin/variables_panel-light.png new file mode 100644 index 0000000..a28c2ba Binary files /dev/null and b/testdata/snapshots/darwin/variables_panel-light.png differ diff --git a/testdata/snapshots/darwin/video_player-dark.png b/testdata/snapshots/darwin/video_player-dark.png new file mode 100644 index 0000000..2759883 Binary files /dev/null and b/testdata/snapshots/darwin/video_player-dark.png differ diff --git a/testdata/snapshots/darwin/video_player-light.png b/testdata/snapshots/darwin/video_player-light.png new file mode 100644 index 0000000..1817b4d Binary files /dev/null and b/testdata/snapshots/darwin/video_player-light.png differ diff --git a/testdata/snapshots/darwin/video_scrubber-dark.png b/testdata/snapshots/darwin/video_scrubber-dark.png new file mode 100644 index 0000000..9781417 Binary files /dev/null and b/testdata/snapshots/darwin/video_scrubber-dark.png differ diff --git a/testdata/snapshots/darwin/video_scrubber-light.png b/testdata/snapshots/darwin/video_scrubber-light.png new file mode 100644 index 0000000..52f3ed7 Binary files /dev/null and b/testdata/snapshots/darwin/video_scrubber-light.png differ diff --git a/testdata/snapshots/darwin/video_thumbnail_strip-dark.png b/testdata/snapshots/darwin/video_thumbnail_strip-dark.png new file mode 100644 index 0000000..d05c3b6 Binary files /dev/null and b/testdata/snapshots/darwin/video_thumbnail_strip-dark.png differ diff --git a/testdata/snapshots/darwin/video_thumbnail_strip-light.png b/testdata/snapshots/darwin/video_thumbnail_strip-light.png new file mode 100644 index 0000000..24fef2c Binary files /dev/null and b/testdata/snapshots/darwin/video_thumbnail_strip-light.png differ diff --git a/testdata/snapshots/darwin/volume_control-dark.png b/testdata/snapshots/darwin/volume_control-dark.png new file mode 100644 index 0000000..cf912f1 Binary files /dev/null and b/testdata/snapshots/darwin/volume_control-dark.png differ diff --git a/testdata/snapshots/darwin/volume_control-light.png b/testdata/snapshots/darwin/volume_control-light.png new file mode 100644 index 0000000..1930be3 Binary files /dev/null and b/testdata/snapshots/darwin/volume_control-light.png differ diff --git a/testdata/snapshots/darwin/waterfall_chart-dark.png b/testdata/snapshots/darwin/waterfall_chart-dark.png new file mode 100644 index 0000000..25c1ea2 Binary files /dev/null and b/testdata/snapshots/darwin/waterfall_chart-dark.png differ diff --git a/testdata/snapshots/darwin/waterfall_chart-light.png b/testdata/snapshots/darwin/waterfall_chart-light.png new file mode 100644 index 0000000..cc16b4b Binary files /dev/null and b/testdata/snapshots/darwin/waterfall_chart-light.png differ diff --git a/testdata/snapshots/darwin/week_picker-dark.png b/testdata/snapshots/darwin/week_picker-dark.png new file mode 100644 index 0000000..1221b30 Binary files /dev/null and b/testdata/snapshots/darwin/week_picker-dark.png differ diff --git a/testdata/snapshots/darwin/week_picker-light.png b/testdata/snapshots/darwin/week_picker-light.png new file mode 100644 index 0000000..74305e3 Binary files /dev/null and b/testdata/snapshots/darwin/week_picker-light.png differ diff --git a/testdata/snapshots/darwin/welcome_screen-dark.png b/testdata/snapshots/darwin/welcome_screen-dark.png new file mode 100644 index 0000000..dc49fa8 Binary files /dev/null and b/testdata/snapshots/darwin/welcome_screen-dark.png differ diff --git a/testdata/snapshots/darwin/welcome_screen-light.png b/testdata/snapshots/darwin/welcome_screen-light.png new file mode 100644 index 0000000..49286a0 Binary files /dev/null and b/testdata/snapshots/darwin/welcome_screen-light.png differ diff --git a/testdata/snapshots/darwin/year_view-dark.png b/testdata/snapshots/darwin/year_view-dark.png new file mode 100644 index 0000000..6a7827e Binary files /dev/null and b/testdata/snapshots/darwin/year_view-dark.png differ diff --git a/testdata/snapshots/darwin/year_view-light.png b/testdata/snapshots/darwin/year_view-light.png new file mode 100644 index 0000000..442d9b1 Binary files /dev/null and b/testdata/snapshots/darwin/year_view-light.png differ diff --git a/thinking_block.go b/thinking_block.go new file mode 100644 index 0000000..e859cc7 --- /dev/null +++ b/thinking_block.go @@ -0,0 +1,50 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ThinkingBlockOptions configure a ThinkingBlock. +type ThinkingBlockOptions struct { + // Thinking shows the spinner and a running timer from Started. + Thinking bool + Started time.Time + // Elapsed is the final thinking time, shown once Thinking is false. + Elapsed time.Duration +} + +// ThinkingBlock folds a model's reasoning under a header with its time; *open's first value sets the default. +func ThinkingBlock(c *ui.Context, open *bool, opts ThinkingBlockOptions, content func()) ChoiceResult { + if opts.Elapsed < 0 || opts.Thinking && opts.Started.IsZero() { + panic("mujica: ThinkingBlock needs Started while Thinking and a non-negative Elapsed") + } + k := Tokens(c) + u := Density(c).Unit() + secs := int(opts.Elapsed.Round(time.Second).Seconds()) + title := fmt.Sprintf(msg(c, "thinking_block.done"), secs) + if opts.Thinking { + run := c.Now().Sub(opts.Started) + secs = int(max(run, 0) / time.Second) + title = fmt.Sprintf(msg(c, "thinking_block.thinking"), secs) + c.After(time.Second - run%time.Second) + } + return Collapsible(c, open, CollapsibleOptions{Title: title, Trigger: func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() { + if opts.Thinking { + Spinner(c, SpinnerOptions{Size: 14, Label: title}) + } else { + ui.Icon(c, icons.Must("brain")).Size(16, 16) + } + ui.Text(c, title).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() + }) + }}, func() { + ui.Column(c).Gap(u*2).Padding(0, 0, 0, u*3).BorderWidth(0, 0, 0, theme.BorderWidth).BorderColor(k.Border). + TextColor(k.TextMuted).Children(content) + }) +} diff --git a/thinking_block_test.go b/thinking_block_test.go new file mode 100644 index 0000000..0688685 --- /dev/null +++ b/thinking_block_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestThinkingBlockFoldsAndTimes(t *testing.T) { + open, thinking := false, true + started := time.Now().Add(-5 * time.Second) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ThinkingBlock(c, &open, ThinkingBlockOptions{Thinking: thinking, Started: started, Elapsed: 12 * time.Second}, func() { + ui.Text(c, "Weigh the guest list") + }) + }, 400, 200) + if !tt.HasText("Thinking… 5 s") || tt.HasText("Weigh the guest list") { + t.Fatalf("collapsed thinking: %q", tt.Texts()) + } + _ = tt.Click("Thinking… 5 s") + if !open || !tt.HasText("Weigh the guest list") { + t.Fatal("click did not open the reasoning") + } + thinking = false + tt.Frame() + if !tt.HasText("Thought for 12 s") { + t.Fatalf("finished header %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if open { + t.Fatal("Space did not fold it") + } +} + +func TestThinkingBlockDefaultOpen(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ThinkingBlock(c, &open, ThinkingBlockOptions{Elapsed: time.Second}, func() { ui.Text(c, "Notes") }) + }, 400, 200) + if !tt.HasText("Notes") { + t.Fatal("default open block hides its content") + } +} + +func TestThinkingBlockNeedsStart(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for Thinking without Started") + } + }() + open := false + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ThinkingBlock(c, &open, ThinkingBlockOptions{Thinking: true}, func() {}) + }, 100, 100) +} + +func TestThinkingBlockSnapshot(t *testing.T) { + open := true + snapshot(t, "thinking_block", 420, 150, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ThinkingBlock(c, &open, ThinkingBlockOptions{Elapsed: 14 * time.Second}, func() { + ui.Text(c, "The guest list has forty names; the hall seats thirty-six. Four must dine in the antechamber.") + }) + }) + }) +} diff --git a/thinking_indicator.go b/thinking_indicator.go new file mode 100644 index 0000000..aa1f61d --- /dev/null +++ b/thinking_indicator.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ThinkingIndicatorOptions configure a ThinkingIndicator. +type ThinkingIndicatorOptions struct { + // Phase is the caller's stage text; empty uses "Thinking". + Phase string +} + +// ThinkingIndicator shows rising diamonds and a phase, still when reduced. +func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element { + k := Tokens(c) + d := Density(c) + phase := opts.Phase + if phase == "" { + phase = msg(c, "thinking_indicator.thinking") + } + still := c.Preferences().ReduceMotion + row := ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Role(ui.RoleStatus).Label(phase) + row.Children(func() { + ui.Box(c).Size(30, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + t := 0.0 + if !still { + t = float64(p.Now().UnixMilli()%1200) / 1200 + p.AnimationFrame() + } + for i := range 3 { + lift := float32(0) + alpha := float32(1) + if !still { + phase := math.Mod(t-float64(i)*0.18+1, 1) + wave := float32(math.Max(0, math.Sin(phase*2*math.Pi))) + lift, alpha = wave*3, 0.45+0.55*wave + } + x := internal.Snap(p, r.X+5+float32(i)*10) + y := internal.Snap(p, r.Y+r.H/2-lift) + p.FillPath(internal.Diamond(x, y, 7), k.AccentText.Alpha(alpha)) + } + }) + text := phase + if still { + text += "…" + } + ui.Text(c, text).FontSize(FontSize(c, theme.BodySize)).TextColor(k.TextMuted).Italic() + }) + return row +} diff --git a/thinking_indicator_test.go b/thinking_indicator_test.go new file mode 100644 index 0000000..6fb8e22 --- /dev/null +++ b/thinking_indicator_test.go @@ -0,0 +1,51 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestThinkingIndicatorPhase(t *testing.T) { + tt := chatTester(400, 100, func(c *ui.Context) { ThinkingIndicator(c, ThinkingIndicatorOptions{}) }) + if !tt.HasText("Thinking") { + t.Fatalf("texts %q", tt.Texts()) + } + tt = chatTester(400, 100, func(c *ui.Context) { + ThinkingIndicator(c, ThinkingIndicatorOptions{Phase: "Reading the archives"}) + }) + if !tt.HasText("Reading the archives") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestThinkingIndicatorReducedMotionIsStill(t *testing.T) { + frame := func(reduce bool, at time.Duration) []byte { + tt := chatTester(200, 60, func(c *ui.Context) { ThinkingIndicator(c, ThinkingIndicatorOptions{}) }) + tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) + tt.Frame() + if at > 0 { + time.Sleep(at) + tt.Frame() + } + return encode(t, tt.Image()) + } + if !bytes.Equal(frame(true, 0), frame(true, 300*time.Millisecond)) { + t.Fatal("reduced motion still moves") + } + tt := chatTester(200, 60, func(c *ui.Context) { ThinkingIndicator(c, ThinkingIndicatorOptions{}) }) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("Thinking…") { + t.Fatalf("static label missing: %q", tt.Texts()) + } +} + +func TestThinkingIndicatorSnapshot(t *testing.T) { + snapshot(t, "thinking_indicator", 360, 70, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ThinkingIndicator(c, ThinkingIndicatorOptions{Phase: "Searching the archives"}) + }) + }) +} diff --git a/three_way_merge.go b/three_way_merge.go new file mode 100644 index 0000000..7a7ab59 --- /dev/null +++ b/three_way_merge.go @@ -0,0 +1,327 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// MergeChoice is how a conflict is resolved. +type MergeChoice int + +const ( + MergeUnresolved MergeChoice = iota + // MergeOurs takes the local (current) side. + MergeOurs + // MergeTheirs takes the remote (incoming) side. + MergeTheirs + // MergeBoth takes ours, then theirs. + MergeBoth + // MergeBase keeps the common ancestor. + MergeBase +) + +// mergeChunk is a merge run; stable when all three agree. +type mergeChunk struct { + base, ours, theirs []string + conflict bool + // auto is the merged text of a non-conflicting change. + auto []string + stable bool +} + +// ThreeWayMergeState holds a merge's chunks and conflict choices. +type ThreeWayMergeState struct { + list ui.ListState + base, ours, theirs string + chunks []mergeChunk + choices []MergeChoice + built bool +} + +// Resolved returns chosen conflicts and all conflicts. +func (s *ThreeWayMergeState) Resolved() (done, total int) { + for i, ch := range s.chunks { + if ch.conflict { + total++ + if s.choices[i] != MergeUnresolved { + done++ + } + } + } + return done, total +} + +// Result is the merged text; unresolved conflicts keep conflict markers. +func (s *ThreeWayMergeState) Result() string { + var out []string + for i, ch := range s.chunks { + out = append(out, mergeChunkText(ch, s.choices[i])...) + } + return strings.Join(out, "\n") +} + +func mergeChunkText(ch mergeChunk, choice MergeChoice) []string { + switch { + case ch.stable: + return ch.base + case !ch.conflict: + return ch.auto + } + switch choice { + case MergeOurs: + return ch.ours + case MergeTheirs: + return ch.theirs + case MergeBoth: + return slices.Concat(ch.ours, ch.theirs) + case MergeBase: + return ch.base + } + return slices.Concat([]string{"<<<<<<< ours"}, ch.ours, []string{"======="}, ch.theirs, []string{">>>>>>> theirs"}) +} + +// ThreeWayMergeOptions configure a ThreeWayMerge. +type ThreeWayMergeOptions struct { + Disabled bool +} + +// ThreeWayMerge shows four columns per chunk; give it a height. +func ThreeWayMerge(c *ui.Context, s *ThreeWayMergeState, base, ours, theirs string, opts ThreeWayMergeOptions) ChoiceResult { + if !s.built || s.base != base || s.ours != ours || s.theirs != theirs { + s.chunks = mergeChunks(internal.SplitLines(base), internal.SplitLines(ours), internal.SplitLines(theirs)) + s.choices = make([]MergeChoice, len(s.chunks)) + s.base, s.ours, s.theirs, s.built = base, ours, theirs, true + _, total := s.Resolved() + slog.Debug("mujica: three way merge chunks", "chunks", len(s.chunks), "conflicts", total) + } + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).AlignItems(ui.Stretch).Disabled(opts.Disabled).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Clip() + res := ChoiceResult{Element: col} + col.Children(func() { + done, total := s.Resolved() + ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3).Background(k.Background).Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "three_way_merge.resolved"), done, total)).FontWeight(600).Role(ui.RoleStatus) + }) + ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Background(k.Background).Children(func() { + for _, key := range []string{"ours", "base", "theirs", "result"} { + ui.Text(c, msg(c, "three_way_merge."+key)).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.Ornament.Mix(k.Text, 0.35)).Padding(u, u*3) + } + }) + ui.List(c, &s.list, len(s.chunks), func(i int) { + if threeWayMergeChunk(c, s, i) && !col.IsDisabled() { + res.changed = true + } + }).Grow(1) + }) + return res +} + +// threeWayMergeChunk builds chunk i and reports a new choice. +func threeWayMergeChunk(c *ui.Context, s *ThreeWayMergeState, i int) bool { + k := Tokens(c) + u := Density(c).Unit() + ch := s.chunks[i] + changed := false + ui.Column(c).AlignItems(ui.Stretch).Children(func() { + grid := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)) + grid.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) }) + grid.Children(func() { + tint := func(lines []string, col ui.Color, on bool) { + cell := ui.Column(c).Padding(u, u*3).MinWidth(0).Clip() + if on { + cell.Background(col.Mix(k.Surface, 0.88)) + } + cell.Children(func() { mergeLines(c, lines, ch.stable) }) + } + if ch.stable { + tint(ch.base, k.Text, false) + tint(ch.base, k.Text, false) + tint(ch.base, k.Text, false) + tint(ch.base, k.Text, false) + return + } + choice := s.choices[i] + tint(ch.ours, k.Info, ch.conflict && (choice == MergeOurs || choice == MergeBoth) || !ch.conflict && slices.Equal(ch.auto, ch.ours)) + tint(ch.base, k.TextMuted, choice == MergeBase) + tint(ch.theirs, k.Warning, ch.conflict && (choice == MergeTheirs || choice == MergeBoth) || !ch.conflict && slices.Equal(ch.auto, ch.theirs)) + result := ui.Column(c).Padding(u, u*3).MinWidth(0).Clip() + result.Children(func() { + if ch.conflict && choice == MergeUnresolved { + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Children(func() { + ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14) + ui.Text(c, msg(c, "three_way_merge.unresolved")).FontSize(FontSize(c, theme.CaptionSize)) + }) + return + } + mergeLines(c, mergeChunkText(ch, choice), false) + }) + }) + if !ch.conflict { + return + } + ui.Row(c).Gap(u*2).Padding(u, u*3, u*2, u*3).Wrap().Children(func() { + for _, opt := range []struct { + choice MergeChoice + key string + }{{MergeOurs, "take_ours"}, {MergeTheirs, "take_theirs"}, {MergeBoth, "take_both"}, {MergeBase, "take_base"}} { + if mergeChoiceButton(c, msg(c, "three_way_merge."+opt.key), s.choices[i] == opt.choice).Clicked() && s.choices[i] != opt.choice { + s.choices[i], changed = opt.choice, true + slog.Debug("mujica: three way merge choose", "chunk", i, "choice", opt.key) + } + } + }) + }) + return changed +} + +// mergeChoiceButton is a toggle marking the chosen side. +func mergeChoiceButton(c *ui.Context, label string, on bool) *ui.Element { + k := Tokens(c) + u := Density(c).Unit() + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*3).Gap(u*1.5).Radius(theme.ControlRadius). + Border(theme.BorderWidth, k.ControlBorder).Label(label).TextColor(k.Text) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case on: + b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).FontWeight(600) + case b.Pressed(): + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Transition(colorFade) + b.Children(func() { + if on { + ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText) + } + ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) + return b +} + +// mergeLines shows code lines, folding long stable runs. +func mergeLines(c *ui.Context, lines []string, fold bool) { + k := Tokens(c) + show := func(ls []string) { + for _, l := range ls { + if l == "" { + l = " " + } + t := ui.Text(c, l).Font(theme.MonoFont).NoWrap().FontSize(FontSize(c, theme.CaptionSize)) + if fold { + t.TextColor(k.TextMuted) + } + } + } + if len(lines) == 0 { + ui.Text(c, "∅").TextColor(k.TextMuted).Label(msg(c, "three_way_merge.empty")) + return + } + if !fold || len(lines) <= 5 { + show(lines) + return + } + show(lines[:2]) + ui.Text(c, fmt.Sprintf(msg(c, "diff_viewer.fold"), len(lines)-4)).Font(theme.SerifFont). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + show(lines[len(lines)-2:]) +} + +// mergeHunk replaces base lines [from, to) with lines. +type mergeHunk struct { + from, to int + lines []string +} + +// mergeHunks lists the changes a diff makes to base. +func mergeHunks(d []internal.DiffLine) []mergeHunk { + var out []mergeHunk + at, open := 0, -1 + for _, l := range d { + if l.Op == internal.DiffEqual { + at = l.Old + open = -1 + continue + } + if open < 0 { + out = append(out, mergeHunk{from: at, to: at}) + open = len(out) - 1 + } + h := &out[open] + if l.Op == internal.DiffDelete { + h.to = l.Old + } else { + h.lines = append(h.lines, l.Text) + } + } + return out +} + +// mergeChunks splits a merge into stable, clean and conflicting runs. +func mergeChunks(base, ours, theirs []string) []mergeChunk { + type tagged struct { + mergeHunk + side int + } + var all []tagged + for side, other := range [][]string{ours, theirs} { + for _, h := range mergeHunks(internal.LineDiff(base, other)) { + all = append(all, tagged{h, side}) + } + } + slices.SortStableFunc(all, func(a, b tagged) int { return a.from - b.from }) + var chunks []mergeChunk + at := 0 + for i := 0; i < len(all); { + from, to := all[i].from, all[i].to + j := i + 1 + for j < len(all) && (all[j].from < to || all[j].from == to && (all[j].from == all[j].to || from == to)) { + to = max(to, all[j].to) + j++ + } + if from > at { + chunks = append(chunks, mergeChunk{base: base[at:from], stable: true}) + } + sides := [2][]string{} + touched := [2]bool{} + for s := range 2 { + cur := from + for _, h := range all[i:j] { + if h.side != s { + continue + } + touched[s] = true + sides[s] = append(sides[s], base[cur:h.from]...) + sides[s] = append(sides[s], h.lines...) + cur = h.to + } + sides[s] = append(sides[s], base[cur:to]...) + } + ch := mergeChunk{base: base[from:to], ours: sides[0], theirs: sides[1]} + switch { + case !touched[1]: + ch.auto = ch.ours + case !touched[0], slices.Equal(ch.ours, ch.theirs): + ch.auto = ch.theirs + default: + ch.conflict = true + } + chunks = append(chunks, ch) + at, i = to, j + } + if at < len(base) { + chunks = append(chunks, mergeChunk{base: base[at:], stable: true}) + } + return chunks +} diff --git a/three_way_merge_test.go b/three_way_merge_test.go new file mode 100644 index 0000000..87e7551 --- /dev/null +++ b/three_way_merge_test.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +const ( + mergeBase = "a\nb\nc\nd\ne\nf" + mergeOurs = "a\nB1\nc\nd\ne\nf\ng" + mergeTheirs = "a\nB2\nc\nD\ne\nf" +) + +func TestMergeChunks(t *testing.T) { + chunks := mergeChunks([]string{"a", "b", "c", "d", "e", "f"}, []string{"a", "B1", "c", "d", "e", "f", "g"}, []string{"a", "B2", "c", "D", "e", "f"}) + // a | b conflict | c | d→D clean | e f | +g clean + if len(chunks) != 6 { + t.Fatalf("%d chunks: %+v", len(chunks), chunks) + } + if !chunks[1].conflict || chunks[1].ours[0] != "B1" || chunks[1].theirs[0] != "B2" { + t.Errorf("conflict chunk %+v", chunks[1]) + } + if chunks[3].conflict || chunks[3].auto[0] != "D" { + t.Errorf("clean remote change %+v", chunks[3]) + } + if chunks[5].conflict || len(chunks[5].auto) != 1 || chunks[5].auto[0] != "g" { + t.Errorf("clean local append %+v", chunks[5]) + } + same := mergeChunks([]string{"x"}, []string{"y"}, []string{"y"}) + if len(same) != 1 || same[0].conflict { + t.Errorf("identical changes conflict: %+v", same) + } +} + +func TestThreeWayMergeTakeSides(t *testing.T) { + var s ThreeWayMergeState + changes := 0 + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := ThreeWayMerge(c, &s, mergeBase, mergeOurs, mergeTheirs, ThreeWayMergeOptions{Disabled: disabled}) + r.Element.Height(560) + if r.Changed() { + changes++ + } + }, 900, 600) + if !tt.HasText("0 of 1 conflicts resolved") || !tt.HasText("Unresolved") { + t.Fatalf("start: %q", tt.Texts()) + } + if got := s.Result(); got != "a\n<<<<<<< ours\nB1\n=======\nB2\n>>>>>>> theirs\nc\nD\ne\nf\ng" { + t.Fatalf("unresolved result %q", got) + } + if err := tt.Click("Take remote"); err != nil { + t.Fatal(err) + } + if s.Result() != "a\nB2\nc\nD\ne\nf\ng" || changes != 1 || !tt.HasText("1 of 1 conflicts resolved") { + t.Fatalf("take remote: %q changes %d", s.Result(), changes) + } + tt.Key(0, ui.KeyTab) + _ = tt.Click("Take both") + if s.Result() != "a\nB1\nB2\nc\nD\ne\nf\ng" { + t.Fatalf("take both: %q", s.Result()) + } + disabled = true + tt.Frame() + _ = tt.Click("Keep base") + if s.Result() != "a\nB1\nB2\nc\nD\ne\nf\ng" || changes != 2 { + t.Fatalf("disabled merge changed: %q", s.Result()) + } +} + +func TestThreeWayMergeSnapshot(t *testing.T) { + snapshot(t, "three_way_merge", 860, 420, func(c *ui.Context) { + s := ui.Local(c.Root(), "m", func() *ThreeWayMergeState { return &ThreeWayMergeState{} }) + ui.Column(c).Padding(16).Children(func() { + ThreeWayMerge(c, *s, mergeBase, mergeOurs, mergeTheirs, ThreeWayMergeOptions{}).Element.Height(380) + }) + }) +} diff --git a/ticker_tape.go b/ticker_tape.go new file mode 100644 index 0000000..67cc954 --- /dev/null +++ b/ticker_tape.go @@ -0,0 +1,155 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// TickerItem is one symbol on a TickerTape. +type TickerItem struct { + Symbol string + Price float64 + ChangePercent float64 +} + +// TickerTapeOptions configure a TickerTape. +type TickerTapeOptions struct { + // Decimals of the prices; zero means 2. + Decimals int + // Speed is in DIPs a second; zero means 40. + Speed float32 + // PerPage is items per reduced-motion page; zero means 4. + PerPage int + // Label names the tape; empty uses the built-in "Market ticker". + Label string +} + +type tickerTapeState struct { + offset float32 + last time.Time + page int + paused bool +} + +// TickerTape loops quotes, pausing on hover or focus. +func TickerTape(c *ui.Context, items []TickerItem, opts TickerTapeOptions) *ui.Element { + if opts.Speed < 0 || opts.PerPage < 0 { + panic("mujica: TickerTape Speed and PerPage cannot be negative") + } + dec := priceDecimals("TickerTape", opts.Decimals) + speed, per := opts.Speed, opts.PerPage + if speed == 0 { + speed = 40 + } + if per == 0 { + per = 4 + } + label := opts.Label + if label == "" { + label = msg(c, "ticker_tape.label") + } + k := Tokens(c) + d := Density(c) + e := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Background(k.Surface). + BorderWidth(theme.BorderWidth, 0).BorderColor(k.Border).Focusable().Role(ui.RoleGroup).Label(label) + e.FocusRing(false) + st := ui.Local(e, "tape", func() tickerTapeState { return tickerTapeState{} }) + if c.Preferences().ReduceMotion || len(items) == 0 { + tickerTapePages(c, e, st, items, per, dec) + return e + } + paused := e.Hovered() || e.FocusWithin() + if paused != st.paused { + st.paused = paused + slog.Debug("mujica: ticker_tape paused", "paused", paused) + } + now := c.Now() + if !paused && !st.last.IsZero() { + st.offset += float32(now.Sub(st.last).Seconds()) * speed + } + st.last = now + if !paused { + c.AnimationFrame() + } + e.Description(msg(c, "ticker_tape.pause_hint")) + e.ClipX().Children(func() { + strip := ui.Row(c).Absolute().Top(0).Bottom(0).AlignItems(ui.Center) + if w := strip.Bounds().W / 2; w > 0 { + st.offset = float32(math.Mod(float64(st.offset), float64(w))) + } + // Quarter steps keep the moving text on the renderer's grid. + strip.Left(-float32(math.Round(float64(st.offset)*4)) / 4) + strip.Children(func() { + // The second copy fills in as the first scrolls out. + for n := range 2 { + r := ui.Row(c).Key(n).AlignItems(ui.Center).Shrink(0) + if n == 1 { + r.Role(ui.RoleNone) + } + r.Children(func() { + for _, it := range items { + tickerTapeItem(c, it, dec) + } + }) + } + }) + }) + return e +} + +// tickerTapePages shows one page of items, for reduced motion. +func tickerTapePages(c *ui.Context, e *ui.Element, st *tickerTapeState, items []TickerItem, per, dec int) { + pages := max(1, (len(items)+per-1)/per) + st.page = min(st.page, pages-1) + turn := func(by int) { + if !e.IsDisabled() { + st.page = (st.page + by + pages) % pages + slog.Debug("mujica: ticker_tape page", "page", st.page) + } + } + if e.Shortcut(0, ui.KeyRight) { + turn(1) + } + if e.Shortcut(0, ui.KeyLeft) { + turn(-1) + } + k := Tokens(c) + e.Children(func() { + ui.Row(c).Grow(1).MinWidth(0).ClipX().AlignItems(ui.Center).Children(func() { + for _, it := range items[st.page*per : min(len(items), (st.page+1)*per)] { + tickerTapeItem(c, it, dec) + } + }) + if pages < 2 { + return + } + if iconAction(c, icons.Must("chevron-left"), msg(c, "ticker_tape.previous")).Clicked() { + turn(-1) + } + ui.Text(c, fmt.Sprintf("%d / %d", st.page+1, pages)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + if iconAction(c, icons.Must("chevron-right"), msg(c, "ticker_tape.next")).Clicked() { + turn(1) + } + }) +} + +// tickerTapeItem is one symbol, price and change. +func tickerTapeItem(c *ui.Context, it TickerItem, dec int) { + u := Density(c).Unit() + sign := trendSign(it.ChangePercent) + ui.Row(c).Gap(u*1.5).AlignItems(ui.Center).Padding(0, u*4).Shrink(0).Children(func() { + ui.Text(c, it.Symbol).FontWeight(600).SingleLine() + ui.Text(c, statisticFormat(it.Price, dec)).FontFeatures("tnum").SingleLine() + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(trendColor(c, sign)).Label(trendWord(c, sign) + " " + statisticSigned(it.ChangePercent, 2) + "%").Children(func() { + ui.Icon(c, trendIcon(sign)).Size(12, 12) + ui.Text(c, statisticSigned(it.ChangePercent, 2)+"%").FontFeatures("tnum").SingleLine() + }) + }) +} diff --git a/ticker_tape_test.go b/ticker_tape_test.go new file mode 100644 index 0000000..12073db --- /dev/null +++ b/ticker_tape_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func tickerTapeItems(n int) []TickerItem { + var items []TickerItem + for i := range n { + items = append(items, TickerItem{Symbol: fmt.Sprintf("SYM%d", i), Price: 100 + float64(i), ChangePercent: float64(i%3 - 1)}) + } + return items +} + +func tickerTapeX(t *testing.T, tt *ui.Tester) float32 { + t.Helper() + r, ok := tt.Find("SYM0") + if !ok { + t.Fatalf("no SYM0 in %q", tt.Texts()) + } + return r.X +} + +func TestTickerTapeScrollsAndPausesOnHover(t *testing.T) { + tt := tradingTester(GreenUp, 400, 80, func(c *ui.Context) { + ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) }) + }) + x0 := tickerTapeX(t, tt) + time.Sleep(60 * time.Millisecond) + tt.Frame() + x1 := tickerTapeX(t, tt) + if x1 >= x0 { + t.Fatalf("did not scroll: %v -> %v", x0, x1) + } + tt.Move(100, 25) + tt.Frame() + x2 := tickerTapeX(t, tt) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if x3 := tickerTapeX(t, tt); x3 != x2 { + t.Fatalf("moved under the pointer: %v -> %v", x2, x3) + } +} + +func TestTickerTapePausesOnFocus(t *testing.T) { + tt := tradingTester(GreenUp, 400, 80, func(c *ui.Context) { + ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) }) + }) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Market ticker") { + t.Fatal("tape not focusable") + } + x0 := tickerTapeX(t, tt) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if x1 := tickerTapeX(t, tt); x1 != x0 { + t.Fatalf("moved while focused: %v -> %v", x0, x1) + } +} + +func TestTickerTapePagesUnderReducedMotion(t *testing.T) { + tt := tradingTester(GreenUp, 500, 80, func(c *ui.Context) { + TickerTape(c, tickerTapeItems(5), TickerTapeOptions{PerPage: 2}) + }) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("SYM0") || tt.HasText("SYM2") || !tt.HasText("1 / 3") || !tt.HasText("−1.00%") { + t.Fatalf("page 1: %q", tt.Texts()) + } + if err := tt.Click("Next page"); err != nil { + t.Fatal(err) + } + if !tt.HasText("SYM2") || tt.HasText("SYM0") || !tt.HasText("+1.00%") { + t.Fatalf("page 2: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + if !tt.HasText("SYM4") || !tt.HasText("3 / 3") { + t.Fatalf("wrap back: %q", tt.Texts()) + } +} + +func TestTickerTapeSnapshot(t *testing.T) { + snapshot(t, "ticker_tape", 520, 60, func(c *ui.Context) { + ui.Column(c).Padding(10).Children(func() { + TickerTape(c, []TickerItem{{"MUJC", 412.08, -1.35}, {"AVE", 88.2, 2.1}, {"OBLV", 1204.5, 0}}, TickerTapeOptions{}) + }) + }) +} diff --git a/time_and_sales.go b/time_and_sales.go new file mode 100644 index 0000000..0a6c186 --- /dev/null +++ b/time_and_sales.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TradePrint is one executed trade on the tape. +type TradePrint struct { + // ID identifies the trade; required and unique. + ID string + Time time.Time + Price float64 + Size float64 + Side OrderSide +} + +// TimeAndSalesOptions configure a TimeAndSales. +type TimeAndSalesOptions struct { + // Decimals of prices and sizes; zero means 2 and 0. + Decimals, SizeDecimals int + // LargeSize marks trades at or above it; zero marks none. + LargeSize float64 + // Height of the list in DIPs; zero means 320. + Height float32 + Status DataStatus + Error string +} + +// TimeAndSalesState is the tape's follow state and list place. +type TimeAndSalesState struct { + // Paused stops new trades from showing until resumed. + Paused bool + frozen int + shown int + list ListState[string] +} + +// TimeAndSales lists trades newest on top; Paused holds the view. +func TimeAndSales(c *ui.Context, s *TimeAndSalesState, trades []TradePrint, opts TimeAndSalesOptions) ChoiceResult { + dec := priceDecimals("TimeAndSales", opts.Decimals) + if opts.SizeDecimals < 0 || opts.LargeSize < 0 || opts.Height < 0 { + panic("mujica: TimeAndSales SizeDecimals, LargeSize and Height cannot be negative") + } + height := opts.Height + if height == 0 { + height = 320 + } + if !s.Paused { + s.frozen = len(trades) + } + s.frozen = min(s.frozen, len(trades)) + n := s.frozen + if !s.Paused && n != s.shown { + s.list.ScrollTo(0) + } + s.shown = n + k := Tokens(c) + d := Density(c) + var r ChoiceResult + r.Element = ui.Column(c).Gap(d.Unit() * 2).Height(height) + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "time_and_sales.title")).Font(theme.SerifFont).FontWeight(600).Grow(1) + label := msg(c, "time_and_sales.pause") + if s.Paused { + label = msg(c, "time_and_sales.resume") + if waiting := len(trades) - n; waiting > 0 { + label = fmt.Sprintf(msg(c, "time_and_sales.resume_count"), waiting) + } + } + if Button(c, label, ButtonOptions{Variant: Secondary}).Clicked() { + s.Paused, r.changed = !s.Paused, true + slog.Debug("mujica: time_and_sales follow", "paused", s.Paused) + } + }) + ui.Row(c).Padding(0, d.Unit()*4).Gap(d.Unit() * 2).Children(func() { + for i, key := range []string{"time_and_sales.time", "time_and_sales.price", "time_and_sales.size"} { + t := ui.Text(c, msg(c, key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1).Basis(0) + if i > 0 { + t.TextAlign(ui.End) + } + } + ui.Box(c).Width(16) + }) + at := func(i int) TradePrint { return trades[n-1-i] } + List(c, &s.list, n, ListOptions[string]{ + Key: func(i int) string { return at(i).ID }, + Label: func(i int) string { + t := at(i) + return timeAndSalesSpoken(c, t, dec, opts) + }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "time_and_sales.empty"), + }, func(i int) { timeAndSalesRow(c, at(i), dec, opts) }).Element.Grow(1) + }) + return r +} + +func timeAndSalesLarge(t TradePrint, opts TimeAndSalesOptions) bool { + return opts.LargeSize > 0 && t.Size >= opts.LargeSize +} + +// timeAndSalesSpoken reads a trade with its side and size. +func timeAndSalesSpoken(c *ui.Context, t TradePrint, dec int, opts TimeAndSalesOptions) string { + s := fmt.Sprintf("%s %s %s %s", t.Time.Format("15:04:05"), msg(c, fmt.Sprintf("order_book.side%d", t.Side)), + statisticFormat(t.Price, dec), statisticFormat(t.Size, opts.SizeDecimals)) + if timeAndSalesLarge(t, opts) { + s += ", " + msg(c, "time_and_sales.large") + } + return s +} + +// timeAndSalesRow is one trade with its side and large mark. +func timeAndSalesRow(c *ui.Context, t TradePrint, dec int, opts TimeAndSalesOptions) { + k := Tokens(c) + large := timeAndSalesLarge(t, opts) + sign := t.Side.sign() + ui.Text(c, t.Time.Format("15:04:05")).FontFeatures("tnum").TextColor(k.TextMuted).Grow(1).Basis(0).SingleLine() + ui.Row(c).Gap(2).Justify(ui.End).AlignItems(ui.Center).Grow(1).Basis(0).TextColor(trendColor(c, sign)).Children(func() { + ui.Icon(c, trendIcon(sign)).Size(12, 12) + ui.Text(c, statisticFormat(t.Price, dec)).FontFeatures("tnum").SingleLine() + }) + size := ui.Text(c, statisticFormat(t.Size, opts.SizeDecimals)).FontFeatures("tnum").TextAlign(ui.End).Grow(1).Basis(0).SingleLine() + mark := ui.Box(c).Size(16, 16).Shrink(0) + if large { + size.FontWeight(700) + mark.Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 10), k.AccentText) + }) + } +} diff --git a/time_and_sales_test.go b/time_and_sales_test.go new file mode 100644 index 0000000..5bb8056 --- /dev/null +++ b/time_and_sales_test.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func timeAndSalesTrades(n int) []TradePrint { + at := time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC) + out := make([]TradePrint, n) + for i := range out { + side := SideBuy + if i%2 == 1 { + side = SideSell + } + out[i] = TradePrint{ID: fmt.Sprint(i), Time: at.Add(time.Duration(i) * time.Second), Price: 100 + float64(i%50)/100, Size: float64(i%7 + 1), Side: side} + } + return out +} + +func TestTimeAndSalesNewestFirstAndLargeMark(t *testing.T) { + var s TimeAndSalesState + trades := timeAndSalesTrades(3) + trades[1].Size = 5000 + tt := tradingTester(GreenUp, 500, 400, func(c *ui.Context) { + TimeAndSales(c, &s, trades, TimeAndSalesOptions{LargeSize: 1000}) + }) + newest, _ := tt.Find("09:30:02") + oldest, _ := tt.Find("09:30:00") + if newest.Y >= oldest.Y { + t.Fatalf("newest %v not above oldest %v", newest, oldest) + } + if !tt.HasText("5,000") { + t.Fatalf("texts %q", tt.Texts()) + } + if _, ok := tt.Find("09:30:01 Ask 100.01 5,000, large trade"); !ok { + t.Fatal("large trade not spoken") + } +} + +func TestTimeAndSalesPauseHoldsView(t *testing.T) { + var s TimeAndSalesState + trades := timeAndSalesTrades(2) + n := 0 + tt := tradingTester(GreenUp, 500, 400, func(c *ui.Context) { + if TimeAndSales(c, &s, trades, TimeAndSalesOptions{}).Changed() { + n++ + } + }) + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + trades = timeAndSalesTrades(5) + tt.Frame() + if tt.HasText("09:30:04") || !tt.HasText("Resume (3 new)") || !s.Paused || n != 1 { + t.Fatalf("paused view moved: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if !tt.HasText("09:30:04") || s.Paused || n != 2 { + t.Fatalf("resume: %q", tt.Texts()) + } +} + +func TestTimeAndSalesBuildsOnlyVisibleRows(t *testing.T) { + var s TimeAndSalesState + trades := timeAndSalesTrades(100_000) + tt := tradingTester(GreenUp, 500, 400, func(c *ui.Context) { + TimeAndSales(c, &s, trades, TimeAndSalesOptions{}) + }) + if !tt.HasText("13:16:39") { + t.Fatalf("newest trade not on top: %q", tt.Texts()[:8]) + } + if got := len(tt.Texts()); got > 120 { + t.Fatalf("built %d texts for 100,000 trades", got) + } +} + +func TestTimeAndSalesSnapshot(t *testing.T) { + trades := timeAndSalesTrades(8) + trades[5].Size = 4000 + snapshot(t, "time_and_sales", 420, 360, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + s := ui.Local(c.Root(), "snap", func() TimeAndSalesState { return TimeAndSalesState{} }) + TimeAndSales(c, s, trades, TimeAndSalesOptions{LargeSize: 1000, Height: 320}) + }) + }) +} diff --git a/timezone_select.go b/timezone_select.go new file mode 100644 index 0000000..c66fd90 --- /dev/null +++ b/timezone_select.go @@ -0,0 +1,208 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + "sync" + "time" + _ "time/tzdata" // zones resolve offline, without the system database + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TimezoneSelectOptions configure a TimezoneSelect. +type TimezoneSelectOptions struct { + // Zones are the IANA names offered; empty uses built-ins. + Zones []string + // Common are pinned first; empty uses a built-in short list. + Common []string + // Now dates the offsets; zero uses the system clock. + Now time.Time + Disabled bool + Label string +} + +var timezoneCommon = []string{"UTC", "Asia/Shanghai", "Asia/Tokyo", "Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles"} + +var timezoneWorld = []string{"Pacific/Honolulu", "America/Anchorage", "America/Los_Angeles", "America/Denver", "America/Chicago", + "America/Mexico_City", "America/New_York", "America/Toronto", "America/Bogota", "America/Lima", "America/Caracas", + "America/Santiago", "America/Halifax", "America/St_Johns", "America/Sao_Paulo", "America/Argentina/Buenos_Aires", + "Atlantic/Azores", "UTC", "Europe/London", "Europe/Dublin", "Europe/Lisbon", "Africa/Lagos", "Europe/Paris", + "Europe/Berlin", "Europe/Madrid", "Europe/Rome", "Europe/Amsterdam", "Europe/Stockholm", "Europe/Warsaw", + "Africa/Cairo", "Africa/Johannesburg", "Europe/Athens", "Europe/Helsinki", "Europe/Kyiv", "Europe/Istanbul", + "Asia/Jerusalem", "Europe/Moscow", "Asia/Riyadh", "Africa/Nairobi", "Asia/Tehran", "Asia/Dubai", "Asia/Kabul", + "Asia/Karachi", "Asia/Tashkent", "Asia/Kolkata", "Asia/Kathmandu", "Asia/Dhaka", "Asia/Yangon", "Asia/Bangkok", + "Asia/Jakarta", "Asia/Ho_Chi_Minh", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Taipei", "Asia/Singapore", + "Asia/Manila", "Australia/Perth", "Asia/Seoul", "Asia/Tokyo", "Australia/Darwin", "Australia/Adelaide", + "Australia/Brisbane", "Australia/Sydney", "Pacific/Guam", "Pacific/Noumea", "Pacific/Auckland", "Pacific/Fiji", + "Pacific/Chatham", "Pacific/Tongatapu", "Pacific/Kiritimati"} + +var timezoneCache sync.Map + +// timezoneLoad resolves an IANA name once or panics. +func timezoneLoad(name string) *time.Location { + if v, ok := timezoneCache.Load(name); ok { + return v.(*time.Location) + } + loc, err := time.LoadLocation(name) + if err != nil { + panic(fmt.Sprintf("mujica: TimezoneSelect zone %q: %v", name, err)) + } + timezoneCache.Store(name, loc) + return loc +} + +// TimezoneOffset formats a zone's offset at t, as "UTC+09:00". +func TimezoneOffset(loc *time.Location, t time.Time) string { + _, off := t.In(loc).Zone() + sign := "+" + if off < 0 { + sign, off = "−", -off + } + return fmt.Sprintf("UTC%s%02d:%02d", sign, off/3600, off%3600/60) +} + +// TimezoneSelect searches zones by name or offset into *zone. +func TimezoneSelect(c *ui.Context, zone *string, opts TimezoneSelectOptions) ChoiceResult { + zones, common := opts.Zones, opts.Common + if len(zones) == 0 { + zones = timezoneWorld + } + if len(common) == 0 { + common = timezoneCommon + } + for _, z := range append(append([]string{}, zones...), common...) { + timezoneLoad(z) + } + if *zone != "" { + timezoneLoad(*zone) + } + now := opts.Now + if now.IsZero() { + now = c.Now() + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "timezone_select.label") + } + old := *zone + col := ui.Column(c).Width(d.ControlHeight() * 9).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled) + open := ui.Local(col, "timezone-open", func() bool { return false }) + query := ui.Local(col, "timezone-query", func() string { return "" }) + hi := ui.Local(col, "timezone-highlight", func() int { return 0 }) + shown := ui.Local(col, "timezone-shown", func() int { return -1 }) + text := "" + if *zone != "" { + text = TimezoneOffset(timezoneLoad(*zone), now) + " · " + timezoneName(*zone) + } + col.Children(func() { + field := datetimeField(c, ui.Row(c), false, false) + field.Children(func() { + t := datetimeTrigger(c, text, msg(c, "timezone_select.placeholder"), "clock").Label(label).Description(text) + if !t.IsDisabled() && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) { + *open, *query, *hi = !*open, "", 0 + } + }) + datetimePopup(c, field, open, false, func() { + list := timezoneFilter(zones, common, *query, now) + in := TextInput(c, query, TextInputOptions{Placeholder: msg(c, "timezone_select.search"), Label: msg(c, "timezone_select.search")}) + in.Input.AutoFocus() + if in.Changed() { + *hi = 0 + } + if !in.Input.IsDisabled() { + switch { + case in.Input.Shortcut(0, ui.KeyDown): + *hi = min(*hi+1, len(list)-1) + case in.Input.Shortcut(0, ui.KeyUp): + *hi = max(*hi-1, 0) + case in.Submitted() && len(list) > 0: + *zone, *open = list[*hi].name, false + } + } + defer func() { *shown = *hi }() + _, wh := c.Size() + ui.Scroll(c).MaxHeight(min(d.ControlHeight()*8, wh/2)).Children(func() { + if len(list) == 0 { + ui.Text(c, msg(c, "timezone_select.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 2) + } + for i, z := range list { + if i == 0 || z.common != list[i-1].common { + key := "timezone_select.all" + if z.common { + key = "timezone_select.common" + } + ui.Text(c, msg(c, key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit(), d.Unit()*2) + } + if timezoneRow(c, z, i == *hi, i == *hi && *hi != *shown, z.name == *zone) { + *zone, *open = z.name, false + } + } + }) + }) + }) + changed := *zone != old + if changed { + slog.Debug("mujica: timezone_select change", "from", old, "to", *zone) + } + return ChoiceResult{Element: col, changed: changed} +} + +type timezoneEntry struct { + name, offset string + common bool +} + +// timezoneFilter matches names and offsets, common zones first. +func timezoneFilter(zones, common []string, query string, now time.Time) []timezoneEntry { + q := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(query, " ", "_"))) + var out []timezoneEntry + add := func(names []string, isCommon bool) { + for _, z := range names { + off := TimezoneOffset(timezoneLoad(z), now) + if q == "" || strings.Contains(strings.ToLower(z), q) || strings.Contains(strings.ToLower(off), q) { + out = append(out, timezoneEntry{z, off, isCommon}) + } + } + } + add(common, true) + add(zones, false) + return out +} + +// timezoneName is a zone's city part, readable. +func timezoneName(z string) string { + return strings.ReplaceAll(z, "_", " ") +} + +// timezoneRow draws one zone; it reports a click. +func timezoneRow(c *ui.Context, z timezoneEntry, highlighted, reveal, chosen bool) bool { + k := Tokens(c) + d := Density(c) + b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(d.Unit() * 3).PaddingX(d.Unit() * 2).Radius(theme.ControlRadius). + Label(z.offset + " " + timezoneName(z.name)) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case chosen || highlighted: + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + if reveal { + b.ScrollIntoView() + } + b.Children(func() { + ui.Text(c, z.offset).Width(d.ControlHeight() * 2.4).Shrink(0).FontFeatures("tnum").TextColor(k.TextMuted) + ui.Text(c, timezoneName(z.name)).TextColor(k.Text).Grow(1).Shrink(1).SingleLine().Ellipsis("…") + if chosen { + ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "check", k.AccentText) }) + } + }) + return b.Clicked() && !b.IsDisabled() +} diff --git a/timezone_select_test.go b/timezone_select_test.go new file mode 100644 index 0000000..084a170 --- /dev/null +++ b/timezone_select_test.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var tzNow = time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC) + +func timezoneTester(zone *string, opts *TimezoneSelectOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := *opts + o.Now = tzNow + ui.Column(c).Padding(16).Children(func() { + if TimezoneSelect(c, zone, o).Changed() { + *n++ + } + }) + }, 520, 640) +} + +func TestTimezoneSelectSearchByNameAndOffset(t *testing.T) { + zone, n := "", 0 + tt := timezoneTester(&zone, &TimezoneSelectOptions{}, &n) + _ = tt.Click("Time zone") + if !tt.HasText("Common") || !tt.HasText("UTC+09:00 Asia/Tokyo") || !tt.HasText("UTC−04:00 America/New York") { + t.Fatalf("summer offsets and sections: %v", tt.Texts()[:12]) + } + tt.Type("kolk") + if !tt.HasText("UTC+05:30 Asia/Kolkata") || tt.HasText("UTC+09:00 Asia/Tokyo") { + t.Fatalf("name search: %v", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if zone != "Asia/Kolkata" || n != 1 || !tt.HasText("UTC+05:30 · Asia/Kolkata") { + t.Fatalf("Enter picked %q", zone) + } + _ = tt.Click("Time zone") + tt.Type("+12:45") + tt.Key(0, ui.KeyEnter) + if zone != "Pacific/Chatham" { + t.Fatalf("offset search picked %q", zone) + } +} + +func TestTimezoneSelectKeysCommonClickEmpty(t *testing.T) { + zone, n := "UTC", 0 + tt := timezoneTester(&zone, &TimezoneSelectOptions{Common: []string{"Europe/Paris"}, Zones: []string{"Asia/Tokyo", "Europe/Paris"}}, &n) + _ = tt.Click("Time zone") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if zone != "Asia/Tokyo" || n != 1 { + t.Fatalf("Down then Enter picked %q", zone) + } + _ = tt.Click("Time zone") + _ = tt.Click("UTC+02:00 Europe/Paris") + if zone != "Europe/Paris" { + t.Fatalf("click picked %q", zone) + } + _ = tt.Click("Time zone") + tt.Type("atlantis") + if !tt.HasText("No matching time zone.") { + t.Fatal("no empty state") + } +} + +func TestTimezoneSelectDisabledAndInvalid(t *testing.T) { + zone, n := "UTC", 0 + tt := timezoneTester(&zone, &TimezoneSelectOptions{Disabled: true}, &n) + _ = tt.Click("Time zone") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if tt.HasText("Common") { + t.Fatal("disabled select opened") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic for an unknown zone") + } + }() + bad := "Mars/Olympus" + timezoneTester(&bad, &TimezoneSelectOptions{}, &n) +} + +func TestTimezoneOffsetHalfHours(t *testing.T) { + if got := TimezoneOffset(timezoneLoad("America/St_Johns"), tzNow); got != "UTC−02:30" { + t.Fatalf("offset %q", got) + } +} + +func TestTimezoneSelectSnapshot(t *testing.T) { + zone := "Asia/Shanghai" + snapshot(t, "timezone_select", 420, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { TimezoneSelect(c, &zone, TimezoneSelectOptions{Now: tzNow}) }) + }) +} diff --git a/tool_approval_dialog.go b/tool_approval_dialog.go new file mode 100644 index 0000000..7509c91 --- /dev/null +++ b/tool_approval_dialog.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ToolApprovalOptions configure a ToolApprovalDialog. Tool is required. +type ToolApprovalOptions struct { + Tool string + // Args is the call's arguments, JSON when valid. + Args string + // Reason says why the agent wants to run the tool. + Reason string +} + +// ToolApprovalResult reports the choice made in this frame. +type ToolApprovalResult struct { + Element *ui.Element + Allowed bool + Denied bool +} + +// ToolApprovalDialog asks to approve a tool call; Deny has focus. +func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) ToolApprovalResult { + if opts.Tool == "" { + panic("mujica: ToolApprovalDialog needs a Tool") + } + var r ToolApprovalResult + was := *open + u := Density(c).Unit() + r.Element = Dialog(c, open, DialogOptions{ + Title: msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true, + Actions: func() { + // Deny first: a reflexive Enter never runs the tool. + if Button(c, msg(c, "tool_approval_dialog.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { + r.Denied = true + } + if Button(c, msg(c, "tool_approval_dialog.allow"), ButtonOptions{}).Clicked() { + r.Allowed = true + } + }, + }, func() { + ui.Column(c).Gap(u * 2).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + agentCaption(c, msg(c, "tool_approval_dialog.tool")) + ui.Text(c, opts.Tool).Font(theme.MonoFont).FontWeight(600) + }) + if opts.Args != "" { + agentCode(c, "json", agentPrettyJSON(opts.Args), 260) + } + }) + }) + if was && !*open && !r.Allowed { + r.Denied = true // Escape + } + if r.Allowed || r.Denied { + slog.Debug("mujica: tool_approval_dialog choose", "tool", opts.Tool, "allowed", r.Allowed) + *open = false + } + return r +} diff --git a/tool_approval_dialog_test.go b/tool_approval_dialog_test.go new file mode 100644 index 0000000..331255e --- /dev/null +++ b/tool_approval_dialog_test.go @@ -0,0 +1,97 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type approvalApp struct { + open bool + allowed, denied int +} + +func (a *approvalApp) view(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Run", ButtonOptions{}).Clicked() { + a.open = true + } + r := ToolApprovalDialog(c, &a.open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"*.tmp"}`, Reason: "Clean the build folder."}) + if r.Allowed { + a.allowed++ + } + if r.Denied { + a.denied++ + } +} + +func newApprovalApp() (*approvalApp, *ui.Tester) { + a := &approvalApp{} + tt := ui.NewTester(a.view, 600, 500) + _ = tt.Click("Run") + return a, tt +} + +func TestToolApprovalDialogDenyFirst(t *testing.T) { + a, tt := newApprovalApp() + for _, s := range []string{"delete_files", "Clean the build folder.", ` "glob": "*.tmp"`} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if !tt.Focused("Deny") { + t.Fatal("Deny does not have the focus") + } + tt.Key(0, ui.KeyEnter) + if a.denied != 1 || a.allowed != 0 || a.open { + t.Fatalf("Enter: denied %d allowed %d open %v", a.denied, a.allowed, a.open) + } + if !tt.Focused("Run") { + t.Fatal("focus not restored") + } +} + +func TestToolApprovalDialogAllowByTabAndBackdrop(t *testing.T) { + a, tt := newApprovalApp() + tt.ClickAt(5, 495) + if !a.open || a.denied != 0 { + t.Fatal("backdrop click acted") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Allow") { + t.Fatal("Tab did not reach Allow") + } + tt.Key(0, ui.KeyEnter) + if a.allowed != 1 || a.denied != 0 || a.open { + t.Fatalf("allowed %d denied %d", a.allowed, a.denied) + } +} + +func TestToolApprovalDialogEscapeDenies(t *testing.T) { + a, tt := newApprovalApp() + tt.Key(0, ui.KeyEscape) + if a.denied != 1 || a.allowed != 0 || a.open { + t.Fatalf("Escape: denied %d allowed %d open %v", a.denied, a.allowed, a.open) + } +} + +func TestToolApprovalDialogNeedsTool(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := true + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolApprovalDialog(c, &open, ToolApprovalOptions{}) + }, 200, 200) +} + +func TestToolApprovalDialogSnapshot(t *testing.T) { + snapshot(t, "tool_approval_dialog", 560, 380, func(c *ui.Context) { + open := true + ToolApprovalDialog(c, &open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"build/*.tmp","dry_run":false}`, + Reason: "The agent wants to clean temporary files from the build folder."}) + }) +} diff --git a/tool_call_card.go b/tool_call_card.go new file mode 100644 index 0000000..164e6ec --- /dev/null +++ b/tool_call_card.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ToolCall is one tool invocation; the caller owns it. +type ToolCall struct { + ID string + Name string + // Args and Result show as JSON when valid. + Args string + Result string + // Error replaces Result when the call failed. + Error string + State AgentState + Duration time.Duration +} + +// ToolCallCardOptions configure a ToolCallCard. +type ToolCallCardOptions struct { + // OpenArgs and OpenResult start the sections expanded. + OpenArgs, OpenResult bool + // Still stops the running animation. + Still bool +} + +type toolCallCardState struct{ args, result bool } + +// ToolCallCard shows a tool call with foldable arguments and result. +func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult { + if call.Name == "" { + panic("mujica: ToolCallCard needs a Name") + } + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = agentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State)) + st := ui.Local(r.Element, "mujica.toolcall", func() toolCallCardState { + return toolCallCardState{opts.OpenArgs, opts.OpenResult} + }) + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() { + agentMark(c, call.State, 16, opts.Still) + ui.Text(c, call.Name).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1) + agentCaption(c, agentWord(c, call.State)) + if call.State == AgentDone || call.State == AgentFailed { + agentCaption(c, agentDuration(call.Duration)) + } + }) + if call.Args != "" { + r.changed = Collapsible(c, &st.args, CollapsibleOptions{Title: msg(c, "tool_call_card.args")}, func() { + agentCode(c, "json", agentPrettyJSON(call.Args), 240) + }).Changed() || r.changed + } + body, title := call.Result, msg(c, "tool_call_card.result") + if call.Error != "" { + body, title = call.Error, msg(c, "tool_call_card.error") + } + if body != "" { + r.changed = Collapsible(c, &st.result, CollapsibleOptions{Title: title}, func() { + if call.Error != "" { + ui.Text(c, call.Error).TextColor(k.Danger).Selectable() + return + } + agentCode(c, "json", agentPrettyJSON(body), 320) + }).Changed() || r.changed + } + }) + if r.changed { + slog.Debug("mujica: tool_call_card toggle", "tool", call.Name, "args", st.args, "result", st.result) + } + return r +} diff --git a/tool_call_card_test.go b/tool_call_card_test.go new file mode 100644 index 0000000..770a4c1 --- /dev/null +++ b/tool_call_card_test.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var toolCallSample = ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`, + Result: `{"lines":40,"truncated":false}`, State: AgentDone, Duration: 420 * time.Millisecond} + +func TestToolCallCardShowsAndFolds(t *testing.T) { + call := toolCallSample + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ToolCallCard(c, call, ToolCallCardOptions{}).Changed() { + changes++ + } + }, 500, 500) + for _, s := range []string{"read_file", "Done", "420 ms", "Arguments", "Result"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if tt.HasText(` "path": "court/ledger.md",`) { + t.Fatal("arguments shown while folded") + } + _ = tt.Click("Arguments") + if changes != 1 || !tt.HasText(` "path": "court/ledger.md",`) { + t.Fatalf("changes %d, pretty JSON %q", changes, tt.Texts()) + } + tt.Key(0, ui.KeyTab) // keyboard reaches the Result fold + tt.Key(0, ui.KeyEnter) + if changes != 2 || !tt.HasText(` "lines": 40,`) { + t.Fatalf("keyboard fold: changes %d", changes) + } +} + +func TestToolCallCardErrorAndPlainText(t *testing.T) { + call := ToolCall{Name: "shell", Args: "not json at all", Error: "permission denied", State: AgentFailed, Duration: 3 * time.Second} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, call, ToolCallCardOptions{OpenArgs: true, OpenResult: true}) + }, 500, 400) + for _, s := range []string{"Failed", "3.0 s", "Error", "permission denied", "not json at all"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } +} + +func TestToolCallCardRunningHidesDuration(t *testing.T) { + call := ToolCall{Name: "search", State: AgentRunning, Duration: time.Second} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, call, ToolCallCardOptions{Still: true}) + }, 400, 200) + if !tt.HasText("Running") || tt.HasText("1.0 s") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestToolCallCardLargeResultIsVirtualized(t *testing.T) { + var b strings.Builder + for i := range 100000 { + fmt.Fprintf(&b, "row %d\n", i) + } + call := ToolCall{Name: "dump", Result: b.String(), State: AgentDone} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, call, ToolCallCardOptions{OpenResult: true}) + }, 500, 500) + if n := len(tt.Texts()); n > 200 { + t.Fatalf("%d texts built for 100000 rows", n) + } + if !tt.HasText("row 0") { + t.Fatal("first row missing") + } +} + +func TestToolCallCardNeedsName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, ToolCall{}, ToolCallCardOptions{}) + }, 200, 200) +} + +func TestToolCallCardSnapshot(t *testing.T) { + snapshot(t, "tool_call_card", 460, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ToolCallCard(c, toolCallSample, ToolCallCardOptions{OpenArgs: true}) + }) + }) +} diff --git a/tool_call_group.go b/tool_call_group.go new file mode 100644 index 0000000..dbe5a61 --- /dev/null +++ b/tool_call_group.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ToolCallGroupOptions configure a ToolCallGroup. +type ToolCallGroupOptions struct { + // Title replaces the built-in "N tool calls". + Title string + Still bool +} + +// ToolCallGroup folds tool calls under a count, time, failures summary. +func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult { + if open == nil { + panic("mujica: ToolCallGroup needs open") + } + seen := make(map[string]bool, len(calls)) + var total time.Duration + failed, running := 0, false + for _, call := range calls { + if call.ID == "" || seen[call.ID] { + panic(fmt.Sprintf("mujica: ToolCallGroup call IDs must be unique and set, got %q", call.ID)) + } + seen[call.ID] = true + total += call.Duration + failed += agentB2I(call.State == AgentFailed) + running = running || call.State == AgentRunning + } + k := Tokens(c) + u := Density(c).Unit() + title := opts.Title + if title == "" { + title = fmt.Sprintf(msg(c, "tool_call_group.count"), len(calls)) + } + state := AgentDone + switch { + case running: + state = AgentRunning + case failed > 0: + state = AgentFailed + } + summary := title + " · " + agentDuration(total) + if failed > 0 { + summary += " · " + fmt.Sprintf(msg(c, "tool_call_group.failed"), failed) + } + return Collapsible(c, open, CollapsibleOptions{Title: summary, Trigger: func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + agentMark(c, state, 16, opts.Still) + ui.Text(c, title).FontWeight(600).SingleLine() + agentCaption(c, agentDuration(total)) + if failed > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger). + FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine() + } + }) + }}, func() { + for _, call := range calls { + ui.Column(c).Key(call.ID).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) }) + } + }) +} + +func agentB2I(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/tool_call_group_test.go b/tool_call_group_test.go new file mode 100644 index 0000000..2e96b31 --- /dev/null +++ b/tool_call_group_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var toolCallGroupSample = []ToolCall{ + {ID: "a", Name: "list_dir", State: AgentDone, Duration: 300 * time.Millisecond}, + {ID: "b", Name: "read_file", State: AgentFailed, Error: "not found", Duration: 1200 * time.Millisecond}, + {ID: "c", Name: "grep", State: AgentDone, Duration: 500 * time.Millisecond}, +} + +func TestToolCallGroupSummaryAndFold(t *testing.T) { + open, changes := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}).Changed() { + changes++ + } + }, 500, 600) + for _, s := range []string{"3 tool calls", "2.0 s", "1 failed"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if tt.HasText("read_file") { + t.Fatal("calls shown while folded") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !open || changes != 1 || !tt.HasText("read_file") || !tt.HasText("grep") { + t.Fatalf("open %v changes %d texts %q", open, changes, tt.Texts()) + } +} + +func TestToolCallGroupRunningAndEmpty(t *testing.T) { + open := false + calls := []ToolCall{{ID: "x", Name: "fetch", State: AgentRunning}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallGroup(c, &open, calls, ToolCallGroupOptions{Title: "Research", Still: true}) + ToolCallGroup(c, &open, nil, ToolCallGroupOptions{}) + }, 500, 300) + if !tt.HasText("Research") || !tt.HasText("0 tool calls") || tt.HasText("1 failed") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestToolCallGroupRejectsDuplicateIDs(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := false + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallGroup(c, &open, []ToolCall{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, ToolCallGroupOptions{}) + }, 200, 200) +} + +func TestToolCallGroupSnapshot(t *testing.T) { + snapshot(t, "tool_call_group", 460, 330, func(c *ui.Context) { + open := true + ui.Column(c).Padding(16).Children(func() { + ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}) + }) + }) +} diff --git a/trade_history_table.go b/trade_history_table.go new file mode 100644 index 0000000..2f64054 --- /dev/null +++ b/trade_history_table.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// FillRow is one of the account's fills; ID is stable. +type FillRow struct { + ID string + Time time.Time + Symbol string + Side OrderSide + Price float64 + Quantity float64 + Fee float64 +} + +// TradeHistoryTableOptions configure a TradeHistoryTable. +type TradeHistoryTableOptions struct { + // Decimals of prices and quantities; zero means 2 and 0. + Decimals, QuantityDecimals int + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// TradeHistoryTableState is the date range and table state. +type TradeHistoryTableState struct { + // Range keeps fills on its days; zero keeps all. + Range DateRange + Table DataTableState[string] + cache tradingRows[FillRow] +} + +// TradeHistoryResult is a built table and its export request. +type TradeHistoryResult struct { + Element *ui.Element + Table DataTableView + exported []FillRow + export bool +} + +// Exported returns the range's fills when export was asked. +func (r TradeHistoryResult) Exported() ([]FillRow, bool) { return r.exported, r.export } + +// TradeHistoryTable lists fills by date range, with export. +func TradeHistoryTable(c *ui.Context, s *TradeHistoryTableState, rows []FillRow, opts TradeHistoryTableOptions) TradeHistoryResult { + dec := priceDecimals("TradeHistoryTable", opts.Decimals) + if opts.QuantityDecimals < 0 { + panic("mujica: TradeHistoryTable with negative QuantityDecimals") + } + rng := s.Range + shown := s.cache.view(rows, opts.Version, rng, func(f FillRow) bool { + if rng.IsZero() { + return true + } + d := DateOf(f.Time) + return d.Compare(rng.Start) >= 0 && d.Compare(rng.End) <= 0 + }) + num := func(id, key string, dp int, v func(FillRow) float64) DataColumn[FillRow] { + return DataColumn[FillRow]{ID: id, Title: msg(c, key), Align: ui.End, Sortable: true, + Compare: func(a, b FillRow) int { return cmp.Compare(v(a), v(b)) }, + Text: func(f FillRow) string { return statisticFormat(v(f), dp) }} + } + cols := []DataColumn[FillRow]{ + {ID: "time", Title: msg(c, "trade_history_table.time"), Sortable: true, Width: 180, + Compare: func(a, b FillRow) int { return a.Time.Compare(b.Time) }, + Text: func(f FillRow) string { return f.Time.Format("2006-01-02 15:04:05") }}, + {ID: "symbol", Title: msg(c, "position_table.symbol"), Sortable: true, + Compare: func(a, b FillRow) int { return cmp.Compare(a.Symbol, b.Symbol) }, + Text: func(f FillRow) string { return f.Symbol }}, + {ID: "side", Title: msg(c, "order_table.side"), Text: func(f FillRow) string { return msg(c, fmt.Sprintf("order_entry.side%d", f.Side)) }, + Cell: func(f FillRow) { tradingSide(c, f.Side) }}, + num("price", "order_book.price", dec, func(f FillRow) float64 { return f.Price }), + num("qty", "order_entry.quantity", opts.QuantityDecimals, func(f FillRow) float64 { return f.Quantity }), + num("fee", "trade_history_table.fee", dec, func(f FillRow) float64 { return f.Fee }), + } + d := Density(c) + var r TradeHistoryResult + r.Element = ui.Column(c).Gap(d.Unit() * 2).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + if DateRangePicker(c, &s.Range, DateRangePickerOptions{Label: msg(c, "trade_history_table.range"), Disabled: disabled}).Changed() { + slog.Debug("mujica: trade_history_table range", "days", s.Range.Days()) + } + if !s.Range.IsZero() && Button(c, msg(c, "trade_history_table.all"), ButtonOptions{Variant: Ghost, Disabled: disabled}).Clicked() && !disabled { + s.Range = DateRange{} + } + ui.Box(c).Grow(1) + if Button(c, msg(c, "trade_history_table.export"), ButtonOptions{Variant: Secondary, Icon: icons.Must("upload"), Disabled: disabled || len(shown) == 0}).Clicked() && !disabled { + r.exported, r.export = shown, true + slog.Debug("mujica: trade_history_table export", "rows", len(shown)) + } + }) + r.Table = DataTable(c, &s.Table, shown, DataTableOptions[FillRow, string]{Columns: cols, Key: func(f FillRow) string { return f.ID }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "trade_history_table.empty")}) + r.Table.Element.Grow(1) + }) + return r +} diff --git a/trade_history_table_test.go b/trade_history_table_test.go new file mode 100644 index 0000000..c2a07b8 --- /dev/null +++ b/trade_history_table_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func tradeHistorySample() []FillRow { + day := func(d, h int) time.Time { return time.Date(2026, 10, d, h, 0, 0, 0, time.UTC) } + return []FillRow{ + {ID: "f1", Time: day(1, 10), Symbol: "MUJC", Side: SideBuy, Price: 400, Quantity: 10, Fee: 1.2}, + {ID: "f2", Time: day(3, 14), Symbol: "AVE", Side: SideSell, Price: 88.2, Quantity: 5, Fee: 0.4}, + {ID: "f3", Time: day(5, 9), Symbol: "OBLV", Side: SideBuy, Price: 12, Quantity: 100, Fee: 0.6}, + } +} + +func TestTradeHistoryTableRangeAndExport(t *testing.T) { + s := TradeHistoryTableState{Range: DateRange{Start: Date{2026, 10, 2}, End: Date{2026, 10, 4}}} + var exported []FillRow + tt := tradingTester(GreenUp, 1000, 600, func(c *ui.Context) { + ui.Box(c).Width(960).Height(400).Children(func() { + if rows, ok := TradeHistoryTable(c, &s, tradeHistorySample(), TradeHistoryTableOptions{}).Exported(); ok { + exported = rows + } + }) + }) + if tt.HasText("MUJC") || !tt.HasText("AVE") || tt.HasText("OBLV") { + t.Fatalf("range: %q", tt.Texts()) + } + if err := tt.Click("Export"); err != nil { + t.Fatal(err) + } + if len(exported) != 1 || exported[0].ID != "f2" { + t.Fatalf("exported %v", exported) + } + if err := tt.Click("All dates"); err != nil { + t.Fatal(err) + } + if !tt.HasText("MUJC") || !tt.HasText("OBLV") || !s.Range.IsZero() { + t.Fatalf("all dates: %q", tt.Texts()) + } + _ = tt.Click("Export") + if len(exported) != 3 { + t.Fatalf("exported %d", len(exported)) + } +} + +func TestTradeHistoryTableDisabled(t *testing.T) { + var s TradeHistoryTableState + exported := false + tt := tradingTester(GreenUp, 1000, 600, func(c *ui.Context) { + ui.Box(c).Width(960).Height(400).Children(func() { + _, exported = TradeHistoryTable(c, &s, tradeHistorySample(), TradeHistoryTableOptions{Disabled: true}).Exported() + }) + }) + _ = tt.Click("Export") + if exported { + t.Fatal("disabled table exported") + } +} + +func TestTradeHistoryTableSnapshot(t *testing.T) { + snapshot(t, "trade_history_table", 900, 260, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(240).Width(880).Children(func() { + s := ui.Local(c.Root(), "snap", func() TradeHistoryTableState { return TradeHistoryTableState{} }) + TradeHistoryTable(c, s, tradeHistorySample(), TradeHistoryTableOptions{}) + }) + }) +} + +// 100,000 fills build only visible rows. +func TestTradeHistoryTableLargeData(t *testing.T) { + var s TradeHistoryTableState + rows := make([]FillRow, 100_000) + for i := range rows { + rows[i] = FillRow{ID: fmt.Sprint(i), Time: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), Symbol: "S", Side: SideSell, Price: 1, Quantity: 1} + } + tt := tradingTester(GreenUp, 1000, 600, func(c *ui.Context) { + ui.Box(c).Width(960).Height(400).Children(func() { TradeHistoryTable(c, &s, rows, TradeHistoryTableOptions{}) }) + }) + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d texts", n) + } +} diff --git a/treemap.go b/treemap.go new file mode 100644 index 0000000..9b072af --- /dev/null +++ b/treemap.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TreemapOptions configure a Treemap. +type TreemapOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// Treemap tiles the children at *path; branches drill in. +func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) ChartResult { + chartCheckTree(root) + node := chartDescend(root, *path) + k := Tokens(c) + u := Density(c).Unit() + size := FontSize(c, theme.CaptionSize) + pal := internal.Palette(k) + var res ChartResult + wrap := ui.Column(c).Gap(u * 2).MinWidth(0) + wrap.Children(func() { + res.value = chartPath(c, root.Name, path, wrap.IsDisabled()) + node = chartDescend(root, *path) + var kids []ChartNode + for _, ch := range node.Children { + if ch.total() > 0 { + kids = append(kids, ch) + } + } + slices.SortStableFunc(kids, func(a, b ChartNode) int { return cmp.Compare(b.total(), a.total()) }) + sum := node.total() + values := make([]float64, len(kids)) + for i, ch := range kids { + values[i] = ch.total() + } + ch := newChart(c, opts.Frame) + tiles := func(r ui.Rect) []ui.Rect { return squarify(values, r) } + spec := chartSpec{n: len(kids)} + spec.readout = func(i int) string { + s := fmt.Sprintf("%s · %s · %.1f%%", kids[i].Name, chartValue(c, values[i], opts.Format), 100*values[i]/sum) + if len(kids[i].Children) > 0 { + s += " · " + msg(c, "chart.opens") + } + return s + } + spec.pick = func(r ui.Rect, x, y float32) int { + for i, t := range tiles(r) { + if t.Contains(x, y) { + return i + } + } + return -1 + } + spec.activate = func(i int) { + if len(kids[i].Children) > 0 { + *path = append(slices.Clone(*path), kids[i].Name) + res.value = true + slog.Debug("mujica: treemap drill", "path", strings.Join(*path, "/")) + } + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + for i, t := range tiles(r) { + bg := pal[i%len(pal)] + p.Fill(t, bg, 2) + p.Stroke(t, k.Surface, 2, 2) + if i == active { + p.Stroke(ui.Rect{X: t.X + 3, Y: t.Y + 3, W: t.W - 6, H: t.H - 6}, chartInk(k, bg), 1, 2) + } + // Labels show only where tiles have room. + if t.W >= 36 && t.H >= 22 { + ink := chartInk(k, bg) + chartText(p, chartFit(p, kids[i].Name, t.W-12, size), t.X+6, t.Y+5, size, ink, 0, 0) + if t.H >= 40 { + chartText(p, chartFit(p, chartValue(c, values[i], opts.Format), t.W-12, size), t.X+6, t.Y+22, size, ink, 0, 0) + } + } + } + } + r, plot := ch.build(spec) + if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { + *path, res.value = (*path)[:len(*path)-1], true + } + res.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry + }) + res.Element = wrap + return res +} diff --git a/treemap_test.go b/treemap_test.go new file mode 100644 index 0000000..4abeacb --- /dev/null +++ b/treemap_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func chartTree() ChartNode { + return ChartNode{Name: "Abbey", Children: []ChartNode{ + {Name: "Library", Children: []ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, + {Name: "Garden", Children: []ChartNode{{Name: "Herbs", Value: 12}, {Name: "Vines", Value: 8}}}, + {Name: "Cell of a very long-winded brother", Value: 3}, + }} +} + +func TestSquarifyFills(t *testing.T) { + rects := squarify([]float64{6, 6, 4, 3, 2, 2, 1}, ui.Rect{W: 600, H: 400}) + area := float32(0) + for _, r := range rects { + area += r.W * r.H + if r.W <= 0 || r.H <= 0 { + t.Fatalf("empty tile %v", r) + } + } + if area < 600*400-1 || area > 600*400+1 { + t.Fatalf("tiles cover %v of %v", area, 600*400) + } +} + +func TestTreemapDrillAndBack(t *testing.T) { + var path []string + values := 0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := Treemap(c, &path, chartTree(), TreemapOptions{Frame: ChartFrame{Label: "Rooms"}}) + if r.ValueChanged() { + values++ + } + return r + }} + tt := ui.NewTester(h.view, 560, 440) + tabTo(t, tt, "Rooms") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Library · 40 · 45.5% · Enter opens" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyEnter) + if fmt.Sprint(path) != "[Library]" || values != 1 || !tt.HasText("Library") { + t.Fatalf("drill path %v events %d", path, values) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) // a leaf stays + if fmt.Sprint(path) != "[Library]" { + t.Fatalf("leaf drilled: %v", path) + } + tt.Key(0, ui.KeyBackspace) + if len(path) != 0 { + t.Fatalf("Backspace kept %v", path) + } + path = []string{"Garden"} + tt.Frame() + if err := tt.Click("Back"); err != nil || len(path) != 0 { + t.Fatalf("Back: %v %v", err, path) + } +} + +func TestTreemapBadPath(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an unknown path") + } + }() + path := []string{"Nowhere"} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Treemap(c, &path, chartTree(), TreemapOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestTreemapSnapshot(t *testing.T) { + snapshot(t, "treemap", 560, 340, func(c *ui.Context) { + var path []string + ui.Column(c).Padding(16).Children(func() { + Treemap(c, &path, chartTree(), TreemapOptions{Frame: ChartFrame{Label: "Rooms"}}) + }) + }) +} diff --git a/typing_indicator.go b/typing_indicator.go new file mode 100644 index 0000000..8657c60 --- /dev/null +++ b/typing_indicator.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// typingPeriod is one cycle of the dots, in milliseconds. +const typingPeriod = 1200 + +// TypingIndicator shows that name is typing: three fading dots, or static text under reduced motion. +func TypingIndicator(c *ui.Context, name string) *ui.Element { + if name == "" { + panic("mujica: TypingIndicator needs a name") + } + k := Tokens(c) + u := Density(c).Unit() + label := fmt.Sprintf(msg(c, "typing_indicator.label"), name) + still := c.Preferences().ReduceMotion + row := ui.Row(c).Gap(u*2).AlignItems(ui.Center).Padding(u, u*3).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleStatus).Label(label).AlignSelf(ui.Start) + row.Children(func() { + if still { + ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + return + } + ui.Box(c).Size(30, 12).Draw(func(p *ui.Painter, r ui.Rect) { + // Repaints only while shown: the painter asks for the next frame. + p.AnimationFrame() + phase := float64(p.Now().UnixMilli()%typingPeriod) / typingPeriod + for i := range 3 { + a := 0.35 + 0.65*(0.5+0.5*math.Cos(2*math.Pi*(phase-float64(i)/6))) + cx := internal.Snap(p, r.X+5+float32(i)*10) + p.Fill(ui.Rect{X: cx - 3, Y: r.Y + r.H/2 - 3, W: 6, H: 6}, k.TextMuted.Alpha(float32(a)), 3) + } + }) + }) + return row +} diff --git a/typing_indicator_test.go b/typing_indicator_test.go new file mode 100644 index 0000000..963d98f --- /dev/null +++ b/typing_indicator_test.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTypingIndicatorMotionAndStatic(t *testing.T) { + for _, reduce := range []bool{false, true} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TypingIndicator(c, "Herald") + }, 300, 100) + tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) + tt.Frame() + if _, ok := tt.Find("Herald is typing…"); !ok { + t.Fatalf("reduce %v: not named", reduce) + } + shown := 0 + for _, s := range tt.Texts() { + if s == "Herald is typing…" { + shown++ + } + } + // The status label always; the static text only under reduced motion. + if want := map[bool]int{false: 1, true: 2}[reduce]; shown != want { + t.Errorf("reduce %v: %d labels, want %d", reduce, shown, want) + } + } +} + +func TestTypingIndicatorNeedsName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without a name") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TypingIndicator(c, "") + }, 100, 100) +} + +func TestTypingIndicatorSnapshot(t *testing.T) { + snapshot(t, "typing_indicator", 240, 60, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { TypingIndicator(c, "Herald") }) + }) +} diff --git a/variables_panel.go b/variables_panel.go new file mode 100644 index 0000000..6e94759 --- /dev/null +++ b/variables_panel.go @@ -0,0 +1,158 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// Variable is a debugged value; Changed marks new values. +type Variable struct { + Name, Value, Type string + Changed bool + Children []Variable +} + +// VariablesPanelState is the open rows, choice and edit. +type VariablesPanelState struct { + outline ui.OutlineState[string] + cursor int + byPath map[string]*Variable + kids map[string][]string + roots []string + data *Variable + n int + editing string + draft string + started bool +} + +// VariablesPanelOptions configure a VariablesPanel. +type VariablesPanelOptions struct { + Disabled bool +} + +// VariableEdit is a typed value for a dotted Path. +type VariableEdit struct{ Path, Value string } + +// VariablesPanelResult carries a committed edit. +type VariablesPanelResult struct { + Element *ui.Element + Edited *VariableEdit +} + +// VariablesPanel shows an editable variable tree. +func VariablesPanel(c *ui.Context, vars []Variable, s *VariablesPanelState, opts VariablesPanelOptions) VariablesPanelResult { + if s == nil { + panic("mujica: VariablesPanel needs a state") + } + if !s.started { + s.started, s.cursor = true, -1 + } + variablesIndex(vars, s) + k := Tokens(c) + d := Density(c) + var res VariablesPanelResult + res.Element = codePanel(c).Disabled(opts.Disabled) + disabled := res.Element.IsDisabled() + if disabled { + s.editing = "" + } + res.Element.Children(func() { + if len(vars) == 0 { + codeCaption(c, msg(c, "variables_panel.empty")).Padding(d.Unit() * 4) + return + } + s.outline.List.Selected = &s.cursor + s.outline.List.Label = func(i int) string { return s.byPath[s.outline.Item(i)].Name } + children := func(p string) []string { return s.kids[p] } + restore := dataSelectTheme(c, 0) + e := ui.Outline(c, &s.outline, s.roots, children, func(p string) { + v := s.byPath[p] + ui.Row(c).MinHeight(d.ControlHeight() - 2*c.Theme().Space(1)).Gap(d.Unit() * 2).AlignItems(ui.Center).Grow(1).MinWidth(0).Children(func() { + codeMono(c, v.Name).TextColor(k.Info).Shrink(0) + if s.editing == p { + in := TextInput(c, &s.draft, TextInputOptions{Label: msg(c, "variables_panel.value")}) + in.Element.Grow(1).MinWidth(0) + in.Input.AutoFocus() + switch { + case in.Input.Shortcut(0, ui.KeyEscape): + s.editing = "" + case in.Submitted() && !disabled: + res.Edited = &VariableEdit{Path: p, Value: s.draft} + s.editing = "" + slog.Debug("mujica: variables_panel edit", "path", p) + } + return + } + variablesValue(c, v) + }) + }) + restore() + e.Grow(1).MinHeight(d.ControlHeight() * 3) + internal.Ring(e, k.Focus, theme.ControlRadius) + if disabled || s.editing != "" || s.cursor < 0 || s.cursor >= s.outline.Rows() { + return + } + p := s.outline.Item(s.cursor) + if len(s.kids[p]) == 0 && s.byPath[p].Children == nil && (e.Submitted() || e.Shortcut(0, ui.KeyF2)) { + s.editing, s.draft = p, s.byPath[p].Value + slog.Debug("mujica: variables_panel editing", "path", p) + } + }) + return res +} + +// variablesValue shows a value, marking changed ones. +func variablesValue(c *ui.Context, v *Variable) { + k := Tokens(c) + if v.Changed { + ui.Box(c).Size(7, 7).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, r.W), k.AccentText) + }) + } + val := codeMono(c, v.Value).MinWidth(0) + if v.Changed { + val.FontWeight(700).TextColor(k.AccentText).Description(msg(c, "variables_panel.changed")) + } + ui.Spacer(c) + if v.Type != "" { + codeCaption(c, v.Type).Shrink(0) + } +} + +// variablesIndex keys variables by dotted path. +func variablesIndex(vars []Variable, s *VariablesPanelState) { + var first *Variable + if len(vars) > 0 { + first = &vars[0] + } + if s.data == first && s.n == len(vars) && s.byPath != nil { + return + } + s.data, s.n = first, len(vars) + s.byPath, s.kids = map[string]*Variable{}, map[string][]string{} + var walk func(list []Variable, prefix string) []string + walk = func(list []Variable, prefix string) []string { + paths := []string{} + for i := range list { + p := list[i].Name + if prefix != "" { + p = prefix + "." + p + } + if _, dup := s.byPath[p]; dup { + panic("mujica: duplicate variable path " + p) + } + s.byPath[p] = &list[i] + if list[i].Children != nil { + s.kids[p] = walk(list[i].Children, p) + } + paths = append(paths, p) + } + return paths + } + s.roots = walk(vars, "") +} diff --git a/variables_panel_test.go b/variables_panel_test.go new file mode 100644 index 0000000..67bd40c --- /dev/null +++ b/variables_panel_test.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func variablesSample() []Variable { + return []Variable{ + {Name: "decree", Type: "court.Decree", Value: "{…}", Children: []Variable{ + {Name: "id", Value: "42", Type: "int", Changed: true}, + {Name: "title", Value: `"Of seals"`, Type: "string"}, + }}, + {Name: "count", Value: "7", Type: "int"}, + } +} + +type variablesProbe struct { + vars []Variable + s VariablesPanelState + disabled bool + edits []VariableEdit +} + +func (p *variablesProbe) view(c *ui.Context) { + Use(c, Settings{}) + r := VariablesPanel(c, p.vars, &p.s, VariablesPanelOptions{Disabled: p.disabled}) + r.Element.Height(300) + if r.Edited != nil { + p.edits = append(p.edits, *r.Edited) + } +} + +func TestVariablesPanelExpandAndChanged(t *testing.T) { + p := &variablesProbe{vars: variablesSample()} + tt := ui.NewTester(p.view, 460, 320) + if tt.HasText("title") { + t.Fatal("children shown before expanding") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + if !tt.HasText("title") || !tt.HasText("42") { + t.Fatalf("Right did not expand: %q", tt.Texts()) + } + if !p.s.byPath["decree.id"].Changed { + t.Fatal("changed flag lost") + } +} + +func TestVariablesPanelEditLeaf(t *testing.T) { + p := &variablesProbe{vars: variablesSample()} + tt := ui.NewTester(p.view, 460, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyF2) + if !tt.Focused("Value") { + t.Fatal("F2 did not open the editor") + } + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("8") + tt.Key(0, ui.KeyEnter) + if len(p.edits) != 1 || p.edits[0] != (VariableEdit{"count", "8"}) { + t.Fatalf("edits %v", p.edits) + } + // Enter on a branch does not edit it. + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyEnter) + if tt.Focused("Value") { + t.Fatal("a branch opened the editor") + } +} + +func TestVariablesPanelEscapeAndDisabled(t *testing.T) { + p := &variablesProbe{vars: variablesSample()} + tt := ui.NewTester(p.view, 460, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyEnter) + tt.Type("x") + tt.Key(0, ui.KeyEscape) + if len(p.edits) != 0 || tt.Focused("Value") { + t.Fatal("Escape committed") + } + p = &variablesProbe{vars: variablesSample(), disabled: true} + tt = ui.NewTester(p.view, 460, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyF2) + if tt.Focused("Value") { + t.Fatal("disabled panel opened the editor") + } +} + +func TestVariablesPanelDuplicatePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("duplicate names did not panic") + } + }() + variablesIndex([]Variable{{Name: "a"}, {Name: "a"}}, &VariablesPanelState{}) +} + +func TestVariablesPanelSnapshot(t *testing.T) { + snapshot(t, "variables_panel", 420, 180, func(c *ui.Context) { + p := ui.Local(c.Root(), "snap", func() *variablesProbe { + p := &variablesProbe{vars: variablesSample()} + p.s.outline.Open.Add("decree") + return p + }) + ui.Column(c).Padding(12).Children(func() { + VariablesPanel(c, (*p).vars, &(*p).s, VariablesPanelOptions{}).Element.Height(156) + }) + }) +} diff --git a/video_player.go b/video_player.go new file mode 100644 index 0000000..1c115b0 --- /dev/null +++ b/video_player.go @@ -0,0 +1,162 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// VideoState is what the caller's playback reports. +type VideoState struct { + // Frame is the current picture; nil shows "No video". + Frame ui.ImageSource + Position, Duration time.Duration + Buffered []MediaRange + Playing bool + // Rate is the speed; zero means 1. + Rate float64 + // Subtitle is the cue to show now; empty shows none. + Subtitle string + SubtitlesOn, Fullscreen, PiP bool +} + +// VideoPlayerOptions are callbacks; nil optional ones hide their buttons. +type VideoPlayerOptions struct { + OnPlay, OnPause func() + OnSeek func(time.Duration) + OnRate func(float64) + OnSubtitles func(on bool) + OnFullscreen func(on bool) + OnPiP func(on bool) + Disabled bool + // Label names the video for assistive technology. + Label string +} + +// videoBlack is the stage behind letterboxed frames in both themes. +var videoBlack = ui.Hex("#0E0C0D") + +// VideoPlayer draws the caller's frame and reports controls through callbacks. +func VideoPlayer(c *ui.Context, st VideoState, opts VideoPlayerOptions) *ui.Element { + if opts.OnPlay == nil || opts.OnPause == nil || opts.OnSeek == nil { + panic("mujica: VideoPlayer needs OnPlay, OnPause and OnSeek") + } + if st.Duration < 0 || st.Rate < 0 { + panic("mujica: VideoPlayer needs Duration and Rate >= 0") + } + k := Tokens(c) + u := Density(c).Unit() + col := ui.Column(c).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).Background(k.Surface). + Clip().Role(ui.RoleGroup).Label(videoLabel(c, opts.Label)).Disabled(opts.Disabled) + col.Children(func() { + videoStage(c, k, st, opts) + ui.Column(c).Padding(u, u*2, u*2, u*2).Children(func() { + if st.Duration > 0 { + pos := min(max(0, st.Position), st.Duration) + if VideoScrubber(c, &pos, VideoScrubberOptions{Duration: st.Duration, Buffered: st.Buffered}).Changed() { + opts.OnSeek(pos) + } + } + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + mediaPlayToggle(c, st.Playing, opts.OnPlay, opts.OnPause) + ui.Text(c, mediaClock(st.Position)+" / "+mediaClock(st.Duration)).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.TextMuted).FontFeatures("tnum").SingleLine().Grow(1) + videoButtons(c, st, opts) + }) + }) + }) + return col +} + +func videoLabel(c *ui.Context, label string) string { + if label != "" { + return label + } + return msg(c, "video_player.label") +} + +// videoStage is the focusable picture with playback keys. +func videoStage(c *ui.Context, k theme.Tokens, st VideoState, opts VideoPlayerOptions) { + stage := ui.Box(c).FillWidth().AspectRatio(16.0 / 9).Background(videoBlack).Center().Focusable(). + Role(ui.RoleImage).Label(videoLabel(c, opts.Label)) + internal.Ring(stage, k.Focus, 0) + if !stage.IsDisabled() { + videoKeys(stage, st, opts) + if stage.Clicked() { + mediaToggle(st.Playing, opts.OnPlay, opts.OnPause) + } + } + stage.Children(func() { + if st.Frame == nil { + ui.Text(c, msg(c, "video_player.empty")).TextColor(ui.Hex("#BCAEAA")) + } else { + ui.Image(c, st.Frame).Fill() + } + if st.SubtitlesOn && st.Subtitle != "" { + ui.Box(c).Absolute().Left(0).Right(0).Bottom(16).Center().PassThrough().Children(func() { + ui.Text(c, st.Subtitle).TextColor(ui.Hex("#F2EBDD")).Background(ui.RGBA(0, 0, 0, 0.72)). + TextAlign(ui.Center).MaxLines(3) + }) + } + }) +} + +func videoKeys(stage *ui.Element, st VideoState, opts VideoPlayerOptions) { + seek := func(d time.Duration) { + to := min(max(0, st.Position+d), st.Duration) + slog.Debug("mujica: video_player seek", "pos", to) + opts.OnSeek(to) + } + switch { + case stage.Shortcut(0, ui.KeySpace), stage.Shortcut(0, ui.KeyK): + mediaToggle(st.Playing, opts.OnPlay, opts.OnPause) + case stage.Shortcut(0, ui.KeyLeft): + seek(-5 * time.Second) + case stage.Shortcut(0, ui.KeyRight): + seek(5 * time.Second) + case opts.OnFullscreen != nil && stage.Shortcut(0, ui.KeyF): + opts.OnFullscreen(!st.Fullscreen) + case opts.OnSubtitles != nil && stage.Shortcut(0, ui.KeyC): + opts.OnSubtitles(!st.SubtitlesOn) + } +} + +// videoButtons are rate, subtitles, picture-in-picture and full screen. +func videoButtons(c *ui.Context, st VideoState, opts VideoPlayerOptions) { + if opts.OnRate != nil { + rate := st.Rate + if rate == 0 { + rate = 1 + } + if PlaybackSpeedControl(c, &rate, PlaybackSpeedControlOptions{}).Changed() { + opts.OnRate(rate) + } + } + toggle := func(icon *ui.SVG, key string, on bool, fn func(bool)) { + if fn == nil { + return + } + b := mediaToggleAction(c, icon, msg(c, key), on) + if b.Clicked() && !b.IsDisabled() { + slog.Debug("mujica: video_player toggle", "control", key, "on", !on) + fn(!on) + } + } + toggle(mediaCaptions, "video_player.subtitles", st.SubtitlesOn, opts.OnSubtitles) + toggle(mediaPiP, "video_player.pip", st.PiP, opts.OnPiP) + toggle(mediaFullscreen, "video_player.fullscreen", st.Fullscreen, opts.OnFullscreen) +} + +// mediaToggleAction is an iconAction that shows when it is on. +func mediaToggleAction(c *ui.Context, icon *ui.SVG, label string, on bool) *ui.Element { + k := Tokens(c) + b := iconAction(c, icon, label).Role(ui.RoleToggleButton).Selected(on) + if on { + b.Background(k.Selection).TextColor(k.AccentText).BorderWidth(0, 0, 2, 0).BorderColor(k.AccentText) + } + return b +} diff --git a/video_player_test.go b/video_player_test.go new file mode 100644 index 0000000..006b3dd --- /dev/null +++ b/video_player_test.go @@ -0,0 +1,120 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +type videoCalls struct { + mediaCalls + rates []float64 + subs, full, pip []bool +} + +func (v *videoCalls) opts() VideoPlayerOptions { + a := v.mediaCalls.opts() + return VideoPlayerOptions{OnPlay: a.OnPlay, OnPause: a.OnPause, OnSeek: a.OnSeek, + OnRate: func(r float64) { v.rates = append(v.rates, r) }, + OnSubtitles: func(on bool) { v.subs = append(v.subs, on) }, + OnFullscreen: func(on bool) { v.full = append(v.full, on) }, + OnPiP: func(on bool) { v.pip = append(v.pip, on) }} +} + +func videoTester(st *VideoState, o *VideoPlayerOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(640).Children(func() { VideoPlayer(c, *st, *o) }) + }, 700, 520) +} + +func TestVideoPlayerStageKeys(t *testing.T) { + st := VideoState{Frame: mediaTestBitmap(160, 90), Position: 20 * time.Second, Duration: time.Minute} + var v videoCalls + o := v.opts() + tt := videoTester(&st, &o) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Video") { + t.Fatal("stage not first in Tab order") + } + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyF) + tt.Key(0, ui.KeyC) + if v.play != 1 || len(v.seeks) != 1 || v.seeks[0] != 25*time.Second || len(v.full) != 1 || !v.full[0] || len(v.subs) != 1 || !v.subs[0] { + t.Fatalf("calls %+v", v) + } +} + +func TestVideoPlayerButtons(t *testing.T) { + st := VideoState{Position: 0, Duration: time.Minute, Fullscreen: true, Rate: 1} + var v videoCalls + o := v.opts() + tt := videoTester(&st, &o) + if !tt.HasText("No video") { + t.Fatal("empty stage not shown") + } + for _, name := range []string{"Picture in picture", "Full screen", "Subtitles"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + if len(v.pip) != 1 || !v.pip[0] || len(v.full) != 1 || v.full[0] || len(v.subs) != 1 || !v.subs[0] { + t.Fatalf("toggles %+v", v) + } + if err := tt.Click("Playback speed"); err != nil { + t.Fatal(err) + } + if err := tt.Click("1.5×"); err != nil { + t.Fatal(err) + } + if len(v.rates) != 1 || v.rates[0] != 1.5 { + t.Fatalf("rates %v", v.rates) + } +} + +func TestVideoPlayerSubtitleAndOptionalButtons(t *testing.T) { + st := VideoState{Duration: time.Minute, Subtitle: "Welcome to the masquerade"} + var v videoCalls + o := v.opts() + o.OnPiP, o.OnRate = nil, nil + tt := videoTester(&st, &o) + if tt.HasText("Welcome to the masquerade") { + t.Fatal("subtitle shown while off") + } + if _, ok := tt.Find("Picture in picture"); ok { + t.Fatal("PiP button without OnPiP") + } + st.SubtitlesOn = true + tt.Frame() + if !tt.HasText("Welcome to the masquerade") { + t.Fatal("subtitle not shown") + } +} + +func TestVideoPlayerDisabled(t *testing.T) { + st := VideoState{Duration: time.Minute} + var v videoCalls + o := v.opts() + o.Disabled = true + tt := videoTester(&st, &o) + _ = tt.Click("Play") + _ = tt.Click("Full screen") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if v.play != 0 || len(v.full) != 0 { + t.Fatalf("disabled player reported %+v", v) + } +} + +func TestVideoPlayerSnapshot(t *testing.T) { + var v videoCalls + snapshot(t, "video_player", 560, 400, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(520).Children(func() { + VideoPlayer(c, VideoState{Frame: mediaTestBitmap(320, 180), Position: 95 * time.Second, Duration: 4 * time.Minute, + Buffered: []MediaRange{{0, 150 * time.Second}}, Playing: true, Rate: 1, SubtitlesOn: true, + Subtitle: "The curtain rises."}, v.opts()) + }) + }) +} diff --git a/video_scrubber.go b/video_scrubber.go new file mode 100644 index 0000000..156e06b --- /dev/null +++ b/video_scrubber.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MediaRange is a span of a media timeline. +type MediaRange struct{ Start, End time.Duration } + +// VideoScrubberOptions configure a VideoScrubber. +type VideoScrubberOptions struct { + // Duration is the timeline's length; required. + Duration time.Duration + // Buffered spans show as a lighter fill. + Buffered []MediaRange + // Preview builds the hover slot's content for a time. + Preview func(at time.Duration) + // PreviewWidth, PreviewHeight size the slot; zero means 160×90. + PreviewWidth, PreviewHeight float32 + // Step is one arrow press; zero means 5 seconds. + Step time.Duration + Disabled bool + // Label names the timeline for assistive technology. + Label string +} + +// VideoScrubber sets *pos by dragging, clicking, arrows, Home and End. +func VideoScrubber(c *ui.Context, pos *time.Duration, opts VideoScrubberOptions) ChoiceResult { + if opts.Duration <= 0 || opts.Step < 0 || opts.PreviewWidth < 0 || opts.PreviewHeight < 0 { + panic("mujica: VideoScrubber needs Duration > 0 and no negative Step or preview size") + } + if *pos < 0 || *pos > opts.Duration { + panic("mujica: VideoScrubber position outside 0..Duration") + } + step := opts.Step + if step == 0 { + step = 5 * time.Second + } + pw, ph := opts.PreviewWidth, opts.PreviewHeight + if pw == 0 { + pw, ph = 160, 90 + } + k := Tokens(c) + dur := opts.Duration.Seconds() + g, h := sliderSetup(0, dur, min(step.Seconds(), dur), nil, false, c) + old := *pos + var s *ui.Element + var next float64 + wrap := ui.Box(c).MinWidth(120).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + s, next, _ = sliderControl(c, old.Seconds(), g, 0, dur) + s.Height(h).PaddingX(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer). + Label(scrubberLabel(c, opts.Label)) + px, _, over := s.PointerPosition() + r := s.Bounds() + at := time.Duration(float64((px-sliderThumb/2)/max(r.W-sliderThumb, 1)) * float64(opts.Duration)) + at = max(0, min(opts.Duration, at)) + if over && !s.IsDisabled() { + scrubberHover(c, k, r, g, at, opts, pw, ph) + } + }) + if !s.IsDisabled() { + *pos = max(0, min(opts.Duration, time.Duration(next*float64(time.Second)).Round(time.Millisecond))) + if next == dur { + *pos = opts.Duration + } + } + changed := *pos != old + if changed { + slog.Debug("mujica: video_scrubber seek", "pos", *pos) + } + v, buffered, focus := pos.Seconds(), opts.Buffered, s + s.Draw(func(p *ui.Painter, r ui.Rect) { + y := r.Y + g.trackY + x0 := g.x(r, 0) + sliderTrackPaint(p, r, g, k, nil, 0, x0, x0) + // Buffered spans fill the rail under the played part. + for _, b := range buffered { + b0, b1 := g.x(r, b.Start.Seconds()), g.x(r, b.End.Seconds()) + p.Fill(ui.Rect{X: b0, Y: y - sliderTrack/2, W: b1 - b0, H: sliderTrack}, k.TextMuted.Alpha(0.45), 0) + } + p.Fill(ui.Rect{X: x0, Y: y - sliderTrack/2, W: g.x(r, v) - x0, H: sliderTrack}, k.AccentText, 0) + sliderThumbPaint(p, k, g.x(r, v), y, focus.Hovered(), focus.FocusVisible()) + }) + return ChoiceResult{Element: s, changed: changed} +} + +func scrubberLabel(c *ui.Context, label string) string { + if label != "" { + return label + } + return msg(c, "video_scrubber.label") +} + +// scrubberHover shows hover time and preview slot above the track. +func scrubberHover(c *ui.Context, k theme.Tokens, r ui.Rect, g sliderGeom, at time.Duration, opts VideoScrubberOptions, pw, ph float32) { + x := g.x(ui.Rect{W: r.W}, at.Seconds()) + w := pw + if opts.Preview == nil { + w = 64 + } + panel := ui.Column(c).Absolute().Left(max(0, min(r.W-w, x-w/2))).Bottom(r.H - g.trackY + sliderThumb). + Width(w).Padding(4).Gap(4).AlignItems(ui.Center).PassThrough() + overlayStyle(c, panel, 1) + panel.Children(func() { + if opts.Preview != nil { + ui.Box(c).Size(pw-8, ph).Clip().Radius(theme.ControlRadius).Background(k.Background).Children(func() { + opts.Preview(at) + }) + } + ui.Text(c, mediaClock(at)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine() + }) +} diff --git a/video_scrubber_test.go b/video_scrubber_test.go new file mode 100644 index 0000000..7805684 --- /dev/null +++ b/video_scrubber_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func scrubberTester(pos *time.Duration, opts VideoScrubberOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(40).Children(func() { + if VideoScrubber(c, pos, opts).Changed() { + *changes++ + } + }) + }, 500, 300) +} + +func TestVideoScrubberKeys(t *testing.T) { + pos, n := 10*time.Second, 0 + tt := scrubberTester(&pos, VideoScrubberOptions{Duration: time.Minute}, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if pos != 15*time.Second { + t.Fatalf("Right: %v", pos) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + if pos != 5*time.Second { + t.Fatalf("Left twice: %v", pos) + } + tt.Key(0, ui.KeyEnd) + if pos != time.Minute { + t.Fatalf("End: %v", pos) + } + tt.Key(0, ui.KeyHome) + if pos != 0 || n != 5 { + t.Fatalf("Home: %v, changes %d", pos, n) + } +} + +func TestVideoScrubberClickAndHover(t *testing.T) { + pos, n := time.Duration(0), 0 + var asked time.Duration + opts := VideoScrubberOptions{Duration: 100 * time.Second, Preview: func(at time.Duration) { asked = at }} + tt := scrubberTester(&pos, opts, &n) + r, ok := tt.Find("Timeline") + if !ok { + t.Fatal("no timeline") + } + mid := r.X + r.W/2 + tt.Move(mid, r.Y+r.H/2) + if asked < 45*time.Second || asked > 55*time.Second || !tt.HasText(mediaClock(asked)) { + t.Fatalf("hover preview at %v, texts %v", asked, tt.Texts()) + } + tt.ClickAt(mid, r.Y+r.H/2) + if pos < 45*time.Second || pos > 55*time.Second || n != 1 { + t.Fatalf("click seek %v, changes %d", pos, n) + } +} + +func TestVideoScrubberDisabled(t *testing.T) { + pos, n := 10*time.Second, 0 + tt := scrubberTester(&pos, VideoScrubberOptions{Duration: time.Minute, Disabled: true}, &n) + r, _ := tt.Find("Timeline") + tt.ClickAt(r.X+r.W-10, r.Y+r.H/2) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if pos != 10*time.Second || n != 0 { + t.Fatalf("disabled moved to %v", pos) + } +} + +func TestVideoScrubberBadOptions(t *testing.T) { + pos := time.Duration(0) + mediaPanics(t, "no duration", func(c *ui.Context) { VideoScrubber(c, &pos, VideoScrubberOptions{}) }) + far := 2 * time.Minute + mediaPanics(t, "past end", func(c *ui.Context) { VideoScrubber(c, &far, VideoScrubberOptions{Duration: time.Minute}) }) +} + +func TestVideoScrubberSnapshot(t *testing.T) { + pos := 40 * time.Second + snapshot(t, "video_scrubber", 420, 80, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + VideoScrubber(c, &pos, VideoScrubberOptions{Duration: 2 * time.Minute, + Buffered: []MediaRange{{0, 70 * time.Second}, {90 * time.Second, 100 * time.Second}}}) + }) + }) +} diff --git a/video_thumbnail_strip.go b/video_thumbnail_strip.go new file mode 100644 index 0000000..af92ed4 --- /dev/null +++ b/video_thumbnail_strip.go @@ -0,0 +1,116 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// StripFrame is one thumbnail of a VideoThumbnailStrip. +type StripFrame struct { + // Image is a bitmap or SVG; nil leaves it empty. + Image ui.ImageSource + At time.Duration +} + +// VideoThumbnailStripOptions configure a VideoThumbnailStrip. +type VideoThumbnailStripOptions struct { + // FrameWidth is each cell's width; zero means 96. + FrameWidth float32 + Disabled bool +} + +const stripGap = 4 + +type stripKey struct{} + +// VideoThumbnailStrip shows the frames fitting around *current. +func VideoThumbnailStrip(c *ui.Context, current *int, frames []StripFrame, opts VideoThumbnailStripOptions) ChoiceResult { + if len(frames) == 0 || *current < 0 || *current >= len(frames) { + panic("mujica: VideoThumbnailStrip current out of range") + } + if opts.FrameWidth < 0 { + panic("mujica: VideoThumbnailStrip FrameWidth must not be negative") + } + fw := opts.FrameWidth + if fw == 0 { + fw = 96 + } + fh := fw * 9 / 16 + k := Tokens(c) + old := *current + row := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Disabled(opts.Disabled) + row.Children(func() { + prev := iconAction(c, icons.Must("chevron-left"), msg(c, "video_thumbnail_strip.previous")).Disabled(*current == 0) + view := ui.Row(c).Grow(1).MinWidth(fw).Height(fh + 4).Gap(stripGap).Clip().Focusable(). + Role(ui.RoleGroup).Label(msg(c, "video_thumbnail_strip.label")). + Description(fmt.Sprintf(msg(c, "video_thumbnail_strip.position"), *current+1, len(frames), mediaClock(frames[*current].At))) + internal.Ring(view, k.Focus, theme.ControlRadius) + next := iconAction(c, icons.Must("chevron-right"), msg(c, "video_thumbnail_strip.next")).Disabled(*current == len(frames)-1) + live := !row.IsDisabled() + switch { + case live && (prev.Clicked() || view.Shortcut(0, ui.KeyLeft)): + *current = max(0, *current-1) + case live && (next.Clicked() || view.Shortcut(0, ui.KeyRight)): + *current = min(len(frames)-1, *current+1) + case live && view.Shortcut(0, ui.KeyHome): + *current = 0 + case live && view.Shortcut(0, ui.KeyEnd): + *current = len(frames) - 1 + } + // Only frames that fit are built, around *current. + w := view.Bounds().W + if w <= 0 { + // The count needs the width: lay out, then rebuild. + c.AnimationFrame() + } + n := max(1, int((w+stripGap)/(fw+stripGap))) + start := ui.Local(view, stripKey{}, func() int { return 0 }) + *start = max(0, min(*start, *current, len(frames)-n)) + if *current >= *start+n { + *start = *current - n + 1 + } + view.Children(func() { + for i := *start; i < min(len(frames), *start+n); i++ { + if stripCell(c, k, frames[i], i, i == *current, fw, fh) && live { + *current = i + } + } + }) + }) + changed := *current != old + if changed { + slog.Debug("mujica: video_strip jump", "index", *current) + } + return ChoiceResult{Element: row, changed: changed} +} + +// stripCell draws one frame and reports a click on it. +func stripCell(c *ui.Context, k theme.Tokens, f StripFrame, i int, on bool, fw, fh float32) bool { + cell := ui.Box(c).Key(i).Size(fw, fh).Shrink(0).Radius(theme.ControlRadius).Clip().Background(videoBlack). + Cursor(ui.CursorPointer).AlignSelf(ui.Center) + edge, width := k.Border, theme.BorderWidth + if on { + edge, width = k.AccentText, 2 + } + cell.Border(width, edge) + cell.Children(func() { + if f.Image != nil { + ui.Image(c, f.Image).Fit(ui.Cover).Size(fw, fh) + } + ui.Text(c, mediaClock(f.At)).Absolute().Left(4).Bottom(3).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(ui.Hex("#F2EBDD")).Background(ui.RGBA(0, 0, 0, 0.6)).FontFeatures("tnum") + if on { + ui.Box(c).Absolute().Top(3).Right(3).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(internal.Snap(p, r.X+5), internal.Snap(p, r.Y+5), 10), k.AccentText) + }) + } + }) + return cell.Clicked() +} diff --git a/video_thumbnail_strip_test.go b/video_thumbnail_strip_test.go new file mode 100644 index 0000000..6cc95fe --- /dev/null +++ b/video_thumbnail_strip_test.go @@ -0,0 +1,102 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func stripFrames(n int) []StripFrame { + f := make([]StripFrame, n) + for i := range f { + f[i].At = time.Duration(i) * time.Second + } + return f +} + +func stripTester(cur *int, frames []StripFrame, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Width(520).Children(func() { + if VideoThumbnailStrip(c, cur, frames, VideoThumbnailStripOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 600, 160) +} + +// stripBuilt counts the time labels the strip built. +func stripBuilt(tt *ui.Tester) int { + k := 0 + for _, s := range tt.Texts() { + if strings.Contains(s, ":") && !strings.Contains(s, " ") { + k++ + } + } + return k +} + +func TestVideoThumbnailStripKeysAndClick(t *testing.T) { + cur, n := 0, 0 + tt := stripTester(&cur, stripFrames(40), false, &n) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Frames") { + t.Fatal("Tab did not reach the frames") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if cur != 2 { + t.Fatalf("Right twice: %d", cur) + } + tt.Key(0, ui.KeyEnd) + if cur != 39 || !tt.HasText("0:39") { + t.Fatalf("End: %d, %v", cur, tt.Texts()) + } + r, _ := tt.Find("0:37") + tt.ClickAt(r.X+20, r.Y-10) + if cur != 37 || n != 4 { + t.Fatalf("click: %d, changes %d", cur, n) + } +} + +func TestVideoThumbnailStripBuildsOnlyVisible(t *testing.T) { + cur, n := 50000, 0 + tt := stripTester(&cur, stripFrames(100000), false, &n) + tt.Frame() + if b := stripBuilt(tt); b < 3 || b > 10 { + t.Fatalf("built %d frames of 100000", b) + } + if !tt.HasText(mediaClock(50000 * time.Second)) { + t.Fatal("current frame not built") + } +} + +func TestVideoThumbnailStripDisabledAndBad(t *testing.T) { + cur, n := 3, 0 + tt := stripTester(&cur, stripFrames(10), true, &n) + _ = tt.Click("Next frame") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if cur != 3 || n != 0 { + t.Fatal("disabled strip moved") + } + bad := 5 + mediaPanics(t, "out of range", func(c *ui.Context) { + VideoThumbnailStrip(c, &bad, stripFrames(3), VideoThumbnailStripOptions{}) + }) +} + +func TestVideoThumbnailStripSnapshot(t *testing.T) { + frames := stripFrames(12) + for i := range frames { + frames[i].Image = mediaTestBitmap(64, 36) + } + cur := 2 + snapshot(t, "video_thumbnail_strip", 560, 110, func(c *ui.Context) { + ui.Column(c).Padding(20).Width(520).Children(func() { + VideoThumbnailStrip(c, &cur, frames, VideoThumbnailStripOptions{}) + }) + }) +} diff --git a/volume_control.go b/volume_control.go new file mode 100644 index 0000000..7fac647 --- /dev/null +++ b/volume_control.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" +) + +// VolumeControlOptions configure a VolumeControl. +type VolumeControlOptions struct { + Disabled bool +} + +// VolumeControl sets *volume in 5% steps and toggles *muted. +func VolumeControl(c *ui.Context, volume *float64, muted *bool, opts VolumeControlOptions) ChoiceResult { + if *volume < 0 || *volume > 1 || math.IsNaN(*volume) { + panic("mujica: VolumeControl volume outside 0..1") + } + oldV, oldM := *volume, *muted + u := Density(c).Unit() + row := ui.Row(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleGroup).Label(msg(c, "volume_control.label")).Disabled(opts.Disabled) + row.Children(func() { + icon, key := mediaVolume, "volume_control.mute" + if *muted || *volume == 0 { + icon, key = mediaMute, "volume_control.unmute" + } + b := mediaToggleAction(c, icon, msg(c, key), *muted) + if b.Clicked() && !b.IsDisabled() { + *muted = !*muted + } + s := Slider(c, volume, SliderOptions{Min: 0, Max: 1, Step: 0.05, Disabled: opts.Disabled, + Label: msg(c, "volume_control.label"), ShowValue: true, Format: func(v float64) string { return fmt.Sprintf("%d%%", int(math.Round(v*100))) }}) + s.Element.Width(120) + // Raising the volume while muted unmutes, as players do. + if s.Changed() && *muted && *volume > oldV { + *muted = false + } + }) + if !row.IsDisabled() && row.Shortcut(0, ui.KeyM) { + *muted = !*muted + } + changed := *volume != oldV || *muted != oldM + if changed { + slog.Debug("mujica: volume changed", "volume", *volume, "muted", *muted) + } + return ChoiceResult{Element: row, changed: changed} +} diff --git a/volume_control_test.go b/volume_control_test.go new file mode 100644 index 0000000..94a6b71 --- /dev/null +++ b/volume_control_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func volumeTester(v *float64, muted *bool, disabled bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(40).AlignItems(ui.Start).Children(func() { + if VolumeControl(c, v, muted, VolumeControlOptions{Disabled: disabled}).Changed() { + *n++ + } + }) + }, 400, 200) +} + +func TestVolumeControlArrowsAndMute(t *testing.T) { + v, muted, n := 0.5, false, 0 + tt := volumeTester(&v, &muted, false, &n) + if err := tt.Click("Mute"); err != nil { + t.Fatal(err) + } + if !muted { + t.Fatal("mute button did not mute") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyLeft) + if v != 0.45 || !muted { + t.Fatalf("Left: %v muted %v", v, muted) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if v != 0.55 || muted { + t.Fatalf("raising should unmute: %v muted %v", v, muted) + } + tt.Key(0, ui.KeyM) + if !muted || n != 5 { + t.Fatalf("M: muted %v changes %d", muted, n) + } + tt.Key(0, ui.KeyEnd) + if v != 1 { + t.Fatalf("End: %v", v) + } +} + +func TestVolumeControlDisabled(t *testing.T) { + v, muted, n := 0.5, false, 0 + tt := volumeTester(&v, &muted, true, &n) + _ = tt.Click("Mute") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyM) + if v != 0.5 || muted || n != 0 { + t.Fatalf("disabled changed %v %v", v, muted) + } +} + +func TestVolumeControlRange(t *testing.T) { + loud, m := 1.5, false + mediaPanics(t, "over 1", func(c *ui.Context) { VolumeControl(c, &loud, &m, VolumeControlOptions{}) }) +} + +func TestVolumeControlSnapshot(t *testing.T) { + a, b, ma, mb := 0.7, 0.3, false, true + snapshot(t, "volume_control", 260, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(16).AlignItems(ui.Start).Children(func() { + VolumeControl(c, &a, &ma, VolumeControlOptions{}) + VolumeControl(c, &b, &mb, VolumeControlOptions{}) + }) + }) +} diff --git a/waterfall_chart.go b/waterfall_chart.go new file mode 100644 index 0000000..e7bf0b7 --- /dev/null +++ b/waterfall_chart.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// WaterfallKind says how a step's bar is drawn. +type WaterfallKind int + +const ( + // WaterfallDelta adds Value to the running total. + WaterfallDelta WaterfallKind = iota + // WaterfallSubtotal shows the running total; its Value must be zero. + WaterfallSubtotal + // WaterfallTotal shows the final total; Value must be zero. + WaterfallTotal +) + +// WaterfallStep is one bar of a WaterfallChart. +type WaterfallStep struct { + Label string + Value float64 + Kind WaterfallKind +} + +// WaterfallChartOptions configure a WaterfallChart. +type WaterfallChartOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// WaterfallChart builds a total from rises and falls. +func WaterfallChart(c *ui.Context, steps []WaterfallStep, opts WaterfallChartOptions) ChartResult { + from, to := make([]float64, len(steps)), make([]float64, len(steps)) + run, lo, hi := 0.0, 0.0, 0.0 + for i, s := range steps { + if s.Label == "" || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) || s.Kind != WaterfallDelta && s.Value != 0 || s.Kind > WaterfallTotal { + panic("mujica: WaterfallChart steps need a label, a finite value, and zero for totals") + } + if s.Kind == WaterfallDelta { + from[i], to[i], run = run, run+s.Value, run+s.Value + } else { + from[i], to[i] = 0, run + } + lo, hi = min(lo, from[i], to[i]), max(hi, from[i], to[i]) + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + var step float64 + if len(steps) > 0 { + pad := (hi - lo) * 0.12 + lo, hi, step = internal.Nice(lo-pad*chartBool(lo < 0), hi+pad, 4) + } + val := func(v float64) string { return chartValue(c, v, opts.Format) } + signed := func(v float64) string { + if v >= 0 { + return "+" + val(v) + } + return "−" + val(-v) + } + geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) { + plot := chartInset(r, 52, 26) + return plot, internal.Band{N: len(steps), 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)} + } + spec := chartSpec{n: len(steps)} + spec.readout = func(i int) string { + if steps[i].Kind != WaterfallDelta { + return steps[i].Label + " · " + val(to[i]) + } + return steps[i].Label + " · " + signed(steps[i].Value) + " · " + msg(c, "waterfall_chart.running") + " " + val(to[i]) + } + spec.pick = func(r ui.Rect, x, y float32) int { + plot, band, _ := geom(r) + if !plot.Contains(x, y) { + return -1 + } + return min(len(steps)-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, s := range steps { + xs, names = append(xs, band.Center(i)), append(names, s.Label) + } + internal.AxisX(p, plot, xs, names, size, k) + internal.Rule(p, plot.X, plot.X+plot.W, ys.Map(0), k.ControlBorder) + for i, s := range steps { + a, b := ys.Map(from[i]), ys.Map(to[i]) + bar := ui.Rect{X: band.Start(i), Y: min(a, b), W: band.Width(), H: max(abs32(a-b), 1)} + col, shape, text := internal.Palette(k)[5], 1, val(to[i]) + switch { + case s.Kind != WaterfallDelta: + case s.Value >= 0: + col, shape, text = k.Success, 3, signed(s.Value) + default: + col, shape, text = k.Danger, 4, signed(s.Value) + } + p.Fill(bar, col, 1) + internal.Marker(p, shape, bar.X+bar.W/2, bar.Y-14+(bar.H+28)*float32(chartBool(s.Kind == WaterfallDelta && s.Value < 0)), 8, col) + ty, ay := bar.Y-22, float32(1) + if s.Kind == WaterfallDelta && s.Value < 0 { + ty, ay = bar.Y+bar.H+22, 0 + } + chartText(p, text, bar.X+bar.W/2, ty, size, k.Text, 0.5, ay) + if i+1 < len(steps) { + y := internal.Snap(p, ys.Map(to[i])) + p.Line(bar.X+bar.W, y, band.Start(i+1), y, 1, k.TextMuted) + } + if i == active { + p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 2, 1.5) + } + } + } + res, _ := ch.build(spec) + return res +} diff --git a/waterfall_chart_test.go b/waterfall_chart_test.go new file mode 100644 index 0000000..70c9b7d --- /dev/null +++ b/waterfall_chart_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var waterfallSteps = []WaterfallStep{ + {Label: "Tithes", Value: 12}, + {Label: "Repairs", Value: -5}, + {Label: "Q1", Kind: WaterfallSubtotal}, + {Label: "Alms", Value: 4}, + {Label: "Year", Kind: WaterfallTotal}, +} + +func TestWaterfallChartReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: ChartFrame{Label: "Ledger"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Ledger") + want := []string{"Tithes · +12 · running total 12", "Repairs · −5 · running total 7", "Q1 · 7", "Alms · +4 · running total 11", "Year · 11"} + for i, w := range want { + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != w { + t.Fatalf("step %d: %q, want %q", i, got, w) + } + } +} + +func TestWaterfallChartBad(t *testing.T) { + for _, s := range []WaterfallStep{{Label: "x", Value: 3, Kind: WaterfallTotal}, {Value: 1}, {Label: "x", Kind: 9}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", s) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + WaterfallChart(c, []WaterfallStep{s}, WaterfallChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) + }() + } +} + +func TestWaterfallChartSnapshot(t *testing.T) { + snapshot(t, "waterfall_chart", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: ChartFrame{Label: "Ledger"}}) + }) + }) +} diff --git a/week_picker.go b/week_picker.go new file mode 100644 index 0000000..d01665a --- /dev/null +++ b/week_picker.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// WeekPickerOptions configure a WeekPicker. +type WeekPickerOptions struct { + WeekStart WeekStart + // Min and Max bound the chosen week's days. + Min, Max Date + // Today is marked; zero uses the system clock. + Today Date + Clearable bool + Disabled bool + Label string +} + +// WeekPicker chooses a week; *value is its first day. +func WeekPicker(c *ui.Context, value *Date, opts WeekPickerOptions) ChoiceResult { + datetimeCheckDate("WeekPicker value", *value) + datetimeCheckBounds("WeekPicker", opts.Min, opts.Max) + first := calendarFirstWeekday(c, opts.WeekStart) + weekOf := func(d Date) Date { return d.AddDays(-((int(d.Weekday()) - int(first) + 7) % 7)) } + if !value.IsZero() && weekOf(*value) != *value { + panic(fmt.Sprintf("mujica: WeekPicker value %v does not start a week", *value)) + } + d := Density(c) + today := datetimeToday(c, opts.Today) + label := opts.Label + if label == "" { + label = msg(c, "week_picker.label") + } + enabled := func(x Date) bool { + w := weekOf(x) + return w.AddDays(6).within(opts.Min, Date{}) && w.within(Date{}, opts.Max) + } + col := ui.Column(c).Width(d.ControlHeight() * 8).Gap(d.Unit()).AlignItems(ui.Stretch).Disabled(opts.Disabled) + open := ui.Local(col, "week-picker-open", func() bool { return false }) + cursor := ui.Local(col, "week-picker-cursor", func() Date { return Date{} }) + hover := ui.Local(col, "week-picker-hover", func() Date { return Date{} }) + old := *value + text := "" + if !value.IsZero() { + text = weekViewTitle(c, *value, value.AddDays(6)) + } + col.Children(func() { + field := datetimeField(c, ui.Row(c), false, false) + field.Children(func() { + t := datetimeTrigger(c, text, msg(c, "week_picker.placeholder"), "calendar").Label(label).Description(text) + if !t.IsDisabled() && (t.Clicked() || !*open && t.Shortcut(0, ui.KeyDown)) { + *open, *hover = !*open, Date{} + *cursor = *value + if cursor.IsZero() { + *cursor = today + } + } + if opts.Clearable && !value.IsZero() { + if b := iconAction(c, icons.Must("x"), msg(c, "calendar.clear")); b.Clicked() && !b.IsDisabled() { + *value = Date{} + } + } + }) + datetimePopup(c, field, open, false, func() { + band := *hover + if band.IsZero() { + band = *cursor + } + picked, hovered := calendarMonth(c, cursor, calendarConfig{ + chosen: func(x Date) bool { return !value.IsZero() && weekOf(x) == *value }, + inRange: func(x Date) bool { return weekOf(x) == weekOf(band) }, + enabled: enabled, + today: today, + weekStart: opts.WeekStart, + autoFocus: true, + }) + if hovered != *hover { + *hover = hovered + c.Invalidate() + } + if !picked.IsZero() { + *value, *open = weekOf(picked), false + } + }) + }) + changed := *value != old + if changed { + slog.Debug("mujica: week_picker change", "from", old, "to", *value) + } + return ChoiceResult{Element: col, changed: changed} +} diff --git a/week_picker_test.go b/week_picker_test.go new file mode 100644 index 0000000..d8c3d8f --- /dev/null +++ b/week_picker_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func weekPickerTester(v *Date, opts *WeekPickerOptions, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := *opts + o.Today, o.Label = calendarToday, "Sprint" + ui.Column(c).Padding(16).Children(func() { + if WeekPicker(c, v, o).Changed() { + *n++ + } + }) + }, 480, 480) +} + +func TestWeekPickerPicksWeekStart(t *testing.T) { + v, n := Date{}, 0 + opts := &WeekPickerOptions{} + tt := weekPickerTester(&v, opts, &n) + _ = tt.Click("Sprint") + _ = tt.Click(datetimeTestLabel(oct(15))) + if v != oct(11) || n != 1 || !tt.HasText("October 11 – October 17, 2026") { + t.Fatalf("Sunday-start week of Oct 15: %v %v", v, tt.Texts()) + } + v, opts.WeekStart = oct(12), WeekStartMonday + tt.Frame() + _ = tt.Click("Sprint") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if v != oct(19) || n != 2 { + t.Fatalf("Monday-start keyboard pick %v", v) + } +} + +func TestWeekPickerBoundsClearDisabled(t *testing.T) { + v, n := oct(11), 0 + opts := &WeekPickerOptions{Min: oct(4), Max: oct(24), Clearable: true} + tt := weekPickerTester(&v, opts, &n) + _ = tt.Click("Sprint") + _ = tt.Click(datetimeTestLabel(oct(26))) + _ = tt.Click(datetimeTestLabel(oct(2))) + if v != oct(11) || n != 0 { + t.Fatalf("out-of-range week chosen: %v", v) + } + tt.Key(0, ui.KeyEscape) + _ = tt.Click("Clear") + if !v.IsZero() || n != 1 { + t.Fatalf("clear left %v", v) + } + opts.Disabled = true + tt.Frame() + _ = tt.Click("Sprint") + if tt.HasText("October 2026") { + t.Fatal("disabled picker opened") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic for a mid-week value") + } + }() + mid := oct(14) + weekPickerTester(&mid, &WeekPickerOptions{}, &n) +} + +func TestWeekPickerSnapshot(t *testing.T) { + v := oct(11) + snapshot(t, "week_picker", 400, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { WeekPicker(c, &v, WeekPickerOptions{Today: calendarToday, Clearable: true}) }) + }) +} diff --git a/welcome_screen.go b/welcome_screen.go new file mode 100644 index 0000000..cd0d46e --- /dev/null +++ b/welcome_screen.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// WelcomeScreenOptions configure a WelcomeScreen. Greeting is required. +type WelcomeScreenOptions struct { + Greeting string + Subtitle string + Cards []CapabilityCard + // Prompts is the slot for starter prompts, such as SuggestionChips. + Prompts func() + Disabled bool +} + +// WelcomeScreen greets a new conversation with cards and prompts. +func WelcomeScreen(c *ui.Context, opts WelcomeScreenOptions) CapabilityCardsView { + if opts.Greeting == "" { + panic("mujica: WelcomeScreen needs a Greeting") + } + k := Tokens(c) + d := Density(c) + var v CapabilityCardsView + col := ui.Column(c).FillWidth().MaxWidth(880).AlignSelf(ui.Center).AlignItems(ui.Center).Gap(d.Unit()*4). + Padding(d.Unit()*8, d.Unit()*4).Disabled(opts.Disabled) + col.Children(func() { + ui.Text(c, opts.Greeting).Font(theme.SerifFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text). + TextAlign(ui.Center).Role(ui.RoleText) + ui.Box(c).Width(220).Height(9).Draw(func(p *ui.Painter, r ui.Rect) { + internal.RuleDiamond(p, r, 8, 6, k.Ornament) + }) + if opts.Subtitle != "" { + ui.Text(c, opts.Subtitle).TextColor(k.TextMuted).TextAlign(ui.Center) + } + if len(opts.Cards) > 0 { + v = CapabilityCards(c, opts.Cards, CapabilityCardsOptions{Columns: min(3, len(opts.Cards)), Disabled: opts.Disabled}) + v.Element.FillWidth() + } + if opts.Prompts != nil { + ui.Column(c).FillWidth().AlignItems(ui.Center).Children(opts.Prompts) + } + }) + v.Element = col + return v +} diff --git a/welcome_screen_test.go b/welcome_screen_test.go new file mode 100644 index 0000000..febf2ae --- /dev/null +++ b/welcome_screen_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestWelcomeScreenGreetsAndChooses(t *testing.T) { + input, got := "", "" + tt := chatTester(900, 600, func(c *ui.Context) { + v := WelcomeScreen(c, WelcomeScreenOptions{Greeting: "Good evening", Subtitle: "How may I serve?", Cards: chatCards(), + Prompts: func() { SuggestionChips(c, &input, []string{"Plan a ball"}, SuggestionChipsOptions{}) }}) + if v.Example != "" { + got = v.Example + } + }) + for _, s := range []string{"Good evening", "How may I serve?", "Research"} { + if !tt.HasText(s) { + t.Fatalf("%q missing from %q", s, tt.Texts()) + } + } + _ = tt.Click("“Explain the minuet”") + if got != "Explain the minuet" { + t.Fatalf("example %q", got) + } + _ = tt.Click("Plan a ball") + if input != "Plan a ball" { + t.Fatalf("slot prompt left %q", input) + } +} + +func TestWelcomeScreenDisabled(t *testing.T) { + got := "" + tt := chatTester(900, 600, func(c *ui.Context) { + if v := WelcomeScreen(c, WelcomeScreenOptions{Greeting: "Hello", Cards: chatCards(), Disabled: true}); 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 screen chose %q", got) + } +} + +func TestWelcomeScreenNeedsGreeting(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + WelcomeScreen(c, WelcomeScreenOptions{}) + }, 100, 100, 1) +} + +func TestWelcomeScreenSnapshot(t *testing.T) { + snapshot(t, "welcome_screen", 900, 420, func(c *ui.Context) { + in := "" + WelcomeScreen(c, WelcomeScreenOptions{Greeting: "Good afternoon", Subtitle: "How may I serve the court?", Cards: chatCards(), + Prompts: func() { SuggestionChips(c, &in, []string{"Plan a ball", "Write a sonnet"}, SuggestionChipsOptions{}) }}) + }) +} diff --git a/year_view.go b/year_view.go new file mode 100644 index 0000000..812c48d --- /dev/null +++ b/year_view.go @@ -0,0 +1,168 @@ +package mujica + +import ( + "fmt" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// YearViewOptions configure a YearView. +type YearViewOptions 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 + // Today is marked; zero uses the system clock. + Today Date + Disabled bool + Label string +} + +// YearView shows twelve months with busy-day dots. +func YearView(c *ui.Context, year *int, events []CalendarEvent, opts YearViewOptions) CalendarViewResult { + loc := eventLocation("YearView", opts.Location) + if *year < 1 { + panic(fmt.Sprintf("mujica: YearView year %d is not valid", *year)) + } + eventsCheck("YearView", events) + d := Density(c) + today := datetimeToday(c, opts.Today) + first := Date{*year, time.January, 1} + label := opts.Label + if label == "" { + label = strconv.Itoa(*year) + } + r := CalendarViewResult{} + root := ui.Column(c).Gap(d.Unit() * 3).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled) + r.Element = root + counts := eventsDerived(root, "year-view-counts", events, opts.Version, eventDayStart(first, loc), eventDayStart(first.AddMonths(12), loc), + func(hits []int) map[Date]int { + m := map[Date]int{} + for _, h := range hits { + ev := events[h] + for day := DateOf(ev.Start.In(loc)); eventDayStart(day, loc).Before(ev.End); day = day.AddDays(1) { + m[day]++ + } + } + return m + }) + cursor := ui.Local(root, "year-view-cursor", func() Date { return Date{} }) + if cursor.Year != *year { + *cursor = Date{*year, today.Month, min(today.Day, 28)} + } + root.Children(func() { + calendarViewHeader(c, strconv.Itoa(*year), msg(c, "calendar.prev_year"), msg(c, "calendar.next_year"), func(dir int) { + *year += dir + }) + grid := ui.Row(c).Wrap().AlignItems(ui.Start).Gap(d.Unit() * 4).FocusGroup(ui.Horizontal | ui.Vertical) + moved := false + if !grid.IsDisabled() { + for key, days := range map[ui.Key]int{ui.KeyLeft: -1, ui.KeyRight: 1, ui.KeyUp: -7, ui.KeyDown: 7} { + if grid.Shortcut(0, key) { + *cursor, moved = cursor.AddDays(days), true + } + } + for key, months := range map[ui.Key]int{ui.KeyPageUp: -1, ui.KeyPageDown: 1} { + if grid.Shortcut(0, key) { + *cursor, moved = cursor.AddMonths(months), true + } + } + if moved && cursor.Year != *year { + *year = cursor.Year + } + } + grid.Children(func() { + for m := time.January; m <= time.December; m++ { + if day, ok := yearMonth(c, *year, m, counts, today, *cursor, moved, opts.WeekStart); ok && !grid.IsDisabled() { + r.day, *cursor = day, day + } + } + }) + }) + return r +} + +// yearMonth draws one small month and reports a day clicked. +func yearMonth(c *ui.Context, year int, m time.Month, counts map[Date]int, today, cursor Date, focus bool, ws WeekStart) (Date, bool) { + k := Tokens(c) + cell := theme.MinHitSize + first := calendarFirstWeekday(c, ws) + start := Date{year, m, 1} + lead := (int(start.Weekday()) - int(first) + 7) % 7 + var picked Date + ui.Column(c).Width(cell * 7).Gap(2).Children(func() { + ui.Text(c, datetimeMonthName(c, m)).Font(theme.SerifFont).TextColor(k.Text).Bold() + ui.Row(c).Children(func() { + for i := range 7 { + wd := (int(first) + i) % 7 + ui.Text(c, msg(c, "calendar.weekday_"+strconv.Itoa(wd))).Width(cell).TextAlign(ui.Center). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + last := start.AddMonths(1).AddDays(-1).Day + for w := 0; w*7 < lead+last; w++ { + ui.Row(c).Children(func() { + for i := range 7 { + n := w*7 + i - lead + 1 + if n < 1 || n > last { + ui.Box(c).Size(cell, cell) + continue + } + day := Date{year, m, n} + busy := counts[day] + text := datetimeDayLabel(c, day) + if busy > 0 { + text += ", " + calendarCount(c, "year_view.events", busy) + } + b := ui.ButtonBase(c).Size(cell, cell).Radius(theme.ControlRadius).Label(text).Column() + internal.Ring(b, k.Focus, theme.ControlRadius) + if day == cursor && focus { + b.Focus() + } + switch { + case day == today: + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + dots := yearDots(busy) + b.Draw(func(p *ui.Painter, r ui.Rect) { + x0 := r.X + r.W/2 - float32(dots*5-2)/2 + for j := range dots { + p.Fill(ui.Rect{X: x0 + float32(j)*5, Y: r.Y + r.H - 6, W: 3, H: 3}, k.AccentText, 1.5) + } + }) + b.Children(func() { + t := ui.Text(c, strconv.Itoa(n)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).FontFeatures("tnum") + if day == today { + t.Bold().TextColor(k.AccentText) + } + }) + if b.Clicked() { + picked = day + } + } + }) + } + }) + return picked, !picked.IsZero() +} + +// yearDots maps an event count to one to three dots. +func yearDots(n int) int { + switch { + case n == 0: + return 0 + case n <= 2: + return 1 + case n <= 5: + return 2 + } + return 3 +} diff --git a/year_view_test.go b/year_view_test.go new file mode 100644 index 0000000..6a42408 --- /dev/null +++ b/year_view_test.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func yearViewTester(year *int, events []CalendarEvent, opts YearViewOptions, days *[]Date) *ui.Tester { + opts.Location, opts.Today = calTokyo, calendarToday + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + if d, ok := YearView(c, year, events, opts).DayChosen(); ok { + *days = append(*days, d) + } + }, 900, 1200) +} + +func TestYearViewDensityMarks(t *testing.T) { + var evs []CalendarEvent + for i := range 4 { + evs = append(evs, calEvent(fmt.Sprint(i), 14, 9+i, 0, 30)) + } + evs = append(evs, calEvent("x", 20, 9, 0, 30)) + year := 2026 + var days []Date + tt := yearViewTester(&year, evs, YearViewOptions{}, &days) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("Wednesday, October 14, 2026, 4 events") || !tt.HasText("Tuesday, October 20, 2026, 1 event") { + t.Fatalf("labels lack counts: %v", tt.Texts()[:5]) + } + img := tt.Image() + dotsAt := func(label string) int { + r, _ := tt.Find(label) + n, prev := 0, false + for x := int(r.X); x < int(r.X+r.W); x++ { + on := datetimeNear(img.RGBAAt(x, int(r.Y+r.H-5)), theme.Light().AccentText) + if on && !prev { + n++ + } + prev = on + } + return n + } + if a, b, z := dotsAt("Wednesday, October 14, 2026, 4 events"), dotsAt("Tuesday, October 20, 2026, 1 event"), dotsAt(datetimeTestLabel(oct(21))); a != 2 || b != 1 || z != 0 { + t.Fatalf("dots %d %d %d", a, b, z) + } + if yearDots(6) != 3 { + t.Fatal("6 events should show three dots") + } +} + +func TestYearViewClickKeysAndYearChange(t *testing.T) { + year := 2026 + var days []Date + tt := yearViewTester(&year, nil, YearViewOptions{}, &days) + _ = tt.Click(datetimeTestLabel(Date{2026, time.March, 3})) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if len(days) != 2 || days[0] != (Date{2026, time.March, 3}) || days[1] != (Date{2026, time.March, 11}) { + t.Fatalf("days %v", days) + } + _ = tt.Click(datetimeTestLabel(Date{2026, time.December, 31})) + tt.Key(0, ui.KeyRight) + if year != 2027 || !tt.Focused(datetimeTestLabel(Date{2027, time.January, 1})) { + t.Fatalf("crossing the year: %d", year) + } + _ = tt.Click("Previous year") + if year != 2026 { + t.Fatalf("year %d", year) + } +} + +func TestYearViewDisabledAndInvalid(t *testing.T) { + year := 2026 + var days []Date + tt := yearViewTester(&year, nil, YearViewOptions{Disabled: true}, &days) + _ = tt.Click(datetimeTestLabel(oct(3))) + _ = tt.Click("Next year") + if len(days) != 0 || year != 2026 { + t.Fatal("disabled year view reacted") + } + defer func() { + if recover() == nil { + t.Fatal("expected panic for year 0") + } + }() + zero := 0 + yearViewTester(&zero, nil, YearViewOptions{}, &days) +} + +func TestYearViewSnapshot(t *testing.T) { + var evs []CalendarEvent + for i := range 40 { + evs = append(evs, calEvent(fmt.Sprint(i), 1+i%28, 9+i%5, 0, 30)) + } + year := 2026 + snapshot(t, "year_view", 680, 960, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + YearView(c, &year, evs, YearViewOptions{Location: calTokyo, Today: calendarToday}) + }) + }) +}