Terminal & Code: components 321-340

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:38:18 -04:00
1 parent 6f0c02d339
commit 0b936c940f
108 files changed
+8733 -1

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Breakpoint stops at File:Line when Condition holds.
type Breakpoint struct {
ID string
File string
Line int
Enabled bool
Condition string
}
// BreakpointListOptions configure a BreakpointList.
type BreakpointListOptions struct {
Disabled bool
}
// BreakpointListResult fields hold breakpoint IDs.
type BreakpointListResult struct {
Element *ui.Element
Toggled, Edited, Opened string
}
type breakpointEdit struct{ id, draft string }
// BreakpointList lists breakpoints with toggles and conditions.
func BreakpointList(c *ui.Context, bps *[]Breakpoint, opts BreakpointListOptions) BreakpointListResult {
if bps == nil {
panic("mujica: BreakpointList needs breakpoints")
}
seen := map[string]bool{}
for _, b := range *bps {
if b.ID == "" || seen[b.ID] {
panic("mujica: Breakpoint IDs must be set and unique")
}
seen[b.ID] = true
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
var res BreakpointListResult
res.Element = codePanel(c).Disabled(opts.Disabled).Label(msg(c, "breakpoint_list.label"))
edit := ui.Local(res.Element, "breakpoint_list.edit", func() breakpointEdit { return breakpointEdit{} })
disabled := res.Element.IsDisabled()
if disabled {
edit.id = ""
}
res.Element.Children(func() {
if len(*bps) == 0 {
codeCaption(c, msg(c, "breakpoint_list.empty")).Padding(u * 4)
return
}
ScrollArea(c, nil, ScrollAreaOptions{}, func() {
for i := range *bps {
b := &(*bps)[i]
ui.Column(c).Key(b.ID).Padding(u, u*2).Gap(u).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
ui.Row(c).MinHeight(d.ControlHeight()).Gap(u * 2).AlignItems(ui.Center).Children(func() {
on := b.Enabled
Checkbox(c, &on, "", CheckboxOptions{Disabled: opts.Disabled}).Label(fmt.Sprintf(msg(c, "breakpoint_list.enable"), path.Base(b.File), b.Line))
if on != b.Enabled && !disabled {
b.Enabled, res.Toggled = on, b.ID
slog.Debug("mujica: breakpoint_list toggle", "id", b.ID, "enabled", on)
}
if breakpointLocation(c, *b).Clicked() && !disabled {
res.Opened = b.ID
slog.Debug("mujica: breakpoint_list open", "id", b.ID)
}
if iconAction(c, icons.Must("funnel"), fmt.Sprintf(msg(c, "breakpoint_list.edit"), path.Base(b.File), b.Line)).Clicked() && !disabled {
*edit = breakpointEdit{b.ID, b.Condition}
}
})
switch {
case edit.id == b.ID:
in := TextInput(c, &edit.draft, TextInputOptions{Placeholder: msg(c, "breakpoint_list.placeholder"), Label: msg(c, "breakpoint_list.condition")})
in.Input.AutoFocus()
if in.Input.Shortcut(0, ui.KeyEscape) {
edit.id = ""
} else if in.Submitted() && !disabled {
b.Condition, res.Edited, edit.id = edit.draft, b.ID, ""
slog.Debug("mujica: breakpoint_list condition", "id", b.ID)
}
case b.Condition != "":
codeMono(c, fmt.Sprintf(msg(c, "breakpoint_list.when"), b.Condition)).TextColor(k.Warning).Padding(0, 0, 0, 28)
}
})
}
}).Grow(1)
})
return res
}
// breakpointLocation is a button opening the location.
func breakpointLocation(c *ui.Context, b Breakpoint) *ui.Element {
k := Tokens(c)
d := Density(c)
e := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(theme.MinHitSize).Justify(ui.Start).Gap(d.Unit()*2).Padding(0, d.Unit()).
Radius(theme.ControlRadius).Label(fmt.Sprintf("%s:%d", b.File, b.Line))
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Hovered() {
e.Background(k.SurfaceHover)
}
fg := k.Text
if !b.Enabled {
fg = k.TextMuted
}
return e.Children(func() {
// A filled dot is enabled, a ring disabled.
dot := ui.Box(c).Size(10, 10).Radius(5).Shrink(0).Role(ui.RoleNone).Border(theme.BorderWidth, k.Danger)
if b.Enabled {
dot.Background(k.Danger)
}
ui.Text(c, path.Base(b.File)).TextColor(fg).SingleLine()
codeCaption(c, fmt.Sprintf("%s:%d", path.Dir(b.File), b.Line)).MinWidth(0)
})
}
+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type breakpointProbe struct {
bps []Breakpoint
disabled bool
toggled, edited, opened []string
}
func (p *breakpointProbe) view(c *ui.Context) {
Use(c, Settings{})
r := BreakpointList(c, &p.bps, BreakpointListOptions{Disabled: p.disabled})
r.Element.Height(300)
for _, e := range []struct {
id string
out *[]string
}{{r.Toggled, &p.toggled}, {r.Edited, &p.edited}, {r.Opened, &p.opened}} {
if e.id != "" {
*e.out = append(*e.out, e.id)
}
}
}
func newBreakpointProbe() *breakpointProbe {
return &breakpointProbe{bps: []Breakpoint{
{ID: "a", File: "court/seal.go", Line: 12, Enabled: true},
{ID: "b", File: "court/ledger.go", Line: 40, Enabled: false, Condition: "n > 3"},
}}
}
func TestBreakpointListToggleAndOpen(t *testing.T) {
p := newBreakpointProbe()
tt := ui.NewTester(p.view, 520, 320)
if err := tt.Click("Enable ledger.go:40"); err != nil {
t.Fatal(err)
}
if !p.bps[1].Enabled || len(p.toggled) != 1 || p.toggled[0] != "b" {
t.Fatalf("toggle: %+v %v", p.bps[1], p.toggled)
}
if err := tt.Click("court/seal.go:12"); err != nil {
t.Fatal(err)
}
if len(p.opened) != 1 || p.opened[0] != "a" {
t.Fatalf("opened %v", p.opened)
}
if !tt.HasText("when n > 3") {
t.Fatalf("condition not shown: %q", tt.Texts())
}
}
func TestBreakpointListEditCondition(t *testing.T) {
p := newBreakpointProbe()
tt := ui.NewTester(p.view, 520, 320)
if err := tt.Click("Edit condition of seal.go:12"); err != nil {
t.Fatal(err)
}
if !tt.Focused("Condition") {
t.Fatal("editor did not take the focus")
}
tt.Type("i == 7")
tt.Key(0, ui.KeyEnter)
if p.bps[0].Condition != "i == 7" || len(p.edited) != 1 {
t.Fatalf("condition %q edited %v", p.bps[0].Condition, p.edited)
}
_ = tt.Click("Edit condition of ledger.go:40")
tt.Type("x")
tt.Key(0, ui.KeyEscape)
if p.bps[1].Condition != "n > 3" {
t.Fatalf("Escape kept %q", p.bps[1].Condition)
}
}
func TestBreakpointListKeyboardToggle(t *testing.T) {
p := newBreakpointProbe()
tt := ui.NewTester(p.view, 520, 320)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if p.bps[0].Enabled {
t.Fatal("Space did not disable the first breakpoint")
}
}
func TestBreakpointListDisabledAndDuplicate(t *testing.T) {
p := newBreakpointProbe()
p.disabled = true
tt := ui.NewTester(p.view, 520, 320)
_ = tt.Click("Enable ledger.go:40")
_ = tt.Click("court/seal.go:12")
if p.bps[1].Enabled || len(p.opened) != 0 {
t.Fatal("disabled list changed")
}
defer func() {
if recover() == nil {
t.Fatal("duplicate IDs did not panic")
}
}()
p = newBreakpointProbe()
p.bps[1].ID = "a"
ui.NewTester(p.view, 520, 320)
}
func TestBreakpointListSnapshot(t *testing.T) {
snapshot(t, "breakpoint_list", 460, 170, func(c *ui.Context) {
p := ui.Local(c.Root(), "snap", newBreakpointProbe)
ui.Column(c).Padding(12).Children(func() {
BreakpointList(c, &(*p).bps, BreakpointListOptions{}).Element.Height(146)
})
})
}
+140
View File
@@ -0,0 +1,140 @@
package mujica
import (
"fmt"
"log/slog"
"path"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// StackFrame is one call of a thread's stack, innermost first.
type StackFrame struct {
ID, Name, File string
Line int
}
// DebugThread is a thread and its stack.
type DebugThread struct {
ID, Name string
Frames []StackFrame
}
// CallStackState is the current frame and folded threads.
type CallStackState struct {
// Current is the chosen frame's ID.
Current string
// Collapsed holds folded thread IDs.
Collapsed ui.Selection[string]
rows ui.ListState
selected int
flat []callRow
seen string
folds int
started bool
}
type callRow struct{ thread, frame int }
// CallStackOptions configure a CallStack.
type CallStackOptions struct {
Disabled bool
}
// CallStack lists frames by thread; choosing sets Current.
func CallStack(c *ui.Context, threads []DebugThread, s *CallStackState, opts CallStackOptions) ChoiceResult {
if s == nil {
panic("mujica: CallStack needs a state")
}
s.flat = s.flat[:0]
at := -1
for ti, t := range threads {
s.flat = append(s.flat, callRow{ti, -1})
if s.Collapsed.Has(t.ID) {
continue
}
for fi, f := range t.Frames {
if f.ID == s.Current {
at = len(s.flat)
}
s.flat = append(s.flat, callRow{ti, fi})
}
}
// Choose the row again after outside changes.
if !s.started || s.seen != s.Current || s.folds != s.Collapsed.Len() || s.selected >= len(s.flat) {
s.started, s.seen, s.selected, s.folds = true, s.Current, at, s.Collapsed.Len()
}
d := Density(c)
var res ChoiceResult
res.Element = codePanel(c).Disabled(opts.Disabled)
disabled := res.Element.IsDisabled()
res.Element.Children(func() {
if len(threads) == 0 {
codeCaption(c, msg(c, "call_stack.empty")).Padding(d.Unit() * 4)
return
}
was := s.selected
s.rows.Selected = &s.selected
s.rows.Key = func(i int) any { return s.flat[i] }
s.rows.Label = func(i int) string { return callLabel(threads, s.flat[i]) }
list := codeRows(c, &s.rows, len(s.flat), func(i int) { callRowView(c, threads, s, s.flat[i], disabled) })
if disabled {
s.selected = was
return
}
if s.selected < 0 || s.selected >= len(s.flat) {
return
}
r := s.flat[s.selected]
t := threads[r.thread]
switch {
case r.frame >= 0 && s.selected != was:
s.Current = t.Frames[r.frame].ID
s.seen, res.changed = s.Current, true
slog.Debug("mujica: call_stack frame", "thread", t.ID, "depth", r.frame)
case r.frame < 0 && (list.Submitted() ||
!s.Collapsed.Has(t.ID) && list.Shortcut(0, ui.KeyLeft) || s.Collapsed.Has(t.ID) && list.Shortcut(0, ui.KeyRight)):
callFold(s, t.ID)
}
})
return res
}
func callFold(s *CallStackState, id string) {
if s.Collapsed.Has(id) {
s.Collapsed.Remove(id)
} else {
s.Collapsed.Add(id)
}
slog.Debug("mujica: call_stack fold", "thread", id, "folded", s.Collapsed.Has(id))
}
func callLabel(threads []DebugThread, r callRow) string {
t := threads[r.thread]
if r.frame < 0 {
return t.Name
}
f := t.Frames[r.frame]
return fmt.Sprintf("%s %s:%d", f.Name, path.Base(f.File), f.Line)
}
func callRowView(c *ui.Context, threads []DebugThread, s *CallStackState, r callRow, disabled bool) {
t := threads[r.thread]
if r.frame < 0 {
chev, key := "chevron-down", "search_panel.fold"
if s.Collapsed.Has(t.ID) {
chev, key = "chevron-right", "search_panel.unfold"
}
if iconAction(c, icons.Must(chev), fmt.Sprintf(msg(c, key), t.Name)).Clicked() && !disabled {
callFold(s, t.ID)
}
ui.Text(c, t.Name).FontWeight(600).SingleLine().Grow(1).MinWidth(0)
codeCaption(c, fmt.Sprintf(msg(c, "call_stack.frames"), len(t.Frames))).Shrink(0)
return
}
f := t.Frames[r.frame]
codeMono(c, f.Name).Grow(1).MinWidth(0).Margin(0, 0, 0, 20)
codeCaption(c, fmt.Sprintf("%s:%d", path.Base(f.File), f.Line)).Shrink(0)
}
+104
View File
@@ -0,0 +1,104 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func callStackSample() []DebugThread {
return []DebugThread{
{ID: "t1", Name: "main", Frames: []StackFrame{
{"f1", "court.Seal", "court/seal.go", 14},
{"f2", "court.Decree", "court/decree.go", 88},
{"f3", "main.main", "main.go", 9},
}},
{ID: "t2", Name: "herald worker", Frames: []StackFrame{{"f4", "herald.Run", "herald/run.go", 31}}},
}
}
type callProbe struct {
threads []DebugThread
s CallStackState
disabled bool
changed int
}
func (p *callProbe) view(c *ui.Context) {
Use(c, Settings{})
r := CallStack(c, p.threads, &p.s, CallStackOptions{Disabled: p.disabled})
r.Element.Height(320)
if r.Changed() {
p.changed++
}
}
func TestCallStackChooseFrame(t *testing.T) {
p := &callProbe{threads: callStackSample(), s: CallStackState{Current: "f1"}}
tt := ui.NewTester(p.view, 460, 340)
if p.s.selected != 1 {
t.Fatalf("current frame row %d, want 1", p.s.selected)
}
if err := tt.Click("court.Decree"); err != nil {
t.Fatal(err)
}
if p.s.Current != "f2" || p.changed != 1 {
t.Fatalf("current %q changed %d", p.s.Current, p.changed)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if p.s.Current != "f3" || p.s.selected != 4 {
t.Fatalf("keys: current %q row %d", p.s.Current, p.s.selected)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if p.s.Current != "f4" {
t.Fatalf("keys did not pass the thread header: %q", p.s.Current)
}
}
func TestCallStackFoldThread(t *testing.T) {
p := &callProbe{threads: callStackSample(), s: CallStackState{Current: "f4"}}
tt := ui.NewTester(p.view, 460, 340)
if err := tt.Click("Fold main"); err != nil {
t.Fatal(err)
}
if tt.HasText("court.Seal") || !tt.HasText("herald.Run") || p.s.Current != "f4" {
t.Fatalf("fold: %q current %q", tt.Texts(), p.s.Current)
}
if !tt.HasText("depth 3") {
t.Fatal("thread header lacks its count")
}
}
func TestCallStackCallerSetsCurrent(t *testing.T) {
p := &callProbe{threads: callStackSample()}
tt := ui.NewTester(p.view, 460, 340)
p.s.Current = "f3"
tt.Frame()
if p.s.selected != 3 || p.changed != 0 {
t.Fatalf("row %d changed %d", p.s.selected, p.changed)
}
}
func TestCallStackDisabledAndEmpty(t *testing.T) {
p := &callProbe{threads: callStackSample(), s: CallStackState{Current: "f1"}, disabled: true}
tt := ui.NewTester(p.view, 460, 340)
_ = tt.Click("court.Decree")
if p.s.Current != "f1" {
t.Fatal("disabled stack changed frame")
}
tt = ui.NewTester((&callProbe{}).view, 460, 340)
if !tt.HasText("Not paused") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestCallStackSnapshot(t *testing.T) {
snapshot(t, "call_stack", 420, 230, func(c *ui.Context) {
p := ui.Local(c.Root(), "snap", func() *callProbe { return &callProbe{threads: callStackSample(), s: CallStackState{Current: "f2"}} })
ui.Column(c).Padding(12).Children(func() {
CallStack(c, (*p).threads, &(*p).s, CallStackOptions{}).Element.Height(206)
})
})
}
+176
View File
@@ -0,0 +1,176 @@
package mujica
import (
"math"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// codeSize is the design size of code and terminal text.
const codeSize float32 = 13
// codeFont is the scaled monospace code font.
func codeFont(c *ui.Context) ui.Font {
return ui.Font{Family: theme.MonoFont, Size: FontSize(c, codeSize)}
}
// codeMono is a single-line monospace text.
func codeMono(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine()
}
// codeCaption is muted caption text.
func codeCaption(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted).SingleLine()
}
// codeTokenColor colors a syntax token kind.
func codeTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
switch kind {
case internal.TokKeyword:
return k.AccentText
case internal.TokString:
return k.Success
case internal.TokNumber:
return k.Warning
case internal.TokComment:
return k.TextMuted
case internal.TokType:
return k.Info
}
return k.Text
}
// codeAnsiColor maps a terminal color onto the theme.
func codeAnsiColor(k theme.Tokens, a internal.AnsiColor) ui.Color {
switch a.Kind {
case internal.AnsiRGB:
return ui.RGB(a.R, a.G, a.B)
case internal.AnsiIndexed:
if a.Index < 16 {
base := [8]ui.Color{k.TextMuted, k.Danger, k.Success, k.Warning, k.Info, k.AccentText, k.Info.Mix(k.Success, 0.5), k.Text}
return base[a.Index%8]
}
if a.Index >= 232 {
v := uint8(8 + 10*int(a.Index-232))
return ui.RGB(v, v, v)
}
i := int(a.Index) - 16
step := func(n int) uint8 {
if n == 0 {
return 0
}
return uint8(55 + 40*n)
}
return ui.RGB(step(i/36), step(i/6%6), step(i%6))
}
return ui.Color{}
}
// codeAnsiSpans turns ANSI text into themed spans.
func codeAnsiSpans(c *ui.Context, line string) []ui.Span {
k := Tokens(c)
parts, _ := internal.ParseAnsi(line, internal.AnsiStyle{})
spans := make([]ui.Span, 0, len(parts))
for _, p := range parts {
s := ui.Span{Text: p.Text, Color: codeAnsiColor(k, p.Style.FG), Underline: p.Style.Underline, Strikethrough: p.Style.Strike, Italic: p.Style.Italic}
if p.Style.Bold {
s.Weight = 700
}
if bg := codeAnsiColor(k, p.Style.BG); bg.A > 0 {
s.Background = bg.Alpha(0.3)
}
spans = append(spans, s)
}
return spans
}
// codeMatchSpans marks each query match in text.
func codeMatchSpans(c *ui.Context, text, query string) []ui.Span {
if query == "" {
return []ui.Span{{Text: text}}
}
k := Tokens(c)
lower, q := strings.ToLower(text), strings.ToLower(query)
var spans []ui.Span
for {
i := strings.Index(lower, q)
if i < 0 {
break
}
if i > 0 {
spans = append(spans, ui.Span{Text: text[:i]})
}
spans = append(spans, ui.Span{Text: text[i : i+len(q)], Background: k.Selection, Underline: true, DecorationColor: k.AccentText, Weight: 600})
text, lower = text[i+len(q):], lower[i+len(q):]
}
if text != "" {
spans = append(spans, ui.Span{Text: text})
}
return spans
}
// codePanel is a framed surface holding a code tool.
func codePanel(c *ui.Context) *ui.Element {
k := Tokens(c)
return ui.Column(c).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).Clip()
}
// codeHeader is a panel's top strip of tools.
func codeHeader(c *ui.Context, fn func()) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return ui.Row(c).Shrink(0).MinHeight(theme.MinHitSize+2*u).Padding(u, u*2).Gap(u*2).AlignItems(ui.Center).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(fn)
}
// codeSelectMark draws a chosen row's diamond.
func codeSelectMark(c *ui.Context, on bool) {
k := Tokens(c)
ui.Box(c).Size(8, 8).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.AccentText)
}
})
}
// codeRows builds a growing virtual list with marks.
func codeRows(c *ui.Context, s *ui.ListState, n int, row func(i int)) *ui.Element {
d := Density(c)
restore := dataSelectTheme(c, 0)
defer restore()
e := ui.List(c, s, n, func(i int) {
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
codeSelectMark(c, s.Selected != nil && *s.Selected == i)
row(i)
})
})
k := Tokens(c)
internal.Ring(e, k.Focus, theme.ControlRadius)
return e.Grow(1).MinHeight(d.ControlHeight() * 3)
}
// codeBadge is a kind letter in a framed square.
func codeBadge(c *ui.Context, letter string, col ui.Color) {
ui.Box(c).Size(18, 18).Shrink(0).Center().Radius(theme.ControlRadius).Border(theme.BorderWidth, col).
Role(ui.RoleNone).Children(func() {
ui.Text(c, letter).Font(theme.MonoFont).FontSize(FontSize(c, 11)).FontWeight(700).TextColor(col)
})
}
// codeRule draws a pixel-aligned 1 DIP line.
func codeRule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
s := float64(p.Scale())
yy := float32((math.Floor(float64(y)*s) + 0.5) / s)
p.Line(x0, yy, x1, yy, 1, c)
}
// codeTextWidth returns the width of s in the code font.
func codeTextWidth(c *ui.Context, s string) float32 {
w, _ := c.MeasureText(0, ui.Span{Text: s, Font: theme.MonoFont, Size: FontSize(c, codeSize)})
return w
}
+253
View File
@@ -0,0 +1,253 @@
package mujica
import (
"fmt"
"log/slog"
"runtime"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CodeViewerOptions configure a CodeViewer.
type CodeViewerOptions struct {
// Language picks the highlighting; empty is plain.
Language string
// Label names the view for assistive technology.
Label string
Disabled bool
}
// CodeViewerState is a CodeViewer's current line, selection, folds and place.
type CodeViewerState struct {
// Selection holds the selected line indices.
Selection ui.Selection[int]
// Folded holds the first lines of folded blocks.
Folded ui.Selection[int]
rows ui.ListState
cursor int
shown []int
inBlock []bool
ends []int
data *string
n int
lang string
folds int
started bool
}
// Line returns the current line index, or -1.
func (s *CodeViewerState) Line() int {
if s.cursor < 0 || s.cursor >= len(s.shown) {
return -1
}
return s.shown[s.cursor]
}
// Visible returns the first and last lines in view.
func (s *CodeViewerState) Visible() (first, last int) {
a, b := s.rows.Visible()
if len(s.shown) == 0 || a < 0 {
return 0, -1
}
return s.shown[min(a, len(s.shown)-1)], s.shown[min(max(b, a), len(s.shown)-1)]
}
// ScrollTo brings line to the top.
func (s *CodeViewerState) ScrollTo(line int) {
row, _ := slices.BinarySearch(s.shown, line)
s.rows.ScrollTo(row, ui.Start)
}
// GoTo makes line current, unfolding and scrolling to it.
func (s *CodeViewerState) GoTo(line int) {
for start := range s.Folded.All() {
if start < line && line <= s.ends[start] {
s.Folded.Remove(start)
}
}
s.folds = -1
s.Selection.Clear()
s.Selection.Add(line)
s.started, s.cursor = true, -2-line // resolved to a row once the rows are known
}
// CodeViewer is a read-only, foldable, virtualized code view.
func CodeViewer(c *ui.Context, lines []string, s *CodeViewerState, opts CodeViewerOptions) ChoiceResult {
if s == nil {
panic("mujica: CodeViewer needs a state")
}
if !s.started {
s.started, s.cursor = true, -1
}
codeViewerPrepare(lines, s, opts.Language)
k := Tokens(c)
d := Density(c)
var res ChoiceResult
res.Element = codePanel(c).Disabled(opts.Disabled)
res.Element.Children(func() {
if len(lines) == 0 {
codeCaption(c, msg(c, "code_viewer.empty")).Padding(d.Unit() * 4)
return
}
digits := len(fmt.Sprint(len(lines)))
gutter := codeTextWidth(c, strings.Repeat("9", digits))
s.rows.Selected = &s.cursor
s.rows.Selection = &s.Selection
s.rows.Key = func(i int) any { return s.shown[i] }
s.rows.Label = func(i int) string { return fmt.Sprintf("%d %s", s.shown[i]+1, lines[s.shown[i]]) }
restore := dataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(s.shown), func(i int) {
li := s.shown[i]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, 0).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
codeSelectMark(c, i == s.cursor)
if s.ends[li] > li {
label := msg(c, "code_viewer.fold")
if s.Folded.Has(li) {
label = msg(c, "code_viewer.unfold")
}
chev := "chevron-down"
if s.Folded.Has(li) {
chev = "chevron-right"
}
if iconAction(c, icons.Must(chev), fmt.Sprintf(label, li+1)).Clicked() && !res.Element.IsDisabled() {
codeViewerToggle(s, li)
}
} else {
ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0)
}
codeMono(c, fmt.Sprint(li+1)).Width(gutter).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
codeViewerLine(c, k, opts.Language, lines[li], s.inBlock[li])
})
})
restore()
list.Grow(1).MinHeight(d.ControlHeight() * 3).Label(opts.Label)
internal.Ring(list, k.Focus, theme.ControlRadius)
if list.Changed() {
res.changed = true
slog.Debug("mujica: code_viewer line", "line", s.Line())
}
codeViewerKeys(c, list, lines, s)
})
return res
}
// codeViewerKeys copies the selection and folds with Left and Right.
func codeViewerKeys(c *ui.Context, list *ui.Element, lines []string, s *CodeViewerState) {
if list.IsDisabled() {
return
}
copyMods := ui.Cmd
if runtime.GOOS != "darwin" {
copyMods = ui.Ctrl
}
line := s.Line()
switch {
case list.Shortcut(copyMods, ui.KeyC):
picked := slices.Sorted(s.Selection.All())
if len(picked) == 0 && line >= 0 {
picked = []int{line}
}
out := make([]string, len(picked))
for i, l := range picked {
out[i] = lines[l]
}
c.WriteClipboard(strings.Join(out, "\n"))
slog.Debug("mujica: code_viewer copy", "lines", len(out))
case list.Shortcut(0, ui.KeyLeft) && line >= 0 && s.ends[line] > line && !s.Folded.Has(line):
codeViewerToggle(s, line)
case list.Shortcut(0, ui.KeyRight) && line >= 0 && s.Folded.Has(line):
codeViewerToggle(s, line)
}
}
func codeViewerToggle(s *CodeViewerState, line int) {
if s.Folded.Has(line) {
s.Folded.Remove(line)
} else {
s.Folded.Add(line)
}
s.folds = -1
slog.Debug("mujica: code_viewer fold", "line", line, "folded", s.Folded.Has(line))
}
// codeViewerLine draws one line's syntax tokens.
func codeViewerLine(c *ui.Context, k theme.Tokens, lang, line string, inBlock bool) {
toks, _ := internal.Highlight(lang, line, inBlock)
spans := make([]ui.Span, len(toks))
for i, t := range toks {
spans[i] = ui.Span{Text: t.Text, Color: codeTokenColor(k, t.Kind), Italic: t.Kind == internal.TokComment}
}
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine().Grow(1).MinWidth(0)
}
// codeViewerPrepare rescans code and folds when they change.
func codeViewerPrepare(lines []string, s *CodeViewerState, lang string) {
var first *string
if len(lines) > 0 {
first = &lines[0]
}
if s.data != first || s.n != len(lines) || s.lang != lang {
s.data, s.n, s.lang, s.folds = first, len(lines), lang, -1
s.inBlock = slices.Grow(s.inBlock[:0], len(lines))[:len(lines)]
in := false
for i, l := range lines {
s.inBlock[i] = in
_, in = internal.Highlight(lang, l, in)
}
s.ends = codeViewerFolds(lines, s.ends)
}
if s.folds == s.Folded.Len() {
return
}
s.folds = s.Folded.Len()
s.shown = s.shown[:0]
for i := 0; i < len(lines); i++ {
s.shown = append(s.shown, i)
if s.Folded.Has(i) {
i = s.ends[i]
}
}
if s.cursor <= -2 {
s.cursor = max(slices.Index(s.shown, -2-s.cursor), 0)
s.rows.ScrollIntoView(s.cursor)
}
s.cursor = min(s.cursor, len(s.shown)-1)
}
// codeViewerFolds returns each line's indented block end.
func codeViewerFolds(lines []string, ends []int) []int {
ends = slices.Grow(ends[:0], len(lines))[:len(lines)]
indent := func(l string) int {
if strings.TrimSpace(l) == "" {
return -1
}
return len(l) - len(strings.TrimLeft(l, " \t"))
}
type open struct{ line, indent int }
var stack []open
last := -1
for i, l := range lines {
ends[i] = i
in := indent(l)
if in < 0 {
continue
}
for len(stack) > 0 && stack[len(stack)-1].indent >= in {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
ends[top.line] = last
}
stack = append(stack, open{i, in})
last = i
}
for _, o := range stack {
ends[o.line] = last
}
return ends
}
+151
View File
@@ -0,0 +1,151 @@
package mujica
import (
"fmt"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
)
var codeViewerSample = []string{
"package court",
"",
"// Seal stamps a decree.",
"func Seal(d string) int {",
"\tif d == \"\" {",
"\t\treturn 0",
"\t}",
"\treturn 42",
"}",
}
func codeViewerTester(lines []string, s *CodeViewerState, disabled bool) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CodeViewer(c, lines, s, CodeViewerOptions{Language: "go", Label: "Source", Disabled: disabled}).Element.Height(400)
}, 520, 420)
}
func TestCodeViewerFoldRanges(t *testing.T) {
ends := codeViewerFolds(codeViewerSample, nil)
if ends[3] != 7 || ends[4] != 5 || ends[0] != 0 || ends[8] != 8 {
t.Fatalf("fold ends %v", ends)
}
}
func TestCodeViewerFoldsAndNumbers(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, false)
if !tt.HasText("9") || !tt.HasText("\treturn 42") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("Fold line 4"); err != nil {
t.Fatal(err)
}
if tt.HasText("\treturn 42") || !tt.HasText("}") || len(s.shown) != 5 {
t.Fatalf("fold kept %d lines: %q", len(s.shown), tt.Texts())
}
if err := tt.Click("Unfold line 4"); err != nil {
t.Fatal(err)
}
if len(s.shown) != 9 {
t.Fatalf("unfold shows %d lines", len(s.shown))
}
}
func TestCodeViewerKeysCurrentLineAndCopy(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, false)
for !tt.Focused("Source") {
tt.Key(0, ui.KeyTab)
}
tt.Key(0, ui.KeyDown)
tt.Key(ui.Shift, ui.KeyDown)
tt.Key(ui.Shift, ui.KeyDown)
tt.Key(ui.Cmd, ui.KeyC)
if want := "package court\n\n// Seal stamps a decree."; tt.Clipboard() != want {
t.Fatalf("copied %q", tt.Clipboard())
}
tt.Key(0, ui.KeyDown)
if s.Line() != 3 {
t.Fatalf("current line %d, want 3", s.Line())
}
tt.Key(0, ui.KeyLeft)
if !s.Folded.Has(3) || len(s.shown) != 5 {
t.Fatal("Left did not fold the block")
}
tt.Key(0, ui.KeyRight)
if s.Folded.Has(3) {
t.Fatal("Right did not unfold the block")
}
}
func TestCodeViewerGoToOpensFold(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, false)
_ = tt.Click("Fold line 4")
s.GoTo(7)
tt.Frame()
if s.Line() != 7 || s.Folded.Has(3) {
t.Fatalf("GoTo gave line %d folded %v", s.Line(), s.Folded.Has(3))
}
}
func TestCodeViewerHighlightColors(t *testing.T) {
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
k := Tokens(c)
kw, str := codeTokenColor(k, internal.TokKeyword), codeTokenColor(k, internal.TokString)
if kw != k.AccentText || str != k.Success {
t.Errorf("keyword %v string %v", kw, str)
}
}, 10, 10)
}
func TestCodeViewerHundredThousandLines(t *testing.T) {
lines := make([]string, 100_000)
for i := range lines {
lines[i] = fmt.Sprintf("x%d := %d // line", i, i)
}
s := &CodeViewerState{}
tt := codeViewerTester(lines, s, false)
rows := 0
for _, t := range tt.Texts() {
if strings.HasPrefix(t, "x") {
rows++
}
}
if rows == 0 || rows > 40 {
t.Fatalf("built %d rows of 100000", rows)
}
s.GoTo(99_998)
tt.Frame()
if !tt.HasText("x99998 := 99998 // line") {
t.Fatal("GoTo did not scroll to the end")
}
}
func TestCodeViewerDisabledFold(t *testing.T) {
s := &CodeViewerState{}
tt := codeViewerTester(codeViewerSample, s, true)
_ = tt.Click("Fold line 4")
if s.Folded.Len() != 0 {
t.Fatal("disabled viewer folded")
}
}
func TestCodeViewerSnapshot(t *testing.T) {
snapshot(t, "code_viewer", 480, 300, func(c *ui.Context) {
s := ui.Local(c.Root(), "snap", func() *CodeViewerState {
s := &CodeViewerState{}
s.GoTo(5)
return s
})
ui.Column(c).Padding(12).Children(func() {
CodeViewer(c, codeViewerSample, *s, CodeViewerOptions{Language: "go"}).Element.Height(276)
})
})
}
+119
View File
@@ -0,0 +1,119 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// HistoryEntry is one command a shell ran.
type HistoryEntry struct {
Command string
Time time.Time
ExitCode int
}
// CommandHistoryState is the filter and place of a CommandHistory.
type CommandHistoryState struct {
Query string
rows ui.ListState
selected int
shown []int
query string
n int
ran bool
}
// CommandHistoryOptions configure a CommandHistory.
type CommandHistoryOptions struct {
Disabled bool
}
// CommandHistoryResult carries the entry to replay.
type CommandHistoryResult struct {
Element *ui.Element
// Replayed is the entry to run again, or nil.
Replayed *HistoryEntry
}
// CommandHistory lists commands newest first with search and replay.
func CommandHistory(c *ui.Context, entries []HistoryEntry, s *CommandHistoryState, opts CommandHistoryOptions) CommandHistoryResult {
if s == nil {
panic("mujica: CommandHistory needs a state")
}
var res CommandHistoryResult
res.Element = codePanel(c).Disabled(opts.Disabled)
disabled := res.Element.IsDisabled()
replay := func(i int) {
if disabled {
return
}
e := entries[i]
res.Replayed = &e
slog.Debug("mujica: command_history replay", "index", i)
}
res.Element.Children(func() {
codeHeader(c, func() {
SearchInput(c, &s.Query, SearchInputOptions{Disabled: opts.Disabled, Placeholder: msg(c, "command_history.search")}).Element.Grow(1)
})
commandHistoryFilter(entries, s)
if len(s.shown) == 0 {
text := msg(c, "command_history.empty")
if len(entries) > 0 {
text = msg(c, "command_history.none")
}
codeCaption(c, text).Padding(Density(c).Unit() * 4)
return
}
s.rows.Selected = &s.selected
s.rows.Key = func(i int) any { return s.shown[i] }
s.rows.Label = func(i int) string { return entries[s.shown[i]].Command }
list := codeRows(c, &s.rows, len(s.shown), func(i int) {
e := entries[s.shown[i]]
commandHistoryRow(c, e)
if iconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(msg(c, "command_history.replay"), e.Command)).Clicked() {
replay(s.shown[i])
}
})
if list.Submitted() && s.selected >= 0 && s.selected < len(s.shown) {
replay(s.shown[s.selected])
}
})
return res
}
// commandHistoryFilter refilters when entries or query change.
func commandHistoryFilter(entries []HistoryEntry, s *CommandHistoryState) {
if s.ran && s.n == len(entries) && s.query == s.Query {
return
}
s.ran, s.n, s.query = true, len(entries), s.Query
s.shown = s.shown[:0]
q := strings.ToLower(strings.TrimSpace(s.Query))
for i := len(entries) - 1; i >= 0; i-- {
if q == "" || strings.Contains(strings.ToLower(entries[i].Command), q) {
s.shown = append(s.shown, i)
}
}
s.selected = min(s.selected, len(s.shown)-1)
}
func commandHistoryRow(c *ui.Context, e HistoryEntry) {
k := Tokens(c)
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
codeMono(c, e.Command)
ui.Row(c).Gap(Density(c).Unit() * 2).Children(func() {
if !e.Time.IsZero() {
codeCaption(c, e.Time.Format("15:04:05"))
}
if e.ExitCode != 0 {
codeCaption(c, fmt.Sprintf(msg(c, "command_history.exit"), e.ExitCode)).TextColor(k.Danger)
}
})
})
}
+117
View File
@@ -0,0 +1,117 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func commandHistorySample() []HistoryEntry {
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
return []HistoryEntry{
{Command: "git status", Time: at},
{Command: "go test ./...", Time: at.Add(time.Minute), ExitCode: 1},
{Command: "go build", Time: at.Add(2 * time.Minute)},
}
}
func commandHistoryTester(entries []HistoryEntry, s *CommandHistoryState, disabled bool, got *[]string) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := CommandHistory(c, entries, s, CommandHistoryOptions{Disabled: disabled})
r.Element.Grow(1)
if r.Replayed != nil {
*got = append(*got, r.Replayed.Command)
}
}, 420, 320)
}
func TestCommandHistoryNewestFirstAndReplay(t *testing.T) {
s := &CommandHistoryState{}
var got []string
tt := commandHistoryTester(commandHistorySample(), s, false, &got)
first, _ := tt.Find("go build")
last, _ := tt.Find("git status")
if first.Y >= last.Y {
t.Fatal("newest command is not on top")
}
if !tt.HasText("exit 1") {
t.Fatal("failed command shows no exit code")
}
if err := tt.Click("Run git status again"); err != nil {
t.Fatal(err)
}
tt.ClickAt(first.X, first.Y+2)
tt.ClickAt(first.X, first.Y+2)
if len(got) != 2 || got[0] != "git status" || got[1] != "go build" {
t.Fatalf("replayed %q", got)
}
}
func TestCommandHistorySearchAndKeys(t *testing.T) {
s := &CommandHistoryState{}
var got []string
tt := commandHistoryTester(commandHistorySample(), s, false, &got)
tt.Key(0, ui.KeyTab)
tt.Type("go ")
if tt.HasText("git status") || !tt.HasText("go build") || !tt.HasText("go test ./...") {
t.Fatalf("filter shows %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0] != "go test ./..." {
t.Fatalf("keyboard replay %q", got)
}
s.Query = "zzz"
tt.Frame()
if !tt.HasText("No matching commands") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestCommandHistoryDisabledAndEmpty(t *testing.T) {
var got []string
tt := commandHistoryTester(commandHistorySample(), &CommandHistoryState{}, true, &got)
_ = tt.Click("Run git status again")
if len(got) != 0 {
t.Fatalf("disabled replayed %q", got)
}
tt = commandHistoryTester(nil, &CommandHistoryState{}, false, &got)
if !tt.HasText("No commands yet") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestCommandHistoryLargeIsVirtual(t *testing.T) {
entries := make([]HistoryEntry, 100_000)
for i := range entries {
entries[i].Command = fmt.Sprintf("echo %d", i)
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CommandHistory(c, entries, &CommandHistoryState{}, CommandHistoryOptions{})
}, 420, 320)
rows := 0
for _, s := range tt.Texts() {
if strings.HasPrefix(s, "echo ") {
rows++
}
}
if !tt.HasText("echo 99999") || rows == 0 || rows > 60 {
t.Fatalf("built %d rows of 100000", rows)
}
}
func TestCommandHistorySnapshot(t *testing.T) {
snapshot(t, "command_history", 420, 240, func(c *ui.Context) {
s := ui.Local(c.Root(), "snap", func() *CommandHistoryState { return &CommandHistoryState{} })
ui.Column(c).Padding(12).Height(240).Children(func() {
CommandHistory(c, commandHistorySample(), *s, CommandHistoryOptions{}).Element.Grow(1)
})
})
}
+129
View File
@@ -0,0 +1,129 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// Completion is one candidate of a CompletionMenu.
type Completion struct {
Label string
Kind SymbolKind
Detail string
// Doc describes the chosen candidate below the list.
Doc string
}
// CompletionMenuState is the highlighted candidate and place of a CompletionMenu.
type CompletionMenuState struct {
Selected int
rows ui.ListState
pending ui.Key
}
// CompletionMenuOptions configure a CompletionMenu.
type CompletionMenuOptions struct {
Disabled bool
}
// CompletionMenuResult carries the accepted candidate.
type CompletionMenuResult struct {
Element *ui.Element
Accepted *Completion
}
// completionRows is how many candidates show before the list scrolls.
const completionRows = 8
// CompletionMenu shows candidates under the editor anchor.
func CompletionMenu(c *ui.Context, anchor *ui.Element, open *bool, items []Completion, s *CompletionMenuState, opts CompletionMenuOptions) CompletionMenuResult {
if anchor == nil || open == nil || s == nil {
panic("mujica: CompletionMenu needs an anchor, open and a state")
}
for _, it := range items {
if it.Kind < SymbolFunction || it.Kind > SymbolModule {
panic("mujica: Completion kind out of range")
}
}
var res CompletionMenuResult
if opts.Disabled || anchor.IsDisabled() || len(items) == 0 {
*open = false
}
if !*open {
return res
}
s.Selected = max(0, min(s.Selected, len(items)-1))
accept := func(i int) {
it := items[i]
res.Accepted = &it
*open = false
slog.Debug("mujica: completion_menu accept", "index", i)
}
move := func(d int) {
s.Selected = (s.Selected + d + len(items)) % len(items)
s.rows.ScrollIntoView(s.Selected)
}
// Take the menu keys before the editor does.
anchor.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputKeyDown || ev.Mods != 0 || !*open || anchor.IsDisabled() {
return false
}
switch ev.Key {
case ui.KeyDown, ui.KeyUp, ui.KeyTab, ui.KeyEnter, ui.KeyEscape:
s.pending = ev.Key
return true
}
return false
})
key := s.pending
s.pending = 0
switch key {
case ui.KeyDown:
move(1)
case ui.KeyUp:
move(-1)
case ui.KeyTab, ui.KeyEnter:
accept(s.Selected)
return res
case ui.KeyEscape:
*open = false
slog.Debug("mujica: completion_menu close")
return res
}
k := Tokens(c)
d := Density(c)
b := anchor.Bounds()
overlayLayer(c, open)
res.Element = ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayAnchored(c, panel, b))
panel.Margin(4, 0, 0, 0).Padding(d.Unit()).Width(popoverWide(c, 360)).MaxHeight(popoverRoom(c, b)).
Label(msg(c, "completion_menu.label")).Role(ui.RoleList)
chosen := s.Selected
s.rows.Selected = &chosen
s.rows.Label = func(i int) string { return items[i].Label }
restore := dataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(items), func(i int) {
it := items[i]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
codeSelectMark(c, i == s.Selected)
codeBadge(c, symbolBadges[it.Kind], k.AccentText)
codeMono(c, it.Label).Grow(1).MinWidth(0)
if it.Detail != "" {
codeCaption(c, it.Detail).MinWidth(0).Shrink(1)
}
})
}).MaxHeight(d.ControlHeight() * completionRows).Shrink(1)
restore()
if list.Changed() && chosen >= 0 {
accept(chosen)
}
if doc := items[s.Selected].Doc; doc != "" {
ui.Text(c, doc).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit()*2).
BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).Shrink(0)
}
})
return res
}
+103
View File
@@ -0,0 +1,103 @@
package mujica
import (
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func completionSample() []Completion {
return []Completion{
{Label: "Seal", Kind: SymbolMethod, Detail: "func(d Decree) error", Doc: "Seal stamps a decree."},
{Label: "Search", Kind: SymbolFunction, Detail: "func(q string) []Decree"},
{Label: "Season", Kind: SymbolConstant, Detail: "= 4"},
}
}
type completionProbe struct {
text string
open bool
s CompletionMenuState
disabled bool
accepted []string
}
func (p *completionProbe) view(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(16).Children(func() {
in := TextInput(c, &p.text, TextInputOptions{Label: "Code"})
if in.Changed() {
p.open = strings.HasPrefix(strings.ToLower(p.text), "se")
}
r := CompletionMenu(c, in.Input, &p.open, completionSample(), &p.s, CompletionMenuOptions{Disabled: p.disabled})
if r.Accepted != nil {
p.accepted = append(p.accepted, r.Accepted.Label)
p.text = r.Accepted.Label
}
})
}
func TestCompletionMenuKeys(t *testing.T) {
p := &completionProbe{}
tt := ui.NewTester(p.view, 480, 400)
tt.Key(0, ui.KeyTab)
tt.Type("se")
if !p.open || !tt.HasText("Seal") || !tt.HasText("Seal stamps a decree.") {
t.Fatalf("menu did not open: %q", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if p.s.Selected != 0 {
t.Fatalf("Down wrapped to %d, want 0", p.s.Selected)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyTab)
if len(p.accepted) != 1 || p.accepted[0] != "Season" || p.open {
t.Fatalf("accepted %v open %v", p.accepted, p.open)
}
if !tt.Focused("Code") {
t.Fatal("accepting moved the focus away from the editor")
}
}
func TestCompletionMenuEnterClickEscape(t *testing.T) {
p := &completionProbe{}
tt := ui.NewTester(p.view, 480, 400)
tt.Key(0, ui.KeyTab)
tt.Type("se")
tt.Key(0, ui.KeyEnter)
if len(p.accepted) != 1 || p.accepted[0] != "Seal" {
t.Fatalf("Enter accepted %v", p.accepted)
}
p.open = true
tt.Frame()
if err := tt.Click("Search"); err != nil {
t.Fatal(err)
}
if len(p.accepted) != 2 || p.accepted[1] != "Search" {
t.Fatalf("click accepted %v", p.accepted)
}
p.open = true
tt.Frame()
tt.Key(0, ui.KeyEscape)
if p.open || len(p.accepted) != 2 {
t.Fatalf("Escape: open %v accepted %v", p.open, p.accepted)
}
}
func TestCompletionMenuDisabledClosed(t *testing.T) {
p := &completionProbe{open: true, disabled: true}
tt := ui.NewTester(p.view, 480, 400)
if p.open || tt.HasText("Search") {
t.Fatal("disabled menu shows")
}
}
func TestCompletionMenuSnapshot(t *testing.T) {
snapshot(t, "completion_menu", 420, 260, func(c *ui.Context) {
p := ui.Local(c.Root(), "snap", func() *completionProbe { return &completionProbe{text: "se", open: true} })
(*p).view(c)
})
}
+141
View File
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` + body + `</svg>`))
}
var debugIcons = map[DebugAction]*ui.SVG{
DebugContinue: debugIcon(`<path d="M7 4l12 8-12 8z" fill="currentColor"/>`),
DebugPause: debugIcon(`<path d="M8 5v14M16 5v14"/>`),
DebugStepOver: debugIcon(`<path d="M4 13a8 8 0 0 1 15-3"/><path d="M20 5v5h-5"/><circle cx="12" cy="19" r="2" fill="currentColor"/>`),
DebugStepInto: debugIcon(`<path d="M12 3v11"/><path d="M7 9l5 5 5-5"/><circle cx="12" cy="20" r="2" fill="currentColor"/>`),
DebugStepOut: debugIcon(`<path d="M12 15V4"/><path d="M7 9l5-5 5 5"/><circle cx="12" cy="20" r="2" fill="currentColor"/>`),
DebugRestart: debugIcon(`<path d="M4 12a8 8 0 1 0 3-6.2"/><path d="M4 4v5h5"/>`),
DebugStop: debugIcon(`<rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor"/>`),
}
// 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",
}
+113
View File
@@ -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{})
})
})
}
+52
View File
@@ -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` 逐格排布并按行缓存,等宽字体由系统回退决定。
+52
View File
@@ -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` 追加。
+48
View File
@@ -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 到达。
## 限制
- 只做纯文字匹配,不支持正则与整词;跨行文字不会匹配。
+50
View File
@@ -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` 会被识别;原地修改中间条目后请传入新切片。
+48
View File
@@ -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 滚动。
## 限制
- 只识别向末尾追加;替换为更短的切片会重新过滤。
- 高亮不过滤行,只标出匹配。
+50
View File
@@ -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 到达。
## 限制
- 组件不发送信号,只回报确认结果。
+46
View File
@@ -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 到达。
## 限制
- 折叠按缩进推断,不解析语法;空行不结束块。
- 只读,不提供编辑与行内(字符级)选择。
+45
View File
@@ -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 到开头与结尾。
## 限制
- 缩略图按行独立着色,不跟踪跨行的块注释。
+51
View File
@@ -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()` 自行标记。
+51
View File
@@ -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 个字节。
+49
View File
@@ -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 或双击打开问题,在文件行上折叠/展开;← 折叠、→ 展开。
## 限制
- 同一文件的问题按行、列排序,文件按路径排序。
+48
View File
@@ -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` 一致)。
+53
View File
@@ -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` 变化时生效,之后用户可自由移动选择。
+50
View File
@@ -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` 会被覆盖。
+48
View File
@@ -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。
+49
View File
@@ -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`。
+52
View File
@@ -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 折叠/展开,← 折叠、→ 展开。
## 限制
- 不区分线程的运行状态;需要时写入线程名。
+49
View File
@@ -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`。
+44
View File
@@ -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+点击扩展。
## 限制
- 只读,不支持编辑与搜索。
+50
View File
@@ -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,请用键盘或先放大。
+276
View File
@@ -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)
}
+243 -1
View File
@@ -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))
}
}
+238
View File
@@ -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))
}
})
}
+230
View File
@@ -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)`
+264
View File
@@ -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)
}
}
+181
View File
@@ -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)
}
+117
View File
@@ -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{})
})
})
}
+276
View File
@@ -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)
}
+143
View File
@@ -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) })
})
}
+157
View File
@@ -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)
}
}
+115
View File
@@ -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)
})
})
}
+130
View File
@@ -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)
})
}
+129
View File
@@ -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)
})
})
}
+101
View File
@@ -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%%"},
})
}
+134
View File
@@ -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)
}
}
+116
View File
@@ -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
}
+93
View File
@@ -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
}
+118
View File
@@ -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)
})
})
}
+194
View File
@@ -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)
}
+106
View File
@@ -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)
})
})
}
+185
View File
@@ -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)
}
+124
View File
@@ -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)
})
})
}
+202
View File
@@ -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)
}
+129
View File
@@ -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)
})
})
}
+235
View File
@@ -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
}
+107
View File
@@ -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)
})
})
}
+308
View File
@@ -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
}
+77
View File
@@ -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
}
+105
View File
@@ -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)
})
})
}
+361
View File
@@ -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))
}
}
+181
View File
@@ -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)
}
})
}
+122
View File
@@ -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")
})
})
})
}
+192
View File
@@ -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)
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more