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(`` + body + ``)) +} + +var debugIcons = map[DebugAction]*ui.SVG{ + DebugContinue: debugIcon(``), + DebugPause: debugIcon(``), + DebugStepOver: debugIcon(``), + DebugStepInto: debugIcon(``), + DebugStepOut: debugIcon(``), + DebugRestart: debugIcon(``), + DebugStop: debugIcon(``), +} + +// debugShortcut is an action's key, as in common IDEs. +type debugShortcut struct { + mods ui.Modifiers + key ui.Key + name string +} + +func debugShortcuts() map[DebugAction]debugShortcut { + cmd := "Ctrl" + if runtime.GOOS == "darwin" { + cmd = "⌘" + } + return map[DebugAction]debugShortcut{ + DebugContinue: {0, ui.KeyF5, "F5"}, + DebugPause: {0, ui.KeyF6, "F6"}, + DebugStepOver: {0, ui.KeyF10, "F10"}, + DebugStepInto: {0, ui.KeyF11, "F11"}, + DebugStepOut: {ui.Shift, ui.KeyF11, "Shift+F11"}, + DebugRestart: {ui.Cmd | ui.Shift, ui.KeyF5, cmd + "+Shift+F5"}, + DebugStop: {ui.Shift, ui.KeyF5, "Shift+F5"}, + } +} + +// DebugEnabled reports whether action applies in state. +func DebugEnabled(state DebugState, a DebugAction) bool { + switch a { + case DebugContinue: + return state != DebugRunning + case DebugPause: + return state == DebugRunning + case DebugStepOver, DebugStepInto, DebugStepOut: + return state == DebugPaused + case DebugRestart, DebugStop: + return state != DebugStopped + } + return false +} + +// DebugToolbar shows debugger actions enabled by state. +func DebugToolbar(c *ui.Context, state DebugState, opts DebugToolbarOptions) DebugToolbarResult { + if state < DebugStopped || state > DebugPaused { + panic("mujica: DebugState out of range") + } + keys := debugShortcuts() + var res DebugToolbarResult + res.Element = Toolbar(c, ToolbarOptions{Label: msg(c, "debug_toolbar.label")}, func() { + first := DebugContinue + if state == DebugRunning { + first = DebugPause + } + for _, a := range []DebugAction{first, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop} { + name := msg(c, debugKeys[a]) + if a == DebugContinue && state == DebugStopped { + name = msg(c, "debug_toolbar.start") + } + on := DebugEnabled(state, a) && !opts.Disabled + b := ToolbarButton(c, name, ToolbarButtonOptions{Icon: debugIcons[a], Disabled: !on}) + b.Tooltip(fmt.Sprintf("%s (%s)", name, keys[a].name)) + if b.Clicked() && on && !b.IsDisabled() { + res.Action = a + } + } + }).Disabled(opts.Disabled) + if res.Action == DebugNone && !res.Element.IsDisabled() { + for _, a := range []DebugAction{DebugContinue, DebugPause, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop} { + if k := keys[a]; DebugEnabled(state, a) && c.Shortcut(k.mods, k.key) { + res.Action = a + break + } + } + } + if res.Action != DebugNone { + slog.Debug("mujica: debug_toolbar action", "action", res.Action, "state", state) + } + return res +} + +var debugKeys = map[DebugAction]string{ + DebugContinue: "debug_toolbar.continue", DebugPause: "debug_toolbar.pause", DebugStepOver: "debug_toolbar.stepOver", + DebugStepInto: "debug_toolbar.stepInto", DebugStepOut: "debug_toolbar.stepOut", DebugRestart: "debug_toolbar.restart", DebugStop: "debug_toolbar.stop", +} diff --git a/debug_toolbar_test.go b/debug_toolbar_test.go new file mode 100644 index 0000000..be2afe1 --- /dev/null +++ b/debug_toolbar_test.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func debugTester(state *DebugState, disabled bool, got *[]DebugAction) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if a := DebugToolbar(c, *state, DebugToolbarOptions{Disabled: disabled}).Action; a != DebugNone { + *got = append(*got, a) + } + }, 480, 120) +} + +func TestDebugEnabledRules(t *testing.T) { + cases := []struct { + state DebugState + on []DebugAction + }{ + {DebugStopped, []DebugAction{DebugContinue}}, + {DebugRunning, []DebugAction{DebugPause, DebugRestart, DebugStop}}, + {DebugPaused, []DebugAction{DebugContinue, DebugStepOver, DebugStepInto, DebugStepOut, DebugRestart, DebugStop}}, + } + for _, tc := range cases { + n := 0 + for a := DebugContinue; a <= DebugStop; a++ { + if DebugEnabled(tc.state, a) { + n++ + } + } + if n != len(tc.on) { + t.Fatalf("state %d enables %d actions, want %d", tc.state, n, len(tc.on)) + } + for _, a := range tc.on { + if !DebugEnabled(tc.state, a) { + t.Fatalf("state %d disables %d", tc.state, a) + } + } + } +} + +func TestDebugToolbarClicksFollowState(t *testing.T) { + state := DebugPaused + var got []DebugAction + tt := debugTester(&state, false, &got) + for _, name := range []string{"Step over", "Step into", "Step out", "Continue"} { + if err := tt.Click(name); err != nil { + t.Fatal(err) + } + } + want := []DebugAction{DebugStepOver, DebugStepInto, DebugStepOut, DebugContinue} + if len(got) != 4 || got[0] != want[0] || got[3] != want[3] { + t.Fatalf("actions %v", got) + } + state = DebugRunning + tt.Frame() + _ = tt.Click("Step over") + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + if len(got) != 5 || got[4] != DebugPause { + t.Fatalf("running: actions %v", got) + } + state = DebugStopped + tt.Frame() + if _, ok := tt.Find("Start"); !ok { + t.Fatal("stopped toolbar does not offer Start") + } +} + +func TestDebugToolbarShortcuts(t *testing.T) { + state := DebugPaused + var got []DebugAction + tt := debugTester(&state, false, &got) + tt.Key(0, ui.KeyF10) + tt.Key(ui.Shift, ui.KeyF11) + tt.Key(ui.Shift, ui.KeyF5) + state = DebugStopped + tt.Frame() + tt.Key(0, ui.KeyF10) + tt.Key(0, ui.KeyF5) + want := []DebugAction{DebugStepOver, DebugStepOut, DebugStop, DebugContinue} + if len(got) != len(want) { + t.Fatalf("actions %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("actions %v, want %v", got, want) + } + } +} + +func TestDebugToolbarDisabled(t *testing.T) { + state := DebugPaused + var got []DebugAction + tt := debugTester(&state, true, &got) + _ = tt.Click("Continue") + tt.Key(0, ui.KeyF10) + if len(got) != 0 { + t.Fatalf("disabled toolbar acted: %v", got) + } +} + +func TestDebugToolbarSnapshot(t *testing.T) { + snapshot(t, "debug_toolbar", 340, 70, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { + DebugToolbar(c, DebugPaused, DebugToolbarOptions{}) + }) + }) +} diff --git a/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) + }) + }) +}