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/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/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_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/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/debug_toolbar.go b/debug_toolbar.go
new file mode 100644
index 0000000..01d88fd
--- /dev/null
+++ b/debug_toolbar.go
@@ -0,0 +1,141 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "runtime"
+
+ "github.com/egoist/mygo/ui"
+)
+
+// DebugState is where a debug session is.
+type DebugState int
+
+const (
+ DebugStopped DebugState = iota
+ DebugRunning
+ DebugPaused
+)
+
+// DebugAction is what the user asked the debugger to do.
+type DebugAction int
+
+const (
+ DebugNone DebugAction = iota
+ // DebugContinue resumes a paused session or starts a stopped one.
+ DebugContinue
+ DebugPause
+ DebugStepOver
+ DebugStepInto
+ DebugStepOut
+ DebugRestart
+ DebugStop
+)
+
+// DebugToolbarOptions configure a DebugToolbar.
+type DebugToolbarOptions struct {
+ Disabled bool
+}
+
+// DebugToolbarResult carries the requested action.
+type DebugToolbarResult struct {
+ Element *ui.Element
+ Action DebugAction
+}
+
+// debugIcon draws a toolbar glyph in a 24-unit box.
+func debugIcon(body string) *ui.SVG {
+ return ui.MustParseSVG([]byte(``))
+}
+
+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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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_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/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/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/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/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/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/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/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/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/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/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/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/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/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)
+ })
+ })
+}