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